Browse Source

bug-fixes

imwald
Silberengel 2 months ago
parent
commit
87f018a5d7
  1. 25
      src/components/AudioPlayer/index.tsx
  2. 109
      src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx
  3. 175
      src/components/MediaPlayer/index.tsx
  4. 22
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  5. 278
      src/components/PostEditor/PostContent.tsx
  6. 2
      src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts
  7. 29
      src/components/VideoPlayer/index.tsx
  8. 102
      src/lib/draft-event.ts

25
src/components/AudioPlayer/index.tsx

@ -11,9 +11,11 @@ import logger from '@/lib/logger' @@ -11,9 +11,11 @@ import logger from '@/lib/logger'
interface AudioPlayerProps {
src: string
className?: string
/** Fires when enough data is buffered to play (e.g. to swap out a blurhash placeholder). */
onReady?: () => void
}
export default function AudioPlayer({ src, className }: AudioPlayerProps) {
export default function AudioPlayer({ src, className, onReady }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
@ -22,6 +24,25 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) { @@ -22,6 +24,25 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false)
useEffect(() => {
if (!onReady) return
const audio = audioRef.current
if (!audio) return
const notify = () => onReady()
if (audio.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
notify()
return
}
audio.addEventListener('canplay', notify, { once: true })
return () => audio.removeEventListener('canplay', notify)
}, [src, onReady])
useEffect(() => {
if (error) {
onReady?.()
}
}, [error, onReady])
useEffect(() => {
const audio = audioRef.current
if (!audio) return
@ -104,7 +125,7 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) { @@ -104,7 +125,7 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
)}
onClick={(e) => e.stopPropagation()}
>
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(false)} />
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(true)} />
{/* Play/Pause Button */}
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>

109
src/components/MediaPlayer/LazyMediaTapPlaceholder.tsx

