{iArticleUrl && (
)}
{/* 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 */}
{/* This includes images from imeta tags when content is empty */}
{carouselImages.length > 0 && (
)}
{/* Render videos from tags that don't appear in content */}
{videosFromTags.map((video) => (
))}
{/* Render audio from tags that don't appear in content */}
{audioFromTags.map((audio) => (
))}
{/* Render YouTube URLs from r tags that don't appear in content */}
{youtubeUrlsFromTags.map((url) => (
))}
{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 (
)
}
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 (
)
}
// 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 - show WebPreview (skip if same as i-tag article)
if (iArticleCleaned && cleanedUrl === iArticleCleaned) {
return null
}
return (
)
}
if (node.type === 'invoice') {
return
}
if (node.type === 'payto') {
return (
)
}
if (node.type === 'websocket-url') {
return
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return
}
if (node.type === 'mention') {
return
}
if (node.type === 'hashtag') {
return
}
if (node.type === 'emoji') {
const shortcode = node.data.slice(1, -1).trim()
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (emoji) return
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) return
return
{node.data}
}
if (node.type === 'youtube') {
return (
)
}
return null
})}
{/* WebPreview cards for links from content (in order of appearance) */}
{contentLinks.length > 0 && (
Links
{contentLinks.map((url, index) => (
))}
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
Related Links
{tagLinks.map((url, index) => (
))}
)}
)
}