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 YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' 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, YOUTUBE_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) + '...' } /** * Check if a URL is a YouTube URL */ function isYouTubeUrl(url: string): boolean { // Create a new regex instance to avoid state issues with global regex // Keep the 'i' flag for case-insensitivity but remove 'g' to avoid state issues const flags = YOUTUBE_URL_REGEX.flags.replace('g', '') const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags) return regex.test(url) } /** * 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 videoPosterMap?: Map } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map } { const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() const footnotes = new Map() 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] } }) } } }) // YouTube URLs - not in markdown links const youtubeUrlMatches = Array.from(content.matchAll(YOUTUBE_URL_REGEX)) youtubeUrlMatches.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 if not in markdown link if (!isInMarkdown && isYouTubeUrl(url)) { patterns.push({ index: match.index, end: match.index + match[0].length, type: 'youtube-url', data: { url } }) } } }) // 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 or YouTube URL const isInMarkdown = patterns.some(p => (p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'youtube-url') && 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, relay URLs, or YouTube 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, relay URL, or YouTube URL const isInOther = patterns.some(p => (p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-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] }) } } }) // Footnote references ([^1], [^note], etc.) - but not definitions const footnoteRefRegex = /\[\^([^\]]+)\]/g const footnoteRefMatches = Array.from(content.matchAll(footnoteRefRegex)) footnoteRefMatches.forEach(match => { if (match.index !== undefined) { // Skip if this is a footnote definition (has : after the closing bracket) const afterMatch = content.substring(match.index + match[0].length, match.index + match[0].length + 2) if (afterMatch.startsWith(']:')) { return // This is a definition, not a reference } // 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: 'footnote-ref', data: match[1] // footnote ID }) } } }) // Block-level patterns: headers, lists, horizontal rules, tables, footnotes - must be at start of line // Process line by line to detect block-level elements const lines = content.split('\n') let currentIndex = 0 const blockPatterns: Array<{ index: number; end: number; type: string; data: any }> = [] // First pass: extract footnote definitions lines.forEach((line) => { const footnoteDefMatch = line.match(/^\[\^([^\]]+)\]:\s+(.+)$/) if (footnoteDefMatch) { const footnoteId = footnoteDefMatch[1] const footnoteText = footnoteDefMatch[2] footnotes.set(footnoteId, footnoteText) } }) // Second pass: detect tables and other block-level elements let lineIdx = 0 while (lineIdx < lines.length) { const line = lines[lineIdx] const lineStartIndex = currentIndex const lineEndIndex = currentIndex + line.length // Tables: detect table rows (must have | characters) // GitHub markdown table format: header row, separator row (|---|), data rows if (line.includes('|') && line.trim().startsWith('|') && line.trim().endsWith('|')) { // Check if this is a table by looking at the next line (separator) if (lineIdx + 1 < lines.length) { const nextLine = lines[lineIdx + 1] const nextLineTrimmed = nextLine.trim() // Table separator looks like: |---|---| or |:---|:---:|---:| or | -------- | ------- | // Must start and end with |, and contain only spaces, dashes, colons, and pipes const isSeparator = nextLineTrimmed.startsWith('|') && nextLineTrimmed.endsWith('|') && /^[\|\s\:\-]+$/.test(nextLineTrimmed) && nextLineTrimmed.includes('-') if (isSeparator) { // This is a table! Collect all table rows const tableRows: string[] = [] const tableStartIndex = lineStartIndex let tableEndIndex = lineEndIndex let tableLineIdx = lineIdx // Collect header row tableRows.push(line) tableLineIdx++ tableEndIndex += nextLine.length + 1 tableLineIdx++ // Skip separator // Collect data rows until we hit a non-table line while (tableLineIdx < lines.length) { const tableLine = lines[tableLineIdx] const tableLineTrimmed = tableLine.trim() // Check if it's a table row (starts and ends with |) if (tableLineTrimmed.startsWith('|') && tableLineTrimmed.endsWith('|')) { // Check if it's another separator row (skip it) const isAnotherSeparator = /^[\|\s\:\-]+$/.test(tableLineTrimmed) && tableLineTrimmed.includes('-') if (!isAnotherSeparator) { tableRows.push(tableLine) tableEndIndex += tableLine.length + 1 } tableLineIdx++ } else { break } } // Parse table rows into cells const parsedRows: string[][] = [] tableRows.forEach((row) => { // Split by |, trim each cell, filter out empty edge cells const rawCells = row.split('|') const cells = rawCells .map(cell => cell.trim()) .filter((cell, idx) => { // Remove empty cells at the very start and end (from leading/trailing |) if (idx === 0 && cell === '') return false if (idx === rawCells.length - 1 && cell === '') return false return true }) if (cells.length > 0) { parsedRows.push(cells) } }) if (parsedRows.length > 0) { blockPatterns.push({ index: tableStartIndex, end: tableEndIndex, type: 'table', data: { rows: parsedRows, lineNum: lineIdx } }) // Skip all table lines currentIndex = tableEndIndex + 1 lineIdx = tableLineIdx continue } } } } // Headers (# Header, ## Header, etc.) const headerMatch = line.match(/^(#{1,6})\s+(.+)$/) if (headerMatch) { const headerLevel = headerMatch[1].length const headerText = headerMatch[2] blockPatterns.push({ index: lineStartIndex, end: lineEndIndex, type: 'header', data: { level: headerLevel, text: headerText, lineNum: lineIdx } }) } // Horizontal rule (---- or ====, at least 3 dashes/equals) else if (line.match(/^[-=]{3,}\s*$/)) { blockPatterns.push({ index: lineStartIndex, end: lineEndIndex, type: 'horizontal-rule', data: { lineNum: lineIdx } }) } // Bullet list (* item or - item) else if (line.match(/^[\*\-\+]\s+.+$/)) { const listMatch = line.match(/^[\*\-\+]\s+(.+)$/) if (listMatch) { blockPatterns.push({ index: lineStartIndex, end: lineEndIndex, type: 'bullet-list-item', data: { text: listMatch[1], lineNum: lineIdx } }) } } // Numbered list (1. item, 2. item, etc.) else if (line.match(/^\d+\.\s+.+$/)) { const listMatch = line.match(/^\d+\.\s+(.+)$/) if (listMatch) { blockPatterns.push({ index: lineStartIndex, end: lineEndIndex, type: 'numbered-list-item', data: { text: listMatch[1], lineNum: lineIdx, number: line.match(/^(\d+)/)?.[1] } }) } } // Footnote definition (already extracted, but mark it so we don't render it in content) else if (line.match(/^\[\^([^\]]+)\]:\s+.+$/)) { blockPatterns.push({ index: lineStartIndex, end: lineEndIndex, type: 'footnote-definition', data: { lineNum: lineIdx } }) } currentIndex += line.length + 1 // +1 for newline lineIdx++ } // Add block patterns to main patterns array blockPatterns.forEach(pattern => { patterns.push(pattern) }) // Sort patterns by index patterns.sort((a, b) => a.index - b.index) // Remove overlapping patterns (keep the first one) // Block-level patterns (headers, lists, horizontal rules, tables) take priority const filteredPatterns: typeof patterns = [] const blockLevelTypes = ['header', 'horizontal-rule', 'bullet-list-item', 'numbered-list-item', 'table', 'footnote-definition'] const blockLevelPatterns = patterns.filter(p => blockLevelTypes.includes(p.type)) const otherPatterns = patterns.filter(p => !blockLevelTypes.includes(p.type)) // First add all block-level patterns blockLevelPatterns.forEach(pattern => { filteredPatterns.push(pattern) }) // Then add other patterns that don't overlap with block-level patterns otherPatterns.forEach(pattern => { const overlapsWithBlock = blockLevelPatterns.some(blockPattern => (pattern.index >= blockPattern.index && pattern.index < blockPattern.end) || (pattern.end > blockPattern.index && pattern.end <= blockPattern.end) || (pattern.index <= blockPattern.index && pattern.end >= blockPattern.end) ) if (!overlapsWithBlock) { // Check for overlaps with existing filtered patterns const overlaps = filteredPatterns.some(p => (pattern.index >= p.index && pattern.index < p.end) || (pattern.end > p.index && pattern.end <= p.end) || (pattern.index <= p.index && pattern.end >= p.end) ) if (!overlaps) { filteredPatterns.push(pattern) } } }) // Re-sort by index filteredPatterns.sort((a, b) => a.index - b.index) // Build React nodes from patterns filteredPatterns.forEach((pattern, patternIdx) => { // Add text before pattern if (pattern.index > lastIndex) { const text = content.slice(lastIndex, pattern.index) // Skip whitespace-only text to avoid empty spans between block elements if (text && text.trim()) { // Process text for inline formatting (bold, italic, etc.) // But skip if this text is part of a table (tables are handled as block patterns) const isInTable = blockLevelPatterns.some(p => p.type === 'table' && lastIndex >= p.index && lastIndex < p.end ) if (!isInTable) { parts.push(...parseInlineMarkdown(text, `text-${patternIdx}`, footnotes)) } } } // 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)) { const poster = videoPosterMap?.get(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 if (isYouTubeUrl(url)) { // Render YouTube URL as embedded player parts.push(
) } else { // Render as WebPreview component (shows opengraph data or fallback card) parts.push(
) } } else if (pattern.type === 'youtube-url') { const { url } = pattern.data // Render YouTube URL as embedded player parts.push(
) } 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 === 'header') { const { level, text } = pattern.data // Parse the header text for inline formatting (but not nested headers) const headerContent = parseInlineMarkdown(text, `header-${patternIdx}`, footnotes) const HeaderTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements parts.push( {headerContent} ) } else if (pattern.type === 'horizontal-rule') { parts.push(
) } else if (pattern.type === 'bullet-list-item') { const { text } = pattern.data const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes) parts.push(
  • {listContent}
  • ) } else if (pattern.type === 'numbered-list-item') { const { text, number } = pattern.data const listContent = parseInlineMarkdown(text, `numbered-${patternIdx}`, footnotes) const itemNumber = number ? parseInt(number, 10) : undefined parts.push(
  • {listContent}
  • ) } else if (pattern.type === 'table') { const { rows } = pattern.data if (rows.length > 0) { const headerRow = rows[0] const dataRows = rows.slice(1) parts.push(
    {headerRow.map((cell: string, cellIdx: number) => ( ))} {dataRows.map((row: string[], rowIdx: number) => ( {row.map((cell: string, cellIdx: number) => ( ))} ))}
    {parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes)}
    {parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes)}
    ) } } else if (pattern.type === 'footnote-definition') { // Don't render footnote definitions in the main content - they'll be rendered at the bottom // Just skip this pattern } else if (pattern.type === 'footnote-ref') { const footnoteId = pattern.data const footnoteText = footnotes.get(footnoteId) if (footnoteText) { parts.push( { e.preventDefault() const footnoteElement = document.getElementById(`footnote-${footnoteId}`) if (footnoteElement) { footnoteElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }} > [{footnoteId}] ) } else { // Footnote not found, just render the reference as-is parts.push([^{footnoteId}]) } } 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) // Skip whitespace-only text to avoid empty spans if (text && text.trim()) { // Process text for inline formatting // But skip if this text is part of a table const isInTable = blockLevelPatterns.some(p => p.type === 'table' && lastIndex >= p.index && lastIndex < p.end ) if (!isInTable) { parts.push(...parseInlineMarkdown(text, 'text-end', footnotes)) } } } // If no patterns, just return the content as text (with inline formatting) if (parts.length === 0) { const formattedContent = parseInlineMarkdown(content, 'text-only', footnotes) return { nodes: formattedContent, hashtagsInContent, footnotes } } // Filter out empty spans before wrapping lists const filteredParts = parts.filter(part => { if (React.isValidElement(part) && part.type === 'span') { const children = part.props.children // Filter out spans with only whitespace or empty content if (typeof children === 'string' && !children.trim()) { return false } if (Array.isArray(children) && children.every(child => typeof child === 'string' && !child.trim())) { return false } } return true }) // Wrap list items in
      or
        tags const wrappedParts: React.ReactNode[] = [] let partIdx = 0 while (partIdx < filteredParts.length) { const part = filteredParts[partIdx] // Check if this is a list item if (React.isValidElement(part) && part.type === 'li') { // Determine if it's a bullet or numbered list const isBullet = part.key && part.key.toString().startsWith('bullet-') const isNumbered = part.key && part.key.toString().startsWith('numbered-') if (isBullet || isNumbered) { // Collect consecutive list items of the same type const listItems: React.ReactNode[] = [part] partIdx++ while (partIdx < filteredParts.length) { const nextPart = filteredParts[partIdx] if (React.isValidElement(nextPart) && nextPart.type === 'li') { const nextIsBullet = nextPart.key && nextPart.key.toString().startsWith('bullet-') const nextIsNumbered = nextPart.key && nextPart.key.toString().startsWith('numbered-') if ((isBullet && nextIsBullet) || (isNumbered && nextIsNumbered)) { listItems.push(nextPart) partIdx++ } else { break } } else { break } } // Wrap in
          or
            if (isBullet) { wrappedParts.push(
              {listItems}
            ) } else { wrappedParts.push(
              {listItems}
            ) } continue } } wrappedParts.push(part) partIdx++ } // Add footnotes section at the end if there are any footnotes if (footnotes.size > 0) { wrappedParts.push(

            Footnotes

              {Array.from(footnotes.entries()).map(([id, text]) => (
            1. [{id}]:{' '} {parseInlineMarkdown(text, `footnote-${id}`, footnotes)} {' '} { e.preventDefault() const refElement = document.getElementById(`footnote-ref-${id}`) if (refElement) { refElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }} > ↩
            2. ))}
            ) } return { nodes: wrappedParts, hashtagsInContent, footnotes } } /** * Parse inline markdown formatting (bold, italic, strikethrough, inline code, footnote references) * Returns an array of React nodes * * Supports: * - Bold: **text** or __text__ (double) or *text* (single asterisk) * - Italic: _text_ (single underscore) or __text__ (double underscore, but bold takes priority) * - Strikethrough: ~~text~~ (double tilde) or ~text~ (single tilde) * - Inline code: ``code`` (double backtick) or `code` (single backtick) * - Footnote references: [^1] (handled at block level, but parsed here for inline context) */ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map = new Map()): React.ReactNode[] { const parts: React.ReactNode[] = [] let lastIndex = 0 const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = [] // Inline code: ``code`` (double backtick) or `code` (single backtick) - process first to avoid conflicts // Double backticks first const doubleCodeRegex = /``([^`\n]+?)``/g const doubleCodeMatches = Array.from(text.matchAll(doubleCodeRegex)) doubleCodeMatches.forEach(match => { if (match.index !== undefined) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'code', data: match[1] }) } }) // Single backtick (but not if already in double backtick) const singleCodeRegex = /`([^`\n]+?)`/g const singleCodeMatches = Array.from(text.matchAll(singleCodeRegex)) singleCodeMatches.forEach(match => { if (match.index !== undefined) { const isInDoubleCode = inlinePatterns.some(p => p.type === 'code' && match.index! >= p.index && match.index! < p.end ) if (!isInDoubleCode) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'code', data: match[1] }) } } }) // Bold: **text** (double asterisk) or __text__ (double underscore) - process first // Also handle *text* (single asterisk) as bold const doubleBoldAsteriskRegex = /\*\*(.+?)\*\*/g const doubleBoldAsteriskMatches = Array.from(text.matchAll(doubleBoldAsteriskRegex)) doubleBoldAsteriskMatches.forEach(match => { if (match.index !== undefined) { // Skip if already in code const isInCode = inlinePatterns.some(p => p.type === 'code' && match.index! >= p.index && match.index! < p.end ) if (!isInCode) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'bold', data: match[1] }) } } }) // Double underscore bold (but check if it's already italic) const doubleBoldUnderscoreRegex = /__(.+?)__/g const doubleBoldUnderscoreMatches = Array.from(text.matchAll(doubleBoldUnderscoreRegex)) doubleBoldUnderscoreMatches.forEach(match => { if (match.index !== undefined) { // Skip if already in code or bold const isInOther = inlinePatterns.some(p => (p.type === 'code' || p.type === 'bold') && match.index! >= p.index && match.index! < p.end ) if (!isInOther) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'bold', data: match[1] }) } } }) // Single asterisk bold: *text* (not part of **bold**) const singleBoldAsteriskRegex = /(? { if (match.index !== undefined) { // Skip if already in code, double bold, or strikethrough const isInOther = inlinePatterns.some(p => (p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') && match.index! >= p.index && match.index! < p.end ) if (!isInOther) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'bold', data: match[1] }) } } }) // Strikethrough: ~~text~~ (double tilde) or ~text~ (single tilde) // Double tildes first const doubleStrikethroughRegex = /~~(.+?)~~/g const doubleStrikethroughMatches = Array.from(text.matchAll(doubleStrikethroughRegex)) doubleStrikethroughMatches.forEach(match => { if (match.index !== undefined) { // Skip if already in code or bold const isInOther = inlinePatterns.some(p => (p.type === 'code' || p.type === 'bold') && match.index! >= p.index && match.index! < p.end ) if (!isInOther) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'strikethrough', data: match[1] }) } } }) // Single tilde strikethrough const singleStrikethroughRegex = /(? { if (match.index !== undefined) { // Skip if already in code, bold, or double strikethrough const isInOther = inlinePatterns.some(p => (p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') && match.index! >= p.index && match.index! < p.end ) if (!isInOther) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'strikethrough', data: match[1] }) } } }) // Italic: _text_ (single underscore) or __text__ (double underscore, but bold takes priority) // Single underscore italic (not part of __bold__) const singleItalicUnderscoreRegex = /(? { if (match.index !== undefined) { // Skip if already in code, bold, or strikethrough const isInOther = inlinePatterns.some(p => (p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') && match.index! >= p.index && match.index! < p.end ) if (!isInOther) { inlinePatterns.push({ index: match.index, end: match.index + match[0].length, type: 'italic', data: match[1] }) } } }) // Double underscore italic (only if not already bold) // Note: __text__ is bold by default, but if user wants it italic, we can add it // For now, we'll keep __text__ as bold only, and _text_ as italic // Sort by index inlinePatterns.sort((a, b) => a.index - b.index) // Remove overlaps (keep first) const filtered: typeof inlinePatterns = [] let lastEnd = 0 inlinePatterns.forEach(pattern => { if (pattern.index >= lastEnd) { filtered.push(pattern) lastEnd = pattern.end } }) // Build nodes filtered.forEach((pattern, i) => { // Add text before pattern if (pattern.index > lastIndex) { const textBefore = text.slice(lastIndex, pattern.index) if (textBefore) { parts.push({textBefore}) } } // Render pattern if (pattern.type === 'bold') { parts.push({pattern.data}) } else if (pattern.type === 'italic') { parts.push({pattern.data}) } else if (pattern.type === 'strikethrough') { parts.push({pattern.data}) } else if (pattern.type === 'code') { parts.push( {pattern.data} ) } lastIndex = pattern.end }) // Add remaining text if (lastIndex < text.length) { const remaining = text.slice(lastIndex) if (remaining) { parts.push({remaining}) } } // If no patterns found, return the text as-is if (parts.length === 0) { return [{text}] } return parts } 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'; poster?: string }> = [] // 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', poster: info.image }) } 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 YouTube URLs from tags (for display at top) const tagYouTubeUrls = useMemo(() => { const youtubeUrls: 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 (!isYouTubeUrl(url)) return const cleaned = cleanUrl(url) if (cleaned && !seenUrls.has(cleaned)) { youtubeUrls.push(cleaned) seenUrls.add(cleaned) } }) return youtubeUrls }, [event.id, JSON.stringify(event.tags)]) // Extract non-media links from tags (excluding YouTube URLs) 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 if (isYouTubeUrl(url)) return // Exclude YouTube URLs 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 YouTube URLs from content const youtubeUrlsInContent = 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 && isYouTubeUrl(cleaned)) { urls.add(cleaned) } } return urls }, [event.content]) // Extract non-media links from content (excluding YouTube URLs) 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) && !isYouTubeUrl(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 YouTube URLs to only show what's not in content const leftoverTagYouTubeUrls = useMemo(() => { return tagYouTubeUrls.filter(url => { const cleaned = cleanUrl(url) return cleaned && !youtubeUrlsInContent.has(cleaned) }) }, [tagYouTubeUrls, youtubeUrlsInContent]) // 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]) // Create video poster map from imeta tags const videoPosterMap = useMemo(() => { const map = new Map() const imetaInfos = getImetaInfosFromEvent(event) imetaInfos.forEach((info) => { if (info.image && (info.m?.startsWith('video/') || isVideo(info.url))) { const cleaned = cleanUrl(info.url) if (cleaned) { map.set(cleaned, info.image) } } }) return map }, [event.id, JSON.stringify(event.tags)]) // Parse markdown content with post-processing for nostr: links and hashtags const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { const result = parseMarkdownContent(preprocessedContent, { eventPubkey: event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap }) // Return nodes and hashtags (footnotes are already included in nodes) return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent } }, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap]) // 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 })}
            )} {/* YouTube URLs from tags (only if not in content) */} {leftoverTagYouTubeUrls.length > 0 && (
            {leftoverTagYouTubeUrls.map((url) => { const cleaned = cleanUrl(url) return (
            ) })}
            )} {/* 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 )} ) }