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. 127
      src/components/MediaPlayer/index.tsx
  4. 22
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  5. 210
      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'
interface AudioPlayerProps { interface AudioPlayerProps {
src: string src: string
className?: 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 audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
@ -22,6 +24,25 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
const seekTimeoutRef = useRef<NodeJS.Timeout>() const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false) 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(() => { useEffect(() => {
const audio = audioRef.current const audio = audioRef.current
if (!audio) return if (!audio) return
@ -104,7 +125,7 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
)} )}
onClick={(e) => e.stopPropagation()} 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 */} {/* Play/Pause Button */}
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}> <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'
import { resolveMediaBlurPlaceholder } from '@/lib/media-placeholder-blurhash' import { resolveMediaBlurPlaceholder } from '@/lib/media-placeholder-blurhash'
import { decode } from 'blurhash' import { decode } from 'blurhash'
import { Music2, Play } from 'lucide-react' import { Music2, Play } from 'lucide-react'
import { useEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const CANVAS_W = 32 const CANVAS_W = 32
@ -31,7 +31,8 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?:
} }
}, [blurHash]) }, [blurHash])
useEffect(() => { // Layout effect so the canvas is painted before the browser's next paint (no empty flash).
useLayoutEffect(() => {
if (!pixels || !canvasRef.current) return if (!pixels || !canvasRef.current) return
const ctx = canvasRef.current.getContext('2d') const ctx = canvasRef.current.getContext('2d')
if (!ctx) return if (!ctx) return
@ -41,7 +42,7 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?:
}, [pixels]) }, [pixels])
if (!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 ( return (
@ -49,12 +50,86 @@ function BlurHashLayer({ blurHash, className }: { blurHash: string; className?:
ref={canvasRef} ref={canvasRef}
width={CANVAS_W} width={CANVAS_W}
height={CANVAS_H} 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)' }} 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({ export default function LazyMediaTapPlaceholder({
src, src,
posterUrl, posterUrl,
@ -70,15 +145,13 @@ export default function LazyMediaTapPlaceholder({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const kind = guessMediaKindFromUrl(src) const kind = guessMediaKindFromUrl(src)
const hash = resolveMediaBlurPlaceholder(src, blurHash)
const poster = posterUrl?.trim()
const label = t('Click to load media') const label = t('Click to load media')
return ( return (
<button <button
type="button" type="button"
className={cn( 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]', kind === 'video' ? 'aspect-video' : 'min-h-[7.5rem] aspect-[21/9]',
className className
)} )}
@ -90,27 +163,7 @@ export default function LazyMediaTapPlaceholder({
aria-label={label} aria-label={label}
title={label} title={label}
> >
{poster ? ( <MediaPlaceholderLayers src={src} posterUrl={posterUrl} blurHash={blurHash} showTapChrome />
<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>
</button> </button>
) )
} }

127
src/components/MediaPlayer/index.tsx

@ -1,10 +1,37 @@
import { isImage } from '@/lib/url' import { isImage } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import AudioPlayer from '../AudioPlayer' import AudioPlayer from '../AudioPlayer'
import VideoPlayer from '../VideoPlayer' import VideoPlayer from '../VideoPlayer'
import ExternalLink from '../ExternalLink' 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({ export default function MediaPlayer({
src, src,
@ -23,6 +50,9 @@ export default function MediaPlayer({
const { autoLoadMedia } = useContentPolicy() const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia) const [display, setDisplay] = useState(autoLoadMedia)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null) 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 // imeta `thumb` / `image` are sometimes the same .mp4 as `url` — <img> cannot use that, and it
// would hide the blurhash placeholder in LazyMediaTapPlaceholder. // would hide the blurhash placeholder in LazyMediaTapPlaceholder.
@ -32,6 +62,12 @@ export default function MediaPlayer({
return isImage(p) ? p : undefined return isImage(p) ? p : undefined
}, [poster]) }, [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(() => { useEffect(() => {
if (autoLoadMedia) { if (autoLoadMedia) {
setDisplay(true) setDisplay(true)
@ -41,15 +77,31 @@ export default function MediaPlayer({
}, [autoLoadMedia]) }, [autoLoadMedia])
useEffect(() => { useEffect(() => {
if (!mustLoad && !display) { readyOnceRef.current = false
setEmbedPainted(false)
setMediaType(null)
setProbeFailed(false)
}, [src])
useEffect(() => {
if (!showEmbed) {
setMediaType(null) setMediaType(null)
setProbeFailed(false)
return return
} }
readyOnceRef.current = false
setEmbedPainted(false)
if (!src) { if (!src) {
setMediaType(null) setProbeFailed(true)
return return
} }
setProbeFailed(false)
setMediaType(null)
let cancelled = false
try {
const url = new URL(src) const url = new URL(src)
const extension = url.pathname.split('.').pop()?.toLowerCase() const extension = url.pathname.split('.').pop()?.toLowerCase()
@ -71,17 +123,30 @@ export default function MediaPlayer({
video.preload = 'metadata' video.preload = 'metadata'
video.onloadedmetadata = () => { video.onloadedmetadata = () => {
if (cancelled) return
setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio') setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio')
} }
video.onerror = () => { video.onerror = () => {
if (cancelled) return
setProbeFailed(true)
setMediaType(null) setMediaType(null)
} }
return () => { return () => {
cancelled = true
video.src = '' video.src = ''
} }
}, [src, display, mustLoad]) } catch {
setProbeFailed(true)
}
}, [src, showEmbed])
const onEmbedReady = useCallback(() => {
if (readyOnceRef.current) return
readyOnceRef.current = true
setEmbedPainted(true)
}, [])
if (!mustLoad && !display) { if (!mustLoad && !display) {
return ( return (
@ -95,13 +160,57 @@ export default function MediaPlayer({
) )
} }
if (!mediaType) { if (probeFailed) {
return <ExternalLink url={src} /> return <ExternalLink url={src} />
} }
if (mediaType === 'video') { if (effectiveMediaType === null) {
return <VideoPlayer src={src} className={className} poster={imagePoster} /> 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 {
UNSIGNED_EXPERIMENTAL_KIND_MIN, UNSIGNED_EXPERIMENTAL_KIND_MIN,
isUnsignedExperimentalKind isUnsignedExperimentalKind
} from '@/constants' } from '@/constants'
import { applyImwaldAttributionTags } from '@/lib/draft-event' import {
applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls,
mergeUploadImetaTagsInto
} from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
@ -161,6 +165,12 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows]) const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows])
const tagsWithContentUploadImeta = useMemo(() => {
const next = [...normalizedTags]
mergeUploadImetaTagsInto(next, collectUploadImetaTagsForContentUrls(content))
return next
}, [normalizedTags, content])
const previewEvent = useMemo(() => { const previewEvent = useMemo(() => {
if (isCreate && parsedCreateKind === null) return null if (isCreate && parsedCreateKind === null) return null
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
@ -168,7 +178,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const base: TDraftEvent = { const base: TDraftEvent = {
kind: k, kind: k,
content, content,
tags: normalizedTags, tags: tagsWithContentUploadImeta,
created_at: now created_at: now
} }
const withAttribution = applyImwaldAttributionTags(base, { const withAttribution = applyImwaldAttributionTags(base, {
@ -181,7 +191,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
pubkey: pubkey ?? '', pubkey: pubkey ?? '',
created_at: now created_at: now
}) })
}, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags, pubkey]) }, [isCreate, parsedCreateKind, sourceEvent, content, tagsWithContentUploadImeta, pubkey])
const buildDraftJson = useCallback(() => { const buildDraftJson = useCallback(() => {
if (isCreate && parsedCreateKind === null) { if (isCreate && parsedCreateKind === null) {
@ -198,7 +208,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const base: TDraftEvent = { const base: TDraftEvent = {
kind: k, kind: k,
content, content,
tags: normalizedTags, tags: tagsWithContentUploadImeta,
created_at: dayjs().unix() created_at: dayjs().unix()
} }
const withAttribution = applyImwaldAttributionTags(base, { const withAttribution = applyImwaldAttributionTags(base, {
@ -218,7 +228,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
_note: unsignedNote _note: unsignedNote
} }
return JSON.stringify(draft, null, 2) 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() : '' const draftJson = activeTab === 'json' ? buildDraftJson() : ''
@ -279,7 +289,7 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
const draft = { const draft = {
kind: publishKind, kind: publishKind,
content, content,
tags: normalizedTags, tags: tagsWithContentUploadImeta,
created_at: dayjs().unix() created_at: dayjs().unix()
} }
const newEvent = await publish(draft, { const newEvent = await publish(draft, {

210
src/components/PostEditor/PostContent.tsx

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

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

@ -15,7 +15,7 @@ const DRAGOVER_CLASS_LIST = [
export interface ClipboardAndDropHandlerOptions { export interface ClipboardAndDropHandlerOptions {
onUploadStart?: (file: File, cancel: () => void) => void 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 onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadEnd?: (file: File) => void onUploadEnd?: (file: File) => void
onUploadProgress?: (file: File, progress: number) => void onUploadProgress?: (file: File, progress: number) => void

29
src/components/VideoPlayer/index.tsx

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

102
src/lib/draft-event.ts

@ -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( export async function createShortTextNoteDraftEvent(
content: string, content: string,
mentions: string[], mentions: string[],
@ -166,6 +220,8 @@ export async function createShortTextNoteDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
/** NIP-94 imeta rows from uploads (audio/video/images as plain URLs in content). */
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations // Process content to prefix nostr addresses before other transformations
@ -183,6 +239,8 @@ export async function createShortTextNoteDraftEvent(
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
// q tags // q tags
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId))) tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
@ -250,6 +308,7 @@ export async function createCommentDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
// Process content to prefix nostr addresses before other transformations // Process content to prefix nostr addresses before other transformations
@ -276,6 +335,8 @@ export async function createCommentDraftEvent(
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push( tags.push(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey)) ...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
) )
@ -372,16 +433,13 @@ export async function createPublicMessageReplyDraftEvent(
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId))) .concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) .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) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
// For kind 24 replies, we use 'q' tag for the parent event (as per NIP-A4) // For kind 24 replies, we use 'q' tag for the parent event (as per NIP-A4)
tags.push(buildQTag(parentEvent.id)) tags.push(buildQTag(parentEvent.id))
@ -449,16 +507,13 @@ export async function createPublicMessageDraftEvent(
const tags = emojiTags const tags = emojiTags
.concat(hashtags.map((hashtag) => buildTTag(hashtag))) .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) const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
// Add 'p' tags for recipients // Add 'p' tags for recipients
tags.push( tags.push(
...recipients.map((pubkey) => buildPTag(pubkey)) ...recipients.map((pubkey) => buildPTag(pubkey))
@ -971,7 +1026,8 @@ export async function createPollDraftEvent(
addExpirationTag, addExpirationTag,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays,
mediaImetaTags
}: { }: {
addClientTag?: boolean // accepted for API compat; client tag is added in publish() addClientTag?: boolean // accepted for API compat; client tag is added in publish()
isNsfw?: boolean isNsfw?: boolean
@ -979,6 +1035,7 @@ export async function createPollDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question) const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question)
@ -994,6 +1051,8 @@ export async function createPollDraftEvent(
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
mergeUploadImetaTagsInto(tags, mediaImetaTags)
// q tags // q tags
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId))) tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
@ -1507,6 +1566,7 @@ export async function createHighlightDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][]
} }
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const tags: string[][] = [] const tags: string[][] = []
@ -1635,6 +1695,8 @@ export async function createHighlightDraftEvent(
tags.push(buildQuietTag(options.quietDays)) tags.push(buildQuietTag(options.quietDays))
} }
mergeUploadImetaTagsInto(tags, options?.mediaImetaTags)
return setDraftEventCache({ return setDraftEventCache({
kind: 9802, // NIP-84 highlight kind kind: 9802, // NIP-84 highlight kind
tags, tags,
@ -1656,6 +1718,8 @@ export async function createVoiceDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
/** Extra NIP-94 rows from uploads (merged after content-derived imeta, deduped by URL). */
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1664,6 +1728,11 @@ export async function createVoiceDraftEvent(
const tags: string[][] = [] const tags: string[][] = []
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) 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(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
@ -1700,6 +1769,8 @@ export async function createVoiceCommentDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
/** NIP-94 rows from file upload (merged before `imetaTags`; deduped by URL). */
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1717,6 +1788,11 @@ export async function createVoiceCommentDraftEvent(
const tags: string[][] = [] const tags: string[][] = []
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) 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(...imetaTags)
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId))) tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
@ -1799,6 +1875,7 @@ export async function createPictureDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1811,6 +1888,7 @@ export async function createPictureDraftEvent(
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags) tags.push(...imetaTags)
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.isNsfw) { if (options.isNsfw) {
@ -1857,6 +1935,7 @@ export async function createVideoDraftEvent(
expirationMonths?: number expirationMonths?: number
addQuietTag?: boolean addQuietTag?: boolean
quietDays?: number quietDays?: number
mediaImetaTags?: string[][]
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
@ -1869,6 +1948,7 @@ export async function createVideoDraftEvent(
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags) tags.push(...imetaTags)
mergeUploadImetaTagsInto(tags, options.mediaImetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.isNsfw) { if (options.isNsfw) {

Loading…
Cancel
Save