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.
 
 
 
 

555 lines
19 KiB

import { useMediaExtraction } from '@/hooks'
import { parseContent, PARSE_CONTENT_PARSERS_NOTE_TEXT } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import logger from '@/lib/logger'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event'
import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url'
import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/url'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
EmbeddedMention,
EmbeddedNote,
EmbeddedWebsocketUrl,
HttpNostrAwareUrl
} from '../Embedded'
import PaytoLink from '../PaytoLink'
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import WebPreview from '../WebPreview'
import { toNote } from '@/lib/link'
import { YOUTUBE_URL_REGEX } from '@/constants'
// Helper function to check if a URL is a YouTube URL
function isYouTubeUrl(url: string): boolean {
if (!url) return false
// Create a new regex instance without global flag for testing
const flags = YOUTUBE_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags)
return regex.test(url)
}
const REDIRECT_REGEX = /Read (naddr1[a-z0-9]+) instead\./i
function renderRedirectText(text: string, key: number) {
const match = text.match(REDIRECT_REGEX)
if (!match) {
return text
}
const [fullMatch, naddr] = match
const [prefix, suffix] = text.split(fullMatch)
const href = toNote(naddr)
return (
<span key={`redirect-${key}`}>
{prefix}
Read{' '}
<a
className="text-primary hover:underline"
href={href}
onClick={(e) => e.stopPropagation()}
>
{naddr}
</a>{' '}
instead.{suffix}
</span>
)
}
export default function Content({
event,
content,
className,
mustLoadMedia
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
}) {
const _content = event?.content ?? content
const iArticleUrl = useMemo(() => (event ? getHttpUrlFromITags(event) : undefined), [event])
const iArticleCleaned = useMemo(
() => (iArticleUrl ? cleanUrl(iArticleUrl) || iArticleUrl : ''),
[iArticleUrl]
)
// Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content)
const { nodes, emojiInfos } = useMemo(() => {
if (!_content) return {}
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const customShortcodes = emojiInfos.map((e) => e.shortcode)
const normalized = replaceStandardEmojiShortcodesInContent(_content, customShortcodes)
if (normalized.includes('nostr:')) {
logContentSpacing('Content:useMemo', {
rawRepr: reprString(_content),
normalizedRepr: reprString(normalized),
same: _content === normalized
})
}
const nodes = parseContent(normalized, PARSE_CONTENT_PARSERS_NOTE_TEXT)
return { nodes, emojiInfos }
}, [_content, event])
// Extract HTTP/HTTPS links from content nodes (in order of appearance) for WebPreview cards at bottom
// Exclude YouTube URLs, images, and media (they're rendered separately)
const contentLinks = useMemo(() => {
if (!nodes) return []
const links: string[] = []
const seenUrls = new Set<string>()
const appOrigin = typeof window !== 'undefined' ? window.location.origin : null
nodes.forEach((node) => {
if (node.type === 'url') {
const url = node.data
if (
(url.startsWith('http://') || url.startsWith('https://')) &&
!isPseudoNostrHttpsUrl(url) &&
!isImage(url) &&
!isMedia(url) &&
!isYouTubeUrl(url)
) {
const cleaned = cleanUrl(url)
if (
cleaned &&
!seenUrls.has(cleaned) &&
!(iArticleCleaned && cleaned === iArticleCleaned) &&
!httpUrlSkipsBottomWebPreview(url, appOrigin)
) {
links.push(cleaned)
seenUrls.add(cleaned)
}
}
}
})
return links
}, [nodes, iArticleCleaned])
// Extract YouTube URLs from r tags to render as players
const youtubeUrlsFromTags = useMemo(() => {
if (!event) return []
const urls: string[] = []
const seenUrls = new Set<string>()
// Check if YouTube URL is already in content
const hasYouTubeInContent = nodes?.some(node => node.type === 'youtube') || false
event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.forEach(tag => {
const url = tag[1]
if (isYouTubeUrl(url)) {
const cleaned = cleanUrl(url)
// Only include if not already in content and not already seen
if (cleaned && !hasYouTubeInContent && !seenUrls.has(cleaned)) {
urls.push(cleaned)
seenUrls.add(cleaned)
}
}
})
return urls
}, [event, nodes])
// Extract HTTP/HTTPS links from r tags (excluding those already in content, YouTube URLs, images, and media)
const tagLinks = useMemo(() => {
if (!event) return []
const links: string[] = []
const seenUrls = new Set<string>()
// Create a set of content link URLs for quick lookup
const contentLinkUrls = new Set(contentLinks)
event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.forEach(tag => {
const url = tag[1]
if (
(url.startsWith('http://') || url.startsWith('https://')) &&
!isPseudoNostrHttpsUrl(url) &&
!isImage(url) &&
!isMedia(url) &&
!isYouTubeUrl(url)
) {
const cleaned = cleanUrl(url)
// Only include if not already in content links and not already seen in tags
if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) {
links.push(cleaned)
seenUrls.add(cleaned)
}
}
})
return links
}, [event, contentLinks])
// Create maps for quick lookup of images/media by cleaned URL
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
extractedMedia.all.forEach((img: TImetaInfo) => {
const cleaned = cleanUrl(img.url)
if (!cleaned) return
if (img.m?.startsWith('image/')) {
imageMap.set(cleaned, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') {
mediaMap.set(cleaned, img)
} else if (isImage(cleaned)) {
imageMap.set(cleaned, img)
} else if (isMedia(cleaned)) {
mediaMap.set(cleaned, img)
}
})
// If no nodes but we have media from tags, still render the media (or i-tag article preview)
if (!nodes || nodes.length === 0) {
if (
extractedMedia.images.length === 0 &&
extractedMedia.videos.length === 0 &&
extractedMedia.audio.length === 0 &&
!iArticleUrl
) {
return null
}
}
// First pass: find which media appears in content (will be rendered in carousels or inline)
const mediaInContent = new Set<string>()
const imagesInContent: TImetaInfo[] = []
const videosInContent: TImetaInfo[] = []
const audioInContent: TImetaInfo[] = []
// Only process nodes if they exist and are not empty
if (nodes && nodes.length > 0) {
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') {
const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl)
const mediaInfo = mediaMap.get(cleanedUrl)
if (mediaInfo) {
if (isVideo(cleanedUrl) || mediaInfo.m?.startsWith('video/')) {
if (!videosInContent.find(v => v.url === cleanedUrl)) {
videosInContent.push(mediaInfo)
}
} else if (isAudio(cleanedUrl) || mediaInfo.m?.startsWith('audio/')) {
if (!audioInContent.find(a => a.url === cleanedUrl)) {
audioInContent.push(mediaInfo)
}
}
}
} 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 (isVideo(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const videoInfo = mediaMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey, m: 'video/*' }
if (!videosInContent.find(v => v.url === cleanedUrl)) {
videosInContent.push(videoInfo)
}
} else if (isAudio(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const audioInfo = mediaMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey, m: 'audio/*' }
if (!audioInContent.find(a => a.url === cleanedUrl)) {
audioInContent.push(audioInfo)
}
} else if (isMedia(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
}
}
})
}
// Filter: only show media that DON'T appear in content (from tags)
// Use cleaned URLs for comparison to ensure consistency
const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => {
const cleaned = cleanUrl(img.url)
return cleaned && !mediaInContent.has(cleaned)
})
const videosFromTags = extractedMedia.videos.filter((video: TImetaInfo) => {
const cleaned = cleanUrl(video.url)
return cleaned && !mediaInContent.has(cleaned)
})
const audioFromTags = extractedMedia.audio.filter((audio: TImetaInfo) => {
const cleaned = cleanUrl(audio.url)
return cleaned && !mediaInContent.has(cleaned)
})
logger.debug('[Content] Parsed content:', {
nodeCount: nodes?.length || 0,
allMedia: extractedMedia.all.length,
images: extractedMedia.images.length,
videos: extractedMedia.videos.length,
audio: extractedMedia.audio.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
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)}>
{iArticleUrl && (
<div className="mb-2 max-w-full">
<WebPreview url={iArticleUrl} className="w-full" />
</div>
)}
{/* Render images that appear in content in a single carousel at the top */}
{imagesInContent.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="content-images-gallery"
images={imagesInContent}
start={0}
end={imagesInContent.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Render images/media that aren't in content in a single carousel */}
{/* This includes images from imeta tags when content is empty */}
{carouselImages.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="tag-images-gallery"
images={carouselImages}
start={0}
end={carouselImages.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Render videos from tags that don't appear in content */}
{videosFromTags.map((video) => (
<div key={`tag-video-${video.url}`} className="mt-2 w-full max-w-full overflow-hidden">
<MediaPlayer
src={video.url}
className="w-full max-w-full"
mustLoad={mustLoadMedia}
poster={video.image || video.thumb}
blurHash={video.blurHash}
/>
</div>
))}
{/* Render audio from tags that don't appear in content */}
{audioFromTags.map((audio) => (
<MediaPlayer
key={`tag-audio-${audio.url}`}
src={audio.url}
className="mt-2"
mustLoad={mustLoadMedia}
poster={audio.thumb}
blurHash={audio.blurHash}
/>
))}
{/* Render YouTube URLs from r tags that don't appear in content */}
{youtubeUrlsFromTags.map((url) => (
<YoutubeEmbeddedPlayer
key={`tag-youtube-${url}`}
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
/>
))}
{nodes && nodes.length > 0 && nodes.map((node, index) => {
if (node.type === 'text') {
// Skip only completely empty text nodes, but preserve whitespace (important for spacing)
if (!node.data || node.data.length === 0) {
return null
}
return renderRedirectText(node.data, index)
}
// 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)
const tagMediaInfo = mediaMap.get(cleanedUrl)
return (
<MediaPlayer
className="mt-2"
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
poster={tagMediaInfo?.image || tagMediaInfo?.thumb}
blurHash={tagMediaInfo?.blurHash}
/>
)
}
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)
const mediaInfo = mediaMap.get(cleanedUrl)
const poster = mediaInfo?.image || mediaInfo?.thumb
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
poster={poster}
blurHash={mediaInfo?.blurHash}
/>
)
}
// 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 (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[imageInfo]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
// Regular URL, not an image or media - show WebPreview (skip if same as i-tag article)
if (iArticleCleaned && cleanedUrl === iArticleCleaned) {
return null
}
return (
<HttpNostrAwareUrl
key={index}
url={node.data}
renderMode="note-content"
containingEvent={event}
/>
)
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'payto') {
return (
<PaytoLink
key={index}
paytoUri={node.data}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
/>
)
}
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" containingEvent={event} />
}
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.slice(1, -1).trim()
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={native.emoji} key={index} />
return <span key={index}>{node.data}</span>
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{/* WebPreview cards for links from content (in order of appearance) */}
{contentLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
)
}