@ -2,7 +2,7 @@ import { cn } from '@/lib/utils' @@ -2,7 +2,7 @@ import { cn } from '@/lib/utils'
import { resolveMediaBlurPlaceholder } from '@/lib/media-placeholder-blurhash'
import { decode } from 'blurhash'
import { Music2, Play } from 'lucide-react'
import { useEffect, useMemo, useRef } from 'react'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
const CANVAS_W = 32
@ -31,7 +31,8 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?: @@ -31,7 +31,8 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?:
}
}, [blurHash])
useEffect(() => {
// Layout effect so the canvas is painted before the browser's next paint (no empty flash).
useLayoutEffect(() => {
if (!pixels || !canvasRef.current) return
const ctx = canvasRef.current.getContext('2d')
if (!ctx) return
@ -41,7 +42,7 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?: @@ -41,7 +42,7 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?:
}, [pixels])
if (!pixels) {
return <div className={cn('absolute inset-0 bg-muted', className)} />
return <div className={cn('absolute inset-0 z-0 bg-muted', className)} />
}
return (
@ -49,12 +50,86 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?: @@ -49,12 +50,86 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?:
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
className={cn('absolute inset-0 h-full w-full object-cover', className)}
className={cn('absolute inset-0 z-0 h-full w-full object-cover', className)}
style={{ imageRendering: 'auto', filter: 'blur(0.5px)' }}
/>
)
}
const frameClass = (kind: 'video' | 'audio', className?: string) =>
cn(
'relative w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 shadow-sm',
kind === 'video' ? 'aspect-video' : 'min-h-[7.5rem] aspect-[21/9]',
className
)
function MediaPlaceholderLayers({
src,
posterUrl,
blurHash,
showTapChrome
}: {
src: string
posterUrl?: string
blurHash?: string
showTapChrome: boolean
}) {
const kind = guessMediaKindFromUrl(src)
const hash = resolveMediaBlurPlaceholder(src, blurHash)
const poster = posterUrl?.trim()
return (
<>
{/* Blur under poster so color shows instantly while the image loads */}
<BlurHashLayer blurHash={hash} />
{poster ? (
<img
src={poster}
alt=""
className="absolute inset-0 z-[1] h-full w-full object-cover"
loading="eager"
decoding="async"
/>
) : null}
<span className="absolute inset-0 z-[2] bg-gradient-to-t from-black/55 via-black/25 to-black/15" aria-hidden />
{showTapChrome ? (
<span className="absolute inset-0 z-[3] flex items-center justify-center" aria-hidden>
<span className="flex size-14 items-center justify-center rounded-full bg-black/55 text-white shadow-md backdrop-blur-[2px] transition-transform group-hover:scale-105 group-focus-visible:scale-105">
{kind === 'video' ? (
<Play className="size-8 fill-current pl-1" strokeWidth={0} />
) : (
<Music2 className="size-8" strokeWidth={2} />
)}
</span>
</span>
) : null}
</>
)
}
/** Blurhash (or poster) frame while video/audio embed loads — no tap target. */
export function MediaEmbedBlurFrame({
src,
posterUrl,
blurHash,
className
}: {
src: string
posterUrl?: string
blurHash?: string
className?: string
}) {
const kind = guessMediaKindFromUrl(src)
return (
<div
className={cn(frameClass(kind, className), 'pointer-events-none select-none')}
aria-hidden
>
<MediaPlaceholderLayers src={src} posterUrl={posterUrl} blurHash={blurHash} showTapChrome={false} />
</div>
)
}
export default function LazyMediaTapPlaceholder({
src,
posterUrl,
@ -70,15 +145,13 @@ export default function LazyMediaTapPlaceholder({ @@ -70,15 +145,13 @@ export default function LazyMediaTapPlaceholder({
}) {
const { t } = useTranslation()
const kind = guessMediaKindFromUrl(src)
const hash = resolveMediaBlurPlaceholder(src, blurHash)
const poster = posterUrl?.trim()
const label = t('Click to load media')
return (
<button
type="button"
className={cn(
'group relative w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 text-left shadow-sm outline-none transition-opacity hover:opacity-95 focus-visible:ring-2 focus-visible:ring-ring',
'group w-full max-w-[400px] overflow-hidden rounded-lg border border-border bg-muted/30 text-left shadow-sm outline-none transition-opacity hover:opacity-95 focus-visible:ring-2 focus-visible:ring-ring',
kind === 'video' ? 'aspect-video' : 'min-h-[7.5rem] aspect-[21/9]',
className
)}
@ -90,27 +163,7 @@ export default function LazyMediaTapPlaceholder({ @@ -90,27 +163,7 @@ export default function LazyMediaTapPlaceholder({
aria-label={label}
title={label}
>
{poster ? (
<img
src={poster}
alt=""
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<BlurHashLayer blurHash={hash} />
)}
<span className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/25 to-black/15" aria-hidden />
<span className="absolute inset-0 flex items-center justify-center" aria-hidden>
<span className="flex size-14 items-center justify-center rounded-full bg-black/55 text-white shadow-md backdrop-blur-[2px] transition-transform group-hover:scale-105 group-focus-visible:scale-105">
{kind === 'video' ? (
<Play className="size-8 fill-current pl-1" strokeWidth={0} />
) : (
<Music2 className="size-8" strokeWidth={2} />
)}
</span>
</span>
<MediaPlaceholderLayers src={src} posterUrl={posterUrl} blurHash={blurHash} showTapChrome />
</button>
)
}

175
src/components/MediaPlayer/index.tsx

@ -1,10 +1,37 @@ @@ -1,10 +1,37 @@
import { isImage } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
import VideoPlayer from '../VideoPlayer'
import ExternalLink from '../ExternalLink'
import LazyMediaTapPlaceholder from './LazyMediaTapPlaceholder'
import LazyMediaTapPlaceholder, { MediaEmbedBlurFrame } from './LazyMediaTapPlaceholder'
/** Same rules as the metadata probe, but synchronous so the first paint can show the embed stack. */
function embedMediaTypeHintFromUrl(src: string): 'video' | 'audio' | null {
try {
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
if (
extension &&
['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka', 'oga'].includes(extension)
) {
return 'audio'
}
if (extension === 'mkv' || extension === 'ogv') {
return 'video'
}
if (
extension &&
['mp4', 'webm', 'm4v', 'mov', 'avi', '3gp', '3g2'].includes(extension)
) {
return 'video'
}
return null
} catch {
return null
}
}
export default function MediaPlayer({
src,
@ -23,6 +50,9 @@ export default function MediaPlayer({ @@ -23,6 +50,9 @@ export default function MediaPlayer({
const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
const [probeFailed, setProbeFailed] = useState(false)
const [embedPainted, setEmbedPainted] = useState(false)
const readyOnceRef = useRef(false)
// imeta `thumb` / `image` are sometimes the same .mp4 as `url` — <img> cannot use that, and it
// would hide the blurhash placeholder in LazyMediaTapPlaceholder.
@ -32,6 +62,12 @@ export default function MediaPlayer({ @@ -32,6 +62,12 @@ export default function MediaPlayer({
return isImage(p) ? p : undefined
}, [poster])
const urlEmbedTypeHint = useMemo(() => embedMediaTypeHintFromUrl(src), [src])
/** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */
const effectiveMediaType = mediaType ?? urlEmbedTypeHint
const showEmbed = mustLoad || display
useEffect(() => {
if (autoLoadMedia) {
setDisplay(true)
@ -41,47 +77,76 @@ export default function MediaPlayer({ @@ -41,47 +77,76 @@ export default function MediaPlayer({
}, [autoLoadMedia])
useEffect(() => {
if (!mustLoad && !display) {
readyOnceRef.current = false
setEmbedPainted(false)
setMediaType(null)
setProbeFailed(false)
}, [src])
useEffect(() => {
if (!showEmbed) {
setMediaType(null)
setProbeFailed(false)
return
}
readyOnceRef.current = false
setEmbedPainted(false)
if (!src) {
setMediaType(null)
setProbeFailed(true)
return
}
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
setProbeFailed(false)
setMediaType(null)
if (
extension &&
['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka'].includes(extension)
) {
setMediaType('audio')
return
}
let cancelled = false
if (extension === 'mkv' || extension === 'ogv') {
setMediaType('video')
return
}
try {
const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase()
const video = document.createElement('video')
video.src = src
video.preload = 'metadata'
if (
extension &&
['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mka'].includes(extension)
) {
setMediaType('audio')
return
}
video.onloadedmetadata = () => {
setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio')
}
if (extension === 'mkv' || extension === 'ogv') {
setMediaType('video')
return
}
video.onerror = () => {
setMediaType(null)
}
const video = document.createElement('video')
video.src = src
video.preload = 'metadata'
return () => {
video.src = ''
video.onloadedmetadata = () => {
if (cancelled) return
setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio')
}
video.onerror = () => {
if (cancelled) return
setProbeFailed(true)
setMediaType(null)
}
return () => {
cancelled = true
video.src = ''
}
} catch {
setProbeFailed(true)
}
}, [src, display, mustLoad])
}, [src, showEmbed])
const onEmbedReady = useCallback(() => {
if (readyOnceRef.current) return
readyOnceRef.current = true
setEmbedPainted(true)
}, [])
if (!mustLoad && !display) {
return (
@ -95,13 +160,57 @@ export default function MediaPlayer({ @@ -95,13 +160,57 @@ export default function MediaPlayer({
)
}
if (!mediaType) {
if (probeFailed) {
return <ExternalLink url={src} />
}
if (mediaType === 'video') {
return <VideoPlayer src={src} className={className} poster={imagePoster} />
if (effectiveMediaType === null) {
return (
<MediaEmbedBlurFrame
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
className={className}
/>
)
}
return <AudioPlayer src={src} className={className} />
const layerTransition =
'transition-opacity duration-300 ease-out motion-reduce:transition-none'
return (
<div className="relative w-full max-w-[400px]">
<div
className={cn(
layerTransition,
embedPainted
? 'pointer-events-none absolute inset-0 z-10 opacity-0'
: 'relative z-10 w-full opacity-100'
)}
aria-hidden={embedPainted}
>
<MediaEmbedBlurFrame
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
className={className}
/>
</div>
<div
className={cn(
layerTransition,
embedPainted
? 'relative z-20 w-full overflow-hidden opacity-100'
: 'absolute inset-0 z-0 w-full overflow-hidden opacity-0 pointer-events-none'
)}
aria-hidden={!embedPainted}
>
{effectiveMediaType === 'video' ? (
<VideoPlayer src={src} className={className} poster={imagePoster} onReady={onEmbedReady} />
) : (
<AudioPlayer src={src} className={className} onReady={onEmbedReady} />
)}
</div>
</div>
)
}

22
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -25,7 +25,11 @@ import { @@ -25,7 +25,11 @@ import {
UNSIGNED_EXPERIMENTAL_KIND_MIN,
isUnsignedExperimentalKind
} from '@/constants'
import { applyImwaldAttributionTags } from '@/lib/draft-event'
import {
applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto
} from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event'
import logger from '@/lib/logger'
import {
@ -161,6 +165,12 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -161,6 +165,12 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows])
const tagsWithContentUploadImeta = useMemo(() => {
const next = [...normalizedTags]
mergeUploadImetaTagsInto(next, collectUploadImetaTagsForContentUrls(content))
return next
}, [normalizedTags, content])
const previewEvent = useMemo(() => {
if (isCreate && parsedCreateKind === null) return null
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
@ -168,7 +178,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -168,7 +178,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const base: TDraftEvent = {
kind: k,
content,
tags: normalizedTags,
tags: tagsWithContentUploadImeta,
created_at: now
}
const withAttribution = applyImwaldAttributionTags(base, {
@ -181,7 +191,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -181,7 +191,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
pubkey: pubkey ?? '',
created_at: now
})
}, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags, pubkey])
}, [isCreate, parsedCreateKind, sourceEvent, content, tagsWithContentUploadImeta, pubkey])
const buildDraftJson = useCallback(() => {
if (isCreate && parsedCreateKind === null) {
@ -198,7 +208,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -198,7 +208,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const base: TDraftEvent = {
kind: k,
content,
tags: normalizedTags,
tags: tagsWithContentUploadImeta,
created_at: dayjs().unix()
}
const withAttribution = applyImwaldAttributionTags(base, {
@ -218,7 +228,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -218,7 +228,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
_note: unsignedNote
}
return JSON.stringify(draft, null, 2)
}, [isCreate, parsedCreateKind, sourceEvent, pubkey, content, normalizedTags, t])
}, [isCreate, parsedCreateKind, sourceEvent, pubkey, content, tagsWithContentUploadImeta, t])
const draftJson = activeTab === 'json' ? buildDraftJson() : ''
@ -279,7 +289,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -279,7 +289,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const draft = {
kind: publishKind,
content,
tags: normalizedTags,
tags: tagsWithContentUploadImeta,
created_at: dayjs().unix()
}
const newEvent = await publish(draft, {

278
src/components/PostEditor/PostContent.tsx

@ -34,7 +34,8 @@ import { @@ -34,7 +34,8 @@ import {
createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent,
applyImwaldAttributionTags
applyImwaldAttributionTags,
mergeUploadImetaTagsInto
} from '@/lib/draft-event'
import { ExtendedKind } from '@/constants'
import { cn, isTouchDevice } from '@/lib/utils'
@ -42,7 +43,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -42,7 +43,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import { cleanUrl, normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors'
import postEditorCache from '@/services/post-editor-cache.service'
@ -303,8 +304,9 @@ export default function PostContent({ @@ -303,8 +304,9 @@ export default function PostContent({
const [showMediaKindDialog, setShowMediaKindDialog] = useState(false)
const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string; tags: string[][]; file: File } | null>(null)
const uploadedMediaFileMap = useRef<Map<string, File>>(new Map())
/** Accumulates imeta tags for kind 20 (picture) so multiple rapid uploads don’t overwrite each other. */
const pictureImetaTagsRef = useRef<string[][]>([])
/** Accumulates imeta tags across uploads (short note or multi-attachment) so files are not dropped. */
const composerImetaTagsRef = useRef<string[][]>([])
const mediaNoteKindRef = useRef<number | null>(null)
/** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */
const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null)
@ -323,10 +325,26 @@ export default function PostContent({ @@ -323,10 +325,26 @@ export default function PostContent({
}, [isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent])
useEffect(() => {
if (mediaNoteKind === ExtendedKind.PICTURE && mediaImetaTags.length > 0) {
pictureImetaTagsRef.current = mediaImetaTags
}
}, [mediaNoteKind, mediaImetaTags])
mediaNoteKindRef.current = mediaNoteKind
}, [mediaNoteKind])
const appendComposerImetaTag = useCallback((newTag: string[]) => {
const urlItem = newTag.find((x) => typeof x === 'string' && x.startsWith('url '))
const rawUrl = urlItem?.slice(4)?.trim()
const normalized = rawUrl ? cleanUrl(rawUrl) || rawUrl : ''
const exists =
normalized &&
composerImetaTagsRef.current.some((tag) => {
const u = tag.find((x) => typeof x === 'string' && x.startsWith('url '))
if (!u) return false
const r = u.slice(4).trim()
return (cleanUrl(r) || r) === normalized
})
if (exists) return
composerImetaTagsRef.current = [...composerImetaTagsRef.current, newTag]
setMediaImetaTags([...composerImetaTagsRef.current])
}, [])
const isFirstRender = useRef(true)
const allAvailableTopics = useMemo(
@ -618,6 +636,8 @@ export default function PostContent({ @@ -618,6 +636,8 @@ export default function PostContent({
// Shared function to create draft event - used by both preview and posting
const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => {
const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined
// Get expiration and quiet settings
const isChattingKind = (kind: number) =>
kind === kinds.ShortTextNote ||
@ -647,7 +667,7 @@ export default function PostContent({ @@ -647,7 +667,7 @@ export default function PostContent({
expirationMonths,
addQuietTag,
quietDays,
mediaImetaTags: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined
mediaImetaTags: uploadImetaTagsOpt
})
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
// For PM replies, always create PM even if there's media
@ -658,7 +678,7 @@ export default function PostContent({ @@ -658,7 +678,7 @@ export default function PostContent({
expirationMonths,
addQuietTag,
quietDays,
mediaImetaTags: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined
mediaImetaTags: uploadImetaTagsOpt
})
}
@ -682,18 +702,20 @@ export default function PostContent({ @@ -682,18 +702,20 @@ export default function PostContent({
tags,
created_at: dayjs().unix()
}
mergeUploadImetaTagsInto(draft.tags, uploadImetaTagsOpt)
return draft
}
// Check for voice comments (only for non-PM replies)
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
const url = mediaUrl || 'placeholder://audio'
const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']]
const voiceImetaRows =
mediaImetaTags.length > 0 ? [] : [['imeta', `url ${url}`, 'm audio/mpeg']]
return await createVoiceCommentDraftEvent(
cleanedText,
parentEvent,
url,
tags,
voiceImetaRows,
mentions,
{
addClientTag,
@ -702,7 +724,8 @@ export default function PostContent({ @@ -702,7 +724,8 @@ export default function PostContent({
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT),
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
}
)
}
@ -710,10 +733,12 @@ export default function PostContent({ @@ -710,10 +733,12 @@ export default function PostContent({
// Media notes
if (mediaNoteKind !== null && mediaUrl) {
if (mediaNoteKind === ExtendedKind.VOICE) {
const voiceImetaRows =
mediaImetaTags.length > 0 ? [] : [['imeta', `url ${mediaUrl}`, 'm audio/mpeg']]
return await createVoiceDraftEvent(
cleanedText,
mediaUrl,
mediaImetaTags,
voiceImetaRows,
mentions,
{
addClientTag,
@ -721,7 +746,8 @@ export default function PostContent({ @@ -721,7 +746,8 @@ export default function PostContent({
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE),
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
}
)
} else if (mediaNoteKind === ExtendedKind.PICTURE) {
@ -735,7 +761,8 @@ export default function PostContent({ @@ -735,7 +761,8 @@ export default function PostContent({
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
}
)
} else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) {
@ -750,7 +777,8 @@ export default function PostContent({ @@ -750,7 +777,8 @@ export default function PostContent({
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
}
)
}
@ -929,7 +957,8 @@ export default function PostContent({ @@ -929,7 +957,8 @@ export default function PostContent({
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
}
)
}
@ -944,7 +973,8 @@ export default function PostContent({ @@ -944,7 +973,8 @@ export default function PostContent({
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT),
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
})
}
@ -956,11 +986,12 @@ export default function PostContent({ @@ -956,11 +986,12 @@ export default function PostContent({
addExpirationTag: false,
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
})
}
// Default: Short text note
// Default: Short text note (kind 1), with optional NIP-94 imeta from uploads while still in "short note" mode
return await createShortTextNoteDraftEvent(cleanedText, mentions, {
parentEvent,
addClientTag,
@ -969,7 +1000,8 @@ export default function PostContent({ @@ -969,7 +1000,8 @@ export default function PostContent({
addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote),
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags: uploadImetaTagsOpt
})
}, [
parentEvent,
@ -1260,6 +1292,7 @@ export default function PostContent({ @@ -1260,6 +1292,7 @@ export default function PostContent({
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
}
}
@ -1283,6 +1316,7 @@ export default function PostContent({ @@ -1283,6 +1316,7 @@ export default function PostContent({
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
}
}
@ -1303,7 +1337,7 @@ export default function PostContent({ @@ -1303,7 +1337,7 @@ export default function PostContent({
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
pictureImetaTagsRef.current = []
composerImetaTagsRef.current = []
uploadedMediaFileMap.current.clear()
}
@ -1395,7 +1429,19 @@ export default function PostContent({ @@ -1395,7 +1429,19 @@ export default function PostContent({
setMediaUrl(found)
setMediaNoteKind(kind)
const mime = mimeFromUrlPathForKind(found, kind)
setMediaImetaTags([['imeta', `url ${found}`, `m ${mime}`]])
const synth: string[] = ['imeta', `url ${found}`, `m ${mime}`]
const foundNorm = cleanUrl(found) || found
setMediaImetaTags((prev) => {
const has = prev.some((tag) => {
const u = tag.find((x) => typeof x === 'string' && x.startsWith('url '))
if (!u) return false
const r = u.slice(4).trim()
return (cleanUrl(r) || r) === foundNorm
})
const next = has ? prev : [...prev, synth]
composerImetaTagsRef.current = next
return next
})
}
const isPlainShortNoteToolbar = useMemo(
@ -1413,8 +1459,7 @@ export default function PostContent({ @@ -1413,8 +1459,7 @@ export default function PostContent({
!isCitationHardcopy &&
!isCitationPrompt &&
!isDiscussionThread &&
mediaNoteKind === null &&
!mediaUrl,
mediaNoteKind === null,
[
parentEvent,
isPoll,
@ -1429,8 +1474,7 @@ export default function PostContent({ @@ -1429,8 +1474,7 @@ export default function PostContent({
isCitationHardcopy,
isCitationPrompt,
isDiscussionThread,
mediaNoteKind,
mediaUrl
mediaNoteKind
]
)
@ -1454,6 +1498,7 @@ export default function PostContent({ @@ -1454,6 +1498,7 @@ export default function PostContent({
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
setAddClientTag(true)
}
}
@ -1471,6 +1516,7 @@ export default function PostContent({ @@ -1471,6 +1516,7 @@ export default function PostContent({
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
const draft = postEditorCache.getThreadDraft()
if (draft) {
setThreadTitle(draft.title)
@ -1558,14 +1604,8 @@ export default function PostContent({ @@ -1558,14 +1604,8 @@ export default function PostContent({
}
// Note: URL will be inserted when upload completes in handleMediaUploadSuccess
}
} else if (!isDiscussionThread) {
// For new posts, detect the kind from the file (async)
getMediaKindFromFile(file, false)
.then((kind) => setMediaNoteKind(kind))
.catch((error) => {
logger.error('Error detecting media kind in handleUploadStart', { error, file: file.name })
})
}
// Root short-note composer: do not switch to a native media kind on upload — user uses "Media kind".
}
}
@ -1643,127 +1683,78 @@ export default function PostContent({ @@ -1643,127 +1683,78 @@ export default function PostContent({
const processMediaUpload = async (url: string, tags: string[][], uploadingFile: File, selectedKind?: number) => {
try {
let kind: number
let resolvedKind: number
if (selectedKind !== undefined) {
// Use the selected kind
kind = selectedKind
resolvedKind = selectedKind
setMediaNoteKind(resolvedKind)
} else {
// Auto-detect the kind
kind = await getMediaKindFromFile(uploadingFile, false)
resolvedKind = await getMediaKindFromFile(uploadingFile, false)
// Root composer: keep kind 1 until the user uses "Media kind" (ambiguous webm/mp4 still sets kind via dialog).
}
setMediaNoteKind(kind)
// For picture notes, support multiple images by accumulating imeta tags
if (kind === ExtendedKind.PICTURE) {
// Get imeta tag from media upload service
const imetaTag = mediaUpload.getImetaTagByUrl(url)
let newImetaTag: string[]
if (imetaTag) {
newImetaTag = imetaTag
} else if (tags && tags.length > 0) {
newImetaTag = nip94PairsToImetaTag(tags)
} else {
// Create a basic imeta tag if none exists
newImetaTag = ['imeta', `url ${url}`]
if (uploadingFile.type) {
newImetaTag.push(`m ${uploadingFile.type}`)
const imetaTag = mediaUpload.getImetaTagByUrl(url)
let newImetaTag: string[]
if (imetaTag) {
newImetaTag = imetaTag
} else if (tags && tags.length > 0) {
newImetaTag = nip94PairsToImetaTag(tags)
} else {
newImetaTag = ['imeta', `url ${url}`]
let mimeType = uploadingFile.type
const kindHint = selectedKind ?? resolvedKind
if (kindHint === ExtendedKind.VOICE || kindHint === ExtendedKind.VOICE_COMMENT) {
const fileName = uploadingFile.name.toLowerCase()
if (/\.webm$/i.test(fileName)) {
mimeType = 'audio/webm'
} else if (/\.mka$/i.test(fileName)) {
mimeType = 'audio/x-matroska'
} else if (/\.mp4$/i.test(fileName)) {
mimeType = 'audio/mp4'
}
} else if (kindHint === ExtendedKind.VIDEO || kindHint === ExtendedKind.SHORT_VIDEO) {
const fileName = uploadingFile.name.toLowerCase()
if (/\.webm$/i.test(fileName)) {
mimeType = 'video/webm'
} else if (/\.mkv$/i.test(fileName)) {
mimeType = 'video/x-matroska'
} else if (/\.mp4$/i.test(fileName)) {
mimeType = 'video/mp4'
}
}
// Accumulate multiple imeta tags for picture notes (use ref so rapid multi-upload doesn’t lose tags)
const urlExists = pictureImetaTagsRef.current.some((tag) => {
const urlItem = tag.find((item) => item.startsWith('url '))
return urlItem && urlItem.slice(4).trim() === url
})
if (!urlExists) {
pictureImetaTagsRef.current = [...pictureImetaTagsRef.current, newImetaTag]
setMediaImetaTags([...pictureImetaTagsRef.current])
if (mimeType) {
newImetaTag.push(`m ${mimeType}`)
}
}
// Set the first URL as the primary mediaUrl (for backwards compatibility)
if (!mediaUrl) {
setMediaUrl(url)
}
appendComposerImetaTag(newImetaTag)
// Insert the URL into the editor content so it shows in the edit pane
// Use setTimeout to ensure the state has updated and editor is ready
setTimeout(() => {
if (textareaRef.current) {
// Check the actual editor content, not the state variable (which might be stale)
const currentText = textareaRef.current.getText()
if (!currentText.includes(url)) {
textareaRef.current.appendText(url, true)
}
}
}, 100)
} else {
// For non-picture media, replace the existing tags (single media)
pictureImetaTagsRef.current = []
if (selectedKind !== undefined) {
setMediaUrl(url)
const imetaTag = mediaUpload.getImetaTagByUrl(url)
if (imetaTag) {
setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) {
setMediaImetaTags([nip94PairsToImetaTag(tags)])
} else {
const basicImetaTag: string[] = ['imeta', `url ${url}`]
// Update MIME type based on selected kind
let mimeType = uploadingFile.type
if (selectedKind === ExtendedKind.VOICE || selectedKind === ExtendedKind.VOICE_COMMENT) {
// Ensure audio MIME type
const fileName = uploadingFile.name.toLowerCase()
if (/\.webm$/i.test(fileName)) {
mimeType = 'audio/webm'
} else if (/\.mka$/i.test(fileName)) {
mimeType = 'audio/x-matroska'
} else if (/\.mp4$/i.test(fileName)) {
mimeType = 'audio/mp4'
}
} else if (selectedKind === ExtendedKind.VIDEO || selectedKind === ExtendedKind.SHORT_VIDEO) {
// Ensure video MIME type
const fileName = uploadingFile.name.toLowerCase()
if (/\.webm$/i.test(fileName)) {
mimeType = 'video/webm'
} else if (/\.mkv$/i.test(fileName)) {
mimeType = 'video/x-matroska'
} else if (/\.mp4$/i.test(fileName)) {
mimeType = 'video/mp4'
}
}
if (mimeType) {
basicImetaTag.push(`m ${mimeType}`)
}
setMediaImetaTags([basicImetaTag])
}
} else if (mediaNoteKindRef.current !== null) {
setMediaUrl((prev) => prev || url)
}
// Insert the URL into the editor content so it shows in the edit pane
// Use setTimeout to ensure the state has updated and editor is ready
setTimeout(() => {
if (textareaRef.current) {
// Check the actual editor content, not the state variable (which might be stale)
const currentText = textareaRef.current.getText()
if (!currentText.includes(url)) {
textareaRef.current.appendText(url, true)
}
setTimeout(() => {
if (textareaRef.current) {
const currentText = textareaRef.current.getText()
if (!currentText.includes(url)) {
textareaRef.current.appendText(url, true)
}
}, 100)
}
}
}, 100)
} catch (error) {
logger.error('Error processing media upload', { error, file: uploadingFile.name })
// Fallback to picture if processing fails
setMediaNoteKind(ExtendedKind.PICTURE)
const imetaTag = mediaUpload.getImetaTagByUrl(url)
const tagToAdd = imetaTag ?? (() => {
const basic: string[] = ['imeta', `url ${url}`]
if (uploadingFile.type) basic.push(`m ${uploadingFile.type}`)
return basic
})()
pictureImetaTagsRef.current = [...pictureImetaTagsRef.current, tagToAdd]
setMediaImetaTags([...pictureImetaTagsRef.current])
if (!mediaUrl) {
setMediaUrl(url)
const tagToAdd =
imetaTag ??
(() => {
const basic: string[] = ['imeta', `url ${url}`]
if (uploadingFile.type) basic.push(`m ${uploadingFile.type}`)
return basic
})()
appendComposerImetaTag(tagToAdd)
if (mediaNoteKindRef.current !== null) {
setMediaUrl((prev) => prev || url)
}
}
}
@ -1890,6 +1881,7 @@ export default function PostContent({ @@ -1890,6 +1881,7 @@ export default function PostContent({
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
// Just add the media URL to the text content
textareaRef.current?.appendText(url, true)
return // Don't set media note kind for non-audio in replies/PMs
@ -1927,7 +1919,7 @@ export default function PostContent({ @@ -1927,7 +1919,7 @@ export default function PostContent({
// Clear uploaded file from map and picture accumulation ref
uploadedMediaFileMap.current.clear()
pictureImetaTagsRef.current = []
composerImetaTagsRef.current = []
}
const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => {
@ -2069,7 +2061,7 @@ export default function PostContent({ @@ -2069,7 +2061,7 @@ export default function PostContent({
sourceValue: ''
})
uploadedMediaFileMap.current.clear()
pictureImetaTagsRef.current = []
composerImetaTagsRef.current = []
setUploadProgresses([])
}

2
src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts

@ -15,7 +15,7 @@ const DRAGOVER_CLASS_LIST = [ @@ -15,7 +15,7 @@ const DRAGOVER_CLASS_LIST = [
export interface ClipboardAndDropHandlerOptions {
onUploadStart?: (file: File, cancel: () => void) => void
/** Same contract as `Uploader` — required so drop/paste uploads set media note state (kind 20/21/22…), not only the URL in text. */
/** Same contract as `Uploader` — drop/paste uploads append URLs + imeta while staying on kind 1 unless the user picks a native media kind. */
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadEnd?: (file: File) => void
onUploadProgress?: (file: File, progress: number) => void

29
src/components/VideoPlayer/index.tsx

@ -6,7 +6,18 @@ import ExternalLink from '../ExternalLink' @@ -6,7 +6,18 @@ import ExternalLink from '../ExternalLink'
import { MediaErrorBoundary } from '../MediaErrorBoundary'
import logger from '@/lib/logger'
export default function VideoPlayer({ src, className, poster }: { src: string; className?: string; poster?: string }) {
export default function VideoPlayer({
src,
className,
poster,
onReady
}: {
src: string
className?: string
poster?: string
/** Fires when the first frame is available (e.g. to swap out a blurhash placeholder). */
onReady?: () => void
}) {
const { autoplay } = useContentPolicy()
const [error, setError] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
@ -42,6 +53,12 @@ export default function VideoPlayer({ src, className, poster }: { src: string; c @@ -42,6 +53,12 @@ export default function VideoPlayer({ src, className, poster }: { src: string; c
}
}, [autoplay])
useEffect(() => {
if (error) {
onReady?.()
}
}, [error, onReady])
if (error) {
return <ExternalLink url={src} />
}
@ -49,10 +66,9 @@ export default function VideoPlayer({ src, className, poster }: { src: string; c @@ -49,10 +66,9 @@ export default function VideoPlayer({ src, className, poster }: { src: string; c
return (
<MediaErrorBoundary
fallback={<ExternalLink url={src} />}
onError={(error) => {
// Don't log expected media errors
if (error.name !== 'AbortError' && !error.message.includes('play() request was interrupted')) {
logger.warn('Video player error', error)
onError={(err) => {
if (err.name !== 'AbortError' && !err.message.includes('play() request was interrupted')) {
logger.warn('Video player error', err)
}
setError(true)
}}
@ -62,11 +78,12 @@ export default function VideoPlayer({ src, className, poster }: { src: string; c @@ -62,11 +78,12 @@ export default function VideoPlayer({ src, className, poster }: { src: string; c
ref={videoRef}
controls
playsInline
preload="none"
preload={onReady ? 'metadata' : 'none'}
className={cn('rounded-lg max-h-[80vh] sm:max-h-[60vh] border w-full h-auto max-w-full', className)}
src={src}
poster={poster}
onClick={(e) => e.stopPropagation()}
onLoadedData={() => onReady?.()}
onPlay={(event) => {
mediaManager.play(event.currentTarget)
}}

102
src/lib/draft-event.ts

@ -154,6 +154,60 @@ export function createRepostDraftEvent(event: Event): TDraftEvent { @@ -154,6 +154,60 @@ export function createRepostDraftEvent(event: Event): TDraftEvent {
}
}
function imetaUrlFromTagRow(tag: string[]): string | undefined {
const item = tag.find((x) => typeof x === 'string' && x.startsWith('url '))
if (!item) return undefined
const raw = item.slice(4).trim()
return cleanUrl(raw) || raw
}
/** Append NIP-94 `imeta` rows from uploads; skip URLs already present on existing `imeta` tags. */
export function mergeUploadImetaTagsInto(tags: string[][], mediaImetaTags?: string[][]): void {
if (!mediaImetaTags?.length) return
const seen = new Set<string>()
for (const t of tags) {
if (t[0] === 'imeta') {
const u = imetaUrlFromTagRow(t)
if (u) seen.add(u)
}
}
for (const row of mediaImetaTags) {
const u = imetaUrlFromTagRow(row)
if (u) {
if (seen.has(u)) continue
seen.add(u)
}
tags.push(row)
}
}
/** NIP-94 `imeta` rows from the upload cache for HTTP(S) URLs that appear in `content`. */
export function collectUploadImetaTagsForContentUrls(content: string): string[][] {
if (!content) return []
const re = /https?:\/\/[^\s<>"']+/g
const out: string[][] = []
const seen = new Set<string>()
let m: RegExpExecArray | null
while ((m = re.exec(content)) !== null) {
const raw = m[0]
const keys = [raw]
const c = cleanUrl(raw)
if (c && c !== raw) keys.push(c)
for (const key of keys) {
const tag = mediaUpload.getImetaTagByUrl(key)
if (tag) {
const u = imetaUrlFromTagRow(tag)
if (u && !seen.has(u)) {
seen.add(u)
out.push(tag)
}
break
}
}
}
return out
}
export async function createShortTextNoteDraftEvent(
content: string,
mentions: string[],
@ -166,6 +220,8 @@ export async function createShortTextNoteDraftEvent( @@ -166,6 +220,8 @@ export async function createShortTextNoteDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
/** NIP-94 imeta rows from uploads (audio/video/images as plain URLs in content). */
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations
@ -183,6 +239,8 @@ export async function createShortTextNoteDraftEvent( @@ -183,6 +239,8 @@ export async function createShortTextNoteDraftEvent(
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
// q tags
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
@ -250,6 +308,7 @@ export async function createCommentDraftEvent( @@ -250,6 +308,7 @@ export async function createCommentDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations
@ -276,6 +335,8 @@ export async function createCommentDraftEvent( @@ -276,6 +335,8 @@ export async function createCommentDraftEvent(
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
)
@ -372,16 +433,13 @@ export async function createPublicMessageReplyDraftEvent( @@ -372,16 +433,13 @@ export async function createPublicMessageReplyDraftEvent(
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
// Add media imeta tags if provided (for audio/video)
if (options.mediaImetaTags && options.mediaImetaTags.length > 0) {
tags.push(...options.mediaImetaTags)
}
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
// For kind 24 replies, we use 'q' tag for the parent event (as per NIP-A4)
tags.push(buildQTag(parentEvent.id))
@ -449,16 +507,13 @@ export async function createPublicMessageDraftEvent( @@ -449,16 +507,13 @@ export async function createPublicMessageDraftEvent(
const tags = emojiTags
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
// Add media imeta tags if provided (for audio/video)
if (options.mediaImetaTags && options.mediaImetaTags.length > 0) {
tags.push(...options.mediaImetaTags)
}
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
// Add 'p' tags for recipients
tags.push(
...recipients.map((pubkey) => buildPTag(pubkey))
@ -971,7 +1026,8 @@ export async function createPollDraftEvent( @@ -971,7 +1026,8 @@ export async function createPollDraftEvent(
addExpirationTag,
expirationMonths,
addQuietTag,
quietDays
quietDays,
mediaImetaTags
}: {
addClientTag?: boolean // accepted for API compat; client tag is added in publish()
isNsfw?: boolean
@ -979,6 +1035,7 @@ export async function createPollDraftEvent( @@ -979,6 +1035,7 @@ export async function createPollDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question)
@ -994,6 +1051,8 @@ export async function createPollDraftEvent( @@ -994,6 +1051,8 @@ export async function createPollDraftEvent(
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, mediaImetaTags)
// q tags
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
@ -1507,6 +1566,7 @@ export async function createHighlightDraftEvent( @@ -1507,6 +1566,7 @@ export async function createHighlightDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][]
}
): Promise<TDraftEvent> {
const tags: string[][] = []
@ -1635,6 +1695,8 @@ export async function createHighlightDraftEvent( @@ -1635,6 +1695,8 @@ export async function createHighlightDraftEvent(
tags.push(buildQuietTag(options.quietDays))
}
mergeUploadImetaTagsInto(tags, options?.mediaImetaTags)
return setDraftEventCache({
kind: 9802, // NIP-84 highlight kind
tags,
@ -1656,6 +1718,8 @@ export async function createVoiceDraftEvent( @@ -1656,6 +1718,8 @@ export async function createVoiceDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
/** Extra NIP-94 rows from uploads (merged after content-derived imeta, deduped by URL). */
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1664,6 +1728,11 @@ export async function createVoiceDraftEvent( @@ -1664,6 +1728,11 @@ export async function createVoiceDraftEvent(
const tags: string[][] = []
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
@ -1700,6 +1769,8 @@ export async function createVoiceCommentDraftEvent( @@ -1700,6 +1769,8 @@ export async function createVoiceCommentDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
/** NIP-94 rows from file upload (merged before `imetaTags`; deduped by URL). */
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1717,6 +1788,11 @@ export async function createVoiceCommentDraftEvent( @@ -1717,6 +1788,11 @@ export async function createVoiceCommentDraftEvent(
const tags: string[][] = []
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(...imetaTags)
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
@ -1799,6 +1875,7 @@ export async function createPictureDraftEvent( @@ -1799,6 +1875,7 @@ export async function createPictureDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1811,6 +1888,7 @@ export async function createPictureDraftEvent( @@ -1811,6 +1888,7 @@ export async function createPictureDraftEvent(
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.isNsfw) {
@ -1857,6 +1935,7 @@ export async function createVideoDraftEvent( @@ -1857,6 +1935,7 @@ export async function createVideoDraftEvent(
expirationMonths?: number
addQuietTag?: boolean
quietDays?: number
mediaImetaTags?: string[][]
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1869,6 +1948,7 @@ export async function createVideoDraftEvent( @@ -1869,6 +1948,7 @@ export async function createVideoDraftEvent(
tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.isNsfw) {

Loading…
Cancel
Save