You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
360 lines
12 KiB
360 lines
12 KiB
import { useTranslatedEvent } from '@/hooks' |
|
import { |
|
EmbeddedEmojiParser, |
|
EmbeddedEventParser, |
|
EmbeddedHashtagParser, |
|
EmbeddedLNInvoiceParser, |
|
EmbeddedMentionParser, |
|
EmbeddedUrlParser, |
|
EmbeddedWebsocketUrlParser, |
|
parseContent |
|
} from '@/lib/content-parser' |
|
import logger from '@/lib/logger' |
|
import { getImetaInfosFromEvent } from '@/lib/event' |
|
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag' |
|
import { cn } from '@/lib/utils' |
|
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, |
|
EmbeddedLNInvoice, |
|
EmbeddedMention, |
|
EmbeddedNormalUrl, |
|
EmbeddedNote, |
|
EmbeddedWebsocketUrl |
|
} from '../Embedded' |
|
import Emoji from '../Emoji' |
|
import ImageGallery from '../ImageGallery' |
|
import MediaPlayer from '../MediaPlayer' |
|
import WebPreview from '../WebPreview' |
|
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' |
|
|
|
export default function Content({ |
|
event, |
|
content, |
|
className, |
|
mustLoadMedia |
|
}: { |
|
event?: Event |
|
content?: string |
|
className?: string |
|
mustLoadMedia?: boolean |
|
}) { |
|
const translatedEvent = useTranslatedEvent(event?.id) |
|
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => { |
|
const _content = translatedEvent?.content ?? event?.content ?? content |
|
if (!_content) return {} |
|
|
|
const nodes = parseContent(_content, [ |
|
EmbeddedUrlParser, |
|
EmbeddedLNInvoiceParser, |
|
EmbeddedWebsocketUrlParser, |
|
EmbeddedEventParser, |
|
EmbeddedMentionParser, |
|
EmbeddedHashtagParser, |
|
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]) |
|
|
|
if (!nodes || nodes.length === 0) { |
|
return null |
|
} |
|
|
|
// 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) => { |
|
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 ( |
|
<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 && ( |
|
<ImageGallery |
|
className="mt-2 mb-4" |
|
key="all-images-gallery" |
|
images={allImages} |
|
start={0} |
|
end={allImages.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 ( |
|
<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} |
|
start={0} |
|
end={imageInfos.length} |
|
mustLoad={mustLoadMedia} |
|
/> |
|
) |
|
} |
|
return null |
|
} |
|
// Render media individually in their content position |
|
if (node.type === 'media') { |
|
const cleanedUrl = cleanUrl(node.data) |
|
return ( |
|
<MediaPlayer |
|
className="mt-2" |
|
key={index} |
|
src={cleanedUrl} |
|
mustLoad={mustLoadMedia} |
|
/> |
|
) |
|
} |
|
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 ( |
|
<ImageGallery |
|
className="mt-2" |
|
key={`url-img-${index}`} |
|
images={[imageInfo]} |
|
start={0} |
|
end={1} |
|
mustLoad={mustLoadMedia} |
|
/> |
|
) |
|
} |
|
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 ( |
|
<ImageGallery |
|
className="mt-2" |
|
key={`url-img-${index}`} |
|
images={[{ url: cleanedUrl, pubkey: event?.pubkey }]} |
|
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} /> |
|
} |
|
if (node.type === 'invoice') { |
|
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" /> |
|
} |
|
if (node.type === 'websocket-url') { |
|
return <EmbeddedWebsocketUrl url={node.data} key={index} /> |
|
} |
|
if (node.type === 'event') { |
|
const id = node.data.split(':')[1] |
|
return <EmbeddedNote key={index} noteId={id} className="mt-2" /> |
|
} |
|
if (node.type === 'mention') { |
|
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> |
|
} |
|
if (node.type === 'hashtag') { |
|
return <EmbeddedHashtag hashtag={node.data} key={index} /> |
|
} |
|
if (node.type === 'emoji') { |
|
const shortcode = node.data.split(':')[1] |
|
const emoji = emojiInfos.find((e) => e.shortcode === shortcode) |
|
if (!emoji) return node.data |
|
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} /> |
|
} |
|
if (node.type === 'youtube') { |
|
return ( |
|
<YoutubeEmbeddedPlayer |
|
key={index} |
|
url={node.data} |
|
className="mt-2" |
|
mustLoad={mustLoadMedia} |
|
/> |
|
) |
|
} |
|
return null |
|
})} |
|
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />} |
|
</div> |
|
) |
|
}
|
|
|