diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index e578193..4b9cbf7 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -1,4 +1,4 @@ -import { useTranslatedEvent } from '@/hooks' +import { useTranslatedEvent, useMediaExtraction } from '@/hooks' import { EmbeddedEmojiParser, EmbeddedEventParser, @@ -10,14 +10,11 @@ import { parseContent } from '@/lib/content-parser' import logger from '@/lib/logger' -import { getImetaInfosFromEvent } from '@/lib/event' -import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag' +import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' -import { cleanUrl, isImage, isMedia } from '@/lib/url' -import mediaUpload from '@/services/media-upload.service' +import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url' import { TImetaInfo } from '@/types' import { Event } from 'nostr-tools' -import { tagNameEquals } from '@/lib/tag' import { useMemo } from 'react' import { EmbeddedHashtag, @@ -45,8 +42,12 @@ export default function Content({ mustLoadMedia?: boolean }) { const translatedEvent = useTranslatedEvent(event?.id) - const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => { - const _content = translatedEvent?.content ?? event?.content ?? content + const _content = translatedEvent?.content ?? event?.content ?? content + + // Use unified media extraction service + const extractedMedia = useMediaExtraction(event, _content) + + const { nodes, lastNormalUrl, emojiInfos } = useMemo(() => { if (!_content) return {} const nodes = parseContent(_content, [ @@ -59,111 +60,14 @@ export default function Content({ EmbeddedEmojiParser ]) - // Collect all images from multiple sources and deduplicate using cleaned URLs - const seenUrls = new Set() - const allImages: TImetaInfo[] = [] - - // Helper to add image if not already seen (using cleaned URL for comparison) - const addImage = (url: string, pubkey?: string, mimeType?: string) => { - if (!url) return - 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 - - seenUrls.add(cleaned) - allImages.push({ - url: cleaned, - pubkey: pubkey || event?.pubkey, - m: mimeType || (isImage(cleaned) ? 'image/*' : 'media/*') - }) - } - - // 1. Extract from imeta tags - if (event) { - const imetaInfos = getImetaInfosFromEvent(event) - imetaInfos.forEach((info) => { - if (info.m?.startsWith('image/') || info.m?.startsWith('video/') || isImage(info.url) || isMedia(info.url)) { - addImage(info.url, info.pubkey, info.m) - } - }) - } - - // 2. Extract from r tags (reference/URL tags) - if (event) { - event.tags.filter(tagNameEquals('r')).forEach(([, url]) => { - if (url && (isImage(url) || isMedia(url))) { - addImage(url) - } - }) - } - - // 2b. Extract from image tag - if (event) { - const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) - if (imageTag?.[1]) { - addImage(imageTag[1]) - } - } - - // 3. Extract from content nodes (already parsed URLs) - nodes.forEach((node) => { - if (node.type === 'image') { - addImage(node.data) - } else if (node.type === 'images') { - const urls = Array.isArray(node.data) ? node.data : [node.data] - urls.forEach(url => addImage(url)) - } else if (node.type === 'url') { - // Check if URL is an image/media file - if (isImage(node.data) || isMedia(node.data)) { - addImage(node.data) - } - } - }) - - // 4. Extract directly from raw content (catch any URLs that weren't parsed) - // This ensures we don't miss any image URLs in the content - if (_content) { - const urlRegex = /https?:\/\/[^\s<>"']+/g - const urlMatches = _content.matchAll(urlRegex) - for (const match of urlMatches) { - const url = match[0] - if (isImage(url) || isMedia(url)) { - addImage(url) - } - } - } - - // 5. Try to match content URLs with imeta tags for better metadata - if (event) { - const imetaInfos = getImetaInfosFromEvent(event) - allImages.forEach((img, index) => { - // Try to find matching imeta info - const matchedImeta = imetaInfos.find(imeta => cleanUrl(imeta.url) === img.url) - if (matchedImeta && matchedImeta.m) { - allImages[index] = { ...img, m: matchedImeta.m } - } else { - // Try to get imeta from media upload service - const tag = mediaUpload.getImetaTagByUrl(img.url) - if (tag) { - const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey) - if (parsedImeta) { - allImages[index] = parsedImeta - } - } - } - }) - } - const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') const lastNormalUrl = typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined - return { nodes, allImages, emojiInfos, lastNormalUrl } - }, [event, translatedEvent, content]) + return { nodes, emojiInfos, lastNormalUrl } + }, [_content, event]) if (!nodes || nodes.length === 0) { return null @@ -172,7 +76,7 @@ export default function Content({ // Create maps for quick lookup of images/media by cleaned URL const imageMap = new Map() const mediaMap = new Map() - allImages.forEach((img) => { + extractedMedia.all.forEach((img: TImetaInfo) => { if (img.m?.startsWith('image/')) { imageMap.set(img.url, img) } else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') { @@ -184,25 +88,85 @@ export default function Content({ } }) + // First pass: find which images appear in content (will be rendered in a single carousel) + const mediaInContent = new Set() + const imagesInContent: TImetaInfo[] = [] + + nodes.forEach((node) => { + if (node.type === 'image') { + const cleanedUrl = cleanUrl(node.data) + mediaInContent.add(cleanedUrl) + const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey } + if (!imagesInContent.find(img => img.url === cleanedUrl)) { + imagesInContent.push(imageInfo) + } + } else if (node.type === 'images') { + const urls = Array.isArray(node.data) ? node.data : [node.data] + urls.forEach(url => { + const cleaned = cleanUrl(url) + mediaInContent.add(cleaned) + const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey } + if (!imagesInContent.find(img => img.url === cleaned)) { + imagesInContent.push(imageInfo) + } + }) + } else if (node.type === 'media') { + mediaInContent.add(cleanUrl(node.data)) + } else if (node.type === 'url') { + const cleanedUrl = cleanUrl(node.data) + if (isImage(cleanedUrl)) { + mediaInContent.add(cleanedUrl) + const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey } + if (!imagesInContent.find(img => img.url === cleanedUrl)) { + imagesInContent.push(imageInfo) + } + } else if (isMedia(cleanedUrl)) { + mediaInContent.add(cleanedUrl) + } + } + }) + + // Filter carousel: only show IMAGES that DON'T appear in content + const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => { + return !mediaInContent.has(img.url) + }) + logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, - allImages: allImages.length, - imageMapSize: imageMap.size, + allMedia: extractedMedia.all.length, + images: extractedMedia.images.length, + videos: extractedMedia.videos.length, + audio: extractedMedia.audio.length, + imageMapSize: imageMap.size, mediaMapSize: mediaMap.size, - allImageUrls: allImages.map(img => img.url), nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) }) + // Track which images/media have been rendered individually to prevent duplicates + const renderedUrls = new Set() + return (
- {/* Render all images/media once in a single carousel if we have any */} - {allImages.length > 0 && ( + {/* Render images that appear in content in a single carousel at the top */} + {imagesInContent.length > 0 && ( + + )} + + {/* Render images/media that aren't in content in a single carousel */} + {carouselImages.length > 0 && ( )} @@ -211,48 +175,18 @@ export default function Content({ if (node.type === 'text') { return node.data } - // Render images individually in their content position - if (node.type === 'image') { - const cleanedUrl = cleanUrl(node.data) - const imageInfo = imageMap.get(cleanedUrl) - logger.debug('[Content] Rendering image node:', { cleanedUrl, hasImageInfo: !!imageInfo, imageMapKeys: Array.from(imageMap.keys()) }) - // Always render, use imageInfo if available - return ( - - ) - } - if (node.type === 'images') { - const urls = Array.isArray(node.data) ? node.data : [node.data] - const imageInfos = urls - .map(url => { - const cleaned = cleanUrl(url) - return imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey } - }) - .filter(Boolean) as TImetaInfo[] - if (imageInfos.length > 0) { - return ( - - ) - } + // Skip image nodes - they're rendered in the carousel at the top + if (node.type === 'image' || node.type === 'images') { return null } // Render media individually in their content position if (node.type === 'media') { const cleanedUrl = cleanUrl(node.data) + // Skip if already rendered + if (renderedUrls.has(cleanedUrl)) { + return null + } + renderedUrls.add(cleanedUrl) return ( ) } + + // Skip image URLs - they're rendered in the carousel at the top if they're in content + // Only render if they're NOT in content (from r tags, etc.) if (isImageUrl) { - // It's an image URL but not in our map, render it anyway - logger.debug('[Content] Rendering image URL node:', { cleanedUrl, isImageUrl }) + // If it's in content, skip it (already in carousel) + if (mediaInContent.has(cleanedUrl)) { + return null + } + // Otherwise it's an image from r tags not in content, render it + renderedUrls.add(cleanedUrl) + const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey } return ( ) } - if (mediaMap.has(cleanedUrl)) { - return ( - - ) - } - if (isMediaUrl) { - // It's a media URL but not in our map, render it anyway - return ( - - ) - } // Regular URL, not an image or media return } diff --git a/src/components/ImageCarousel/ImageCarousel.tsx b/src/components/ImageCarousel/ImageCarousel.tsx index 6fe31ec..98eb14c 100644 --- a/src/components/ImageCarousel/ImageCarousel.tsx +++ b/src/components/ImageCarousel/ImageCarousel.tsx @@ -80,7 +80,7 @@ export default function ImageCarousel({ images, className = '' }: ImageCarouselP {currentImage.m?.startsWith('video/') ? (
)} diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx index 3642c04..1fc787a 100644 --- a/src/components/ImageWithLightbox/index.tsx +++ b/src/components/ImageWithLightbox/index.tsx @@ -57,7 +57,7 @@ export default function ImageWithLightbox({ } return ( -
+
{ + if (!content || !groupImagesInCarousel) return [] + + const urlRegex = /https?:\/\/[^\s<>"']+/g + const urlMatches = content.matchAll(urlRegex) + const imageUrls = new Set() + + for (const match of urlMatches) { + const url = match[0] + const cleaned = cleanUrl(url) + if (isImage(cleaned)) { + imageUrls.add(cleaned) + } + } + + // Get image info for URLs that appear in content + return extractedMedia.images.filter(img => imageUrls.has(img.url)) + }, [content, extractedMedia.images, groupImagesInCarousel]) + + // Images from tags only (not in content) go in separate carousel + const imagesFromTags = useMemo(() => { + if (!content || !groupImagesInCarousel) return extractedMedia.images + + const urlRegex = /https?:\/\/[^\s<>"']+/g + const urlMatches = content.matchAll(urlRegex) + const contentImageUrls = new Set() + + for (const match of urlMatches) { + const url = match[0] + const cleaned = cleanUrl(url) + if (isImage(cleaned)) { + contentImageUrls.add(cleaned) + } + } + + return extractedMedia.images.filter(img => !contentImageUrls.has(img.url)) + }, [content, extractedMedia.images, groupImagesInCarousel]) + + return ( +
+ {/* Render images from content in a single carousel at the top */} + {groupImagesInCarousel && imagesInContent.length > 0 && ( + + )} + + {/* Render images from tags only (not in content) in a separate carousel */} + {groupImagesInCarousel && imagesFromTags.length > 0 && ( + + )} + + {/* Videos and audio should never be in carousel - they're rendered individually elsewhere */} + {/* This component just provides the extracted media data */} +
+ ) +} + diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx index f2975b5..ed023ab 100644 --- a/src/components/Note/Article/index.tsx +++ b/src/components/Note/Article/index.tsx @@ -216,7 +216,7 @@ export default function Article({
)} diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 54160a3..ec20774 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -278,7 +278,7 @@ export default function AsciidocArticle({
)} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 31d8b19..51af133 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -5,8 +5,8 @@ import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList, toProfile } from '@/lib/link' -import { extractAllImagesFromEvent } from '@/lib/image-extraction' -import { getImetaInfosFromEvent } from '@/lib/event' +import { useMediaExtraction } from '@/hooks' +import { cleanUrl } from '@/lib/url' import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import React, { useMemo, useEffect, useRef, useState } from 'react' @@ -32,11 +32,11 @@ export default function MarkdownArticle({ const { push } = useSecondaryPage() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const [isImagesOpen, setIsImagesOpen] = useState(false) - - // Extract all images from the event - const allImages = useMemo(() => extractAllImagesFromEvent(event), [event]) const contentRef = useRef(null) + // Use unified media extraction service + const extractedMedia = useMediaExtraction(event, event.content) + // Extract hashtags that are actually present in the content (as literal #hashtag) // This ensures we only render green links for hashtags that are in the content, not from t-tags const contentHashtags = useMemo(() => { @@ -49,91 +49,41 @@ export default function MarkdownArticle({ return hashtags }, [event.content]) - // Extract, normalize, and deduplicate all media URLs (images, audio, video) - // from content, imeta tags, and image tags - const mediaUrls = useMemo(() => { - if (showImageGallery) return [] // Don't render inline for article content - - const seenUrls = new Set() - const mediaUrls: string[] = [] - - // Helper to normalize and add URL - const addUrl = (url: string) => { - if (!url) return - - // Normalize URL by removing tracking parameters and cleaning it - let normalizedUrl = url - .replace(/[?&](utm_[^&]*)/g, '') - .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') - .replace(/[?&]w=\d+/g, '') - .replace(/[?&]h=\d+/g, '') - .replace(/[?&]q=\d+/g, '') - .replace(/[?&]f=\w+/g, '') - .replace(/[?&]auto=\w+/g, '') - .replace(/[?&]format=\w+/g, '') - .replace(/[?&]fit=\w+/g, '') - .replace(/[?&]crop=\w+/g, '') - .replace(/[?&]&+/g, '&') - .replace(/[?&]$/, '') - .replace(/\?$/, '') - - try { - // Validate URL - const parsedUrl = new URL(normalizedUrl) - const extension = parsedUrl.pathname.split('.').pop()?.toLowerCase() - - // Check if it's a media file - const isMedia = - // Audio extensions - (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) || - // Video extensions - (extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) || - // Image extensions - (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) - - if (isMedia && !seenUrls.has(normalizedUrl)) { - mediaUrls.push(normalizedUrl) - seenUrls.add(normalizedUrl) + // Track which image URLs appear in the markdown content (for deduplication) + // Use cleaned URLs for comparison with extractedMedia + const imagesInContent = useMemo(() => { + const imageUrls = new Set() + const urlRegex = /https?:\/\/[^\s<>"']+/g + const urlMatches = event.content.matchAll(urlRegex) + for (const match of urlMatches) { + const url = match[0] + // Check if it's an image URL + const extension = url.split('.').pop()?.toLowerCase() + if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) { + const cleaned = cleanUrl(url) + if (cleaned) { + imageUrls.add(cleaned) } - } catch { - // Invalid URL, skip } } - - // 1. Extract from content - all URLs (need to match exactly what markdown will find) - const content = event.content || '' - // Match URLs that could be in markdown links or plain text - const urlMatches = content.match(/https?:\/\/[^\s<>"']+/g) || [] - urlMatches.forEach(url => { - // Normalize the URL before adding - const normalized = url.replace(/[?&](utm_[^&]*)/g, '') - .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') - .replace(/[?&]w=\d+/g, '') - .replace(/[?&]h=\d+/g, '') - .replace(/[?&]q=\d+/g, '') - .replace(/[?&]f=\w+/g, '') - .replace(/[?&]auto=\w+/g, '') - .replace(/[?&]format=\w+/g, '') - .replace(/[?&]fit=\w+/g, '') - .replace(/[?&]crop=\w+/g, '') - .replace(/[?&]&+/g, '&') - .replace(/[?&]$/, '') - .replace(/\?$/, '') - addUrl(normalized) - }) - - // 2. Extract from imeta tags - const imetaInfos = getImetaInfosFromEvent(event) - imetaInfos.forEach(info => addUrl(info.url)) - - // 3. Extract from image tag - const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) - if (imageTag?.[1]) { - addUrl(imageTag[1]) + // Also check markdown image syntax: ![alt](url) + const markdownImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g + let imgMatch + while ((imgMatch = markdownImageRegex.exec(event.content)) !== null) { + if (imgMatch[1]) { + const cleaned = cleanUrl(imgMatch[1]) + if (cleaned) { + imageUrls.add(cleaned) + } + } } - - return mediaUrls - }, [event.content, event.tags, event.pubkey, showImageGallery]) + return imageUrls + }, [event.content]) + + // Images that should appear in the carousel (from tags only, not in content) + const carouselImages = useMemo(() => { + return extractedMedia.images.filter(img => !imagesInContent.has(img.url)) + }, [extractedMedia.images, imagesInContent]) // Initialize highlight.js for syntax highlighting useEffect(() => { @@ -236,7 +186,10 @@ export default function MarkdownArticle({ .replace(/[?&]$/, '') .replace(/\?$/, '') - if (mediaUrls.includes(normalizedHref)) { + // Check if this is a media URL that should be rendered inline + // Videos and audio are handled separately below + const extension = normalizedHref.split('.').pop()?.toLowerCase() + if (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) { return null } } @@ -337,21 +290,17 @@ export default function MarkdownArticle({ img: ({ src }) => { if (!src) return null - // If showing image gallery, don't render inline images - they'll be shown in the carousel - if (showImageGallery) { - return null - } - - // For all other content, render images inline + // Always render images inline in their content position + // The carousel at the bottom only shows images from tags that aren't in content return ( ) } }) as Components, - [showImageGallery, event.pubkey, mediaUrls, event.kind, contentHashtags] + [showImageGallery, event.pubkey, event.kind, contentHashtags] ) return ( @@ -502,35 +451,33 @@ export default function MarkdownArticle({
{/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */} - {!showImageGallery && mediaUrls.length > 0 && ( + {!showImageGallery && extractedMedia.videos.length > 0 && (
- {mediaUrls.map((url) => { - const extension = url.split('.').pop()?.toLowerCase() - - // Images are already handled by the img component - if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension)) { - return null - } - - // Render audio and video - return ( - - ) - })} + {extractedMedia.videos.map((video) => ( + + ))} +
+ )} + {!showImageGallery && extractedMedia.audio.length > 0 && ( +
+ {extractedMedia.audio.map((audio) => ( + + ))}
)} {/* Image Carousel - Only show for article content (30023, 30041, 30817, 30818) */} - {showImageGallery && allImages.length > 0 && ( + {/* Only show images that aren't already in the content (from tags only) */} + {showImageGallery && carouselImages.length > 0 && ( - + )} diff --git a/src/components/Note/PictureNote.tsx b/src/components/Note/PictureNote.tsx index 8615e7d..7134624 100644 --- a/src/components/Note/PictureNote.tsx +++ b/src/components/Note/PictureNote.tsx @@ -1,16 +1,29 @@ -import { getImetaInfosFromEvent } from '@/lib/event' import { Event } from 'nostr-tools' import { useMemo } from 'react' +import { cleanUrl } from '@/lib/url' +import { useMediaExtraction } from '@/hooks/useMediaExtraction' import Content from '../Content' import ImageGallery from '../ImageGallery' export default function PictureNote({ event, className }: { event: Event; className?: string }) { - const imageInfos = useMemo(() => getImetaInfosFromEvent(event), [event]) + const { images } = useMediaExtraction(event, event.content) + + // Extract cleaned URLs from content to avoid duplicate rendering + const contentUrls = useMemo(() => { + const content = event.content || '' + const urlMatches = content.match(/https?:\/\/[^\s]+/g) || [] + return new Set(urlMatches.map(url => cleanUrl(url))) + }, [event.content]) + + // Images that don't appear in content (from tags only) + const imagesFromTags = useMemo(() => { + return images.filter(img => !contentUrls.has(img.url)) + }, [images, contentUrls]) return (
- {imageInfos.length > 0 && } + {imagesFromTags.length > 0 && }
) } diff --git a/src/components/Note/VideoNote.tsx b/src/components/Note/VideoNote.tsx index 8301e45..255130e 100644 --- a/src/components/Note/VideoNote.tsx +++ b/src/components/Note/VideoNote.tsx @@ -1,37 +1,31 @@ -import { getImetaInfosFromEvent } from '@/lib/event' import { Event } from 'nostr-tools' import { useMemo } from 'react' +import { cleanUrl } from '@/lib/url' +import { useMediaExtraction } from '@/hooks/useMediaExtraction' import Content from '../Content' import MediaPlayer from '../MediaPlayer' export default function VideoNote({ event, className }: { event: Event; className?: string }) { - const videoInfos = useMemo(() => getImetaInfosFromEvent(event), [event]) + const { videos } = useMediaExtraction(event, event.content) - // Extract URLs from content to avoid duplicate rendering + // Extract cleaned URLs from content to avoid duplicate rendering const contentUrls = useMemo(() => { const content = event.content || '' const urlMatches = content.match(/https?:\/\/[^\s]+/g) || [] - return urlMatches.map(url => { - try { - return new URL(url).href - } catch { - return url - } - }) + return new Set(urlMatches.map(url => cleanUrl(url))) }, [event.content]) + // Videos that don't appear in content (from tags only) + const videosFromTags = useMemo(() => { + return videos.filter(video => !contentUrls.has(video.url)) + }, [videos, contentUrls]) + return (
- {videoInfos - .filter((video) => { - // Only render videos from imeta tags that are not already in the content - const videoUrl = new URL(video.url).href - return !contentUrls.includes(videoUrl) - }) - .map((video) => ( - - ))} + {videosFromTags.map((video) => ( + + ))}
) } diff --git a/src/components/UniversalContent/EnhancedContent.tsx b/src/components/UniversalContent/EnhancedContent.tsx index 97e41a7..1012111 100644 --- a/src/components/UniversalContent/EnhancedContent.tsx +++ b/src/components/UniversalContent/EnhancedContent.tsx @@ -3,7 +3,7 @@ * while maintaining compatibility with existing embedded content */ -import { useTranslatedEvent } from '@/hooks' +import { useTranslatedEvent, useMediaExtraction } from '@/hooks' import { EmbeddedEmojiParser, EmbeddedEventParser, @@ -15,11 +15,9 @@ import { parseContent } from '@/lib/content-parser' import logger from '@/lib/logger' -import { getImetaInfosFromEvent } from '@/lib/event' -import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag, tagNameEquals } from '@/lib/tag' +import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url' -import mediaUpload from '@/services/media-upload.service' import { TImetaInfo } from '@/types' import { Event } from 'nostr-tools' import { useMemo } from 'react' @@ -52,6 +50,10 @@ export default function EnhancedContent({ useEnhancedParsing?: boolean }) { const translatedEvent = useTranslatedEvent(event?.id) + const _content = translatedEvent?.content ?? event?.content ?? content + + // Use unified media extraction service + const extractedMedia = useMediaExtraction(event, _content) // If enhanced parsing is enabled and we have an event, use the new parser if (useEnhancedParsing && event) { @@ -69,8 +71,7 @@ export default function EnhancedContent({ } // Fallback to original parsing logic - const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => { - const _content = translatedEvent?.content ?? event?.content ?? content + const { nodes, lastNormalUrl, emojiInfos } = useMemo(() => { if (!_content) return {} const nodes = parseContent(_content, [ @@ -83,126 +84,14 @@ export default function EnhancedContent({ EmbeddedEmojiParser ]) - // Collect all images from multiple sources and deduplicate using cleaned URLs - const seenUrls = new Set() - const allImages: TImetaInfo[] = [] - - // Helper to add image/media if not already seen (using cleaned URL for comparison) - const addImage = (url: string, pubkey?: string, mimeType?: string) => { - if (!url) return - 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 - - seenUrls.add(cleaned) - - // Determine mime type if not provided - let mime = mimeType - if (!mime) { - if (isImage(cleaned)) { - mime = 'image/*' - } else if (isAudio(cleaned)) { - mime = 'audio/*' - } else if (isVideo(cleaned)) { - mime = 'video/*' - } else { - mime = 'media/*' - } - } - - allImages.push({ - url: cleaned, - pubkey: pubkey || event?.pubkey, - m: mime - }) - } - - // 1. Extract from imeta tags - if (event) { - const imetaInfos = getImetaInfosFromEvent(event) - imetaInfos.forEach((info) => { - if (info.m?.startsWith('image/') || info.m?.startsWith('video/') || info.m?.startsWith('audio/') || isImage(info.url) || isMedia(info.url)) { - addImage(info.url, info.pubkey, info.m) - } - }) - } - - // 2. Extract from r tags (reference/URL tags) - if (event) { - event.tags.filter(tagNameEquals('r')).forEach(([, url]) => { - if (url && (isImage(url) || isMedia(url))) { - addImage(url) - } - }) - } - - // 2b. Extract from image tag - if (event) { - const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) - if (imageTag?.[1]) { - addImage(imageTag[1]) - } - } - - // 3. Extract from content nodes (already parsed URLs) - nodes.forEach((node) => { - if (node.type === 'image') { - addImage(node.data) - } else if (node.type === 'images') { - const urls = Array.isArray(node.data) ? node.data : [node.data] - urls.forEach(url => addImage(url)) - } else if (node.type === 'url') { - // Check if URL is an image/media file - if (isImage(node.data) || isMedia(node.data)) { - addImage(node.data) - } - } - }) - - // 4. Extract directly from raw content (catch any URLs that weren't parsed) - // This ensures we don't miss any image URLs in the content - if (_content) { - const urlRegex = /https?:\/\/[^\s<>"']+/g - const urlMatches = _content.matchAll(urlRegex) - for (const match of urlMatches) { - const url = match[0] - if (isImage(url) || isMedia(url)) { - addImage(url) - } - } - } - - // 5. Try to match content URLs with imeta tags for better metadata - if (event) { - const imetaInfos = getImetaInfosFromEvent(event) - allImages.forEach((img, index) => { - // Try to find matching imeta info - const matchedImeta = imetaInfos.find(imeta => cleanUrl(imeta.url) === img.url) - if (matchedImeta && matchedImeta.m) { - allImages[index] = { ...img, m: matchedImeta.m } - } else { - // Try to get imeta from media upload service - const tag = mediaUpload.getImetaTagByUrl(img.url) - if (tag) { - const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey) - if (parsedImeta) { - allImages[index] = parsedImeta - } - } - } - }) - } - const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') const lastNormalUrl = typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined - return { nodes, allImages, emojiInfos, lastNormalUrl } - }, [event, translatedEvent, content]) + return { nodes, emojiInfos, lastNormalUrl } + }, [_content, event]) if (!nodes || nodes.length === 0) { return null @@ -211,7 +100,7 @@ export default function EnhancedContent({ // Create maps for quick lookup of images/media by cleaned URL const imageMap = new Map() const mediaMap = new Map() - allImages.forEach((img) => { + extractedMedia.all.forEach((img: TImetaInfo) => { if (img.m?.startsWith('image/')) { imageMap.set(img.url, img) } else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') { @@ -225,10 +114,12 @@ export default function EnhancedContent({ logger.debug('[EnhancedContent] Parsed content:', { nodeCount: nodes.length, - allImages: allImages.length, + allMedia: extractedMedia.all.length, + images: extractedMedia.images.length, + videos: extractedMedia.videos.length, + audio: extractedMedia.audio.length, imageMapSize: imageMap.size, mediaMapSize: mediaMap.size, - allImageUrls: allImages.map(img => img.url), nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) }) @@ -276,13 +167,9 @@ export default function EnhancedContent({ // Filter carousel: only show IMAGES that DON'T appear in content // (videos and audio should never be in carousel - they're rendered individually) // (images in content will be rendered in a single carousel, not individually) - const carouselImages = allImages.filter(img => { - // Never include videos or audio in carousel - if (isVideo(img.url) || isAudio(img.url) || img.m?.startsWith('video/') || img.m?.startsWith('audio/')) { - return false - } + const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => { // Only include images that don't appear in content - return !mediaInContent.has(img.url) && isImage(img.url) + return !mediaInContent.has(img.url) }) return ( diff --git a/src/constants.ts b/src/constants.ts index d9d26f8..1d901c8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -104,6 +104,8 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://relay.damus.io', 'wss://relay.lumina.rocks', 'wss://relay.snort.social', + 'wss://nos.lol', + 'wss://nostr.mom' ] export const PROFILE_RELAY_URLS = [ diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 2bf759d..227d80a 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -7,3 +7,4 @@ export * from './useFetchRelayInfos' export * from './useFetchRelayList' export * from './useSearchProfiles' export * from './useTranslatedEvent' +export * from './useMediaExtraction' diff --git a/src/hooks/useMediaExtraction.tsx b/src/hooks/useMediaExtraction.tsx new file mode 100644 index 0000000..dc0203c --- /dev/null +++ b/src/hooks/useMediaExtraction.tsx @@ -0,0 +1,19 @@ +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { extractAllMediaFromEvent, ExtractedMedia } from '@/services/media-extraction.service' + +/** + * Hook to extract all media from an event + */ +export function useMediaExtraction( + event?: Event, + content?: string +): ExtractedMedia { + return useMemo(() => { + if (!event) { + return { images: [], videos: [], audio: [], all: [] } + } + return extractAllMediaFromEvent(event, content) + }, [event, content]) +} + diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts new file mode 100644 index 0000000..557816f --- /dev/null +++ b/src/services/media-extraction.service.ts @@ -0,0 +1,149 @@ +import { Event } from 'nostr-tools' +import { getImetaInfosFromEvent } from '@/lib/event' +import { tagNameEquals } from '@/lib/tag' +import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url' +import { TImetaInfo } from '@/types' +import mediaUpload from './media-upload.service' +import { getImetaInfoFromImetaTag } from '@/lib/tag' + +export interface ExtractedMedia { + images: TImetaInfo[] + videos: TImetaInfo[] + audio: TImetaInfo[] + all: TImetaInfo[] +} + +/** + * Unified service for extracting all media (images, videos, audio) from an event + * Sources: imeta tags, r tags, image tags, and content field + */ +export function extractAllMediaFromEvent( + event: Event, + content?: string +): ExtractedMedia { + const seenUrls = new Set() + const allMedia: TImetaInfo[] = [] + + // Helper to add media if not already seen (using cleaned URL for comparison) + const addMedia = (url: string, pubkey?: string, mimeType?: string) => { + if (!url) return + 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 + + seenUrls.add(cleaned) + + // Determine mime type if not provided + let mime = mimeType + if (!mime) { + if (isImage(cleaned)) { + mime = 'image/*' + } else if (isAudio(cleaned)) { + mime = 'audio/*' + } else if (isVideo(cleaned)) { + mime = 'video/*' + } else { + mime = 'media/*' + } + } + + allMedia.push({ + url: cleaned, + pubkey: pubkey || event.pubkey, + m: mime + }) + } + + // 1. Extract from imeta tags + const imetaInfos = getImetaInfosFromEvent(event) + imetaInfos.forEach((info) => { + if ( + info.m?.startsWith('image/') || + info.m?.startsWith('video/') || + info.m?.startsWith('audio/') || + isImage(info.url) || + isMedia(info.url) + ) { + addMedia(info.url, info.pubkey, info.m) + } + }) + + // 2. Extract from r tags (reference/URL tags) + event.tags.filter(tagNameEquals('r')).forEach(([, url]) => { + if (url && (isImage(url) || isMedia(url))) { + addMedia(url) + } + }) + + // 3. Extract from image tag + const imageTag = event.tags.find((tag) => tag[0] === 'image' && tag[1]) + if (imageTag?.[1]) { + addMedia(imageTag[1]) + } + + // 4. Extract from content (if provided) + if (content) { + // Extract directly from raw content (catch any URLs that weren't parsed) + const urlRegex = /https?:\/\/[^\s<>"']+/g + const urlMatches = content.matchAll(urlRegex) + for (const match of urlMatches) { + const url = match[0] + if (isImage(url) || isMedia(url)) { + addMedia(url) + } + } + } + + // 5. Try to match content URLs with imeta tags for better metadata + imetaInfos.forEach((imeta) => { + allMedia.forEach((media, index) => { + // Try to find matching imeta info + if (cleanUrl(imeta.url) === media.url && imeta.m) { + allMedia[index] = { ...media, m: imeta.m } + } else { + // Try to get imeta from media upload service + const tag = mediaUpload.getImetaTagByUrl(media.url) + if (tag) { + const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey) + if (parsedImeta) { + allMedia[index] = parsedImeta + } + } + } + }) + }) + + // Categorize media + const images: TImetaInfo[] = [] + const videos: TImetaInfo[] = [] + const audio: TImetaInfo[] = [] + + allMedia.forEach((media) => { + if (media.m?.startsWith('image/') || isImage(media.url)) { + images.push(media) + } else if (media.m?.startsWith('video/') || isVideo(media.url)) { + videos.push(media) + } else if (media.m?.startsWith('audio/') || isAudio(media.url)) { + audio.push(media) + } else { + // Fallback: try to determine by URL extension + if (isImage(media.url)) { + images.push(media) + } else if (isVideo(media.url)) { + videos.push(media) + } else if (isAudio(media.url)) { + audio.push(media) + } + } + }) + + return { + images, + videos, + audio, + all: allMedia + } +} +