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.
 
 
 

1567 lines
56 KiB

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<string, number>
openLightbox: (index: number) => void
navigateToHashtag: (href: string) => void
navigateToRelay: (url: string) => void
videoPosterMap?: Map<string, string>
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string> } {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap } = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>()
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(
<div key={`img-${patternIdx}`} className="my-2 block">
<Image
image={{ url, pubkey: eventPubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg block',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (imageIndex !== undefined) {
openLightbox(imageIndex)
}
}}
/>
</div>
)
} else if (isVideo(cleaned) || isAudio(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
parts.push(
<div key={`media-${patternIdx}`} className="my-2">
<MediaPlayer
src={cleaned}
className="max-w-[400px]"
mustLoad={false}
poster={poster}
/>
</div>
)
}
} 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(
<a
key={`relay-${patternIdx}`}
href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
}}
title={text.length > 200 ? text : undefined}
>
{displayText}
</a>
)
} else if (isYouTubeUrl(url)) {
// Render YouTube URL as embedded player
parts.push(
<div key={`youtube-${patternIdx}`} className="my-2">
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
/>
</div>
)
} else {
// Render as WebPreview component (shows opengraph data or fallback card)
parts.push(
<div key={`link-${patternIdx}`} className="my-2">
<WebPreview url={url} className="w-full" />
</div>
)
}
} else if (pattern.type === 'youtube-url') {
const { url } = pattern.data
// Render YouTube URL as embedded player
parts.push(
<div key={`youtube-url-${patternIdx}`} className="my-2">
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
/>
</div>
)
} else if (pattern.type === 'relay-url') {
const { url } = pattern.data
const relayPath = `/relays/${encodeURIComponent(url)}`
const displayText = truncateLinkText(url)
parts.push(
<a
key={`relay-${patternIdx}`}
href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
}}
title={url.length > 200 ? url : undefined}
>
{displayText}
</a>
)
} 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(
<HeaderTag
key={`header-${patternIdx}`}
className={`font-bold break-words block mt-4 mb-2 ${
level === 1 ? 'text-3xl' :
level === 2 ? 'text-2xl' :
level === 3 ? 'text-xl' :
level === 4 ? 'text-lg' :
level === 5 ? 'text-base' :
'text-sm'
}`}
>
{headerContent}
</HeaderTag>
)
} else if (pattern.type === 'horizontal-rule') {
parts.push(
<hr key={`hr-${patternIdx}`} className="my-4 border-t border-gray-300 dark:border-gray-700" />
)
} else if (pattern.type === 'bullet-list-item') {
const { text } = pattern.data
const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes)
parts.push(
<li key={`bullet-${patternIdx}`} className="list-disc list-inside my-1">
{listContent}
</li>
)
} 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(
<li key={`numbered-${patternIdx}`} className="leading-tight" value={itemNumber}>
{listContent}
</li>
)
} else if (pattern.type === 'table') {
const { rows } = pattern.data
if (rows.length > 0) {
const headerRow = rows[0]
const dataRows = rows.slice(1)
parts.push(
<div key={`table-${patternIdx}`} className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-300 dark:border-gray-700">
<thead>
<tr>
{headerRow.map((cell: string, cellIdx: number) => (
<th
key={`th-${patternIdx}-${cellIdx}`}
className="border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-left"
>
{parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes)}
</th>
))}
</tr>
</thead>
<tbody>
{dataRows.map((row: string[], rowIdx: number) => (
<tr key={`tr-${patternIdx}-${rowIdx}`}>
{row.map((cell: string, cellIdx: number) => (
<td
key={`td-${patternIdx}-${rowIdx}-${cellIdx}`}
className="border border-gray-300 dark:border-gray-700 px-4 py-2"
>
{parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
} 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(
<sup key={`footnote-ref-${patternIdx}`} className="footnote-ref">
<a
href={`#footnote-${footnoteId}`}
id={`footnote-ref-${footnoteId}`}
className="text-blue-600 dark:text-blue-400 hover:underline no-underline"
onClick={(e) => {
e.preventDefault()
const footnoteElement = document.getElementById(`footnote-${footnoteId}`)
if (footnoteElement) {
footnoteElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
[{footnoteId}]
</a>
</sup>
)
} else {
// Footnote not found, just render the reference as-is
parts.push(<span key={`footnote-ref-${patternIdx}`}>[^{footnoteId}]</span>)
}
} 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(
<span key={`nostr-${patternIdx}`} className="inline-block">
<EmbeddedMention userId={bech32Id} />
</span>
)
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) {
// Embedded events should be block-level and fill width
parts.push(
<div key={`nostr-${patternIdx}`} className="w-full my-2">
<EmbeddedNote noteId={bech32Id} />
</div>
)
} else {
parts.push(<span key={`nostr-${patternIdx}`}>nostr:{bech32Id}</span>)
}
} else if (pattern.type === 'hashtag') {
const tag = pattern.data
const tagLower = tag.toLowerCase()
hashtagsInContent.add(tagLower) // Track hashtags rendered inline
parts.push(
<a
key={`hashtag-${patternIdx}`}
href={`/notes?t=${tagLower}`}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToHashtag(`/notes?t=${tagLower}`)
}}
>
#{tag}
</a>
)
} 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(
<Wikilink key={`wikilink-${patternIdx}`} dTag={dtag} displayText={displayText} />
)
}
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 <ul> or <ol> 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 <ul> or <ol>
if (isBullet) {
wrappedParts.push(
<ul key={`ul-${partIdx}`} className="list-disc list-inside my-2 space-y-1">
{listItems}
</ul>
)
} else {
wrappedParts.push(
<ol key={`ol-${partIdx}`} className="list-decimal list-outside my-2 ml-6">
{listItems}
</ol>
)
}
continue
}
}
wrappedParts.push(part)
partIdx++
}
// Add footnotes section at the end if there are any footnotes
if (footnotes.size > 0) {
wrappedParts.push(
<div key="footnotes-section" className="mt-8 pt-4 border-t border-gray-300 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Footnotes</h3>
<ol className="list-decimal list-inside space-y-2">
{Array.from(footnotes.entries()).map(([id, text]) => (
<li
key={`footnote-${id}`}
id={`footnote-${id}`}
className="text-sm text-gray-700 dark:text-gray-300"
>
<span className="font-semibold">[{id}]:</span>{' '}
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes)}</span>
{' '}
<a
href={`#footnote-ref-${id}`}
className="text-blue-600 dark:text-blue-400 hover:underline text-xs"
onClick={(e) => {
e.preventDefault()
const refElement = document.getElementById(`footnote-ref-${id}`)
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
</a>
</li>
))}
</ol>
</div>
)
}
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<string, string> = 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 = /(?<!\*)\*([^*\n]+?)\*(?!\*)/g
const singleBoldAsteriskMatches = Array.from(text.matchAll(singleBoldAsteriskRegex))
singleBoldAsteriskMatches.forEach(match => {
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 = /(?<!~)~([^~\n]+?)~(?!~)/g
const singleStrikethroughMatches = Array.from(text.matchAll(singleStrikethroughRegex))
singleStrikethroughMatches.forEach(match => {
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 = /(?<!_)_([^_\n]+?)_(?!_)/g
const singleItalicUnderscoreMatches = Array.from(text.matchAll(singleItalicUnderscoreRegex))
singleItalicUnderscoreMatches.forEach(match => {
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(<span key={`${keyPrefix}-inline-text-${i}`}>{textBefore}</span>)
}
}
// Render pattern
if (pattern.type === 'bold') {
parts.push(<strong key={`${keyPrefix}-bold-${i}`}>{pattern.data}</strong>)
} else if (pattern.type === 'italic') {
parts.push(<em key={`${keyPrefix}-italic-${i}`}>{pattern.data}</em>)
} else if (pattern.type === 'strikethrough') {
parts.push(<del key={`${keyPrefix}-strikethrough-${i}`} className="line-through">{pattern.data}</del>)
} else if (pattern.type === 'code') {
parts.push(
<code key={`${keyPrefix}-code-${i}`} className="bg-muted px-1 py-0.5 rounded text-sm font-mono">
{pattern.data}
</code>
)
}
lastIndex = pattern.end
})
// Add remaining text
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex)
if (remaining) {
parts.push(<span key={`${keyPrefix}-inline-text-final`}>{remaining}</span>)
}
}
// If no patterns found, return the text as-is
if (parts.length === 0) {
return [<span key={`${keyPrefix}-plain`}>{text}</span>]
}
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<string>()
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<string>()
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<string>()
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<string>()
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<string, number>()
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<string>()
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<string>()
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<string>()
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<string, string>()
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 (
<>
<style>{`
.prose ol[class*="list-decimal"] {
list-style-type: decimal !important;
}
.prose ol[class*="list-decimal"] li {
display: list-item !important;
list-style-position: outside !important;
line-height: 1.25 !important;
margin-bottom: 0 !important;
}
`}</style>
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{/* Metadata */}
{!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideMetadata && metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{hideMetadata && metadata.title && event.kind !== ExtendedKind.DISCUSSION && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{/* 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 (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto my-0 cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (metadataImageIndex !== undefined) {
openLightbox(metadataImageIndex)
}
}}
/>
)
})()}
{/* Media from tags (only if not in content) */}
{leftoverTagMedia.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagMedia.map((media) => {
const cleaned = cleanUrl(media.url)
const mediaIndex = imageIndexMap.get(cleaned)
if (media.type === 'image') {
return (
<div key={`tag-media-${cleaned}`} className="my-2">
<Image
image={{ url: media.url, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (mediaIndex !== undefined) {
openLightbox(mediaIndex)
}
}}
/>
</div>
)
} else if (media.type === 'video' || media.type === 'audio') {
return (
<div key={`tag-media-${cleaned}`} className="my-2">
<MediaPlayer
src={media.url}
className="max-w-[400px]"
mustLoad={true}
poster={media.poster}
/>
</div>
)
}
return null
})}
</div>
)}
{/* YouTube URLs from tags (only if not in content) */}
{leftoverTagYouTubeUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagYouTubeUrls.map((url) => {
const cleaned = cleanUrl(url)
return (
<div key={`tag-youtube-${cleaned}`} className="my-2">
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
/>
</div>
)
})}
</div>
)}
{/* Parsed content */}
<div className="break-words whitespace-pre-wrap">
{parsedContent}
</div>
{/* Hashtags from metadata (only if not already in content) */}
{leftoverMetadataTags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2 mt-4">
{leftoverMetadataTags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
{/* 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 && (
<div className="space-y-3 mt-6">
{leftoverTagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
{/* Image gallery lightbox */}
{allImages.length > 0 && lightboxIndex >= 0 && createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({
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
}}
/>
</div>,
document.body
)}
</>
)
}