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.
1432 lines
57 KiB
1432 lines
57 KiB
import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager' |
|
import Image from '@/components/Image' |
|
import MediaPlayer from '@/components/MediaPlayer' |
|
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 { useMemo, useState, useCallback, useEffect, useRef } from 'react' |
|
import { createPortal } from 'react-dom' |
|
import { createRoot, Root } from 'react-dom/client' |
|
import Lightbox from 'yet-another-react-lightbox' |
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
|
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' |
|
import Wikilink from '@/components/UniversalContent/Wikilink' |
|
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' |
|
import logger from '@/lib/logger' |
|
import katex from 'katex' |
|
import 'katex/dist/katex.min.css' |
|
import { WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' |
|
|
|
/** |
|
* 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) |
|
} |
|
|
|
/** |
|
* Convert markdown syntax to AsciiDoc syntax |
|
* This converts all markdown elements to their AsciiDoc equivalents before processing |
|
*/ |
|
function convertMarkdownToAsciidoc(content: string): string { |
|
let asciidoc = content |
|
|
|
// Note: We don't remove front matter here because the user's content uses --- as horizontal rules |
|
// If there's actual YAML front matter, it should be handled separately |
|
// For now, we'll convert --- to horizontal rules (except table separators) |
|
|
|
// Convert nostr addresses directly to AsciiDoc link format |
|
// Do this early so they're protected from other markdown conversions |
|
// naddr addresses can be 200+ characters, so we use + instead of specific length |
|
asciidoc = asciidoc.replace(/nostr:(npub1[a-z0-9]{58,}|nprofile1[a-z0-9]+|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g, (_match, bech32Id) => { |
|
// Convert directly to AsciiDoc link format |
|
// This will be processed later in HTML post-processing to render as React components |
|
return `link:nostr:${bech32Id}[${bech32Id}]` |
|
}) |
|
|
|
// Protect code blocks - we'll process them separately |
|
const codeBlockPlaceholders: string[] = [] |
|
asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => { |
|
const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__` |
|
codeBlockPlaceholders.push(`[source${lang ? ',' + lang : ''}]\n----\n${code.trim()}\n----`) |
|
return placeholder |
|
}) |
|
|
|
// Protect inline code - but handle LaTeX math separately |
|
const inlineCodePlaceholders: string[] = [] |
|
|
|
// Handle LaTeX math in inline code blocks like `$...$` |
|
// The content may have escaped backslashes: `$\\frac{\\infty}{21,000,000} = \\infty$` |
|
// We need to detect LaTeX math and convert it to AsciiDoc stem: syntax |
|
asciidoc = asciidoc.replace(/`([^`\n]+)`/g, (_match, content) => { |
|
// Check if this is LaTeX math - pattern: $...$ where ... contains LaTeX syntax |
|
// Match the full pattern: $ followed by LaTeX expression and ending with $ |
|
const latexMatch = content.match(/^\$([^$]+)\$$/) |
|
if (latexMatch) { |
|
// This is pure LaTeX math - convert to AsciiDoc stem syntax |
|
const latexExpr = latexMatch[1] |
|
// The latexExpr contains the LaTeX code (backslashes are already in the string) |
|
// AsciiDoc stem:[...] will process this with the stem processor |
|
return `stem:[${latexExpr}]` |
|
} |
|
|
|
// Check if content contains LaTeX math mixed with other text |
|
if (content.includes('$') && content.match(/\$[^$]+\$/)) { |
|
// Replace $...$ parts with stem:[...] |
|
const processed = content.replace(/\$([^$]+)\$/g, 'stem:[$1]') |
|
// If it's now just stem, return it directly, otherwise it needs to be in code |
|
if (processed.startsWith('stem:[') && processed.endsWith(']') && !processed.includes('`')) { |
|
return processed |
|
} |
|
// Mixed content - keep as code but with stem inside (won't work well, but preserve it) |
|
const placeholder = `__INLINE_CODE_${inlineCodePlaceholders.length}__` |
|
inlineCodePlaceholders.push(`\`${processed}\``) |
|
return placeholder |
|
} |
|
|
|
// Regular inline code - preserve it |
|
const placeholder = `__INLINE_CODE_${inlineCodePlaceholders.length}__` |
|
inlineCodePlaceholders.push(`\`${content}\``) |
|
return placeholder |
|
}) |
|
|
|
// Convert headers (must be at start of line) |
|
asciidoc = asciidoc.replace(/^#{6}\s+(.+)$/gm, '====== $1 ======') |
|
asciidoc = asciidoc.replace(/^#{5}\s+(.+)$/gm, '===== $1 =====') |
|
asciidoc = asciidoc.replace(/^#{4}\s+(.+)$/gm, '==== $1 ====') |
|
asciidoc = asciidoc.replace(/^#{3}\s+(.+)$/gm, '=== $1 ===') |
|
asciidoc = asciidoc.replace(/^#{2}\s+(.+)$/gm, '== $1 ==') |
|
asciidoc = asciidoc.replace(/^#{1}\s+(.+)$/gm, '= $1 =') |
|
|
|
// Convert tables BEFORE horizontal rules (to avoid converting table separators) |
|
// Markdown tables: | col1 | col2 |\n|------|------|\n| data1 | data2 | |
|
// Use a simpler approach: match lines with pipes, separator row, and data rows |
|
asciidoc = asciidoc.replace(/(\|[^\n]+\|\s*\n\|[\s\-\|:]+\|\s*\n(?:\|[^\n]+\|\s*\n?)+)/gm, (match) => { |
|
const lines = match.trim().split('\n').map(line => line.trim()).filter(line => line) |
|
if (lines.length < 2) return match |
|
|
|
// First line is header, second is separator, rest are data |
|
const headerRow = lines[0] |
|
const separatorRow = lines[1] |
|
|
|
// Verify it's a table separator (has dashes) |
|
if (!separatorRow.match(/[\-:]/)) return match |
|
|
|
// Parse header cells - markdown format: | col1 | col2 | col3 | |
|
// When split by |, we get: ['', ' col1 ', ' col2 ', ' col3 ', ''] |
|
// We need to extract all non-empty cells |
|
const headerParts = headerRow.split('|') |
|
const headerCells: string[] = [] |
|
for (let i = 0; i < headerParts.length; i++) { |
|
const cell = headerParts[i].trim() |
|
// Skip empty cells only at the very start and end |
|
if (cell === '' && (i === 0 || i === headerParts.length - 1)) continue |
|
headerCells.push(cell) |
|
} |
|
|
|
if (headerCells.length < 2) return match |
|
|
|
const colCount = headerCells.length |
|
const dataRows = lines.slice(2) |
|
|
|
// Build AsciiDoc table - use equal width columns |
|
let tableAsciidoc = `[cols="${Array(colCount).fill('*').join(',')}"]\n|===\n` |
|
|
|
// Header row - prefix each cell with . to make it a header cell in AsciiDoc |
|
// Ensure cells are properly formatted (no leading/trailing spaces, escape special chars) |
|
const headerRowCells = headerCells.map(cell => { |
|
// Clean up the cell content |
|
let cleanCell = cell.trim() |
|
// Escape pipe characters if any |
|
cleanCell = cleanCell.replace(/\|/g, '\\|') |
|
// Return with . prefix for header |
|
return `.${cleanCell}` |
|
}) |
|
tableAsciidoc += headerRowCells.join('|') + '\n\n' |
|
|
|
// Data rows |
|
dataRows.forEach(row => { |
|
if (!row.includes('|')) return |
|
const rowParts = row.split('|') |
|
const rowCells: string[] = [] |
|
|
|
// Parse data row cells the same way as header |
|
for (let i = 0; i < rowParts.length; i++) { |
|
const cell = rowParts[i].trim() |
|
// Skip empty cells only at the very start and end |
|
if (cell === '' && (i === 0 || i === rowParts.length - 1)) continue |
|
rowCells.push(cell) |
|
} |
|
|
|
// Ensure we have the right number of cells |
|
while (rowCells.length < colCount) { |
|
rowCells.push('') |
|
} |
|
|
|
// Take only the number of columns we need |
|
const finalCells = rowCells.slice(0, colCount) |
|
tableAsciidoc += finalCells.map(cell => cell.replace(/\|/g, '\\|')).join('|') + '\n' |
|
}) |
|
|
|
tableAsciidoc += '|===' |
|
return tableAsciidoc |
|
}) |
|
|
|
// Convert horizontal rules (but not table separators, which are already processed) |
|
// Convert standalone --- lines to AsciiDoc horizontal rule |
|
// We do this after table processing to avoid interfering with table separators |
|
asciidoc = asciidoc.replace(/^---\s*$/gm, (match, offset, string) => { |
|
// Check if this is part of a table separator (would have been processed already) |
|
const lines = string.split('\n') |
|
const lineIndex = string.substring(0, offset).split('\n').length - 1 |
|
const prevLine = lines[lineIndex - 1]?.trim() || '' |
|
const nextLine = lines[lineIndex + 1]?.trim() || '' |
|
|
|
// If it looks like a table separator (has pipes nearby), don't convert |
|
if (prevLine.includes('|') || nextLine.includes('|')) { |
|
return match |
|
} |
|
|
|
// Convert to AsciiDoc horizontal rule (three single quotes) |
|
return '\'\'\'' |
|
}) |
|
|
|
// Convert blockquotes - handle multi-line blockquotes |
|
// Match consecutive lines starting with > |
|
asciidoc = asciidoc.replace(/(^>\s+.+(?:\n>\s+.+)*)/gm, (match) => { |
|
const lines = match.split('\n').map((line: string) => line.replace(/^>\s*/, '')) |
|
const content = lines.join('\n').trim() |
|
return `____\n${content}\n____` |
|
}) |
|
|
|
// Convert lists (must be at start of line) |
|
// Unordered lists: *, -, + |
|
asciidoc = asciidoc.replace(/^(\s*)[\*\-\+]\s+(.+)$/gm, '$1* $2') |
|
// Ordered lists: 1., 2., etc. |
|
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') |
|
|
|
// Convert images:  -> image:url[alt] (single colon for inline, but AsciiDoc will render as block) |
|
// For block images in AsciiDoc, we can use image:: or just ensure it's on its own line |
|
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { |
|
// Escape brackets in alt text and URL if needed |
|
const escapedAlt = alt.replace(/\[/g, '\\[').replace(/\]/g, '\\]').replace(/"/g, '"') |
|
// Use image:: for block-level images (double colon) |
|
// Add width attribute to make it responsive |
|
return `image::${url}[${escapedAlt},width=100%]` |
|
}) |
|
|
|
// Convert links: [text](url) -> link:url[text] |
|
asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { |
|
// Skip if it was an image (shouldn't happen after image conversion, but safety check) |
|
if (match.startsWith('![')) return match |
|
// Escape brackets in link text |
|
const escapedText = text.replace(/\[/g, '\\[').replace(/\]/g, '\\]') |
|
return `link:${url}[${escapedText}]` |
|
}) |
|
|
|
// Nostr addresses are already converted to link: format above, no need to restore |
|
|
|
// Convert strikethrough: ~~text~~ -> [line-through]#text# |
|
// Also handle single tilde strikethrough: ~text~ -> [line-through]#text# |
|
asciidoc = asciidoc.replace(/~~([^~\n]+?)~~/g, '[line-through]#$1#') |
|
// Single tilde strikethrough (common in some markdown flavors) |
|
asciidoc = asciidoc.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '[line-through]#$1#') |
|
|
|
// Note: Subscript ~text~ is now handled as strikethrough above |
|
// If you need subscript, use a different syntax or handle it differently |
|
|
|
// Convert superscript: ^text^ |
|
asciidoc = asciidoc.replace(/\^([^\^\n]+?)\^/g, '[superscript]#$1#') |
|
|
|
// Convert bold: **text** or __text__ |
|
asciidoc = asciidoc.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*') |
|
asciidoc = asciidoc.replace(/__(?!_)([^_\n]+?)(?<!_)__/g, '*$1*') |
|
|
|
// Convert italic: *text* or _text_ (but not if already bold) |
|
// Process single asterisk for italic (but not if it's part of **bold**) |
|
asciidoc = asciidoc.replace(/(?<!\*)\*(?![\*\s])([^\*\n]+?)(?<!\*)\*(?!\*)/g, (match, text) => { |
|
// Skip if it looks like a list item |
|
if (/^\s*\*\s/.test(match)) return match |
|
// Skip if already processed as bold (shouldn't happen, but safety) |
|
if (match.includes('*$1*')) return match |
|
return `_${text}_` |
|
}) |
|
// Process single underscore for italic |
|
asciidoc = asciidoc.replace(/(?<!_)_(?!_)([^_\n]+?)(?<!_)_(?!_)/g, (match, text) => { |
|
// Skip if already processed as bold |
|
if (match.includes('*$1*')) return match |
|
return `_${text}_` |
|
}) |
|
|
|
// Restore inline code |
|
inlineCodePlaceholders.forEach((code, index) => { |
|
asciidoc = asciidoc.replace(`__INLINE_CODE_${index}__`, code) |
|
}) |
|
|
|
// Restore code blocks |
|
codeBlockPlaceholders.forEach((block, index) => { |
|
asciidoc = asciidoc.replace(`__CODE_BLOCK_${index}__`, block) |
|
}) |
|
|
|
return asciidoc |
|
} |
|
|
|
export default function AsciidocArticle({ |
|
event, |
|
className, |
|
hideImagesAndInfo = false |
|
}: { |
|
event: Event |
|
className?: string |
|
hideImagesAndInfo?: boolean |
|
}) { |
|
const { push } = useSecondaryPage() |
|
const { navigateToHashtag } = useSmartHashtagNavigation() |
|
const { navigateToRelay } = useSmartRelayNavigation() |
|
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
|
const contentRef = useRef<HTMLDivElement>(null) |
|
|
|
// Preprocess content: convert all markdown to AsciiDoc syntax |
|
const processedContent = useMemo(() => { |
|
let content = event.content |
|
|
|
// Convert all markdown syntax to AsciiDoc syntax |
|
content = convertMarkdownToAsciidoc(content) |
|
|
|
// Now process raw URLs that aren't already in AsciiDoc syntax |
|
content = preprocessAsciidocMediaLinks(content) |
|
|
|
// Convert "Read naddr... instead." patterns to AsciiDoc links |
|
const redirectRegex = /Read (naddr1[a-z0-9]+) instead\./gi |
|
content = content.replace(redirectRegex, (_match, naddr) => { |
|
return `Read link:/notes/${naddr}[${naddr}] instead.` |
|
}) |
|
|
|
return content |
|
}, [event.content]) |
|
|
|
// 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 && !hideImagesAndInfo) return false |
|
return true |
|
}) |
|
}, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo]) |
|
|
|
// 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]) |
|
|
|
// Extract hashtags from content (for deduplication with metadata tags) |
|
const hashtagsInContent = useMemo(() => { |
|
const tags = new Set<string>() |
|
const hashtagRegex = /#([a-zA-Z0-9_]+)/g |
|
let match |
|
while ((match = hashtagRegex.exec(event.content)) !== null) { |
|
tags.add(match[1].toLowerCase()) |
|
} |
|
return tags |
|
}, [event.content]) |
|
|
|
// 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]) |
|
|
|
// Parse AsciiDoc content and post-process for nostr: links and hashtags |
|
const [parsedHtml, setParsedHtml] = useState<string>('') |
|
const [isLoading, setIsLoading] = useState(true) |
|
|
|
useEffect(() => { |
|
let cancelled = false |
|
|
|
const parseAsciidoc = async () => { |
|
setIsLoading(true) |
|
try { |
|
const Asciidoctor = await import('@asciidoctor/core') |
|
const asciidoctor = Asciidoctor.default() |
|
|
|
if (cancelled) return |
|
|
|
const html = asciidoctor.convert(processedContent, { |
|
safe: 'safe', |
|
backend: 'html5', |
|
doctype: 'article', |
|
attributes: { |
|
'showtitle': true, |
|
'sectanchors': true, |
|
'sectlinks': true, |
|
'toc': 'left', |
|
'toclevels': 6, |
|
'toc-title': 'Table of Contents', |
|
'source-highlighter': 'highlight.js', |
|
'stem': 'latexmath', |
|
'data-uri': true, |
|
'imagesdir': '', |
|
'linkcss': false, |
|
'stylesheet': '', |
|
'stylesdir': '', |
|
'prewrap': true, |
|
'sectnums': false, |
|
'sectnumlevels': 6, |
|
'experimental': true, |
|
'compat-mode': false, |
|
'attribute-missing': 'warn', |
|
'attribute-undefined': 'warn', |
|
'skip-front-matter': true |
|
} |
|
}) |
|
|
|
if (cancelled) return |
|
|
|
let htmlString = typeof html === 'string' ? html : html.toString() |
|
|
|
// Note: Markdown is now converted to AsciiDoc in preprocessing, |
|
// so post-processing markdown should not be necessary |
|
|
|
// Post-process HTML to handle nostr: links |
|
// Mentions (npub/nprofile) should be inline, events (note/nevent/naddr) should be block-level |
|
// First, handle nostr: links in <a> tags (from AsciiDoc link: syntax) |
|
// Match the full bech32 address format - addresses can vary in length |
|
// npub: 58 chars, nprofile: variable, note: 58 chars, nevent: variable, naddr: 200+ chars |
|
// Use a more flexible pattern that matches any valid bech32 address |
|
htmlString = htmlString.replace(/<a[^>]*href=["']nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]{20,})["'][^>]*>([^<]*)<\/a>/gi, (_match, bech32Id, _linkText) => { |
|
// Validate bech32 ID and create appropriate placeholder |
|
if (!bech32Id) return _match |
|
|
|
// Escape the bech32 ID for HTML attributes |
|
const escapedId = bech32Id.replace(/"/g, '"').replace(/'/g, ''') |
|
|
|
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { |
|
return `<span data-nostr-mention="${escapedId}" class="nostr-mention-placeholder"></span>` |
|
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { |
|
return `<div data-nostr-note="${escapedId}" class="nostr-note-placeholder"></div>` |
|
} |
|
return _match |
|
}) |
|
|
|
// Also handle nostr: addresses in plain text nodes (not already in <a> tags) |
|
// Process text nodes by replacing content between > and < |
|
// Use more flexible regex that matches any valid bech32 address |
|
htmlString = htmlString.replace(/>([^<]*nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)[^<]*)</g, (_match, textContent) => { |
|
// Extract nostr addresses from the text content - use the same flexible pattern |
|
const nostrRegex = /nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)/g |
|
let processedText = textContent |
|
const replacements: Array<{ start: number; end: number; replacement: string }> = [] |
|
|
|
let m |
|
while ((m = nostrRegex.exec(textContent)) !== null) { |
|
const bech32Id = m[1] |
|
const start = m.index |
|
const end = m.index + m[0].length |
|
|
|
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { |
|
replacements.push({ |
|
start, |
|
end, |
|
replacement: `<span data-nostr-mention="${bech32Id}" class="nostr-mention-placeholder"></span>` |
|
}) |
|
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { |
|
replacements.push({ |
|
start, |
|
end, |
|
replacement: `<div data-nostr-note="${bech32Id}" class="nostr-note-placeholder"></div>` |
|
}) |
|
} |
|
} |
|
|
|
// Apply replacements in reverse order to preserve indices |
|
for (let i = replacements.length - 1; i >= 0; i--) { |
|
const r = replacements[i] |
|
processedText = processedText.substring(0, r.start) + r.replacement + processedText.substring(r.end) |
|
} |
|
|
|
return `>${processedText}<` |
|
}) |
|
|
|
// Handle LaTeX math expressions from AsciiDoc stem processor |
|
// AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math |
|
// In HTML, these appear as literal \( and \) characters (backslash + parenthesis) |
|
// We need to match the literal backslash-paren sequence |
|
// In regex: \\ matches a literal backslash, \( matches a literal ( |
|
htmlString = htmlString.replace(/\\\(([^)]+?)\\\)/g, (_match, latex) => { |
|
// Inline math - escape for HTML attribute |
|
// Unescape any HTML entities that might have been created |
|
const unescaped = latex.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') |
|
const escaped = unescaped.replace(/"/g, '"').replace(/'/g, ''') |
|
return `<span data-latex-inline="${escaped}" class="latex-inline-placeholder"></span>` |
|
}) |
|
htmlString = htmlString.replace(/\\\[([^\]]+?)\\\]/g, (_match, latex) => { |
|
// Block math - escape for HTML attribute |
|
const unescaped = latex.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') |
|
const escaped = unescaped.replace(/"/g, '"').replace(/'/g, ''') |
|
return `<div data-latex-block="${escaped}" class="latex-block-placeholder my-4"></div>` |
|
}) |
|
|
|
// Handle wikilinks - convert passthrough markers to placeholders |
|
// AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML |
|
// Match WIKILINK: followed by any characters (including |) until end of text or HTML tag |
|
htmlString = htmlString.replace(/WIKILINK:([^<>\s]+)/g, (_match, linkContent) => { |
|
// Escape special characters for HTML attributes |
|
const escaped = linkContent.replace(/"/g, '"').replace(/'/g, ''') |
|
return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>` |
|
}) |
|
|
|
// Handle YouTube URLs and relay URLs in links |
|
// Process all link matches first to determine which are standalone |
|
const linkMatches: Array<{ match: string; href: string; linkText: string; index: number; isStandalone: boolean }> = [] |
|
const linkRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g |
|
let linkMatch |
|
while ((linkMatch = linkRegex.exec(htmlString)) !== null) { |
|
const match = linkMatch[0] |
|
const href = linkMatch[1] |
|
const linkText = linkMatch[2] |
|
const index = linkMatch.index |
|
|
|
// Check if link is standalone (on its own line, not part of a sentence/list/quote) |
|
let isStandalone = false |
|
if (href.startsWith('http://') || href.startsWith('https://')) { |
|
// Get context around the link |
|
const beforeMatch = htmlString.substring(Math.max(0, index - 500), index) |
|
const afterMatch = htmlString.substring(index + match.length, Math.min(htmlString.length, index + match.length + 500)) |
|
|
|
// Extract the parent paragraph/div content |
|
const paragraphMatch = beforeMatch.match(/<p[^>]*>([^<]*)$/) |
|
const divMatch = beforeMatch.match(/<div[^>]*>([^<]*)$/) |
|
|
|
// If link is in a paragraph, check if paragraph contains only the link |
|
if (paragraphMatch) { |
|
const paragraphEnd = afterMatch.match(/^([^<]*)<\/p>/) |
|
const paragraphContent = paragraphMatch[1] + linkText + (paragraphEnd?.[1] || '') |
|
const trimmedContent = paragraphContent.trim() |
|
// If paragraph contains only the link (possibly with whitespace), it's standalone |
|
if (trimmedContent === linkText.trim() || trimmedContent === '') { |
|
// Check if it's in a list or blockquote by looking further back |
|
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) |
|
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) { |
|
isStandalone = true |
|
} |
|
} |
|
} |
|
|
|
// If link is in a div and the div contains only the link, it's standalone |
|
if (!isStandalone && divMatch) { |
|
const divEnd = afterMatch.match(/^([^<]*)<\/div>/) |
|
const divContent = divMatch[1] + linkText + (divEnd?.[1] || '') |
|
const trimmedContent = divContent.trim() |
|
if (trimmedContent === linkText.trim() || trimmedContent === '') { |
|
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) |
|
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) { |
|
isStandalone = true |
|
} |
|
} |
|
} |
|
|
|
// If link appears to be on its own line (surrounded by block-level tags or whitespace) |
|
if (!isStandalone) { |
|
const beforeTrimmed = beforeMatch.replace(/\s*$/, '') |
|
const afterTrimmed = afterMatch.replace(/^\s*/, '') |
|
if ( |
|
(beforeTrimmed.endsWith('</p>') || beforeTrimmed.endsWith('</div>') || beforeTrimmed.endsWith('<br') || beforeTrimmed === '') && |
|
(afterTrimmed.startsWith('</p>') || afterTrimmed.startsWith('</div>') || afterTrimmed.startsWith('<p') || afterTrimmed.startsWith('<div') || afterTrimmed === '') |
|
) { |
|
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) |
|
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) { |
|
isStandalone = true |
|
} |
|
} |
|
} |
|
} |
|
|
|
linkMatches.push({ match, href, linkText, index, isStandalone }) |
|
} |
|
|
|
// Replace links in reverse order to preserve indices |
|
for (let i = linkMatches.length - 1; i >= 0; i--) { |
|
const { match, href, linkText, isStandalone } = linkMatches[i] |
|
let replacement = match |
|
|
|
// Check if the href is a YouTube URL |
|
if (isYouTubeUrl(href)) { |
|
const cleanedUrl = cleanUrl(href) |
|
replacement = `<div data-youtube-url="${cleanedUrl.replace(/"/g, '"')}" class="youtube-placeholder my-2"></div>` |
|
} |
|
// Check if the href is a relay URL |
|
else if (isWebsocketUrl(href)) { |
|
const relayPath = `/relays/${encodeURIComponent(href)}` |
|
replacement = `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '"')}">${linkText}</a>` |
|
} |
|
// For regular HTTP/HTTPS links, check if standalone |
|
else if (href.startsWith('http://') || href.startsWith('https://')) { |
|
if (isStandalone) { |
|
// Standalone link - render as WebPreview |
|
const cleanedUrl = cleanUrl(href) |
|
replacement = `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '"')}" class="webpreview-placeholder my-2"></div>` |
|
} else { |
|
// Inline link - keep as regular link |
|
const escapedLinkText = linkText.replace(/"/g, '"') |
|
replacement = `<a href="${href}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" target="_blank" rel="noopener noreferrer" data-original-text="${escapedLinkText}">${linkText}</a>` |
|
} |
|
} |
|
// For other links (like relative links), keep as-is but add data attribute |
|
else { |
|
const escapedLinkText = linkText.replace(/"/g, '"') |
|
replacement = match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`) |
|
} |
|
|
|
htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length) |
|
} |
|
|
|
// Handle YouTube URLs in plain text (not in <a> tags) |
|
// Create a new regex instance to avoid state issues |
|
const youtubeRegex = new RegExp(YOUTUBE_URL_REGEX.source, YOUTUBE_URL_REGEX.flags) |
|
htmlString = htmlString.replace(youtubeRegex, (match) => { |
|
// Only replace if not already in a tag (basic check) |
|
if (!match.includes('<') && !match.includes('>') && isYouTubeUrl(match)) { |
|
const cleanedUrl = cleanUrl(match) |
|
return `<div data-youtube-url="${cleanedUrl.replace(/"/g, '"')}" class="youtube-placeholder my-2"></div>` |
|
} |
|
return match |
|
}) |
|
|
|
// Handle relay URLs in plain text (not in <a> tags) - convert to relay page links |
|
htmlString = htmlString.replace(WS_URL_REGEX, (match) => { |
|
// Only replace if not already in a tag (basic check) |
|
if (!match.includes('<') && !match.includes('>') && isWebsocketUrl(match)) { |
|
const relayPath = `/relays/${encodeURIComponent(match)}` |
|
return `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${match}" data-original-text="${match.replace(/"/g, '"')}">${match}</a>` |
|
} |
|
return match |
|
}) |
|
|
|
// Handle plain HTTP/HTTPS URLs in text (not in <a> tags, not YouTube, not relay) - convert to WebPreview placeholders |
|
const httpUrlRegex = /https?:\/\/[^\s<>"']+/g |
|
htmlString = htmlString.replace(httpUrlRegex, (match) => { |
|
// Only replace if not already in a tag (basic check) |
|
if (!match.includes('<') && !match.includes('>')) { |
|
// Skip if it's a YouTube URL or relay URL (already handled) |
|
if (isYouTubeUrl(match) || isWebsocketUrl(match)) { |
|
return match |
|
} |
|
// Skip if it's an image or media URL (handled separately) |
|
if (isImage(match) || isVideo(match) || isAudio(match)) { |
|
return match |
|
} |
|
const cleanedUrl = cleanUrl(match) |
|
return `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '"')}" class="webpreview-placeholder my-2"></div>` |
|
} |
|
return match |
|
}) |
|
|
|
setParsedHtml(htmlString) |
|
} catch (error) { |
|
logger.error('Failed to parse AsciiDoc', error as Error) |
|
setParsedHtml('<p>Error parsing AsciiDoc content</p>') |
|
} finally { |
|
if (!cancelled) { |
|
setIsLoading(false) |
|
} |
|
} |
|
} |
|
|
|
parseAsciidoc() |
|
|
|
return () => { |
|
cancelled = true |
|
} |
|
}, [processedContent]) |
|
|
|
// Store React roots for cleanup |
|
const reactRootsRef = useRef<Map<Element, Root>>(new Map()) |
|
|
|
// Post-process rendered HTML to inject React components for nostr: links and handle hashtags |
|
useEffect(() => { |
|
if (!contentRef.current || !parsedHtml || isLoading) return |
|
|
|
// Clean up previous roots |
|
reactRootsRef.current.forEach((root, element) => { |
|
root.unmount() |
|
reactRootsRef.current.delete(element) |
|
}) |
|
|
|
// Process nostr: mentions - replace placeholders with React components (inline) |
|
const nostrMentions = contentRef.current.querySelectorAll('.nostr-mention-placeholder[data-nostr-mention]') |
|
nostrMentions.forEach((element) => { |
|
const bech32Id = element.getAttribute('data-nostr-mention') |
|
if (!bech32Id) { |
|
logger.warn('Nostr mention placeholder found but no bech32Id attribute') |
|
return |
|
} |
|
|
|
// Create an inline container for React component (mentions should be inline) |
|
const container = document.createElement('span') |
|
container.className = 'inline-block' |
|
const parent = element.parentNode |
|
if (!parent) { |
|
logger.warn('Nostr mention placeholder has no parent node') |
|
return |
|
} |
|
parent.replaceChild(container, element) |
|
|
|
// Use React to render the component |
|
const root = createRoot(container) |
|
root.render(<EmbeddedMention userId={bech32Id} />) |
|
reactRootsRef.current.set(container, root) |
|
}) |
|
|
|
// Process nostr: notes - replace placeholders with React components |
|
const nostrNotes = contentRef.current.querySelectorAll('.nostr-note-placeholder[data-nostr-note]') |
|
nostrNotes.forEach((element) => { |
|
const bech32Id = element.getAttribute('data-nostr-note') |
|
if (!bech32Id) { |
|
logger.warn('Nostr note placeholder found but no bech32Id attribute') |
|
return |
|
} |
|
|
|
// Create a block-level container for React component that fills width |
|
const container = document.createElement('div') |
|
container.className = 'w-full my-2' |
|
const parent = element.parentNode |
|
if (!parent) { |
|
logger.warn('Nostr note placeholder has no parent node') |
|
return |
|
} |
|
parent.replaceChild(container, element) |
|
|
|
// Use React to render the component |
|
const root = createRoot(container) |
|
root.render(<EmbeddedNote noteId={bech32Id} />) |
|
reactRootsRef.current.set(container, root) |
|
}) |
|
|
|
// Process LaTeX math expressions - render with KaTeX |
|
const latexInlinePlaceholders = contentRef.current.querySelectorAll('.latex-inline-placeholder[data-latex-inline]') |
|
latexInlinePlaceholders.forEach((element) => { |
|
const latex = element.getAttribute('data-latex-inline') |
|
if (!latex) return |
|
|
|
try { |
|
// Render LaTeX with KaTeX |
|
const rendered = katex.renderToString(latex, { |
|
throwOnError: false, |
|
displayMode: false |
|
}) |
|
// Replace the placeholder with the rendered HTML |
|
element.outerHTML = rendered |
|
} catch (error) { |
|
logger.error('Error rendering LaTeX inline math:', error) |
|
// On error, show the raw LaTeX |
|
element.outerHTML = `<span>$${latex}$</span>` |
|
} |
|
}) |
|
|
|
const latexBlockPlaceholders = contentRef.current.querySelectorAll('.latex-block-placeholder[data-latex-block]') |
|
latexBlockPlaceholders.forEach((element) => { |
|
const latex = element.getAttribute('data-latex-block') |
|
if (!latex) return |
|
|
|
try { |
|
// Render LaTeX with KaTeX in display mode |
|
const rendered = katex.renderToString(latex, { |
|
throwOnError: false, |
|
displayMode: true |
|
}) |
|
// Replace the placeholder with the rendered HTML |
|
element.outerHTML = rendered |
|
} catch (error) { |
|
logger.error('Error rendering LaTeX block math:', error) |
|
// On error, show the raw LaTeX |
|
element.outerHTML = `<div>$$${latex}$$</div>` |
|
} |
|
}) |
|
|
|
// Process YouTube URLs - replace placeholders with React components |
|
const youtubePlaceholders = contentRef.current.querySelectorAll('.youtube-placeholder[data-youtube-url]') |
|
youtubePlaceholders.forEach((element) => { |
|
const youtubeUrl = element.getAttribute('data-youtube-url') |
|
if (!youtubeUrl) return |
|
|
|
// Create a container for React component |
|
const container = document.createElement('div') |
|
container.className = 'my-2' |
|
element.parentNode?.replaceChild(container, element) |
|
|
|
// Use React to render the component |
|
const root = createRoot(container) |
|
root.render(<YoutubeEmbeddedPlayer url={youtubeUrl} className="max-w-[400px]" mustLoad={false} />) |
|
reactRootsRef.current.set(container, root) |
|
}) |
|
|
|
// Process wikilinks - replace placeholders with React components |
|
const wikilinks = contentRef.current.querySelectorAll('.wikilink-placeholder[data-wikilink]') |
|
wikilinks.forEach((element) => { |
|
const linkContent = element.getAttribute('data-wikilink') |
|
if (!linkContent) return |
|
|
|
// Parse wikilink: extract target and display text |
|
let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() |
|
let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() |
|
|
|
// Handle book: prefix |
|
if (linkContent.startsWith('book:')) { |
|
target = linkContent.replace('book:', '').trim() |
|
} |
|
|
|
// Convert to d-tag format (same as MarkdownArticle) |
|
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') |
|
|
|
// Create a container for React component |
|
const container = document.createElement('span') |
|
container.className = 'inline-block' |
|
element.parentNode?.replaceChild(container, element) |
|
|
|
// Use React to render the component |
|
const root = createRoot(container) |
|
root.render(<Wikilink dTag={dtag} displayText={displayText} />) |
|
reactRootsRef.current.set(container, root) |
|
}) |
|
|
|
// Process WebPreview placeholders - replace with React components |
|
const webpreviewPlaceholders = contentRef.current.querySelectorAll('.webpreview-placeholder[data-webpreview-url]') |
|
webpreviewPlaceholders.forEach((element) => { |
|
const url = element.getAttribute('data-webpreview-url') |
|
if (!url) return |
|
|
|
// Create a container for React component |
|
const container = document.createElement('div') |
|
container.className = 'my-2' |
|
element.parentNode?.replaceChild(container, element) |
|
|
|
// Use React to render the component |
|
const root = createRoot(container) |
|
root.render(<WebPreview url={url} className="w-full" />) |
|
reactRootsRef.current.set(container, root) |
|
}) |
|
|
|
// Process hashtags in text nodes - convert #tag to links |
|
const walker = document.createTreeWalker( |
|
contentRef.current, |
|
NodeFilter.SHOW_TEXT, |
|
{ |
|
acceptNode: (node) => { |
|
// Skip if parent is a link, code, or pre tag |
|
const parent = node.parentElement |
|
if (!parent) return NodeFilter.FILTER_ACCEPT |
|
if (parent.tagName === 'A' || parent.tagName === 'CODE' || parent.tagName === 'PRE') { |
|
return NodeFilter.FILTER_REJECT |
|
} |
|
return NodeFilter.FILTER_ACCEPT |
|
} |
|
} |
|
) |
|
|
|
const textNodes: Text[] = [] |
|
let node |
|
while ((node = walker.nextNode())) { |
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) { |
|
textNodes.push(node as Text) |
|
} |
|
} |
|
|
|
textNodes.forEach((textNode) => { |
|
const text = textNode.textContent || '' |
|
const hashtagRegex = /#([a-zA-Z0-9_]+)/g |
|
const matches = Array.from(text.matchAll(hashtagRegex)) |
|
|
|
if (matches.length > 0) { |
|
const fragment = document.createDocumentFragment() |
|
let lastIndex = 0 |
|
|
|
matches.forEach((match) => { |
|
if (match.index === undefined) return |
|
|
|
// Add text before hashtag |
|
if (match.index > lastIndex) { |
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))) |
|
} |
|
|
|
// Create hashtag link |
|
const link = document.createElement('a') |
|
link.href = `/notes?t=${match[1].toLowerCase()}` |
|
link.className = 'inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer' |
|
link.textContent = `#${match[1]}` |
|
link.addEventListener('click', (e) => { |
|
e.stopPropagation() |
|
e.preventDefault() |
|
navigateToHashtag(`/notes?t=${match[1].toLowerCase()}`) |
|
}) |
|
fragment.appendChild(link) |
|
|
|
lastIndex = match.index + match[0].length |
|
}) |
|
|
|
// Add remaining text |
|
if (lastIndex < text.length) { |
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex))) |
|
} |
|
|
|
textNode.parentNode?.replaceChild(fragment, textNode) |
|
} |
|
}) |
|
|
|
// Handle all links - truncate display text and add click handlers for relay URLs |
|
const allLinks = contentRef.current.querySelectorAll('a[href]') |
|
allLinks.forEach((link) => { |
|
const href = link.getAttribute('href') |
|
if (!href) return |
|
|
|
// Get current link text (this might be the full URL or custom text) |
|
const linkText = link.textContent || '' |
|
|
|
// Truncate link text if it's longer than 200 characters |
|
if (linkText.length > 200) { |
|
const truncatedText = truncateLinkText(linkText) |
|
link.textContent = truncatedText |
|
// Store full text as title for tooltip |
|
if (!link.getAttribute('title')) { |
|
link.setAttribute('title', linkText) |
|
} |
|
} |
|
|
|
// Handle relay URL links - add click handlers to navigate to relay page |
|
const relayUrl = link.getAttribute('data-relay-url') |
|
if (relayUrl) { |
|
const relayPath = `/relays/${encodeURIComponent(relayUrl)}` |
|
link.setAttribute('href', relayPath) |
|
link.addEventListener('click', (e) => { |
|
e.stopPropagation() |
|
e.preventDefault() |
|
navigateToRelay(relayPath) |
|
}) |
|
} |
|
}) |
|
|
|
// Cleanup function |
|
return () => { |
|
reactRootsRef.current.forEach((root) => { |
|
root.unmount() |
|
}) |
|
reactRootsRef.current.clear() |
|
} |
|
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay]) |
|
|
|
// Initialize syntax highlighting |
|
useEffect(() => { |
|
const initHighlight = async () => { |
|
if (typeof window !== 'undefined') { |
|
const hljs = await import('highlight.js') |
|
if (contentRef.current) { |
|
contentRef.current.querySelectorAll('pre code').forEach((block) => { |
|
const element = block as HTMLElement |
|
element.style.color = 'inherit' |
|
element.classList.add('text-gray-900', 'dark:text-gray-100') |
|
hljs.default.highlightElement(element) |
|
element.style.color = 'inherit' |
|
}) |
|
} |
|
} |
|
} |
|
|
|
const timeoutId = setTimeout(initHighlight, 100) |
|
return () => clearTimeout(timeoutId) |
|
}, [parsedHtml]) |
|
|
|
return ( |
|
<> |
|
<style>{` |
|
.hljs { |
|
background: transparent !important; |
|
} |
|
.hljs-keyword, |
|
.hljs-selector-tag, |
|
.hljs-literal, |
|
.hljs-title, |
|
.hljs-section, |
|
.hljs-doctag, |
|
.hljs-type, |
|
.hljs-name, |
|
.hljs-strong { |
|
color: #dc2626 !important; |
|
font-weight: bold !important; |
|
} |
|
.hljs-string, |
|
.hljs-title.class_, |
|
.hljs-attr, |
|
.hljs-symbol, |
|
.hljs-bullet, |
|
.hljs-addition, |
|
.hljs-code, |
|
.hljs-regexp, |
|
.hljs-selector-pseudo, |
|
.hljs-selector-attr, |
|
.hljs-selector-class, |
|
.hljs-selector-id { |
|
color: #0284c7 !important; |
|
} |
|
.hljs-comment, |
|
.hljs-quote { |
|
color: #6b7280 !important; |
|
} |
|
.hljs-number, |
|
.hljs-deletion { |
|
color: #0d9488 !important; |
|
} |
|
.dark .hljs-keyword, |
|
.dark .hljs-selector-tag, |
|
.dark .hljs-literal, |
|
.dark .hljs-title, |
|
.dark .hljs-section, |
|
.dark .hljs-doctag, |
|
.dark .hljs-type, |
|
.dark .hljs-name, |
|
.dark .hljs-strong { |
|
color: #f87171 !important; |
|
} |
|
.dark .hljs-string, |
|
.dark .hljs-title.class_, |
|
.dark .hljs-attr, |
|
.dark .hljs-symbol, |
|
.dark .hljs-bullet, |
|
.dark .hljs-addition, |
|
.dark .hljs-code, |
|
.dark .hljs-regexp, |
|
.dark .hljs-selector-pseudo, |
|
.dark .hljs-selector-attr, |
|
.dark .hljs-selector-class, |
|
.dark .hljs-selector-id { |
|
color: #38bdf8 !important; |
|
} |
|
.dark .hljs-comment, |
|
.dark .hljs-quote { |
|
color: #9ca3af !important; |
|
} |
|
.dark .hljs-number, |
|
.dark .hljs-deletion { |
|
color: #5eead4 !important; |
|
} |
|
.asciidoc-content img { |
|
display: block; |
|
max-width: 400px; |
|
height: auto; |
|
border-radius: 0.5rem; |
|
cursor: zoom-in; |
|
margin: 0.5rem 0; |
|
} |
|
.asciidoc-content a[href^="/notes?t="] { |
|
color: #16a34a !important; |
|
text-decoration: none !important; |
|
} |
|
.asciidoc-content a[href^="/notes?t="]:hover { |
|
color: #15803d !important; |
|
text-decoration: underline !important; |
|
} |
|
.dark .asciidoc-content a[href^="/notes?t="] { |
|
color: #4ade80 !important; |
|
} |
|
.dark .asciidoc-content a[href^="/notes?t="]:hover { |
|
color: #86efac !important; |
|
} |
|
`}</style> |
|
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}> |
|
{/* Metadata */} |
|
{!hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>} |
|
{!hideImagesAndInfo && metadata.summary && ( |
|
<blockquote> |
|
<p className="break-words">{metadata.summary}</p> |
|
</blockquote> |
|
)} |
|
{hideImagesAndInfo && metadata.title && ( |
|
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2> |
|
)} |
|
|
|
{/* Metadata image */} |
|
{!hideImagesAndInfo && 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 w-full max-w-full overflow-hidden"> |
|
<MediaPlayer |
|
src={media.url} |
|
className="max-w-full sm:max-w-[400px] w-full" |
|
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 AsciiDoc content */} |
|
{isLoading ? ( |
|
<div>Loading content...</div> |
|
) : ( |
|
<div |
|
ref={contentRef} |
|
className="asciidoc-content break-words" |
|
dangerouslySetInnerHTML={{ __html: parsedHtml }} |
|
/> |
|
)} |
|
|
|
{/* Hashtags from metadata (only if not already in content) */} |
|
{!hideImagesAndInfo && 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 links in the AsciiDoc HTML 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 |
|
)} |
|
</> |
|
) |
|
} |
|
|
|
|