|
|
|
|
@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
|
|
|
|
|
import { useTranslatedEvent } from '@/hooks' |
|
|
|
|
import { useTranslatedEvent, useMediaExtraction } from '@/hooks' |
|
|
|
|
import { |
|
|
|
|
EmbeddedEmojiParser, |
|
|
|
|
EmbeddedEventParser, |
|
|
|
|
@ -10,14 +10,11 @@ import {
@@ -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({
@@ -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 |
|
|
|
|
|
|
|
|
|
// 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({
@@ -59,111 +60,14 @@ export default function Content({
|
|
|
|
|
EmbeddedEmojiParser |
|
|
|
|
]) |
|
|
|
|
|
|
|
|
|
// Collect all images from multiple sources and deduplicate using cleaned URLs
|
|
|
|
|
const seenUrls = new Set<string>() |
|
|
|
|
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({
@@ -172,7 +76,7 @@ export default function Content({
|
|
|
|
|
// Create maps for quick lookup of images/media by cleaned URL
|
|
|
|
|
const imageMap = new Map<string, TImetaInfo>() |
|
|
|
|
const mediaMap = new Map<string, TImetaInfo>() |
|
|
|
|
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,75 +88,105 @@ export default function Content({
@@ -184,75 +88,105 @@ export default function Content({
|
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
// First pass: find which images appear in content (will be rendered in a single carousel)
|
|
|
|
|
const mediaInContent = new Set<string>() |
|
|
|
|
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,
|
|
|
|
|
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<string>() |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}> |
|
|
|
|
{/* 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 && ( |
|
|
|
|
<ImageGallery |
|
|
|
|
className="mt-2 mb-4" |
|
|
|
|
key="all-images-gallery" |
|
|
|
|
images={allImages} |
|
|
|
|
key="content-images-gallery" |
|
|
|
|
images={imagesInContent} |
|
|
|
|
start={0} |
|
|
|
|
end={allImages.length} |
|
|
|
|
end={imagesInContent.length} |
|
|
|
|
mustLoad={mustLoadMedia} |
|
|
|
|
/> |
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
{nodes.map((node, index) => { |
|
|
|
|
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 ( |
|
|
|
|
{/* Render images/media that aren't in content in a single carousel */} |
|
|
|
|
{carouselImages.length > 0 && ( |
|
|
|
|
<ImageGallery |
|
|
|
|
className="mt-2" |
|
|
|
|
key={`img-${index}`} |
|
|
|
|
images={imageInfo ? [imageInfo] : [{ url: cleanedUrl, pubkey: event?.pubkey }]} |
|
|
|
|
start={0} |
|
|
|
|
end={1} |
|
|
|
|
mustLoad={mustLoadMedia} |
|
|
|
|
/> |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
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 ( |
|
|
|
|
<ImageGallery |
|
|
|
|
className="mt-2" |
|
|
|
|
key={`imgs-${index}`} |
|
|
|
|
images={imageInfos} |
|
|
|
|
className="mt-2 mb-4" |
|
|
|
|
key="all-images-gallery" |
|
|
|
|
images={carouselImages} |
|
|
|
|
start={0} |
|
|
|
|
end={imageInfos.length} |
|
|
|
|
end={carouselImages.length} |
|
|
|
|
mustLoad={mustLoadMedia} |
|
|
|
|
/> |
|
|
|
|
) |
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
{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') { |
|
|
|
|
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 ( |
|
|
|
|
<MediaPlayer
|
|
|
|
|
className="mt-2"
|
|
|
|
|
@ -264,59 +198,50 @@ export default function Content({
@@ -264,59 +198,50 @@ export default function Content({
|
|
|
|
|
} |
|
|
|
|
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
|
|
|
|
|
// Check if it's an image, video, or audio that should be rendered inline
|
|
|
|
|
const isImageUrl = isImage(cleanedUrl) |
|
|
|
|
const isMediaUrl = isMedia(cleanedUrl) |
|
|
|
|
const isVideoUrl = isVideo(cleanedUrl) |
|
|
|
|
const isAudioUrl = isAudio(cleanedUrl) |
|
|
|
|
|
|
|
|
|
// Skip if already rendered (regardless of type)
|
|
|
|
|
if (renderedUrls.has(cleanedUrl)) { |
|
|
|
|
return null |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (imageMap.has(cleanedUrl)) { |
|
|
|
|
const imageInfo = imageMap.get(cleanedUrl)! |
|
|
|
|
// Check video/audio first - never put them in ImageGallery
|
|
|
|
|
if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) { |
|
|
|
|
renderedUrls.add(cleanedUrl) |
|
|
|
|
return ( |
|
|
|
|
<ImageGallery |
|
|
|
|
<MediaPlayer
|
|
|
|
|
className="mt-2"
|
|
|
|
|
key={`url-img-${index}`} |
|
|
|
|
images={[imageInfo]} |
|
|
|
|
start={0} |
|
|
|
|
end={1} |
|
|
|
|
key={`url-media-${index}`}
|
|
|
|
|
src={cleanedUrl}
|
|
|
|
|
mustLoad={mustLoadMedia}
|
|
|
|
|
/> |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 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 ( |
|
|
|
|
<ImageGallery |
|
|
|
|
className="mt-2" |
|
|
|
|
key={`url-img-${index}`} |
|
|
|
|
images={[{ url: cleanedUrl, pubkey: event?.pubkey }]} |
|
|
|
|
images={[imageInfo]} |
|
|
|
|
start={0} |
|
|
|
|
end={1} |
|
|
|
|
mustLoad={mustLoadMedia} |
|
|
|
|
/> |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
if (mediaMap.has(cleanedUrl)) { |
|
|
|
|
return ( |
|
|
|
|
<MediaPlayer
|
|
|
|
|
className="mt-2"
|
|
|
|
|
key={`url-media-${index}`}
|
|
|
|
|
src={cleanedUrl}
|
|
|
|
|
mustLoad={mustLoadMedia}
|
|
|
|
|
/> |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
if (isMediaUrl) { |
|
|
|
|
// It's a media URL but not in our map, render it anyway
|
|
|
|
|
return ( |
|
|
|
|
<MediaPlayer
|
|
|
|
|
className="mt-2"
|
|
|
|
|
key={`url-media-${index}`}
|
|
|
|
|
src={cleanedUrl}
|
|
|
|
|
mustLoad={mustLoadMedia}
|
|
|
|
|
/> |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
// Regular URL, not an image or media
|
|
|
|
|
return <EmbeddedNormalUrl url={node.data} key={index} /> |
|
|
|
|
} |
|
|
|
|
|