From e11bdb6d3a4f7ba8d080df9a9b5fe8846d51d6db Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 9 Apr 2026 17:17:34 +0200 Subject: [PATCH] bug-fixes --- src/components/ImageGallery/index.tsx | 138 +++++++++++------- src/components/ImageWithLightbox/index.tsx | 131 ++++++++--------- .../Note/MarkdownArticle/MarkdownArticle.tsx | 87 ++++++++--- src/components/PostEditor/PostContent.tsx | 4 +- src/lib/article-media.ts | 2 +- src/lib/compress-upload-media.ts | 2 +- src/lib/draft-event.ts | 24 ++- src/lib/image-extraction.ts | 4 +- src/lib/lightbox-slides.ts | 2 + src/lib/media-kind-detection.ts | 2 +- src/lib/nostr-parser.tsx | 4 +- src/lib/tag.ts | 53 ++++++- src/lib/upload-nip94-imeta.ts | 2 + src/lib/url.ts | 3 + src/services/content-parser.service.ts | 6 +- src/services/media-extraction.service.ts | 64 +++++++- 16 files changed, 352 insertions(+), 176 deletions(-) diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index 45e7ea01..4524fb93 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -1,7 +1,7 @@ import { randomString } from '@/lib/random' import { cn } from '@/lib/utils' import logger from '@/lib/logger' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import modalManager from '@/services/modal-manager.service' import { TImetaInfo } from '@/types' import { ReactNode, useEffect, useMemo, useState } from 'react' @@ -15,6 +15,9 @@ import 'yet-another-react-lightbox/plugins/captions.css' import Image from '../Image' import ImageWithLightbox from '../ImageWithLightbox' +const galleryImageWrapper = (className?: string) => + cn('w-full max-w-full min-w-0', className) + export default function ImageGallery({ className, images, @@ -29,8 +32,11 @@ export default function ImageGallery({ mustLoad?: boolean }) { const id = useMemo(() => `image-gallery-${randomString()}`, []) - const { autoLoadMedia } = useContentPolicy() + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const [index, setIndex] = useState(-1) + const [lightboxPortalActive, setLightboxPortalActive] = useState(false) + useEffect(() => { if (index >= 0) { modalManager.register(id, () => { @@ -39,18 +45,38 @@ export default function ImageGallery({ } else { modalManager.unregister(id) } - }, [index]) + }, [id, index]) const handlePhotoClick = (event: React.MouseEvent, current: number) => { event.stopPropagation() event.preventDefault() const newIndex = start + current - logger.debug('[ImageGallery] Click:', { start, current, newIndex, totalImages: images.length, displayImages: displayImages.length }) + logger.debug('[ImageGallery] Click:', { + start, + current, + newIndex, + totalImages: images.length, + displayImages: displayImages.length + }) + setLightboxPortalActive(true) setIndex(newIndex) } const displayImages = images.slice(start, end) + if (displayImages.length === 1) { + return ( + + ) + } + if (!mustLoad && !autoLoadMedia) { return displayImages.map((image, i) => ( )) } let imageContent: ReactNode | null = null - if (displayImages.length === 1) { - imageContent = ( - handlePhotoClick(e, 0)} - /> - ) - } else if (displayImages.length === 2 || displayImages.length === 4) { + if (displayImages.length === 2 || displayImages.length === 4) { imageContent = (
{displayImages.map((image, i) => ( @@ -105,46 +119,58 @@ export default function ImageGallery({ ) } + const portal = + lightboxPortalActive && typeof document !== 'undefined' + ? createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + { + const slides = images.map((img) => lightboxSlideFromImeta(img)) + logger.debug('[ImageGallery] Lightbox slides:', { + index, + slidesCount: slides.length, + slides + }) + return slides + })()} + plugins={[Video, Zoom, Captions]} + open={index >= 0} + close={() => setIndex(-1)} + on={{ + exited: () => setLightboxPortalActive(false) + }} + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true + }} + render={{ + buttonPrev: images.length <= 1 ? () => null : undefined, + buttonNext: images.length <= 1 ? () => null : undefined + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + carousel={{ + finite: false + }} + /> +
, + document.body + ) + : null + return ( -
+
{imageContent} - {createPortal( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - > - { - const slides = images.map((img) => lightboxSlideFromImeta(img)) - logger.debug('[ImageGallery] Lightbox slides:', { index, slidesCount: slides.length, slides }) - return slides - })()} - plugins={[Video, Zoom, Captions]} - open={index >= 0} - close={() => setIndex(-1)} - controller={{ - closeOnBackdropClick: false, - closeOnPullUp: true, - closeOnPullDown: true - }} - render={{ - buttonPrev: images.length <= 1 ? () => null : undefined, - buttonNext: images.length <= 1 ? () => null : undefined - }} - styles={{ - toolbar: { paddingTop: '2.25rem' } - }} - carousel={{ - finite: false - }} - /> -
, - document.body - )} + {portal}
) } diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx index cd36a6a5..f31c8934 100644 --- a/src/components/ImageWithLightbox/index.tsx +++ b/src/components/ImageWithLightbox/index.tsx @@ -1,12 +1,11 @@ import { randomString } from '@/lib/random' import { cn } from '@/lib/utils' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import modalManager from '@/services/modal-manager.service' import { TImetaInfo } from '@/types' import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' -import { useTranslation } from 'react-i18next' import Lightbox from 'yet-another-react-lightbox' import Captions from 'yet-another-react-lightbox/plugins/captions' import Video from 'yet-another-react-lightbox/plugins/video' @@ -17,26 +16,22 @@ import Image from '../Image' export default function ImageWithLightbox({ image, className, - classNames = {} + classNames = {}, + /** When true, load inline image immediately (ignore tap-to-load policy). */ + mustLoad = false }: { image: TImetaInfo className?: string classNames?: { wrapper?: string } + mustLoad?: boolean }) { const id = useMemo(() => `image-with-lightbox-${randomString()}`, []) - const { t } = useTranslation() - const { autoLoadMedia } = useContentPolicy() - const [display, setDisplay] = useState(autoLoadMedia) + const contentPolicy = useContentPolicyOptional() + const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const [index, setIndex] = useState(-1) - - useEffect(() => { - setDisplay(autoLoadMedia) - if (!autoLoadMedia) { - setIndex(-1) - } - }, [autoLoadMedia]) + const [lightboxPortalActive, setLightboxPortalActive] = useState(false) useEffect(() => { if (index >= 0) { @@ -51,67 +46,63 @@ export default function ImageWithLightbox({ const handlePhotoClick = (event: React.MouseEvent) => { event.stopPropagation() event.preventDefault() + setLightboxPortalActive(true) setIndex(0) } - // The portal is always mounted (not conditional on `index >= 0`) so that React - // never removes it while yet-another-react-lightbox is mid-cleanup, which would - // otherwise cause "Node.removeChild: The node to be removed is not a child of - // this node". Visibility is controlled via the `open` prop instead. + const holdUntilClick = !mustLoad && !autoLoadMedia + + const portal = + lightboxPortalActive && typeof document !== 'undefined' + ? createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + = 0} + close={() => setIndex(-1)} + on={{ + exited: () => setLightboxPortalActive(false) + }} + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true + }} + render={{ + buttonPrev: () => null, + buttonNext: () => null + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + /> +
, + document.body + ) + : null + return ( -
- {display ? ( - handlePhotoClick(e)} - /> - ) : ( - { - e.stopPropagation() - setDisplay(true) - }} - > - [{t('Click to load image')}] - - )} - {createPortal( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - > - = 0} - close={() => setIndex(-1)} - controller={{ - closeOnBackdropClick: false, - closeOnPullUp: true, - closeOnPullDown: true - }} - render={{ - buttonPrev: () => null, - buttonNext: () => null - }} - styles={{ - toolbar: { paddingTop: '2.25rem' } - }} - /> -
, - document.body - )} +
+ handlePhotoClick(e)} + /> + {portal}
) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 7767e6cf..34b873e4 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -46,6 +46,40 @@ import '@/styles/katex-bundle.css' import { isContentSpacingDebug, reprString } from '@/lib/content-spacing-debug' import logger from '@/lib/logger' +/** + * Inline/block image metadata: use merged rows from {@link extractAllMediaFromEvent} first + * (imeta + content + upload cache), then raw `imeta` tags on the event with URL / filename fallback. + */ +function resolveImetaForMarkdownImageUrl( + cleaned: string, + eventPubkey: string, + args: { + resolveFromExtractedMedia?: (cleaned: string) => TImetaInfo | undefined + containingEvent?: Event + getImageIdentifier?: (url: string) => string | null + } +): TImetaInfo { + const fromExtracted = args.resolveFromExtractedMedia?.(cleaned) + if (fromExtracted) return { ...fromExtracted, url: cleaned } + + if (args.containingEvent) { + const infos = getImetaInfosFromEvent(args.containingEvent) + const hit = infos.find((i) => cleanUrl(i.url) === cleaned) + if (hit) return { ...hit, url: cleaned } + if (args.getImageIdentifier) { + const id = args.getImageIdentifier(cleaned) + if (id) { + const byId = infos.find((i) => { + const ic = cleanUrl(i.url) + return !!ic && args.getImageIdentifier!(ic) === id + }) + if (byId) return { ...byId, url: cleaned } + } + } + } + return { url: cleaned, pubkey: eventPubkey } +} + /** * Truncate link display text to 200 characters, adding ellipsis if truncated */ @@ -604,6 +638,8 @@ function parseMarkdownContentLegacy( containingEvent?: Event /** Hold images as placeholders until clicked (lightbox). False in detail/full views. */ lazyMedia?: boolean + /** Prefer rows from {@link useMediaExtraction} / {@link extractAllMediaFromEvent} for blurHash etc. */ + resolveImetaForImageUrl?: (cleaned: string) => TImetaInfo | undefined } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { const { @@ -619,7 +655,8 @@ function parseMarkdownContentLegacy( fullCalendarInvite, suppressStandaloneWebPreviewCleanedUrls, containingEvent, - lazyMedia = true + lazyMedia = true, + resolveImetaForImageUrl } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() @@ -627,16 +664,12 @@ function parseMarkdownContentLegacy( const citations: Array<{ id: string; type: string; citationId: string }> = [] let lastIndex = 0 - // Build imeta lookup map once for blurHash and other NIP-94 metadata - const imetaByCleanedUrl = new Map() - if (containingEvent) { - getImetaInfosFromEvent(containingEvent).forEach((info) => { - const cleaned = cleanUrl(info.url) - if (cleaned) imetaByCleanedUrl.set(cleaned, info) - }) - } const imetaInfoForUrl = (cleaned: string): TImetaInfo => - imetaByCleanedUrl.get(cleaned) ?? { url: cleaned, pubkey: eventPubkey } + resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, { + resolveFromExtractedMedia: resolveImetaForImageUrl, + containingEvent, + getImageIdentifier + }) // Helper function to check if an index range falls within any block-level pattern const isWithinBlockPattern = (start: number, end: number, blockPatterns: Array<{ index: number; end: number }>): boolean => { @@ -2932,6 +2965,7 @@ function parseMarkdownContentMarked( containingEvent?: Event /** Hold images as placeholders until clicked (lightbox). False in detail/full views. */ lazyMedia?: boolean + resolveImetaForImageUrl?: (cleaned: string) => TImetaInfo | undefined } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { const { @@ -2946,18 +2980,17 @@ function parseMarkdownContentMarked( fullCalendarInvite, suppressStandaloneWebPreviewCleanedUrls, containingEvent, - lazyMedia = true + lazyMedia = true, + resolveImetaForImageUrl } = options /** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */ - const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo => { - if (containingEvent) { - const infos = getImetaInfosFromEvent(containingEvent) - const hit = infos.find((i) => cleanUrl(i.url) === cleaned) - if (hit) return { ...hit, url: cleaned } - } - return { url: cleaned, pubkey: eventPubkey } - } + const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo => + resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, { + resolveFromExtractedMedia: resolveImetaForImageUrl, + containingEvent, + getImageIdentifier + }) const renderStandaloneHttpsImageBlock = (cleaned: string, reactKey: string) => { let imageIndex = imageIndexMap.get(cleaned) @@ -4801,6 +4834,18 @@ export default function MarkdownArticle({ // Parse markdown content with post-processing for nostr: links and hashtags const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { + const resolveImetaForImageUrl = (cleaned: string): TImetaInfo | undefined => { + for (const img of extractedMedia.images) { + const ic = cleanUrl(img.url) + if (!ic) continue + if (ic === cleaned) return { ...img, url: cleaned } + const idC = getImageIdentifier(cleaned) + const idI = getImageIdentifier(ic) + if (idC && idI && idC === idI) return { ...img, url: cleaned } + } + return undefined + } + const parseOptions = { eventPubkey: event.pubkey, imageIndexMap, @@ -4814,6 +4859,7 @@ export default function MarkdownArticle({ fullCalendarInvite, containingEvent: event, lazyMedia, + resolveImetaForImageUrl, suppressStandaloneWebPreviewCleanedUrls: webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined } @@ -4840,7 +4886,8 @@ export default function MarkdownArticle({ emojiInfos, fullCalendarInvite, lazyMedia, - webPreviewSuppressCleanedSet + webPreviewSuppressCleanedSet, + extractedMedia.images ]) // Filter metadata tags to only show what's not already in content diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 2a347f2c..7cdbb31e 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1311,7 +1311,7 @@ export default function PostContent({ const path = url.split(/[?#]/)[0].toLowerCase() if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE - if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg)$/i.test(path)) return ExtendedKind.SHORT_VIDEO + if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg|3gp|3g2)$/i.test(path)) return ExtendedKind.SHORT_VIDEO return null } @@ -1331,6 +1331,8 @@ export default function PostContent({ } if (path.endsWith('.mkv')) return 'video/x-matroska' if (path.endsWith('.webm')) return 'video/webm' + if (path.endsWith('.3gp')) return 'video/3gpp' + if (path.endsWith('.3g2')) return 'video/3gpp2' return 'video/mp4' } diff --git a/src/lib/article-media.ts b/src/lib/article-media.ts index 3eb2d981..b7b3e9d1 100644 --- a/src/lib/article-media.ts +++ b/src/lib/article-media.ts @@ -61,7 +61,7 @@ function extractUrlsFromContent(content: string): string[] { // Check if it's a media file const mediaExtensions = [ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.tiff', - '.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv', + '.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv', '.3gp', '.3g2', '.mp3', '.wav', '.flac', '.aac', '.m4a', '.mka' ] diff --git a/src/lib/compress-upload-media.ts b/src/lib/compress-upload-media.ts index 65d77697..a806dea7 100644 --- a/src/lib/compress-upload-media.ts +++ b/src/lib/compress-upload-media.ts @@ -40,7 +40,7 @@ const VIDEO_TARGET_BITRATE_MIN = 450_000 const VIDEO_AUDIO_BITRATE = 96_000 /** Browsers often leave `File.type` empty for some paths; still treat as video. */ -const VIDEO_FILENAME_RE = /\.(mp4|m4v|mov|mkv|webm|ogv|avi|mpeg|mpg)$/i +const VIDEO_FILENAME_RE = /\.(mp4|m4v|mov|mkv|webm|ogv|avi|mpeg|mpg|3gp|3g2)$/i /** Image/audio extensions for drag/drop and paste when `File.type` is empty (common on Linux). */ const IMAGE_FILENAME_RE = /\.(jpe?g|png|gif|webp|bmp|svg|ico|heic|heif|avif)$/i diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index d445ec92..d6fb711a 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -33,7 +33,7 @@ import { import { cleanUrl } from '@/lib/url' import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip' import { randomString } from './random' -import { generateBech32IdFromETag, tagNameEquals } from './tag' +import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' function canonicalizeHttpUrlForITags(url: string): string { if (!url.startsWith('http://') && !url.startsWith('https://')) return url @@ -1812,22 +1812,34 @@ export async function createPictureDraftEvent( tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...imetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - + if (options.isNsfw) { tags.push(buildNsfwTag()) } - + if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) } - + if (options.addQuietTag && options.quietDays) { tags.push(buildQuietTag(options.quietDays)) } - + + // Kind 20 caption is user text only; the file URL lives in `imeta`. Many indexers and caches + // still deliver full tags, but mirroring the URL in `content` matches kind-1-style clients and + // keeps {@link Content} / URL extraction working when tags are missing or non-standard. + const mediaUrlFromImeta = imetaTags + .map((t) => getImetaInfoFromImetaTag(t)) + .find((info) => info?.url)?.url + let pictureContent = transformedEmojisContent + if (mediaUrlFromImeta && !pictureContent.includes(mediaUrlFromImeta)) { + const trimmed = pictureContent.trimEnd() + pictureContent = trimmed ? `${trimmed}\n\n${mediaUrlFromImeta}` : mediaUrlFromImeta + } + return setDraftEventCache({ kind: ExtendedKind.PICTURE, - content: transformedEmojisContent, + content: pictureContent, tags }) } diff --git a/src/lib/image-extraction.ts b/src/lib/image-extraction.ts index a5d69770..1552104f 100644 --- a/src/lib/image-extraction.ts +++ b/src/lib/image-extraction.ts @@ -77,7 +77,7 @@ export function extractAllImagesFromEvent(event: Event): TImetaInfo[] { // 7. Extract from content - general URL patterns that look like media const mediaUrlRegex = - /https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv|mka)(?:\?[^\s<>"']*)?/gi + /https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv|mka|3gp|3g2|ogv)(?:\?[^\s<>"']*)?/gi while ((match = mediaUrlRegex.exec(event.content)) !== null) { addMedia(match[0]) } @@ -160,7 +160,7 @@ export function isImageUrl(url: string): boolean { * Check if URL is likely a video */ function isVideoUrl(url: string): boolean { - const videoExtensions = /\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp|ogv)(\?.*)?$/i + const videoExtensions = /\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp|3g2|ogv)(\?.*)?$/i const videoDomains = [ 'youtube.com', 'youtu.be', diff --git a/src/lib/lightbox-slides.ts b/src/lib/lightbox-slides.ts index 33c619a6..6a9e6bd5 100644 --- a/src/lib/lightbox-slides.ts +++ b/src/lib/lightbox-slides.ts @@ -17,6 +17,8 @@ function sourceTypeFromPath(url: string, kind: 'video' | 'audio'): string { if (path.endsWith('.webm')) return 'video/webm' if (path.endsWith('.mkv')) return 'video/x-matroska' if (path.endsWith('.ogv')) return 'video/ogg' + if (path.endsWith('.3gp')) return 'video/3gpp' + if (path.endsWith('.3g2')) return 'video/3gpp2' return 'video/mp4' } diff --git a/src/lib/media-kind-detection.ts b/src/lib/media-kind-detection.ts index 06df39fa..82a15025 100644 --- a/src/lib/media-kind-detection.ts +++ b/src/lib/media-kind-detection.ts @@ -28,7 +28,7 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false) fileType === 'audio/x-matroska' const isVideoMime = fileType.startsWith('video/') const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) - const isVideoExt = /\.(mp4|ogg|mov|avi|mkv|m4v)$/i.test(fileName) + const isVideoExt = /\.(mp4|ogg|mov|avi|mkv|m4v|3gp|3g2)$/i.test(fileName) // m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this) const isM4aFile = /\.m4a$/i.test(fileName) diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index cc49693a..d24975be 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -167,7 +167,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo // Check if it's media (video/audio) else if (isMedia(cleanedUrl)) { // Determine if it's video or audio based on extension - const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(cleanedUrl) + const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|3gp|3g2|ogv)$/i.test(cleanedUrl) allMatches.push({ type: isVideo ? 'video' : 'audio', match: urlMatch, @@ -416,7 +416,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo if (!alreadyProcessed) { // Determine if it's video or audio based on extension - const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(imetaInfo.url) + const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|3gp|3g2|ogv)$/i.test(imetaInfo.url) elements.push({ type: isVideo ? 'video' : 'audio', content: imetaInfo.url, diff --git a/src/lib/tag.ts b/src/lib/tag.ts index 47d11d5d..19f959c8 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -1,5 +1,5 @@ import { TEmoji, TImetaInfo } from '@/types' -import { cleanUrl } from './url' +import { cleanUrl, isImage, isMedia } from './url' import { isBlurhashValid } from 'blurhash' import { nip19 } from 'nostr-tools' import { isValidPubkey } from './pubkey' @@ -78,20 +78,59 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta url = tag[urlIndex + 1] } } - + + // Some publishers use a bare https URL as a tag value (e.g. ["imeta", "https://…"]) without `url `. + if (!url) { + const spaceMime = tag.find((item) => typeof item === 'string' && item.startsWith('m '))?.slice(2) + const mIdx = tag.findIndex((item) => item === 'm') + const sepMime = + mIdx !== -1 && mIdx + 1 < tag.length && typeof tag[mIdx + 1] === 'string' + ? tag[mIdx + 1] + : undefined + const mimeHint = spaceMime || sepMime + + for (let i = 1; i < tag.length; i++) { + const item = tag[i] + if (typeof item !== 'string') continue + const t = item.trim() + if (!/^https?:\/\//i.test(t)) continue + if ( + isImage(t) || + isMedia(t) || + (mimeHint && + (mimeHint.startsWith('image/') || + mimeHint.startsWith('video/') || + mimeHint.startsWith('audio/'))) + ) { + url = t + break + } + } + } + if (!url) return null // Clean the URL to remove tracking parameters const cleanedUrl = cleanUrl(url) const imeta: TImetaInfo = { url: cleanedUrl, pubkey } - // Parse blurhash + // Parse blurhash (`blurhash …` NIP-94; some publishers use `bh …` only) const blurHashItem = tag.find((item) => item.startsWith('blurhash ')) - const blurHash = blurHashItem?.slice(9) - if (blurHash) { - const validRes = isBlurhashValid(blurHash) + const blurHashFromTag = blurHashItem?.slice(9)?.trim() + if (blurHashFromTag) { + const validRes = isBlurhashValid(blurHashFromTag) if (validRes.result) { - imeta.blurHash = blurHash + imeta.blurHash = blurHashFromTag + } + } + if (!imeta.blurHash) { + const bhItem = tag.find((item) => item.startsWith('bh ')) + const bh = bhItem?.slice(3)?.trim() + if (bh) { + const validRes = isBlurhashValid(bh) + if (validRes.result) { + imeta.blurHash = bh + } } } diff --git a/src/lib/upload-nip94-imeta.ts b/src/lib/upload-nip94-imeta.ts index 627ad4e0..8928be62 100644 --- a/src/lib/upload-nip94-imeta.ts +++ b/src/lib/upload-nip94-imeta.ts @@ -18,6 +18,8 @@ const EXT_TO_MIME: Record = { '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', + '.3gp': 'video/3gpp', + '.3g2': 'video/3gpp2', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.mka': 'audio/x-matroska', diff --git a/src/lib/url.ts b/src/lib/url.ts index 3f714510..e2589eb1 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -294,6 +294,8 @@ export function isMedia(url: string) { '.mov', '.mkv', '.mka', + '.3gp', + '.3g2', '.mp3', '.wav', '.flac', @@ -344,6 +346,7 @@ export function isVideo(url: string) { '.mkv', '.m4v', '.3gp', + '.3g2', '.ogv' ] return videoExtensions.some((ext) => path.endsWith(ext)) diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts index ed9511b7..7b2688da 100644 --- a/src/services/content-parser.service.ts +++ b/src/services/content-parser.service.ts @@ -774,7 +774,7 @@ class ContentParserService { imageMatches.forEach(match => { const url = match.match(/!\[[^\]]*\]\(([^)]+)\)/)?.[1] if (url && !seenUrls.has(url)) { - const isVideo = /\.(mp4|webm|ogg)$/i.test(url) + const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url) media.push({ url, pubkey: event?.pubkey || '', @@ -789,7 +789,7 @@ class ContentParserService { asciidocImageMatches.forEach(match => { const url = match.match(/image::([^\[]+)\[/)?.[1] if (url && !seenUrls.has(url)) { - const isVideo = /\.(mp4|webm|ogg)$/i.test(url) + const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url) media.push({ url, pubkey: event?.pubkey || '', @@ -804,7 +804,7 @@ class ContentParserService { rawUrls.forEach(url => { if (!seenUrls.has(url)) { const isImage = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url) - const isVideo = /\.(mp4|webm|ogg)$/i.test(url) + const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url) if (isImage || isVideo) { media.push({ url, diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts index 38f9ee76..e7633b2f 100644 --- a/src/services/media-extraction.service.ts +++ b/src/services/media-extraction.service.ts @@ -1,6 +1,11 @@ import { Event } from 'nostr-tools' import { getImetaInfosFromEvent } from '@/lib/event' import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url' + +/** Any URL we may embed or extract from note bodies (incl. video-only extensions like .3gp). */ +function isEmbeddableMediaUrl(cleaned: string): boolean { + return isImage(cleaned) || isMedia(cleaned) || isVideo(cleaned) || isAudio(cleaned) +} import { TImetaInfo } from '@/types' import mediaUpload from './media-upload.service' import { getImetaInfoFromImetaTag } from '@/lib/tag' @@ -29,8 +34,7 @@ export function extractAllMediaFromEvent( const cleaned = cleanUrl(url) if (!cleaned || seenUrls.has(cleaned)) return - // Only add if it's actually an image or media file - if (!isImage(cleaned) && !isMedia(cleaned)) return + if (!isEmbeddableMediaUrl(cleaned)) return seenUrls.add(cleaned) @@ -60,18 +64,47 @@ export function extractAllMediaFromEvent( imetaInfos.forEach((info) => { const cleaned = cleanUrl(info.url) if (!cleaned || seenUrls.has(cleaned)) return + const nip94Signals = !!(info.blurHash || info.dim || info.x) if ( info.m?.startsWith('image/') || info.m?.startsWith('video/') || info.m?.startsWith('audio/') || isImage(info.url) || - isMedia(info.url) + isMedia(info.url) || + isVideo(info.url) || + isAudio(info.url) || + // Blossom / NIP-94 URLs often have no file extension; metadata still identifies the blob. + (nip94Signals && !!info.url) ) { seenUrls.add(cleaned) allMedia.push({ ...info, url: cleaned }) } }) + // Non-standard imeta layouts (no `url ` prefix, concatenated fields, etc.) + const looseHttpsFromImetaValue = (s: string): string[] => { + const out: string[] = [] + const re = /https?:\/\/[^\s<>"'[\]()]+/gi + let m: RegExpExecArray | null + re.lastIndex = 0 + while ((m = re.exec(s)) !== null) { + out.push(m[0]) + } + return out + } + + event.tags.forEach((tag) => { + if (tag[0] !== 'imeta') return + if (getImetaInfoFromImetaTag(tag, event.pubkey)) return + for (let i = 1; i < tag.length; i++) { + const part = tag[i] + if (typeof part !== 'string') continue + for (const raw of looseHttpsFromImetaValue(part)) { + addMedia(raw, event.pubkey) + } + } + }) + // 2. Extract from image tag const imageTag = event.tags.find((tag) => tag[0] === 'image' && tag[1]) if (imageTag?.[1]) { @@ -87,7 +120,7 @@ export function extractAllMediaFromEvent( while ((imgMatch = markdownImageRegex.exec(content)) !== null) { if (imgMatch[1]) { const url = imgMatch[1] - if (isImage(url) || isMedia(url)) { + if (isEmbeddableMediaUrl(cleanUrl(url) || url)) { addMedia(url) } } @@ -98,17 +131,36 @@ export function extractAllMediaFromEvent( const urlMatches = content.matchAll(urlRegex) for (const match of urlMatches) { const url = match[0] - if (isImage(url) || isMedia(url)) { + const c = cleanUrl(url) || url + if (isEmbeddableMediaUrl(c)) { addMedia(url) } } } // 5. Try to match content URLs with imeta tags for better metadata (alt, dim, blurHash, m) + const imageIdentityKey = (url: string): string | null => { + try { + const u = cleanUrl(url) + if (!u) return null + const pathname = new URL(u).pathname + const filename = pathname.split('/').pop() || '' + if (filename && /^[a-f0-9]{32,}\.(png|jpg|jpeg|gif|webp|svg|avif|apng)$/i.test(filename)) { + return filename.toLowerCase() + } + return u + } catch { + return cleanUrl(url) || null + } + } + imetaInfos.forEach((imeta) => { const imetaUrl = cleanUrl(imeta.url) + const imetaKey = imetaUrl ? imageIdentityKey(imetaUrl) : null allMedia.forEach((media, index) => { - if (imetaUrl === media.url) { + if (imetaUrl && imetaUrl === media.url) { + allMedia[index] = { ...media, ...imeta, url: media.url } + } else if (imetaKey && imetaKey === imageIdentityKey(media.url)) { allMedia[index] = { ...media, ...imeta, url: media.url } } else { // Try to get imeta from media upload service