import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' import WebPreview from '@/components/WebPreview' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' import { useMediaExtraction } from '@/hooks' import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url' import { getImetaInfosFromEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { ExtendedKind, WS_URL_REGEX } from '@/constants' import React, { useMemo, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import { preprocessMarkdownMediaLinks } from './preprocessMarkup' /** * Truncate link display text to 200 characters, adding ellipsis if truncated */ function truncateLinkText(text: string, maxLength: number = 200): string { if (text.length <= maxLength) { return text } return text.substring(0, maxLength) + '...' } /** * Parse markdown content and render with post-processing for nostr: links and hashtags * Post-processes: * - nostr: links -> EmbeddedNote or EmbeddedMention * - #hashtags -> green hyperlinks to /notes?t=hashtag * - wss:// and ws:// URLs -> hyperlinks to /relays/{url} * Returns both rendered nodes and a set of hashtags found in content (for deduplication) */ function parseMarkdownContent( content: string, options: { eventPubkey: string imageIndexMap: Map openLightbox: (index: number) => void navigateToHashtag: (href: string) => void navigateToRelay: (url: string) => void } ): { nodes: React.ReactNode[]; hashtagsInContent: Set } { const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() let lastIndex = 0 // Find all patterns: markdown images, markdown links, relay URLs, nostr addresses, hashtags, wikilinks const patterns: Array<{ index: number; end: number; type: string; data: any }> = [] // Markdown images: ![](url) or ![alt](url) const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g const imageMatches = Array.from(content.matchAll(markdownImageRegex)) imageMatches.forEach(match => { if (match.index !== undefined) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'markdown-image', data: { alt: match[1], url: match[2] } }) } }) // Markdown links: [text](url) - but not images const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g const linkMatches = Array.from(content.matchAll(markdownLinkRegex)) linkMatches.forEach(match => { if (match.index !== undefined) { // Skip if this is already an image const isImage = content.substring(Math.max(0, match.index - 1), match.index) === '!' if (!isImage) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'markdown-link', data: { text: match[1], url: match[2] } }) } } }) // Relay URLs (wss:// or ws://) - not in markdown links const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX)) relayUrlMatches.forEach(match => { if (match.index !== undefined) { const url = match[0] // Only add if not already covered by a markdown link/image const isInMarkdown = patterns.some(p => (p.type === 'markdown-link' || p.type === 'markdown-image') && match.index! >= p.index && match.index! < p.end ) // Only process valid websocket URLs if (!isInMarkdown && isWebsocketUrl(url)) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'relay-url', data: { url } }) } } }) // Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links or relay URLs const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g const nostrMatches = Array.from(content.matchAll(nostrRegex)) nostrMatches.forEach(match => { if (match.index !== undefined) { // Only add if not already covered by a markdown link/image or relay URL const isInOther = patterns.some(p => (p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url') && match.index! >= p.index && match.index! < p.end ) if (!isInOther) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'nostr', data: match[1] }) } } }) // Hashtags (#tag) - but not inside markdown links, relay URLs, or nostr addresses const hashtagRegex = /#([a-zA-Z0-9_]+)/g const hashtagMatches = Array.from(content.matchAll(hashtagRegex)) hashtagMatches.forEach(match => { if (match.index !== undefined) { // Only add if not already covered by another pattern const isInOther = patterns.some(p => match.index! >= p.index && match.index! < p.end ) if (!isInOther) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'hashtag', data: match[1] }) } } }) // Wikilinks ([[link]] or [[link|display]]) const wikilinkRegex = /\[\[([^\]]+)\]\]/g const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex)) wikilinkMatches.forEach(match => { if (match.index !== undefined) { // Only add if not already covered by another pattern const isInOther = patterns.some(p => match.index! >= p.index && match.index! < p.end ) if (!isInOther) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'wikilink', data: match[1] }) } } }) // Sort patterns by index patterns.sort((a, b) => a.index - b.index) // Remove overlapping patterns (keep the first one) const filteredPatterns: typeof patterns = [] let lastEnd = 0 patterns.forEach(pattern => { if (pattern.index >= lastEnd) { filteredPatterns.push(pattern) lastEnd = pattern.end } }) // Build React nodes from patterns filteredPatterns.forEach((pattern, i) => { // Add text before pattern if (pattern.index > lastIndex) { const text = content.slice(lastIndex, pattern.index) if (text) { parts.push({text}) } } // Render pattern if (pattern.type === 'markdown-image') { const { url } = pattern.data const cleaned = cleanUrl(url) const imageIndex = imageIndexMap.get(cleaned) if (isImage(cleaned)) { parts.push(
{ e.stopPropagation() if (imageIndex !== undefined) { openLightbox(imageIndex) } }} />
) } else if (isVideo(cleaned) || isAudio(cleaned)) { parts.push(
) } } else if (pattern.type === 'markdown-link') { const { text, url } = pattern.data const displayText = truncateLinkText(text) // Check if it's a relay URL - if so, link to relay page instead if (isWebsocketUrl(url)) { const relayPath = `/relays/${encodeURIComponent(url)}` parts.push( { e.stopPropagation() e.preventDefault() navigateToRelay(relayPath) }} title={text.length > 200 ? text : undefined} > {displayText} ) } else { // Render as green link (will show WebPreview at bottom for HTTP/HTTPS) parts.push( e.stopPropagation()} title={text.length > 200 ? text : undefined} > {displayText} ) } } else if (pattern.type === 'relay-url') { const { url } = pattern.data const relayPath = `/relays/${encodeURIComponent(url)}` const displayText = truncateLinkText(url) parts.push( { e.stopPropagation() e.preventDefault() navigateToRelay(relayPath) }} title={url.length > 200 ? url : undefined} > {displayText} ) } else if (pattern.type === 'nostr') { const bech32Id = pattern.data // Check if it's a profile type (mentions/handles should be inline) if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { parts.push( ) } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { // Embedded events should be block-level and fill width parts.push(
) } else { parts.push(nostr:{bech32Id}) } } else if (pattern.type === 'hashtag') { const tag = pattern.data const tagLower = tag.toLowerCase() hashtagsInContent.add(tagLower) // Track hashtags rendered inline parts.push( { e.stopPropagation() e.preventDefault() navigateToHashtag(`/notes?t=${tagLower}`) }} > #{tag} ) } else if (pattern.type === 'wikilink') { const linkContent = pattern.data let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() if (linkContent.startsWith('book:')) { target = linkContent.replace('book:', '').trim() } const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') parts.push( ) } lastIndex = pattern.end }) // Add remaining text if (lastIndex < content.length) { const text = content.slice(lastIndex) if (text) { parts.push({text}) } } // If no patterns, just return the content as text if (parts.length === 0) { return { nodes: [{content}], hashtagsInContent } } return { nodes: parts, hashtagsInContent } } export default function MarkdownArticle({ event, className, hideMetadata = false }: { event: Event className?: string hideMetadata?: boolean }) { const { push } = useSecondaryPage() const { navigateToHashtag } = useSmartHashtagNavigation() const { navigateToRelay } = useSmartRelayNavigation() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) // Extract all media from event const extractedMedia = useMediaExtraction(event, event.content) // Extract media from tags only (for display at top) const tagMedia = useMemo(() => { const seenUrls = new Set() const media: Array<{ url: string; type: 'image' | 'video' | 'audio' }> = [] // Extract from imeta tags const imetaInfos = getImetaInfosFromEvent(event) imetaInfos.forEach((info) => { const cleaned = cleanUrl(info.url) if (!cleaned || seenUrls.has(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned)) return seenUrls.add(cleaned) if (info.m?.startsWith('image/') || isImage(cleaned)) { media.push({ url: info.url, type: 'image' }) } else if (info.m?.startsWith('video/') || isVideo(cleaned)) { media.push({ url: info.url, type: 'video' }) } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { media.push({ url: info.url, type: 'audio' }) } }) // Extract from r tags event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => { const url = tag[1] const cleaned = cleanUrl(url) if (!cleaned || seenUrls.has(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned)) return seenUrls.add(cleaned) if (isImage(cleaned)) { media.push({ url, type: 'image' }) } else if (isVideo(cleaned)) { media.push({ url, type: 'video' }) } else if (isAudio(cleaned)) { media.push({ url, type: 'audio' }) } }) // Extract from image tag const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) if (imageTag?.[1]) { const cleaned = cleanUrl(imageTag[1]) if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { seenUrls.add(cleaned) media.push({ url: imageTag[1], type: 'image' }) } } return media }, [event.id, JSON.stringify(event.tags)]) // Extract non-media links from tags const tagLinks = useMemo(() => { const links: string[] = [] const seenUrls = new Set() event.tags .filter(tag => tag[0] === 'r' && tag[1]) .forEach(tag => { const url = tag[1] if (!url.startsWith('http://') && !url.startsWith('https://')) return if (isImage(url) || isMedia(url)) return const cleaned = cleanUrl(url) if (cleaned && !seenUrls.has(cleaned)) { links.push(cleaned) seenUrls.add(cleaned) } }) return links }, [event.id, JSON.stringify(event.tags)]) // Get all images for gallery (deduplicated) const allImages = useMemo(() => { const seenUrls = new Set() const images: Array<{ url: string; alt?: string }> = [] // Add images from extractedMedia extractedMedia.images.forEach(img => { const cleaned = cleanUrl(img.url) if (cleaned && !seenUrls.has(cleaned)) { seenUrls.add(cleaned) images.push({ url: img.url, alt: img.alt }) } }) // Add metadata image if it exists if (metadata.image) { const cleaned = cleanUrl(metadata.image) if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { seenUrls.add(cleaned) images.push({ url: metadata.image }) } } return images }, [extractedMedia.images, metadata.image]) // Create image index map for lightbox const imageIndexMap = useMemo(() => { const map = new Map() allImages.forEach((img, index) => { const cleaned = cleanUrl(img.url) if (cleaned) map.set(cleaned, index) }) return map }, [allImages]) // Parse content to find media URLs that are already rendered const mediaUrlsInContent = useMemo(() => { const urls = new Set() const urlRegex = /https?:\/\/[^\s<>"']+/g let match while ((match = urlRegex.exec(event.content)) !== null) { const url = match[0] const cleaned = cleanUrl(url) if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned))) { urls.add(cleaned) } } return urls }, [event.content]) // Extract non-media links from content const contentLinks = useMemo(() => { const links: string[] = [] const seenUrls = new Set() const urlRegex = /https?:\/\/[^\s<>"']+/g let match while ((match = urlRegex.exec(event.content)) !== null) { const url = match[0] if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { const cleaned = cleanUrl(url) if (cleaned && !seenUrls.has(cleaned)) { links.push(cleaned) seenUrls.add(cleaned) } } } return links }, [event.content]) // Image gallery state const [lightboxIndex, setLightboxIndex] = useState(-1) const openLightbox = useCallback((index: number) => { setLightboxIndex(index) }, []) // Filter tag media to only show what's not in content const leftoverTagMedia = useMemo(() => { const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null return tagMedia.filter(media => { const cleaned = cleanUrl(media.url) if (!cleaned) return false // Skip if already in content if (mediaUrlsInContent.has(cleaned)) return false // Skip if this is the metadata image (shown separately) if (metadataImageUrl && cleaned === metadataImageUrl && !hideMetadata) return false return true }) }, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata]) // Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards) const leftoverTagLinks = useMemo(() => { const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean)) return tagLinks.filter(link => { const cleaned = cleanUrl(link) return cleaned && !contentLinksSet.has(cleaned) }) }, [tagLinks, contentLinks]) // Preprocess content to convert URLs to markdown syntax const preprocessedContent = useMemo(() => { return preprocessMarkdownMediaLinks(event.content) }, [event.content]) // Parse markdown content with post-processing for nostr: links and hashtags const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { return parseMarkdownContent(preprocessedContent, { eventPubkey: event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay }) }, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay]) // Filter metadata tags to only show what's not already in content const leftoverMetadataTags = useMemo(() => { return metadata.tags.filter(tag => !hashtagsInContent.has(tag.toLowerCase())) }, [metadata.tags, hashtagsInContent]) return ( <>
{/* Metadata */} {!hideMetadata && metadata.title &&

{metadata.title}

} {!hideMetadata && metadata.summary && (

{metadata.summary}

)} {hideMetadata && metadata.title && event.kind !== ExtendedKind.DISCUSSION && (

{metadata.title}

)} {/* Metadata image */} {!hideMetadata && metadata.image && (() => { const cleanedMetadataImage = cleanUrl(metadata.image) // Don't show if already in content if (cleanedMetadataImage && mediaUrlsInContent.has(cleanedMetadataImage)) { return null } const metadataImageIndex = imageIndexMap.get(cleanedMetadataImage) return ( { e.stopPropagation() if (metadataImageIndex !== undefined) { openLightbox(metadataImageIndex) } }} /> ) })()} {/* Media from tags (only if not in content) */} {leftoverTagMedia.length > 0 && (
{leftoverTagMedia.map((media) => { const cleaned = cleanUrl(media.url) const mediaIndex = imageIndexMap.get(cleaned) if (media.type === 'image') { return (
{ e.stopPropagation() if (mediaIndex !== undefined) { openLightbox(mediaIndex) } }} />
) } else if (media.type === 'video' || media.type === 'audio') { return (
) } return null })}
)} {/* Parsed content */}
{parsedContent}
{/* Hashtags from metadata (only if not already in content) */} {leftoverMetadataTags.length > 0 && (
{leftoverMetadataTags.map((tag) => (
{ e.stopPropagation() push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) }} > #{tag}
))}
)} {/* WebPreview cards for links from tags (only if not already in content) */} {/* Note: Links in content are already rendered as green hyperlinks above, so we don't show WebPreview for them */} {leftoverTagLinks.length > 0 && (
{leftoverTagLinks.map((url, index) => ( ))}
)}
{/* Image gallery lightbox */} {allImages.length > 0 && lightboxIndex >= 0 && createPortal(
e.stopPropagation()}> ({ src: url, alt: alt || url }))} plugins={[Zoom]} open={lightboxIndex >= 0} close={() => setLightboxIndex(-1)} controller={{ closeOnBackdropClick: true, closeOnPullUp: true, closeOnPullDown: true }} styles={{ toolbar: { paddingTop: '2.25rem' } }} carousel={{ finite: false }} />
, document.body )} ) }