diff --git a/package.json b/package.json index bd9fb73..13f283c 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "react-dom": "^18.3.1", "react-i18next": "^15.2.0", "react-katex": "^3.0.1", - "react-markdown": "^10.1.0", "react-simple-pull-to-refresh": "^1.3.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx deleted file mode 100644 index df5bde1..0000000 --- a/src/components/Note/Article/index.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { useSecondaryPage } from '@/PageManager' -import ImageWithLightbox from '@/components/ImageWithLightbox' -import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' -import { toNoteList } from '@/lib/link' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { Event, kinds } from 'nostr-tools' -import { useMemo, useState, useEffect, useRef } from 'react' -import { useEventFieldParser } from '@/hooks/useContentParser' -import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview' -import { Button } from '@/components/ui/button' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { ExtendedKind } from '@/constants' - -export default function Article({ - event, - className -}: { - event: Event - className?: string -}) { - const { push } = useSecondaryPage() - const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) - const [isInfoOpen, setIsInfoOpen] = useState(false) - - // Determine if this is an article-type event that should show ToC and Article Info - const isArticleType = useMemo(() => { - return event.kind === kinds.LongFormArticle || - event.kind === ExtendedKind.WIKI_ARTICLE || - event.kind === ExtendedKind.PUBLICATION || - event.kind === ExtendedKind.PUBLICATION_CONTENT - }, [event.kind]) - - // Use the comprehensive content parser - const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', { - enableMath: true, - enableSyntaxHighlighting: true - }) - - const contentRef = useRef(null) - - // Handle wikilink clicks - useEffect(() => { - if (!contentRef.current) return - - const handleWikilinkClick = (event: MouseEvent) => { - const target = event.target as HTMLElement - if (target.classList.contains('wikilink')) { - event.preventDefault() - const dTag = target.getAttribute('data-dtag') - const displayText = target.getAttribute('data-display') - - if (dTag && displayText) { - // Create a simple dropdown menu - const existingDropdown = document.querySelector('.wikilink-dropdown') - if (existingDropdown) { - existingDropdown.remove() - } - - const dropdown = document.createElement('div') - dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2' - dropdown.style.left = `${event.pageX}px` - dropdown.style.top = `${event.pageY + 10}px` - - const wikistrButton = document.createElement('button') - wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2' - wikistrButton.innerHTML = 'View on Wikistr' - wikistrButton.onclick = () => { - window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer') - dropdown.remove() - } - - const alexandriaButton = document.createElement('button') - alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2' - alexandriaButton.innerHTML = 'View on Alexandria' - alexandriaButton.onclick = () => { - window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer') - dropdown.remove() - } - - dropdown.appendChild(wikistrButton) - dropdown.appendChild(alexandriaButton) - document.body.appendChild(dropdown) - - // Close dropdown when clicking outside - const closeDropdown = (e: MouseEvent) => { - if (!dropdown.contains(e.target as Node)) { - dropdown.remove() - document.removeEventListener('click', closeDropdown) - } - } - setTimeout(() => document.addEventListener('click', closeDropdown), 0) - } - } - } - - contentRef.current.addEventListener('click', handleWikilinkClick) - - return () => { - contentRef.current?.removeEventListener('click', handleWikilinkClick) - } - }, [parsedContent]) - - // Process nostr addresses and other interactive elements after HTML is rendered - useEffect(() => { - if (!contentRef.current || !parsedContent) return - - const processInteractiveElements = () => { - // Process embedded note containers - const embeddedNotes = contentRef.current?.querySelectorAll('[data-embedded-note]') - embeddedNotes?.forEach((container) => { - const bech32Id = container.getAttribute('data-embedded-note') - if (bech32Id) { - // Replace with actual EmbeddedNote component - const embeddedNoteElement = document.createElement('div') - embeddedNoteElement.innerHTML = `
Loading embedded event...
` - container.parentNode?.replaceChild(embeddedNoteElement.firstChild!, container) - } - }) - - // Process user handles - const userHandles = contentRef.current?.querySelectorAll('[data-pubkey]') - userHandles?.forEach((handle) => { - const pubkey = handle.getAttribute('data-pubkey') - if (pubkey) { - // Replace with actual Username component - const usernameElement = document.createElement('span') - usernameElement.innerHTML = `@${handle.textContent}` - handle.parentNode?.replaceChild(usernameElement.firstChild!, handle) - } - }) - } - - // Process elements after a short delay to ensure content is rendered - const timeoutId = setTimeout(processInteractiveElements, 100) - - return () => clearTimeout(timeoutId) - }, [parsedContent?.html]) - - // Add ToC return buttons to section headers - useEffect(() => { - if (!contentRef.current || !isArticleType || !parsedContent) return - - const addTocReturnButtons = () => { - const headers = contentRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6') - if (!headers) return - - headers.forEach((header) => { - // Skip if button already exists - if (header.querySelector('.toc-return-btn')) return - - // Create the return button - const returnBtn = document.createElement('span') - returnBtn.className = 'toc-return-btn' - returnBtn.innerHTML = '↑ ToC' - returnBtn.title = 'Return to Table of Contents' - - // Add click handler - returnBtn.addEventListener('click', (e) => { - e.preventDefault() - e.stopPropagation() - // Scroll to the ToC - const tocElement = document.getElementById('toc') - if (tocElement) { - tocElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - }) - - // Add the button to the header - header.appendChild(returnBtn) - }) - } - - // Add buttons after a short delay to ensure content is rendered - const timeoutId = setTimeout(addTocReturnButtons, 100) - - return () => clearTimeout(timeoutId) - }, [parsedContent?.html, isArticleType]) - - - if (isLoading) { - return ( -
-
Loading content...
-
- ) - } - - if (error) { - return ( -
-
Error loading content: {error.message}
-
- ) - } - - if (!parsedContent) { - return ( -
-
No content available
-
- ) - } - - return ( -
- {/* Article metadata */} -
-

{metadata.title}

- {metadata.summary && ( -
-

{metadata.summary}

-
- )} - {metadata.image && ( -
- -
- )} -
- - {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} -
- - {/* Collapsible Article Info - only for article-type events */} - {isArticleType && (parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && ( - - - - - - - {/* Highlight sources */} - {parsedContent?.highlightSources?.length > 0 && ( -
-

Highlight sources:

-
- {parsedContent?.highlightSources?.map((source, index) => ( - - ))} -
-
- )} - - {/* Hashtags */} - {parsedContent?.hashtags?.length > 0 && ( -
-

Tags:

-
- {parsedContent?.hashtags?.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} -
-
- )} -
-
- )} -
- ) -} \ No newline at end of file diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 422a56c..22a9d6d 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -1,22 +1,22 @@ import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' -import ImageWithLightbox from '@/components/ImageWithLightbox' +import Image from '@/components/Image' +import MediaPlayer from '@/components/MediaPlayer' +import WebPreview from '@/components/WebPreview' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' -import { ChevronDown, ChevronRight } from 'lucide-react' +import { useMediaExtraction } from '@/hooks' +import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url' +import { getImetaInfosFromEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' -import { useMemo, useState, useEffect, useRef } from 'react' -import { useEventFieldParser } from '@/hooks/useContentParser' -import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview' -import { Button } from '@/components/ui/button' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { ExtendedKind } from '@/constants' +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 { TImetaInfo } from '@/types' -import { useMediaExtraction } from '@/hooks' -import WebPreview from '@/components/WebPreview' -import { cleanUrl, isImage, isMedia } from '@/lib/url' +import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' +import Wikilink from '@/components/UniversalContent/Wikilink' +import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' +import logger from '@/lib/logger' export default function AsciidocArticle({ event, @@ -30,445 +30,695 @@ export default function AsciidocArticle({ const { push } = useSecondaryPage() const { navigateToHashtag } = useSmartHashtagNavigation() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) - const [isInfoOpen, setIsInfoOpen] = useState(false) - - // Determine if this is an article-type event that should show ToC and Article Info - const isArticleType = useMemo(() => { - return event.kind === kinds.LongFormArticle || - event.kind === ExtendedKind.WIKI_ARTICLE || - event.kind === ExtendedKind.PUBLICATION || - event.kind === ExtendedKind.PUBLICATION_CONTENT - }, [event.kind]) - - // Use the comprehensive content parser - const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', { - enableMath: true, - enableSyntaxHighlighting: true - }) - const contentRef = useRef(null) - - // Handle wikilink clicks - useEffect(() => { - if (!contentRef.current) return - - const handleWikilinkClick = (event: MouseEvent) => { - const target = event.target as HTMLElement - if (target.classList.contains('wikilink')) { - event.preventDefault() - const dTag = target.getAttribute('data-dtag') - const displayText = target.getAttribute('data-display') - - if (dTag && displayText) { - // Create a simple dropdown menu - const existingDropdown = document.querySelector('.wikilink-dropdown') - if (existingDropdown) { - existingDropdown.remove() - } - - const dropdown = document.createElement('div') - dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2' - dropdown.style.left = `${event.pageX}px` - dropdown.style.top = `${event.pageY + 10}px` - - const wikistrButton = document.createElement('button') - wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2' - wikistrButton.innerHTML = 'View on Wikistr' - wikistrButton.onclick = () => { - window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer') - dropdown.remove() - } - - const alexandriaButton = document.createElement('button') - alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2' - alexandriaButton.innerHTML = 'View on Alexandria' - alexandriaButton.onclick = () => { - window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer') - dropdown.remove() - } - - dropdown.appendChild(wikistrButton) - dropdown.appendChild(alexandriaButton) - document.body.appendChild(dropdown) - - // Close dropdown when clicking outside - const closeDropdown = (e: MouseEvent) => { - if (!dropdown.contains(e.target as Node)) { - dropdown.remove() - document.removeEventListener('click', closeDropdown) - } - } - setTimeout(() => document.addEventListener('click', closeDropdown), 0) - } - } - } - - contentRef.current.addEventListener('click', handleWikilinkClick) - - return () => { - contentRef.current?.removeEventListener('click', handleWikilinkClick) - } - }, [parsedContent]) - - // Process nostr addresses and other interactive elements after HTML is rendered - useEffect(() => { - if (!contentRef.current || !parsedContent) return - - const processInteractiveElements = () => { - // Process embedded note containers - const embeddedNotes = contentRef.current?.querySelectorAll('[data-embedded-note]') - embeddedNotes?.forEach((container) => { - const bech32Id = container.getAttribute('data-embedded-note') - if (bech32Id) { - // Replace with actual EmbeddedNote component - const embeddedNoteElement = document.createElement('div') - embeddedNoteElement.innerHTML = `
Loading embedded event...
` - container.parentNode?.replaceChild(embeddedNoteElement.firstChild!, container) - } - }) - - // Process user handles - const userHandles = contentRef.current?.querySelectorAll('[data-pubkey]') - userHandles?.forEach((handle) => { - const pubkey = handle.getAttribute('data-pubkey') - if (pubkey) { - // Replace with actual Username component - const usernameElement = document.createElement('span') - usernameElement.innerHTML = `@${handle.textContent}` - handle.parentNode?.replaceChild(usernameElement.firstChild!, handle) - } - }) - - // Process hashtag links in content - const hashtagLinks = contentRef.current?.querySelectorAll('a.hashtag-link, a[href^="/notes?t="], a[href^="notes?t="]') - hashtagLinks?.forEach((link) => { - const href = link.getAttribute('href') - if (href && (href.startsWith('/notes?t=') || href.startsWith('notes?t='))) { - // Normalize href to include leading slash if missing - const normalizedHref = href.startsWith('/') ? href : `/${href}` - // Remove existing click handlers to avoid duplicates - const newLink = link.cloneNode(true) as HTMLElement - link.parentNode?.replaceChild(newLink, link) - - newLink.addEventListener('click', (e) => { - e.preventDefault() - e.stopPropagation() - navigateToHashtag(normalizedHref) - }) - } - }) - - // Process wikilinks - const wikilinks = contentRef.current?.querySelectorAll('.wikilink') - wikilinks?.forEach((wikilink) => { - const dTag = wikilink.getAttribute('data-dtag') - const displayText = wikilink.getAttribute('data-display') - if (dTag && displayText) { - // Add click handler for wikilinks - wikilink.addEventListener('click', (e) => { - e.preventDefault() - e.stopPropagation() - const mouseEvent = e as MouseEvent - // Create dropdown menu similar to the original implementation - const existingDropdown = document.querySelector('.wikilink-dropdown') - if (existingDropdown) { - existingDropdown.remove() - } - - const dropdown = document.createElement('div') - dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2' - dropdown.style.left = `${mouseEvent.pageX}px` - dropdown.style.top = `${mouseEvent.pageY + 10}px` - - const wikistrButton = document.createElement('button') - wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2' - wikistrButton.innerHTML = 'View on Wikistr' - wikistrButton.onclick = () => { - window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer') - dropdown.remove() - } - - const alexandriaButton = document.createElement('button') - alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2' - alexandriaButton.innerHTML = 'View on Alexandria' - alexandriaButton.onclick = () => { - window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer') - dropdown.remove() - } - - dropdown.appendChild(wikistrButton) - dropdown.appendChild(alexandriaButton) - document.body.appendChild(dropdown) - - // Close dropdown when clicking outside - const closeDropdown = (e: MouseEvent) => { - if (!dropdown.contains(e.target as Node)) { - dropdown.remove() - document.removeEventListener('click', closeDropdown) - } - } - setTimeout(() => document.addEventListener('click', closeDropdown), 0) - }) - } - }) - } - - // Process elements after a short delay to ensure content is rendered - const timeoutId = setTimeout(processInteractiveElements, 100) + + // Preprocess content to convert URLs to AsciiDoc syntax + const processedContent = useMemo(() => { + let content = preprocessAsciidocMediaLinks(event.content) - return () => clearTimeout(timeoutId) - }, [parsedContent?.html]) - - // Style external HTTP/HTTPS links as green (like hashtags) - useEffect(() => { - if (!contentRef.current || !parsedContent) return - - const styleExternalLinks = () => { - const links = contentRef.current?.querySelectorAll('a[href^="http://"], a[href^="https://"]') - links?.forEach((link) => { - const href = link.getAttribute('href') - if (href && !isImage(href) && !isMedia(href)) { - // Add green link styling - link.classList.add('text-green-600', 'dark:text-green-400', 'hover:text-green-700', 'dark:hover:text-green-300', 'hover:underline') - } - }) - } - - const timeoutId = setTimeout(styleExternalLinks, 100) - return () => clearTimeout(timeoutId) - }, [parsedContent?.html]) - - // Add ToC return buttons to section headers - useEffect(() => { - if (!contentRef.current || !isArticleType || !parsedContent) return - - const addTocReturnButtons = () => { - const headers = contentRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6') - if (!headers) return - - headers.forEach((header) => { - // Skip if button already exists - if (header.querySelector('.toc-return-btn')) return - - // Create the return button - const returnBtn = document.createElement('span') - returnBtn.className = 'toc-return-btn' - returnBtn.innerHTML = '↑ ToC' - returnBtn.title = 'Return to Table of Contents' - - // Add click handler - returnBtn.addEventListener('click', (e) => { - e.preventDefault() - e.stopPropagation() - // Scroll to the ToC - const tocElement = document.getElementById('toc') - if (tocElement) { - tocElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - }) - - // Add the button to the header - header.appendChild(returnBtn) - }) - } - - // Add buttons after a short delay to ensure content is rendered - const timeoutId = setTimeout(addTocReturnButtons, 100) + // 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 () => clearTimeout(timeoutId) - }, [parsedContent?.html, isArticleType]) - - // Extract images from content using the unified media extraction service - // This includes images from tags, content, and parsed HTML + return content + }, [event.content]) + + // Extract all media from event const extractedMedia = useMediaExtraction(event, event.content) - // Extract HTTP/HTTPS links from parsed content (in order of appearance) for WebPreview cards at bottom - const contentLinks = useMemo(() => { - if (!parsedContent?.links) return [] - const links: string[] = [] + // Extract media from tags only (for display at top) + const tagMedia = useMemo(() => { const seenUrls = new Set() + const media: Array<{ url: string; type: 'image' | 'video' | 'audio' }> = [] - parsedContent.links.forEach((link) => { - if (link.isExternal && (link.url.startsWith('http://') || link.url.startsWith('https://')) && !isImage(link.url) && !isMedia(link.url)) { - const cleaned = cleanUrl(link.url) - if (cleaned && !seenUrls.has(cleaned)) { - links.push(cleaned) - seenUrls.add(cleaned) - } + // Extract from imeta tags + const imetaInfos = getImetaInfosFromEvent(event) + imetaInfos.forEach((info) => { + const cleaned = cleanUrl(info.url) + if (!cleaned || seenUrls.has(cleaned)) return + if (!isImage(cleaned) && !isMedia(cleaned)) return + + seenUrls.add(cleaned) + if (info.m?.startsWith('image/') || isImage(cleaned)) { + media.push({ url: info.url, type: 'image' }) + } else if (info.m?.startsWith('video/') || isVideo(cleaned)) { + media.push({ url: info.url, type: 'video' }) + } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { + media.push({ url: info.url, type: 'audio' }) } }) - return links - }, [parsedContent?.links]) - - // Extract HTTP/HTTPS links from r tags (excluding those already in content) + // Extract from r tags + event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => { + const url = tag[1] + const cleaned = cleanUrl(url) + if (!cleaned || seenUrls.has(cleaned)) return + if (!isImage(cleaned) && !isMedia(cleaned)) return + + seenUrls.add(cleaned) + if (isImage(cleaned)) { + media.push({ url, type: 'image' }) + } else if (isVideo(cleaned)) { + media.push({ url, type: 'video' }) + } else if (isAudio(cleaned)) { + media.push({ url, type: 'audio' }) + } + }) + + // Extract from image tag + const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) + if (imageTag?.[1]) { + const cleaned = cleanUrl(imageTag[1]) + if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { + seenUrls.add(cleaned) + media.push({ url: imageTag[1], type: 'image' }) + } + } + + return media + }, [event.id, JSON.stringify(event.tags)]) + + // Extract non-media links from tags const tagLinks = useMemo(() => { const links: string[] = [] const seenUrls = new Set() - // Create a set of content link URLs for quick lookup - const contentLinkUrls = new Set(contentLinks) - event.tags .filter(tag => tag[0] === 'r' && tag[1]) .forEach(tag => { const url = tag[1] - if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { - const cleaned = cleanUrl(url) - // Only include if not already in content links and not already seen in tags - if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) { - links.push(cleaned) - seenUrls.add(cleaned) - } + if (!url.startsWith('http://') && !url.startsWith('https://')) return + if (isImage(url) || isMedia(url)) return + + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) } }) return links - }, [event.tags, contentLinks]) - - // Extract images from parsed HTML (after AsciiDoc processing) for carousel - // This ensures we get images that were rendered in the HTML output - const imagesInContent = useMemo(() => { - if (!parsedContent?.html || !event) return [] - - const images: TImetaInfo[] = [] - const imgRegex = /]+src=["']([^"']+)["'][^>]*>/gi + }, [event.id, JSON.stringify(event.tags)]) + + // Get all images for gallery (deduplicated) + const allImages = useMemo(() => { const seenUrls = new Set() + const images: Array<{ url: string; alt?: string }> = [] - // Create a map of extracted media by URL for metadata lookup - const mediaMap = new Map() - extractedMedia.all.forEach((media) => { - if (media.m?.startsWith('image/')) { - mediaMap.set(media.url, media) + // 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 }) } }) - let match - while ((match = imgRegex.exec(parsedContent.html)) !== null) { - const url = match[1] - if (url && !seenUrls.has(url)) { - seenUrls.add(url) - // Use metadata from extracted media if available, otherwise create basic entry - const mediaInfo = mediaMap.get(url) || { url, pubkey: event.pubkey } - images.push(mediaInfo) + // 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 - }, [parsedContent?.html, event, extractedMedia]) - - // Handle image clicks to open carousel + }, [extractedMedia.images, metadata.image]) + + // Create image index map for lightbox + const imageIndexMap = useMemo(() => { + const map = new Map() + allImages.forEach((img, index) => { + const cleaned = cleanUrl(img.url) + if (cleaned) map.set(cleaned, index) + }) + return map + }, [allImages]) + + // Parse content to find media URLs that are already rendered + const mediaUrlsInContent = useMemo(() => { + const urls = new Set() + const urlRegex = /https?:\/\/[^\s<>"']+/g + let match + while ((match = urlRegex.exec(event.content)) !== null) { + const url = match[0] + const cleaned = cleanUrl(url) + if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned))) { + urls.add(cleaned) + } + } + return urls + }, [event.content]) + + // Extract non-media links from content + const contentLinks = useMemo(() => { + const links: string[] = [] + const seenUrls = new Set() + const urlRegex = /https?:\/\/[^\s<>"']+/g + let match + while ((match = urlRegex.exec(event.content)) !== null) { + const url = match[0] + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + } + return links + }, [event.content]) + + // Image gallery state const [lightboxIndex, setLightboxIndex] = useState(-1) + const openLightbox = useCallback((index: number) => { + setLightboxIndex(index) + }, []) + + // Filter tag media to only show what's not in content + const leftoverTagMedia = useMemo(() => { + const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null + return tagMedia.filter(media => { + const cleaned = cleanUrl(media.url) + if (!cleaned) return false + // Skip if already in content + if (mediaUrlsInContent.has(cleaned)) return false + // Skip if this is the metadata image (shown separately) + if (metadataImageUrl && cleaned === metadataImageUrl && !hideImagesAndInfo) return false + return true + }) + }, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo]) + + // Parse AsciiDoc content and post-process for nostr: links and hashtags + const [parsedHtml, setParsedHtml] = useState('') + const [isLoading, setIsLoading] = useState(true) + useEffect(() => { - if (!contentRef.current || imagesInContent.length === 0) return - - const handleImageClick = (event: MouseEvent) => { - const target = event.target as HTMLElement - if (target.tagName === 'IMG' && target.hasAttribute('data-asciidoc-image')) { - event.preventDefault() - event.stopPropagation() + 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() + + // Post-process HTML to handle nostr: links + // Mentions (npub/nprofile) should be inline, events (note/nevent/naddr) should be block-level + htmlString = htmlString.replace(/]*href=["']nostr:([^"']+)["'][^>]*>(.*?)<\/a>/g, (_match, bech32Id) => { + if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { + return `` + } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { + return `
` + } + return _match + }) + + // Also handle nostr: links in plain text (not in tags) + htmlString = htmlString.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) => { + // Only replace if not already in a tag (basic check) + if (!match.includes('<') && !match.includes('>')) { + if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { + return `` + } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { + return `
` + } + } + return match + }) + + // 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 `` + }) - const imageIndex = target.getAttribute('data-image-index') - if (imageIndex !== null) { - setLightboxIndex(parseInt(imageIndex, 10)) + setParsedHtml(htmlString) + } catch (error) { + logger.error('Failed to parse AsciiDoc', error as Error) + setParsedHtml('

Error parsing AsciiDoc content

') + } finally { + if (!cancelled) { + setIsLoading(false) } } } - - const contentElement = contentRef.current - contentElement.addEventListener('click', handleImageClick) + + parseAsciidoc() return () => { - contentElement.removeEventListener('click', handleImageClick) + cancelled = true } - }, [imagesInContent.length]) - - if (isLoading) { - return ( -
-
Loading content...
-
- ) - } - - if (error) { - return ( -
-
Error loading content: {error.message}
-
- ) - } - - if (!parsedContent) { - return ( -
-
No content available
-
+ }, [processedContent]) + + // Store React roots for cleanup + const reactRootsRef = useRef>(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) return + + // Create an inline container for React component (mentions should be inline) + 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() + 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) return + + // Create a block-level container for React component that fills width + const container = document.createElement('div') + container.className = 'w-full my-2' + element.parentNode?.replaceChild(container, element) + + // Use React to render the component + const root = createRoot(container) + root.render() + 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() + 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) + } + }) + + // Cleanup function + return () => { + reactRootsRef.current.forEach((root) => { + root.unmount() + }) + reactRootsRef.current.clear() + } + }, [parsedHtml, isLoading, navigateToHashtag]) + + // 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 ( -
- {/* Article metadata - hide when used as nested content */} - {!hideImagesAndInfo && ( -
-

{metadata.title}

- {metadata.summary && ( -
-

{metadata.summary}

-
- )} - {metadata.image && ( -
- -
- )} -
- )} - - {/* Show title inline when used as nested content */} - {hideImagesAndInfo && metadata.title && ( -

{metadata.title}

- )} - - {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} -
- - {/* Image carousel lightbox */} - {imagesInContent.length > 0 && lightboxIndex >= 0 && createPortal( + <> + +
+ {/* Metadata */} + {!hideImagesAndInfo && metadata.title &&

{metadata.title}

} + {!hideImagesAndInfo && metadata.summary && ( +
+

{metadata.summary}

+
+ )} + {hideImagesAndInfo && metadata.title && ( +

{metadata.title}

+ )} + + {/* 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 ( + { + e.stopPropagation() + if (metadataImageIndex !== undefined) { + openLightbox(metadataImageIndex) + } + }} + /> + ) + })()} + + {/* Media from tags (only if not in content) */} + {leftoverTagMedia.length > 0 && ( +
+ {leftoverTagMedia.map((media) => { + const cleaned = cleanUrl(media.url) + const mediaIndex = imageIndexMap.get(cleaned) + + if (media.type === 'image') { + return ( +
+ { + e.stopPropagation() + if (mediaIndex !== undefined) { + openLightbox(mediaIndex) + } + }} + /> +
+ ) + } else if (media.type === 'video' || media.type === 'audio') { + return ( +
+ +
+ ) + } + return null + })} +
+ )} + + {/* Parsed AsciiDoc content */} + {isLoading ? ( +
Loading content...
+ ) : ( +
+ )} + + {/* Hashtags from metadata */} + {!hideImagesAndInfo && metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( +
{ + e.stopPropagation() + push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) + }} + > + #{tag} +
+ ))} +
+ )} + + {/* WebPreview cards for links from content */} + {contentLinks.length > 0 && ( +
+

Links

+ {contentLinks.map((url, index) => ( + + ))} +
+ )} + + {/* WebPreview cards for links from tags */} + {tagLinks.length > 0 && ( +
+

Related Links

+ {tagLinks.map((url, index) => ( + + ))} +
+ )} +
+ + {/* Image gallery lightbox */} + {allImages.length > 0 && lightboxIndex >= 0 && createPortal(
e.stopPropagation()}> ({ + slides={allImages.map(({ url, alt }) => ({ src: url, - alt: url + alt: alt || url }))} plugins={[Zoom]} open={lightboxIndex >= 0} @@ -488,85 +738,7 @@ export default function AsciidocArticle({
, document.body )} - - {/* Collapsible Article Info - only for article-type events */} - {!hideImagesAndInfo && isArticleType && (parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && ( - - - - - - - {/* Highlight sources */} - {parsedContent?.highlightSources?.length > 0 && ( -
-

Highlight sources:

-
- {parsedContent?.highlightSources?.map((source, index) => ( - - ))} -
-
- )} - - {/* Hashtags - only show t-tags that don't appear as #hashtag in content */} - {(() => { - // Get content hashtags from parsedContent (hashtags extracted from content as #hashtag) - // Normalize to lowercase for comparison - const contentHashtags = new Set((parsedContent?.hashtags || []).map(t => t.toLowerCase())) - // Filter metadata.tags (t-tags from event) to exclude those already in content - const tagsToShow = (metadata.tags || []).filter(tag => !contentHashtags.has(tag.toLowerCase())) - return tagsToShow.length > 0 && ( -
-

Tags:

-
- {tagsToShow.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} -
-
- ) - })()} -
-
- )} - - {/* WebPreview cards for links from content (in order of appearance) */} - {contentLinks.length > 0 && ( -
-

Links

- {contentLinks.map((url, index) => ( - - ))} -
- )} - - {/* WebPreview cards for links from tags */} - {tagLinks.length > 0 && ( -
-

Related Links

- {tagLinks.map((url, index) => ( - - ))} -
- )} -
+ ) -} \ No newline at end of file +} + diff --git a/src/components/Note/LongFormArticle/NostrNode.tsx b/src/components/Note/LongFormArticle/NostrNode.tsx deleted file mode 100644 index b92ad8f..0000000 --- a/src/components/Note/LongFormArticle/NostrNode.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' -import { nip19 } from 'nostr-tools' -import { useMemo } from 'react' -import logger from '@/lib/logger' - -interface NostrNodeProps { - rawText: string - bech32Id?: string -} - -export default function NostrNode({ rawText, bech32Id }: NostrNodeProps) { - const { type, id } = useMemo(() => { - if (!bech32Id) return { type: 'invalid', id: '' } - try { - const decoded = nip19.decode(bech32Id) - if (decoded.type === 'npub' || decoded.type === 'nprofile') { - return { type: 'mention', id: bech32Id } - } - if (decoded.type === 'nevent' || decoded.type === 'naddr' || decoded.type === 'note') { - return { type: 'note', id: bech32Id } - } - } catch (error) { - logger.error('Invalid bech32 ID', { bech32Id, error }) - } - return { type: 'invalid', id: '' } - }, [bech32Id]) - - if (type === 'invalid') return rawText - - if (type === 'mention') { - return - } - return -} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index a7b9b21..d542cd5 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -1,808 +1,572 @@ -import { SecondaryPageLink, useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' +import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' import WebPreview from '@/components/WebPreview' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' -import { toNote, toNoteList, toProfile } from '@/lib/link' +import { toNoteList } from '@/lib/link' import { useMediaExtraction } from '@/hooks' import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url' -import { ExternalLink } from 'lucide-react' +import { getImetaInfosFromEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' -import { ExtendedKind, URL_REGEX } from '@/constants' -import React, { useMemo, useEffect, useRef, useState } from 'react' -import Markdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' +import { ExtendedKind } 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 'katex/dist/katex.min.css' -import NostrNode from './NostrNode' -import { remarkNostr } from './remarkNostr' -import { remarkHashtags } from './remarkHashtags' -import { remarkUnwrapImages } from './remarkUnwrapImages' -import { remarkUnwrapNostr } from './remarkUnwrapNostr' -import { preprocessMediaLinks } from './preprocessMediaLinks' -import { Components } from './types' +import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' +import { preprocessMarkdownMediaLinks } from './preprocessMarkup' + +/** + * 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 + */ +function parseMarkdownContent( + content: string, + options: { + eventPubkey: string + imageIndexMap: Map + openLightbox: (index: number) => void + navigateToHashtag: (href: string) => void + } +): React.ReactNode[] { + const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag } = options + const parts: React.ReactNode[] = [] + let lastIndex = 0 + + // Find all patterns: markdown images, markdown links, 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] } + }) + } + } + }) + + // Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links + 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 + const isInMarkdown = patterns.some(p => + (p.type === 'markdown-link' || p.type === 'markdown-image') && + match.index! >= p.index && + match.index! < p.end + ) + if (!isInMarkdown) { + patterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'nostr', + data: match[1] + }) + } + } + }) + + // Hashtags (#tag) - but not inside markdown links or nostr addresses + const hashtagRegex = /#([a-zA-Z0-9_]+)/g + const hashtagMatches = Array.from(content.matchAll(hashtagRegex)) + hashtagMatches.forEach(match => { + if (match.index !== undefined) { + // Only add if not already covered by another pattern + const isInOther = patterns.some(p => + match.index! >= p.index && + match.index! < p.end + ) + if (!isInOther) { + patterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'hashtag', + data: match[1] + }) + } + } + }) + + // Wikilinks ([[link]] or [[link|display]]) + const wikilinkRegex = /\[\[([^\]]+)\]\]/g + const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex)) + wikilinkMatches.forEach(match => { + if (match.index !== undefined) { + // Only add if not already covered by another pattern + const isInOther = patterns.some(p => + match.index! >= p.index && + match.index! < p.end + ) + if (!isInOther) { + patterns.push({ + index: match.index, + end: match.index + match[0].length, + type: 'wikilink', + data: match[1] + }) + } + } + }) + + // Sort patterns by index + patterns.sort((a, b) => a.index - b.index) + + // Remove overlapping patterns (keep the first one) + const filteredPatterns: typeof patterns = [] + let lastEnd = 0 + patterns.forEach(pattern => { + if (pattern.index >= lastEnd) { + filteredPatterns.push(pattern) + lastEnd = pattern.end + } + }) + + // Build React nodes from patterns + filteredPatterns.forEach((pattern, i) => { + // Add text before pattern + if (pattern.index > lastIndex) { + const text = content.slice(lastIndex, pattern.index) + if (text) { + parts.push({text}) + } + } + + // Render pattern + if (pattern.type === 'markdown-image') { + const { url } = pattern.data + const cleaned = cleanUrl(url) + const imageIndex = imageIndexMap.get(cleaned) + if (isImage(cleaned)) { + parts.push( +
+ { + e.stopPropagation() + if (imageIndex !== undefined) { + openLightbox(imageIndex) + } + }} + /> +
+ ) + } else if (isVideo(cleaned) || isAudio(cleaned)) { + parts.push( +
+ +
+ ) + } + } else if (pattern.type === 'markdown-link') { + const { text, url } = pattern.data + // Render as green link (will show WebPreview at bottom for HTTP/HTTPS) + parts.push( +
e.stopPropagation()} + > + {text} + + ) + } else if (pattern.type === 'nostr') { + const bech32Id = pattern.data + // Check if it's a profile type (mentions/handles should be inline) + if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { + parts.push( + + + + ) + } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { + // Embedded events should be block-level and fill width + parts.push( +
+ +
+ ) + } else { + parts.push(nostr:{bech32Id}) + } + } else if (pattern.type === 'hashtag') { + const tag = pattern.data + parts.push( + { + e.stopPropagation() + e.preventDefault() + navigateToHashtag(`/notes?t=${tag.toLowerCase()}`) + }} + > + #{tag} + + ) + } else if (pattern.type === 'wikilink') { + const linkContent = pattern.data + let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() + let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() + + if (linkContent.startsWith('book:')) { + target = linkContent.replace('book:', '').trim() + } + + const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + + parts.push( + + ) + } + + lastIndex = pattern.end + }) + + // Add remaining text + if (lastIndex < content.length) { + const text = content.slice(lastIndex) + if (text) { + parts.push({text}) + } + } + + // If no patterns, just return the content as text + if (parts.length === 0) { + return [{content}] + } + + return parts +} export default function MarkdownArticle({ event, className, - showImageGallery = false, hideMetadata = false }: { event: Event className?: string - showImageGallery?: boolean hideMetadata?: boolean }) { const { push } = useSecondaryPage() const { navigateToHashtag } = useSmartHashtagNavigation() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) - const contentRef = useRef(null) - - // Preprocess content to convert plain media URLs to markdown syntax - // Also convert "Read naddr... instead." patterns to hyperlinks - const processedContent = useMemo(() => { - let content = preprocessMediaLinks(event.content) - - // Convert "Read naddr... instead." patterns to markdown links for replaceable events - // This is a standard format for forwarding readers to referred events (e.g., in wikis) - const redirectRegex = /Read (naddr1[a-z0-9]+) instead\./gi - content = content.replace(redirectRegex, (_match, naddr) => { - const href = toNote(naddr) - return `Read [${naddr}](${href}) instead.` - }) - - return content - }, [event.content]) - // Use unified media extraction service + // Extract all media from event const extractedMedia = useMediaExtraction(event, event.content) - // Extract hashtags that are actually present in the content (as literal #hashtag) - // This ensures we only render green links for hashtags that are in the content, not from t-tags - const contentHashtags = useMemo(() => { - const hashtags = new Set() - const hashtagRegex = /#(\w+)/g - let match - while ((match = hashtagRegex.exec(event.content)) !== null) { - hashtags.add(match[1].toLowerCase()) - } - return hashtags - }, [event.content]) - - // Create a stable key for contentHashtags to prevent unnecessary re-renders - const contentHashtagsKey = useMemo(() => { - return Array.from(contentHashtags).sort().join(',') - }, [contentHashtags]) - - // Extract HTTP/HTTPS links from content (in order of appearance) for WebPreview cards at bottom - const contentLinks = useMemo(() => { - const links: string[] = [] + // Extract media from tags only (for display at top) + const tagMedia = useMemo(() => { const seenUrls = new Set() + const media: Array<{ url: string; type: 'image' | 'video' | 'audio' }> = [] - // Extract markdown links: [text](url) - const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g - let match - while ((match = markdownLinkRegex.exec(event.content)) !== null) { - const url = match[2] - if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { - const cleaned = cleanUrl(url) - if (cleaned && !seenUrls.has(cleaned)) { - links.push(cleaned) - seenUrls.add(cleaned) - } + // Extract from imeta tags + const imetaInfos = getImetaInfosFromEvent(event) + imetaInfos.forEach((info) => { + const cleaned = cleanUrl(info.url) + if (!cleaned || seenUrls.has(cleaned)) return + if (!isImage(cleaned) && !isMedia(cleaned)) return + + seenUrls.add(cleaned) + if (info.m?.startsWith('image/') || isImage(cleaned)) { + media.push({ url: info.url, type: 'image' }) + } else if (info.m?.startsWith('video/') || isVideo(cleaned)) { + media.push({ url: info.url, type: 'video' }) + } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { + media.push({ url: info.url, type: 'audio' }) } - } + }) - // Extract raw URLs - while ((match = URL_REGEX.exec(event.content)) !== null) { - const url = match[0] - if (!isImage(url) && !isMedia(url)) { - const cleaned = cleanUrl(url) - if (cleaned && !seenUrls.has(cleaned)) { - links.push(cleaned) - seenUrls.add(cleaned) - } + // 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 links - }, [event.content]) - - // Extract HTTP/HTTPS links from r tags (excluding those already in content) + return media + }, [event.id, JSON.stringify(event.tags)]) + + // Extract non-media links from tags const tagLinks = useMemo(() => { const links: string[] = [] const seenUrls = new Set() - // Create a set of content link URLs for quick lookup - const contentLinkUrls = new Set(contentLinks) - event.tags .filter(tag => tag[0] === 'r' && tag[1]) .forEach(tag => { const url = tag[1] - if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { - const cleaned = cleanUrl(url) - // Only include if not already in content links and not already seen in tags - if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) { - links.push(cleaned) - seenUrls.add(cleaned) - } + if (!url.startsWith('http://') && !url.startsWith('https://')) return + if (isImage(url) || isMedia(url)) return + + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) } }) return links - }, [event.tags, contentLinks]) + }, [event.id, JSON.stringify(event.tags)]) + + // Get all images for gallery (deduplicated) + const allImages = useMemo(() => { + const seenUrls = new Set() + const images: Array<{ url: string; alt?: string }> = [] + + // Add images from extractedMedia + extractedMedia.images.forEach(img => { + const cleaned = cleanUrl(img.url) + if (cleaned && !seenUrls.has(cleaned)) { + seenUrls.add(cleaned) + images.push({ url: img.url, alt: img.alt }) + } + }) + + // Add metadata image if it exists + if (metadata.image) { + const cleaned = cleanUrl(metadata.image) + if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { + seenUrls.add(cleaned) + images.push({ url: metadata.image }) + } + } + + return images + }, [extractedMedia.images, metadata.image]) + + // Create image index map for lightbox + const imageIndexMap = useMemo(() => { + const map = new Map() + allImages.forEach((img, index) => { + const cleaned = cleanUrl(img.url) + if (cleaned) map.set(cleaned, index) + }) + return map + }, [allImages]) - // Extract media URLs that are in the content (so we don't render them twice) + // Parse content to find media URLs that are already rendered const mediaUrlsInContent = useMemo(() => { const urls = new Set() - const mediaUrlRegex = /(https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|heic|mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|mp3|wav|flac|aac|m4a|opus|wma)(\?[^\s<>"']*)?)/gi + const urlRegex = /https?:\/\/[^\s<>"']+/g let match - while ((match = mediaUrlRegex.exec(event.content)) !== null) { - urls.add(cleanUrl(match[0])) + 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]) - // All images from useMediaExtraction are already cleaned and deduplicated - // This includes images from content, tags, imeta, r tags, etc. - // Memoize with stable key based on image URLs to prevent unnecessary re-renders - const allImagesKey = useMemo(() => { - return extractedMedia.images.map(img => img.url).sort().join(',') - }, [extractedMedia.images]) - - const allImages = useMemo(() => { - return extractedMedia.images - }, [allImagesKey]) - - // Handle image clicks to open carousel - const [lightboxIndex, setLightboxIndex] = useState(-1) - - useEffect(() => { - if (!contentRef.current || allImages.length === 0) return - - const handleImageClick = (event: MouseEvent) => { - const target = event.target as HTMLElement - if (target.tagName === 'IMG' && target.hasAttribute('data-markdown-image')) { - event.preventDefault() - event.stopPropagation() - - const imageIndex = target.getAttribute('data-image-index') - if (imageIndex !== null) { - setLightboxIndex(parseInt(imageIndex, 10)) - } - } - } - - const contentElement = contentRef.current - contentElement.addEventListener('click', handleImageClick) - - return () => { - contentElement.removeEventListener('click', handleImageClick) - } - }, [allImages.length]) - - // Initialize highlight.js for 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) => { - // Ensure text color is visible before highlighting - const element = block as HTMLElement - element.style.color = 'inherit' - element.classList.add('text-gray-900', 'dark:text-gray-100') - hljs.default.highlightElement(element) - // Ensure text color remains visible after highlighting - element.style.color = 'inherit' - }) + // Extract non-media links from content + const contentLinks = useMemo(() => { + const links: string[] = [] + const seenUrls = new Set() + const urlRegex = /https?:\/\/[^\s<>"']+/g + let match + while ((match = urlRegex.exec(event.content)) !== null) { + const url = match[0] + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) } } } - - // Run highlight after a short delay to ensure content is rendered - const timeoutId = setTimeout(initHighlight, 100) - return () => clearTimeout(timeoutId) + return links }, [event.content]) - - const components = useMemo( - () => - ({ - nostr: ({ rawText, bech32Id }) => ( -
- -
- ), - a: ({ href, children, ...props }) => { - if (!href) { - return - } - - // Handle hashtag links (format: /notes?t=tag) - if (href.startsWith('/notes?t=') || href.startsWith('notes?t=')) { - // Normalize href to include leading slash if missing - const normalizedHref = href.startsWith('/') ? href : `/${href}` - // Render hashtags as inline green links - remarkHashtags only processes hashtags in content - return ( - { - e.stopPropagation() - e.preventDefault() - navigateToHashtag(normalizedHref) - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation() - e.preventDefault() - navigateToHashtag(normalizedHref) - } - }} - role="button" - tabIndex={0} - > - {children} - - ) - } - - // Handle wikilinks - only handle if href looks like a wikilink format - // (we'll handle wikilinks in the text component below) - - if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { - return ( - - {children} - - ) - } - if (href.startsWith('npub1') || href.startsWith('nprofile1')) { - return ( - - {children} - - ) - } - - // Check if this is a media URL that should be rendered inline (for non-article content) - // If so, don't render it as a link - it will be rendered as inline media below - if (!showImageGallery) { - // Normalize the href to match the normalized mediaUrls - const normalizedHref = href.replace(/[?&](utm_[^&]*)/g, '') - .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') - .replace(/[?&]w=\d+/g, '') - .replace(/[?&]h=\d+/g, '') - .replace(/[?&]q=\d+/g, '') - .replace(/[?&]f=\w+/g, '') - .replace(/[?&]auto=\w+/g, '') - .replace(/[?&]format=\w+/g, '') - .replace(/[?&]fit=\w+/g, '') - .replace(/[?&]crop=\w+/g, '') - .replace(/[?&]&+/g, '&') - .replace(/[?&]$/, '') - .replace(/\?$/, '') - - // Check if this is a media URL that should be rendered inline - // Videos and audio are handled separately below - const extension = normalizedHref.split('.').pop()?.toLowerCase() - if (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma', 'mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) { - return null - } - } - - // If the link contains an image, handle it specially - // When markdown processes [![](url)](link), it creates - // The img component handler will convert to component - // So we check if children contains an Image component - const hasImage = React.Children.toArray(children).some( - child => React.isValidElement(child) && child.type === Image - ) - - // If link contains only an image, render just the image without the link wrapper - // This prevents the image from opening as a file - clicking opens lightbox instead - if (hasImage) { - // Check if this is just an image with no other content - const childrenArray = React.Children.toArray(children) - const onlyImage = childrenArray.length === 1 && - React.isValidElement(childrenArray[0]) && - childrenArray[0].type === Image - - if (onlyImage) { - // Just render the image directly, no link wrapper - return <>{children} - } - - // If there's text along with the image, keep the link wrapper - // but prevent navigation when clicking the image itself - return ( - { - // Only open link if not clicking directly on the image itself - // The image component will handle its own click for the lightbox - const target = e.target as HTMLElement - if (target.tagName === 'IMG' || target.closest('img')) { - // Prevent link navigation when clicking the image - // The image's onClick will handle opening the lightbox - e.preventDefault() - e.stopPropagation() - return - } - // Allow default link behavior for non-image clicks - }} - > - {children} - - ) + + // 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]) + + // Preprocess content to convert URLs to markdown syntax + const preprocessedContent = useMemo(() => { + return preprocessMarkdownMediaLinks(event.content) + }, [event.content]) + + // Parse markdown content with post-processing for nostr: links and hashtags + const parsedContent = useMemo(() => { + return parseMarkdownContent(preprocessedContent, { + eventPubkey: event.pubkey, + imageIndexMap, + openLightbox, + navigateToHashtag + }) + }, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag]) + + return ( + <> +
+ {/* Metadata */} + {!hideMetadata && metadata.title &&

{metadata.title}

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

{metadata.summary}

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

{metadata.title}

+ )} + + {/* Metadata image */} + {!hideMetadata && metadata.image && (() => { + const cleanedMetadataImage = cleanUrl(metadata.image) + // Don't show if already in content + if (cleanedMetadataImage && mediaUrlsInContent.has(cleanedMetadataImage)) { + return null } - // For regular HTTP/HTTPS URLs, render as green text link (like hashtags) instead of WebPreview - // WebPreview cards will be shown at the bottom - const cleanedHref = cleanUrl(href) - const isRegularUrl = href.startsWith('http://') || href.startsWith('https://') - - if (isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref)) { - return ( - - {children} - - ) - } + const metadataImageIndex = imageIndexMap.get(cleanedMetadataImage) return ( - - {children} - - ) - }, - p: (props) => { - // Check if the paragraph contains block-level elements that cannot be inside

- // Convert to

to avoid DOM nesting warnings - const children = props.children - const childrenArray = React.Children.toArray(children) - - // Helper to check if a child is a block-level component - const isBlockLevel = (child: React.ReactNode): boolean => { - if (!React.isValidElement(child)) return false - - // Any div element is block-level and cannot be inside

- if (child.type === 'div') { - return true - } - - // Check for known block-level components - if (child.type === 'img' || - child.type === Image || - child.type === MediaPlayer || - child.type === NostrNode || - child.props?.['data-markdown-image'] || - child.props?.['data-markdown-image-wrapper'] || - child.props?.['data-nostr-node'] || - child.props?.['data-embedded-note']) { - return true - } - - // Check children recursively (up to 3 levels deep for nested structures like EmbeddedNote -> MarkdownArticle) - if (child.props?.children) { - const grandchildren = React.Children.toArray(child.props.children) - if (grandchildren.some((gc: React.ReactNode) => isBlockLevel(gc))) { - return true - } - // Check one more level deep - for (const gc of grandchildren) { - if (React.isValidElement(gc) && gc.props?.children) { - const greatGrandchildren = React.Children.toArray(gc.props.children) - if (greatGrandchildren.some((ggc: React.ReactNode) => isBlockLevel(ggc))) { - return true - } - // Check one more level for deeply nested structures - for (const ggc of greatGrandchildren) { - if (React.isValidElement(ggc) && ggc.props?.children) { - const greatGreatGrandchildren = React.Children.toArray(ggc.props.children) - if (greatGreatGrandchildren.some((gggc: React.ReactNode) => isBlockLevel(gggc))) { - return true - } - } - } + { + e.stopPropagation() + if (metadataImageIndex !== undefined) { + openLightbox(metadataImageIndex) } - } - } - - return false - } - - // Check all children for block-level elements - if (childrenArray.some(isBlockLevel)) { - return

- } - - return

- }, - div: (props) =>

, - code: ({ className, children, ...props }: any) => { - const match = /language-(\w+)/.exec(className || '') - const isInline = !match - return !isInline && match ? ( -
-              
-                {children}
-              
-            
- ) : ( - - {children} - - ) - }, - text: ({ children }) => { - if (typeof children !== 'string') { - return <>{children} - } - - // Don't process hashtags in text component - they're already handled by split-based approach - // Only handle wikilinks here - const wikilinkRegex = /\[\[([^\]]+)\]\]/g - const allMatches: Array<{index: number, end: number, type: 'wikilink', data: any}> = [] - - let match - while ((match = wikilinkRegex.exec(children)) !== null) { - const content = match[1] - let target = content.includes('|') ? content.split('|')[0].trim() : content.trim() - let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim() - - if (content.startsWith('book:')) { - target = content.replace('book:', '').trim() - } - - const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') - - allMatches.push({ - index: match.index, - end: match.index + match[0].length, - type: 'wikilink', - data: { dtag, displayText } - }) - } - - if (allMatches.length === 0) return <>{children} - - allMatches.sort((a, b) => a.index - b.index) - - const parts: (string | JSX.Element)[] = [] - let lastIndex = 0 - - for (const match of allMatches) { - if (match.index > lastIndex) { - parts.push(children.slice(lastIndex, match.index)) - } - - parts.push() - - lastIndex = match.end - } - - if (lastIndex < children.length) { - parts.push(children.slice(lastIndex)) - } - - return <>{parts} - }, - img: ({ src }) => { - if (!src) return null - - const cleanedSrc = cleanUrl(src) - - // Check if this is actually a video or audio URL (converted by remarkMedia) - if (cleanedSrc && (isVideo(cleanedSrc) || isAudio(cleanedSrc))) { - // Wrap MediaPlayer in a div to ensure it's block-level and breaks out of paragraphs - // Use stable key to prevent flickering - const stableKey = cleanedSrc - return ( -
- -
- ) - } - - // Find the index of this image in allImages (includes content and tags, already deduplicated) - const imageIndex = cleanedSrc - ? allImages.findIndex(img => cleanUrl(img.url) === cleanedSrc) - : -1 - - // Always render images inline in their content position - // The shared lightbox will show all images (content + tags) when clicked - // Wrap in div to ensure block-level rendering and prevent paragraph nesting - // Use stable key based on cleaned URL to prevent flickering - const stableKey = cleanedSrc || src - return ( -
- = 0 ? imageIndex.toString() : undefined} - onClick={(e) => { - e.stopPropagation() - if (imageIndex >= 0) { - setLightboxIndex(imageIndex) - } - }} - /> -
+ }} + /> ) - } - }) as Components, - [showImageGallery, event.pubkey, event.kind, contentHashtagsKey, allImagesKey, navigateToHashtag] - ) - - return ( - <> - -
- {!hideMetadata && metadata.title &&

{metadata.title}

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

{metadata.summary}

-
- )} - {/* Show title inline when metadata is hidden (for nested content) */} - {/* Don't show title for discussions - it's already shown by the Note component */} - {hideMetadata && metadata.title && event.kind !== ExtendedKind.DISCUSSION && ( -

{metadata.title}

- )} - {!hideMetadata && metadata.image && (() => { - // Find the index of the metadata image in allImages - const cleanedMetadataImage = cleanUrl(metadata.image) - const metadataImageIndex = cleanedMetadataImage - ? allImages.findIndex(img => cleanUrl(img.url) === cleanedMetadataImage) - : -1 + })()} - return ( - = 0 ? metadataImageIndex.toString() : undefined} - onClick={(e) => { - e.stopPropagation() - if (metadataImageIndex >= 0) { - setLightboxIndex(metadataImageIndex) + {/* Media from tags (only if not in content) */} + {leftoverTagMedia.length > 0 && ( +
+ {leftoverTagMedia.map((media) => { + const cleaned = cleanUrl(media.url) + const mediaIndex = imageIndexMap.get(cleaned) + + if (media.type === 'image') { + return ( +
+ { + e.stopPropagation() + if (mediaIndex !== undefined) { + openLightbox(mediaIndex) + } + }} + /> +
+ ) + } else if (media.type === 'video' || media.type === 'audio') { + return ( +
+ +
+ ) } - }} - /> - ) - })()} - - {processedContent} - - - {/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */} - {/* Only render media that's not already in the content (from tags, imeta, etc.) */} - {!showImageGallery && extractedMedia.videos.filter(v => !mediaUrlsInContent.has(v.url)).length > 0 && ( -
- {extractedMedia.videos.filter(v => !mediaUrlsInContent.has(v.url)).map((video) => ( - - ))} -
- )} - {!showImageGallery && extractedMedia.audio.filter(a => !mediaUrlsInContent.has(a.url)).length > 0 && ( -
- {extractedMedia.audio.filter(a => !mediaUrlsInContent.has(a.url)).map((audio) => ( - - ))} + return null + })} +
+ )} + + {/* Parsed content */} +
+ {parsedContent}
- )} - - {metadata.tags.filter(tag => !contentHashtags.has(tag.toLowerCase())).length > 0 && ( -
- {metadata.tags - .filter(tag => !contentHashtags.has(tag.toLowerCase())) - .map((tag) => ( + + {/* Hashtags from metadata */} + {metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => (
{tag}
))} -
- )} +
+ )} - {/* WebPreview cards for links from content (in order of appearance) */} - {contentLinks.length > 0 && ( -
-

Links

- {contentLinks.map((url, index) => ( - - ))} -
- )} + {/* WebPreview cards for links from content */} + {contentLinks.length > 0 && ( +
+

Links

+ {contentLinks.map((url, index) => ( + + ))} +
+ )} - {/* WebPreview cards for links from tags */} - {tagLinks.length > 0 && ( -
-

Related Links

- {tagLinks.map((url, index) => ( - - ))} -
- )} + {/* WebPreview cards for links from tags */} + {tagLinks.length > 0 && ( +
+

Related Links

+ {tagLinks.map((url, index) => ( + + ))} +
+ )}
- {/* Image carousel lightbox - shows all images (content + tags), already cleaned and deduplicated */} + {/* Image gallery lightbox */} {allImages.length > 0 && lightboxIndex >= 0 && createPortal(
e.stopPropagation()}> ) { - const { type, id } = useMemo(() => { - if (!bech32Id) return { type: 'invalid', id: '' } - try { - const { type } = nip19.decode(bech32Id) - if (type === 'npub' || type === 'nprofile') { - return { type: 'mention', id: bech32Id } - } - if (type === 'nevent' || type === 'naddr' || type === 'note') { - return { type: 'note', id: bech32Id } - } - } catch (error) { - logger.error('Invalid bech32 ID', { bech32Id, error }) - } - return { type: 'invalid', id: '' } - }, [bech32Id]) - - if (type === 'invalid') return rawText - - if (type === 'mention') { - return - } - return -} diff --git a/src/components/Note/MarkdownArticle/preprocessMarkup.ts b/src/components/Note/MarkdownArticle/preprocessMarkup.ts new file mode 100644 index 0000000..19ff002 --- /dev/null +++ b/src/components/Note/MarkdownArticle/preprocessMarkup.ts @@ -0,0 +1,207 @@ +import { isImage, isVideo, isAudio } from '@/lib/url' +import { URL_REGEX } from '@/constants' + +/** + * Preprocess content to convert raw media URLs and hyperlinks to markdown syntax + * - Images: https://example.com/image.png -> ![](https://example.com/image.png) + * - Videos: https://example.com/video.mp4 -> ![](https://example.com/video.mp4) + * - Audio: https://example.com/audio.mp3 -> ![](https://example.com/audio.mp3) + * - Hyperlinks: https://example.com/page -> [https://example.com/page](https://example.com/page) + */ +export function preprocessMarkdownMediaLinks(content: string): string { + let processed = content + + // Find all URLs but process them in reverse order to preserve indices + const allMatches: Array<{ url: string; index: number }> = [] + + let match + const regex = new RegExp(URL_REGEX.source, URL_REGEX.flags) + while ((match = regex.exec(content)) !== null) { + const index = match.index + const url = match[0] + const before = content.substring(Math.max(0, index - 20), index) + + // Check if this URL is already part of markdown syntax + // Skip if preceded by: [text](url, ![text](url, or ](url + if (before.match(/\[[^\]]*$/) || before.match(/\]\([^)]*$/) || before.match(/!\[[^\]]*$/)) { + continue + } + + allMatches.push({ url, index }) + } + + // Process in reverse order to preserve indices + for (let i = allMatches.length - 1; i >= 0; i--) { + const { url, index } = allMatches[i] + + // Check if URL is in code block + const beforeUrl = content.substring(0, index) + const backticksCount = (beforeUrl.match(/```/g) || []).length + if (backticksCount % 2 === 1) { + continue // In code block + } + + // Check if URL is in inline code + const lastBacktick = beforeUrl.lastIndexOf('`') + if (lastBacktick !== -1) { + const afterUrl = content.substring(index + url.length) + const nextBacktick = afterUrl.indexOf('`') + if (nextBacktick !== -1) { + const codeBefore = beforeUrl.substring(lastBacktick + 1) + const codeAfter = afterUrl.substring(0, nextBacktick) + // If no newlines between backticks, it's inline code + if (!codeBefore.includes('\n') && !codeAfter.includes('\n')) { + continue + } + } + } + + // Check if it's a media URL + const isImageUrl = isImage(url) + const isVideoUrl = isVideo(url) + const isAudioUrl = isAudio(url) + + let replacement: string + if (isImageUrl || isVideoUrl || isAudioUrl) { + // Media URLs: convert to ![](url) + replacement = `![](${url})` + } else { + // Regular hyperlinks: convert to [url](url) format + replacement = `[${url}](${url})` + } + + // Replace the URL + processed = processed.substring(0, index) + replacement + processed.substring(index + url.length) + } + + return processed +} + +/** + * Preprocess content to convert raw media URLs and hyperlinks to AsciiDoc syntax + * - Images: https://example.com/image.png -> image::https://example.com/image.png[] + * - Videos: https://example.com/video.mp4 -> video::https://example.com/video.mp4[] + * - Audio: https://example.com/audio.mp3 -> audio::https://example.com/audio.mp3[] + * - Hyperlinks: https://example.com/page -> https://example.com/page[link text] + * - Wikilinks: [[link]] or [[link|display]] -> +++WIKILINK:link|display+++ (passthrough for post-processing) + */ +export function preprocessAsciidocMediaLinks(content: string): string { + let processed = content + + // First, protect wikilinks by converting them to passthrough format + // This prevents AsciiDoc from processing them and prevents URLs inside from being processed + const wikilinkRegex = /\[\[([^\]]+)\]\]/g + const wikilinkRanges: Array<{ start: number; end: number }> = [] + const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex)) + wikilinkMatches.forEach(match => { + if (match.index !== undefined) { + wikilinkRanges.push({ + start: match.index, + end: match.index + match[0].length + }) + } + }) + + processed = processed.replace(wikilinkRegex, (_match, linkContent) => { + // Convert to AsciiDoc passthrough format so it's preserved + return `+++WIKILINK:${linkContent}+++` + }) + + // Find all URLs but process them in reverse order to preserve indices + const allMatches: Array<{ url: string; index: number }> = [] + + let match + const regex = new RegExp(URL_REGEX.source, URL_REGEX.flags) + while ((match = regex.exec(content)) !== null) { + const index = match.index + const url = match[0] + const urlEnd = index + url.length + + // Skip URLs that are inside wikilinks + const isInWikilink = wikilinkRanges.some(range => + index >= range.start && urlEnd <= range.end + ) + if (isInWikilink) { + continue + } + + const before = content.substring(Math.max(0, index - 30), index) + + // Check if this URL is already part of AsciiDoc syntax + // Skip if preceded by: image::, video::, audio::, or link: + if (before.match(/image::\s*$/) || + before.match(/video::\s*$/) || + before.match(/audio::\s*$/) || + before.match(/link:\S+\[/) || + before.match(/https?:\/\/[^\s]*\[/)) { + continue + } + + allMatches.push({ url, index }) + } + + // Process in reverse order to preserve indices + for (let i = allMatches.length - 1; i >= 0; i--) { + const { url, index } = allMatches[i] + + // Check if URL is in code block + const beforeUrl = content.substring(0, index) + const codeBlockCount = (beforeUrl.match(/----/g) || []).length + if (codeBlockCount % 2 === 1) { + continue // In code block + } + + // Check if it's a media URL + const isImageUrl = isImage(url) + const isVideoUrl = isVideo(url) + const isAudioUrl = isAudio(url) + + let replacement: string + if (isImageUrl) { + // Images: convert to image::url[] + replacement = `image::${url}[]` + } else if (isVideoUrl) { + // Videos: convert to video::url[] + replacement = `video::${url}[]` + } else if (isAudioUrl) { + // Audio: convert to audio::url[] + replacement = `audio::${url}[]` + } else { + // Regular hyperlinks: convert to link:url[url] + replacement = `link:${url}[${url}]` + } + + // Replace the URL + processed = processed.substring(0, index) + replacement + processed.substring(index + url.length) + } + + return processed +} + +/** + * Post-process content to convert nostr: links and hashtags + * This should be applied AFTER markup processing + */ +export function postProcessNostrLinks(content: string): string { + let processed = content + + // Convert nostr: prefixed links to embedded format + // nostr:npub1... -> [nostr:npub1...] + // nostr:note1... -> [nostr:note1...] + // etc. + 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 + processed = processed.replace(nostrRegex, (match) => { + // Already in a link? Don't double-wrap + // Check if it's already in markdown link syntax [text](nostr:...) + // or AsciiDoc link syntax link:nostr:...[text] + return match // Keep as is for now, will be processed by the parser + }) + + // Convert hashtags to links + // #tag -> link:/notes?t=tag[#tag] (for AsciiDoc) or [#tag](/notes?t=tag) (for Markdown) + // But only if not already in a link + // We'll handle this in the rendering phase to avoid breaking markup + + return processed +} + diff --git a/src/components/Note/MarkdownArticle/preprocessMediaLinks.ts b/src/components/Note/MarkdownArticle/preprocessMediaLinks.ts deleted file mode 100644 index 216492b..0000000 --- a/src/components/Note/MarkdownArticle/preprocessMediaLinks.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { isImage, isVideo, isAudio } from '@/lib/url' - -/** - * Preprocess markdown content to convert plain media URLs to proper markdown syntax - * - Images: `https://example.com/image.png` -> `![](https://example.com/image.png)` - * - Videos: `https://example.com/video.mp4` -> `![](https://example.com/video.mp4)` - * - Audio: `https://example.com/audio.mp3` -> `![](https://example.com/audio.mp3)` - */ -export function preprocessMediaLinks(content: string): string { - let processed = content - - // Find all matches but process them manually to avoid complex regex lookbehind - const allMatches: Array<{ url: string; index: number }> = [] - let match - - // Find all candidate URLs - const tempRegex = /https?:\/\/[^\s<>"']+/gi - while ((match = tempRegex.exec(content)) !== null) { - const index = match.index - const url = match[0] - const before = content.substring(Math.max(0, index - 10), index) - - // Check if this URL is already part of markdown syntax - // Skip if preceded by: [text](url, ![text](url, or ](url - if (before.match(/\[[^\]]*$/) || before.match(/\]\([^)]*$/) || before.match(/!\[[^\]]*$/)) { - continue - } - - allMatches.push({ url, index }) - } - - // Process in reverse order to preserve indices - for (let i = allMatches.length - 1; i >= 0; i--) { - const { url, index } = allMatches[i] - - // Check if URL is in code block - const beforeUrl = content.substring(0, index) - const backticksCount = (beforeUrl.match(/```/g) || []).length - if (backticksCount % 2 === 1) { - continue // In code block - } - - // Check if URL is in inline code - const lastBacktick = beforeUrl.lastIndexOf('`') - if (lastBacktick !== -1) { - const afterUrl = content.substring(index + url.length) - const nextBacktick = afterUrl.indexOf('`') - if (nextBacktick !== -1) { - const codeBefore = beforeUrl.substring(lastBacktick + 1) - const codeAfter = afterUrl.substring(0, nextBacktick) - // If no newlines between backticks, it's inline code - if (!codeBefore.includes('\n') && !codeAfter.includes('\n')) { - continue - } - } - } - - // Check if it's a media URL - const isImageUrl = isImage(url) - const isVideoUrl = isVideo(url) - const isAudioUrl = isAudio(url) - - let replacement: string - if (isImageUrl || isVideoUrl || isAudioUrl) { - // Media URLs: convert to ![](url) - replacement = `![](${url})` - } else { - // Don't convert non-media URLs - let autolink handle them - continue - } - - // Replace the URL - processed = processed.substring(0, index) + replacement + processed.substring(index + url.length) - } - - return processed -} - diff --git a/src/components/Note/MarkdownArticle/remarkHashtags.ts b/src/components/Note/MarkdownArticle/remarkHashtags.ts deleted file mode 100644 index 8e1c250..0000000 --- a/src/components/Note/MarkdownArticle/remarkHashtags.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { PhrasingContent, Root, Text } from 'mdast' -import type { Plugin } from 'unified' -import { visit } from 'unist-util-visit' - -const HASHTAG_REGEX = /#([a-zA-Z0-9_]+)/g - -export const remarkHashtags: Plugin<[], Root> = () => { - return (tree) => { - visit(tree, 'text', (node: Text, index, parent) => { - if (!parent || typeof index !== 'number') return - - const text = node.value - const matches = Array.from(text.matchAll(HASHTAG_REGEX)) - - if (matches.length === 0) return - - const children: PhrasingContent[] = [] - let lastIndex = 0 - - matches.forEach((match) => { - const matchStart = match.index! - const matchEnd = matchStart + match[0].length - const hashtag = match[1] - - // Add text before the hashtag - // Normalize whitespace to prevent paragraph breaks around hashtags - if (matchStart > lastIndex) { - const beforeText = text.slice(lastIndex, matchStart) - // Replace ALL newlines with spaces to keep hashtags inline - // This prevents markdown from treating newlines as paragraph breaks - const normalized = beforeText.replace(/\s*\n+\s*/g, ' ') - if (normalized.trim()) { - children.push({ - type: 'text', - value: normalized - }) - } - } - - // Create a link node for the hashtag - children.push({ - type: 'link', - url: `/notes?t=${hashtag.toLowerCase()}`, - children: [ - { - type: 'text', - value: `#${hashtag}` - } - ] - }) - - lastIndex = matchEnd - }) - - // Add remaining text after the last match - // Normalize whitespace to prevent paragraph breaks - if (lastIndex < text.length) { - const afterText = text.slice(lastIndex) - // Replace ALL newlines with spaces to keep hashtags inline - // This prevents markdown from treating newlines as paragraph breaks - const normalized = afterText.replace(/\s*\n+\s*/g, ' ') - if (normalized.trim()) { - children.push({ - type: 'text', - value: normalized - }) - } - } - - // Filter out empty text nodes to prevent paragraph breaks - const filteredChildren = children.filter((child) => { - if (child.type === 'text') { - return child.value.trim().length > 0 - } - return true - }) - - // Replace the text node with the processed children - parent.children.splice(index, 1, ...filteredChildren) - }) - } -} - diff --git a/src/components/Note/MarkdownArticle/remarkNostr.ts b/src/components/Note/MarkdownArticle/remarkNostr.ts deleted file mode 100644 index e128ca3..0000000 --- a/src/components/Note/MarkdownArticle/remarkNostr.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { PhrasingContent, Root, Text } from 'mdast' -import type { Plugin } from 'unified' -import { visit } from 'unist-util-visit' -import { NostrNode } from './types' - -const NOSTR_REGEX = - /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 NOSTR_REFERENCE_REGEX = - /\[[^\]]+\]\[(nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+))\]/g - -export const remarkNostr: Plugin<[], Root> = () => { - return (tree) => { - visit(tree, 'text', (node: Text, index, parent) => { - if (!parent || typeof index !== 'number') return - - const text = node.value - - // First, handle reference-style nostr links [text][nostr:...] - const refMatches = Array.from(text.matchAll(NOSTR_REFERENCE_REGEX)) - // Then, handle direct nostr links that are not part of reference links - const directMatches = Array.from(text.matchAll(NOSTR_REGEX)).filter((directMatch) => { - return !refMatches.some( - (refMatch) => - directMatch.index! >= refMatch.index! && - directMatch.index! < refMatch.index! + refMatch[0].length - ) - }) - - // Combine and sort matches by position - const allMatches = [ - ...refMatches.map((match) => ({ - ...match, - type: 'reference' as const, - bech32Id: match[2], - rawText: match[0] - })), - ...directMatches.map((match) => ({ - ...match, - type: 'direct' as const, - bech32Id: match[1], - rawText: match[0] - })) - ].sort((a, b) => a.index! - b.index!) - - if (allMatches.length === 0) return - - const children: (Text | NostrNode)[] = [] - let lastIndex = 0 - - allMatches.forEach((match) => { - const matchStart = match.index! - const matchEnd = matchStart + match[0].length - - // Add text before the match - if (matchStart > lastIndex) { - children.push({ - type: 'text', - value: text.slice(lastIndex, matchStart) - }) - } - - // Create custom nostr node with type information - const nostrNode: NostrNode = { - type: 'nostr', - data: { - hName: 'nostr', - hProperties: { - bech32Id: match.bech32Id, - rawText: match.rawText - } - } - } - children.push(nostrNode) - - lastIndex = matchEnd - }) - - // Add remaining text after the last match - if (lastIndex < text.length) { - children.push({ - type: 'text', - value: text.slice(lastIndex) - }) - } - - // Type assertion to tell TypeScript these are valid AST nodes - parent.children.splice(index, 1, ...(children as PhrasingContent[])) - }) - } -} diff --git a/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts b/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts deleted file mode 100644 index beb7726..0000000 --- a/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Paragraph, Root, Image, Link, RootContent } from 'mdast' -import type { Plugin } from 'unified' -import { visit } from 'unist-util-visit' - -/** - * Remark plugin to unwrap images from paragraphs - * This prevents the DOM nesting warning where
(Image component) appears inside

- * - * Markdown wraps standalone images in paragraphs. This plugin unwraps them at the AST level - * so they render directly without a

wrapper. - */ -export const remarkUnwrapImages: Plugin<[], Root> = () => { - return (tree) => { - visit(tree, 'paragraph', (node: Paragraph, index, parent) => { - if (!parent || typeof index !== 'number') return - - const children = node.children - - // Case 1: Paragraph contains only an image: ![alt](url) - if (children.length === 1 && children[0].type === 'image') { - // Replace the paragraph with the image directly - const image = children[0] as Image - parent.children.splice(index, 1, image as unknown as RootContent) - return - } - - // Case 2: Paragraph contains only a link with an image: [![alt](url)](link) - if (children.length === 1 && children[0].type === 'link') { - const link = children[0] as Link - if (link.children.length === 1 && link.children[0].type === 'image') { - // Keep the link but remove the paragraph wrapper - parent.children.splice(index, 1, link as unknown as RootContent) - return - } - } - - // Case 3: Paragraph contains images mixed with text - // Split the paragraph: extract images as separate block elements, keep text in paragraph - const imageIndices: number[] = [] - children.forEach((child, i) => { - if (child.type === 'image') { - imageIndices.push(i) - } else if (child.type === 'link' && child.children.some(c => c.type === 'image')) { - imageIndices.push(i) - } - }) - - if (imageIndices.length > 0) { - // We have images in the paragraph - need to split it - const newNodes: RootContent[] = [] - let lastIndex = 0 - - imageIndices.forEach((imgIndex) => { - // Add text before the image as a paragraph (if any) - if (imgIndex > lastIndex) { - const textBefore = children.slice(lastIndex, imgIndex) - if (textBefore.length > 0 && textBefore.some(c => c.type === 'text' && c.value.trim())) { - newNodes.push({ - type: 'paragraph', - children: textBefore - } as unknown as RootContent) - } - } - - // Add the image as a separate block element - const imageChild = children[imgIndex] - if (imageChild.type === 'image') { - newNodes.push(imageChild as unknown as RootContent) - } else if (imageChild.type === 'link') { - newNodes.push(imageChild as unknown as RootContent) - } - - lastIndex = imgIndex + 1 - }) - - // Add remaining text after the last image (if any) - if (lastIndex < children.length) { - const textAfter = children.slice(lastIndex) - if (textAfter.length > 0 && textAfter.some(c => c.type === 'text' && c.value.trim())) { - newNodes.push({ - type: 'paragraph', - children: textAfter - } as unknown as RootContent) - } - } - - // If we only had images and whitespace, just use the images - if (newNodes.length === 0) { - // All content was images, extract them - children.forEach(child => { - if (child.type === 'image') { - newNodes.push(child as unknown as RootContent) - } else if (child.type === 'link' && child.children.some(c => c.type === 'image')) { - newNodes.push(child as unknown as RootContent) - } - }) - } - - // Replace the paragraph with the split nodes - if (newNodes.length > 0) { - parent.children.splice(index, 1, ...newNodes) - } - } - }) - } -} - diff --git a/src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts b/src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts deleted file mode 100644 index d28f0ad..0000000 --- a/src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Paragraph, Root, RootContent } from 'mdast' -import type { Plugin } from 'unified' -import { visit } from 'unist-util-visit' -import { NostrNode } from './types' - -/** - * Remark plugin to unwrap nostr nodes from paragraphs - * This prevents the DOM nesting warning where

(EmbeddedNote/EmbeddedMention) appears inside

- * - * Markdown wraps standalone nostr references in paragraphs. This plugin unwraps them at the AST level - * so they render directly without a

wrapper. - */ -export const remarkUnwrapNostr: Plugin<[], Root> = () => { - return (tree) => { - visit(tree, 'paragraph', (node: Paragraph, index, parent) => { - if (!parent || typeof index !== 'number') return - - const children = node.children - - // Type guard to check if a node is a NostrNode - const isNostrNode = (node: any): node is NostrNode => { - return node && node.type === 'nostr' - } - - // Case 1: Paragraph contains only a nostr node - if (children.length === 1 && isNostrNode(children[0])) { - // Replace the paragraph with the nostr node directly - // Cast to RootContent since we're promoting it to block level - const nostrNode = children[0] as unknown as RootContent - parent.children.splice(index, 1, nostrNode) - return - } - - // Case 2: Paragraph contains text and a nostr node - // If the paragraph only contains whitespace and a nostr node, unwrap it - const hasOnlyNostrAndWhitespace = children.every(child => { - if (isNostrNode(child)) return true - if (child.type === 'text') { - return !child.value.trim() // Only whitespace - } - return false - }) - - if (hasOnlyNostrAndWhitespace) { - // Find the nostr node and unwrap it - const nostrNode = children.find(isNostrNode) - if (nostrNode) { - // Cast to RootContent since we're promoting it to block level - parent.children.splice(index, 1, nostrNode as unknown as RootContent) - return - } - } - - // Case 3: Paragraph contains mixed content (text + nostr node) - // We'll leave these as-is since they're mixed content - // The paragraph handler in the component will convert them to divs - }) - } -} - diff --git a/src/components/Note/MarkdownArticle/types.ts b/src/components/Note/MarkdownArticle/types.ts deleted file mode 100644 index 175b8c6..0000000 --- a/src/components/Note/MarkdownArticle/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ComponentProps } from 'react' -import type { Components as RmComponents } from 'react-markdown' -import type { Data, Node } from 'unist' - -// Extend the Components interface to include your custom component -export interface Components extends RmComponents { - nostr: React.ComponentType<{ - rawText: string - bech32Id?: string - }> -} - -export interface NostrNode extends Node { - type: 'nostr' - data: Data & { - hName: string - hProperties: ComponentProps - } -} diff --git a/src/components/Note/SimpleContent/index.tsx b/src/components/Note/SimpleContent/index.tsx deleted file mode 100644 index d6a567e..0000000 --- a/src/components/Note/SimpleContent/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Event } from 'nostr-tools' -import { useMemo } from 'react' -import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx' -import { cn } from '@/lib/utils' - -export default function SimpleContent({ - event, - className -}: { - event: Event - className?: string -}) { - const parsedContent = useMemo(() => { - return parseNostrContent(event.content, event) - }, [event.content, event]) - - return renderNostrContent(parsedContent, cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className)) -} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index e2c87e2..874c392 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -107,7 +107,7 @@ export default function Note({ ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { content = showFull ? ( - + ) : ( ) @@ -125,7 +125,7 @@ export default function Note({ ) } else if (event.kind === kinds.LongFormArticle) { content = showFull ? ( - + ) : ( ) @@ -168,12 +168,7 @@ export default function Note({ content = } else { // Use MarkdownArticle for all other kinds - // Only 30023, 30041, 30817, and 30818 will show image gallery and article info - const showImageGallery = event.kind === kinds.LongFormArticle || - event.kind === ExtendedKind.PUBLICATION_CONTENT || - event.kind === ExtendedKind.WIKI_ARTICLE || - event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN - content = + content = } return (