From 10ea704004a29ed0eea6d2215aa2c8dfab0e6027 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 31 Oct 2025 11:42:41 +0100 Subject: [PATCH] fix image and media rendering kind 1 --- src/components/Content/index.tsx | 277 ++++++++++++--- src/components/Note/index.tsx | 3 + .../UniversalContent/EnhancedContent.tsx | 329 ++++++++++++++---- src/lib/url.ts | 37 ++ 4 files changed, 532 insertions(+), 114 deletions(-) diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 1c994ce..e578193 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -13,10 +13,11 @@ import logger from '@/lib/logger' import { getImetaInfosFromEvent } from '@/lib/event' import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag' import { cn } from '@/lib/utils' -import { cleanUrl } from '@/lib/url' +import { cleanUrl, isImage, isMedia } from '@/lib/url' import mediaUpload from '@/services/media-upload.service' import { TImetaInfo } from '@/types' import { Event } from 'nostr-tools' +import { tagNameEquals } from '@/lib/tag' import { useMemo } from 'react' import { EmbeddedHashtag, @@ -58,59 +59,108 @@ export default function Content({ EmbeddedEmojiParser ]) - const imetaInfos = event ? getImetaInfosFromEvent(event) : [] - const allImages = nodes - .map((node) => { - if (node.type === 'image') { - // Always ensure we have a valid image info object - const imageInfo = imetaInfos.find((image) => image.url === node.data) - if (imageInfo) { - return imageInfo - } - + // 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(node.data) + const tag = mediaUpload.getImetaTagByUrl(img.url) if (tag) { - const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey) + const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey) if (parsedImeta) { - return parsedImeta + allImages[index] = parsedImeta } } - - // Fallback: always create a basic image info object with cleaned URL - return { url: cleanUrl(node.data), pubkey: event?.pubkey } } - if (node.type === 'images') { - const urls = Array.isArray(node.data) ? node.data : [node.data] - return urls.map((url) => { - const imageInfo = imetaInfos.find((image) => image.url === url) - if (imageInfo) { - return imageInfo - } - - // Try to get imeta from media upload service - const tag = mediaUpload.getImetaTagByUrl(url) - if (tag) { - const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey) - if (parsedImeta) { - return parsedImeta - } - } - - // Fallback: always create a basic image info object with cleaned URL - return { url: cleanUrl(url), pubkey: event?.pubkey } - }) - } - return null }) - .filter(Boolean) - .flat() as TImetaInfo[] + } const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') const lastNormalUrl = - typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined + typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined return { nodes, allImages, emojiInfos, lastNormalUrl } }, [event, translatedEvent, content]) @@ -119,36 +169,155 @@ export default function Content({ return null } - let imageIndex = 0 - logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, allImages: allImages.length, nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) }) + // Create maps for quick lookup of images/media by cleaned URL + const imageMap = new Map() + const mediaMap = new Map() + allImages.forEach((img) => { + if (img.m?.startsWith('image/')) { + imageMap.set(img.url, img) + } else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') { + mediaMap.set(img.url, img) + } else if (isImage(img.url)) { + imageMap.set(img.url, img) + } else if (isMedia(img.url)) { + mediaMap.set(img.url, img) + } + }) + + logger.debug('[Content] Parsed content:', { + nodeCount: nodes.length, + allImages: allImages.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 })) + }) + return (
+ {/* Render all images/media once in a single carousel if we have any */} + {allImages.length > 0 && ( + + )} + {nodes.map((node, index) => { if (node.type === 'text') { return node.data } - if (node.type === 'image' || node.type === 'images') { - const start = imageIndex - const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1) - imageIndex = end - logger.debug('[Content] Creating ImageGallery:', { nodeType: node.type, start, end, totalImages: allImages.length, nodeData: Array.isArray(node.data) ? node.data.length : 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 ( + + ) + } + return null + } + // Render media individually in their content position if (node.type === 'media') { + const cleanedUrl = cleanUrl(node.data) return ( - + ) } if (node.type === 'url') { + const cleanedUrl = cleanUrl(node.data) + // Check if it's an image or media that should be rendered inline + // Also check if it's an image/media file even if not in our maps + const isImageUrl = isImage(cleanedUrl) + const isMediaUrl = isMedia(cleanedUrl) + + if (imageMap.has(cleanedUrl)) { + const imageInfo = imageMap.get(cleanedUrl)! + return ( + + ) + } + if (isImageUrl) { + // It's an image URL but not in our map, render it anyway + logger.debug('[Content] Rendering image URL node:', { cleanedUrl, isImageUrl }) + 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 } if (node.type === 'invoice') { diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 148af4d..de01511 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -164,6 +164,9 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { content = + } else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) { + // Plain text notes use EnhancedContent for proper image/media rendering + content = } else { // Use MarkdownArticle for all other kinds // Only 30023, 30041, 30817, and 30818 will show image gallery and article info diff --git a/src/components/UniversalContent/EnhancedContent.tsx b/src/components/UniversalContent/EnhancedContent.tsx index 63fc88a..97e41a7 100644 --- a/src/components/UniversalContent/EnhancedContent.tsx +++ b/src/components/UniversalContent/EnhancedContent.tsx @@ -16,9 +16,9 @@ import { } from '@/lib/content-parser' import logger from '@/lib/logger' import { getImetaInfosFromEvent } from '@/lib/event' -import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag' +import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag, tagNameEquals } from '@/lib/tag' import { cn } from '@/lib/utils' -import { cleanUrl } from '@/lib/url' +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' @@ -83,59 +83,123 @@ export default function EnhancedContent({ EmbeddedEmojiParser ]) - const imetaInfos = event ? getImetaInfosFromEvent(event) : [] - const allImages = nodes - .map((node) => { - if (node.type === 'image') { - // Always ensure we have a valid image info object - const imageInfo = imetaInfos.find((image) => image.url === node.data) - if (imageInfo) { - return imageInfo - } - + // 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(node.data) + const tag = mediaUpload.getImetaTagByUrl(img.url) if (tag) { - const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey) + const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey) if (parsedImeta) { - return parsedImeta + allImages[index] = parsedImeta } } - - // Fallback: always create a basic image info object with cleaned URL - return { url: cleanUrl(node.data), pubkey: event?.pubkey } - } - if (node.type === 'images') { - const urls = Array.isArray(node.data) ? node.data : [node.data] - return urls.map((url) => { - const imageInfo = imetaInfos.find((image) => image.url === url) - if (imageInfo) { - return imageInfo - } - - // Try to get imeta from media upload service - const tag = mediaUpload.getImetaTagByUrl(url) - if (tag) { - const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey) - if (parsedImeta) { - return parsedImeta - } - } - - // Fallback: always create a basic image info object with cleaned URL - return { url: cleanUrl(url), pubkey: event?.pubkey } - }) } - return null }) - .filter(Boolean) - .flat() as TImetaInfo[] + } const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') const lastNormalUrl = - typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined + typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined return { nodes, allImages, emojiInfos, lastNormalUrl } }, [event, translatedEvent, content]) @@ -144,36 +208,181 @@ export default function EnhancedContent({ return null } - let imageIndex = 0 - logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, allImages: allImages.length, nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) }) + // Create maps for quick lookup of images/media by cleaned URL + const imageMap = new Map() + const mediaMap = new Map() + allImages.forEach((img) => { + if (img.m?.startsWith('image/')) { + imageMap.set(img.url, img) + } else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') { + mediaMap.set(img.url, img) + } else if (isImage(img.url)) { + imageMap.set(img.url, img) + } else if (isMedia(img.url)) { + mediaMap.set(img.url, img) + } + }) + + logger.debug('[EnhancedContent] Parsed content:', { + nodeCount: nodes.length, + allImages: allImages.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() + + // First pass: find which images/media appear in the content (will be rendered in a single carousel) + const mediaInContent = new Set() + const imagesInContent: TImetaInfo[] = [] // Collect actual image info for carousel + + 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 + // (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 + } + // Only include images that don't appear in content + return !mediaInContent.has(img.url) && isImage(img.url) + }) + return (
+ {/* 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 && ( + + )} + {nodes.map((node, index) => { if (node.type === 'text') { return node.data } + // Skip image nodes - they're rendered in the carousel at the top if (node.type === 'image' || node.type === 'images') { - const start = imageIndex - const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1) - imageIndex = end - logger.debug('[Content] Creating ImageGallery:', { nodeType: node.type, start, end, totalImages: allImages.length, nodeData: Array.isArray(node.data) ? node.data.length : node.data }) - return ( - - ) + return null } + // Render media individually in their content position (only once per URL) if (node.type === 'media') { + const cleanedUrl = cleanUrl(node.data) + // Skip if already rendered + if (renderedUrls.has(cleanedUrl)) { + return null + } + renderedUrls.add(cleanedUrl) return ( - + ) } if (node.type === 'url') { + const cleanedUrl = cleanUrl(node.data) + // Check if it's an image, video, or audio that should be rendered inline + const isImageUrl = isImage(cleanedUrl) + const isVideoUrl = isVideo(cleanedUrl) + const isAudioUrl = isAudio(cleanedUrl) + + // Skip if already rendered (regardless of type) + if (renderedUrls.has(cleanedUrl)) { + return null + } + + // Check video/audio first - never put them in ImageGallery + if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) { + 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) { + // 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 ( + + ) + } + // Regular URL, not an image or media return } if (node.type === 'invoice') { diff --git a/src/lib/url.ts b/src/lib/url.ts index be4ff1b..0337214 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -173,6 +173,43 @@ export function isMedia(url: string) { } } +export function isAudio(url: string) { + try { + const audioExtensions = [ + '.mp3', + '.wav', + '.flac', + '.aac', + '.m4a', + '.opus', + '.wma', + '.ogg' // ogg can be audio + ] + return audioExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) + } catch { + return false + } +} + +export function isVideo(url: string) { + try { + const videoExtensions = [ + '.mp4', + '.webm', + '.mov', + '.avi', + '.wmv', + '.flv', + '.mkv', + '.m4v', + '.3gp' + ] + return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) + } catch { + return false + } +} + /** * Remove tracking parameters from URLs * Removes common tracking parameters like utm_*, fbclid, gclid, etc.