import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react' import ReactPlayer from 'react-player' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import rehypePrism from 'rehype-prism-plus' import VideoEmbedProcessor from './VideoEmbedProcessor' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import 'prismjs/themes/prism-tomorrow.css' import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons' import { ContentSkeleton } from './Skeletons' import { nip19 } from 'nostr-tools' import { getNostrUrl, getSearchUrl } from '../config/nostrGateways' import { RelayPool } from 'applesauce-relay' import { getActiveRelayUrls } from '../services/relayManager' import { IAccount } from 'applesauce-accounts' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../types/highlights' import { readingTime } from 'reading-time-estimator' import { hexToRgb } from '../utils/colorHelpers' import ReaderHeader from './ReaderHeader' import { HighlightVisibility } from './HighlightsPanel' import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML' import { useHighlightedContent } from '../hooks/useHighlightedContent' import { useHighlightInteractions } from '../hooks/useHighlightInteractions' import { UserSettings } from '../services/settingsService' import { createEventReaction, createWebsiteReaction, hasMarkedEventAsRead, hasMarkedWebsiteAsRead } from '../services/reactionService' import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService' import { archiveController } from '../services/archiveController' import AuthorCard from './AuthorCard' import { faBooks } from '../icons/customIcons' import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService' import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers' import { buildNativeVideoUrl } from '../utils/videoHelpers' import { useReadingPosition } from '../hooks/useReadingPosition' import { ReadingProgressIndicator } from './ReadingProgressIndicator' import { EventFactory } from 'applesauce-factory' import { Hooks } from 'applesauce-react' import { generateArticleIdentifier, saveReadingPosition, collectReadingPositionsOnce } from '../services/readingPositionService' import TTSControls from './TTSControls' interface ContentPanelProps { loading: boolean title?: string html?: string markdown?: string selectedUrl?: string image?: string summary?: string published?: number highlights?: Highlight[] showHighlights?: boolean highlightStyle?: 'marker' | 'underline' highlightColor?: string onHighlightClick?: (highlightId: string) => void selectedHighlightId?: string highlightVisibility?: HighlightVisibility currentUserPubkey?: string followedPubkeys?: Set settings?: UserSettings relayPool?: RelayPool | null activeAccount?: IAccount | null currentArticle?: NostrEvent | null // For highlight creation onTextSelection?: (text: string) => void onClearSelection?: () => void // For reading progress indicator positioning isSidebarCollapsed?: boolean isHighlightsCollapsed?: boolean onOpenHighlights?: () => void } const ContentPanel: React.FC = ({ loading, title, html, markdown, selectedUrl, image, summary, published, highlights = [], showHighlights = true, highlightStyle = 'marker', highlightColor = '#ffff00', settings, relayPool, activeAccount, currentArticle, onHighlightClick, selectedHighlightId, highlightVisibility = { nostrverse: true, friends: true, mine: true }, currentUserPubkey, followedPubkeys = new Set(), onTextSelection, onClearSelection, isSidebarCollapsed = false, isHighlightsCollapsed = false, onOpenHighlights }) => { const [isMarkedAsRead, setIsMarkedAsRead] = useState(false) const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false) const [showCheckAnimation, setShowCheckAnimation] = useState(false) const [showArticleMenu, setShowArticleMenu] = useState(false) const [showVideoMenu, setShowVideoMenu] = useState(false) const [showExternalMenu, setShowExternalMenu] = useState(false) const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false) const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false) const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false) const articleMenuRef = useRef(null) const videoMenuRef = useRef(null) const externalMenuRef = useRef(null) const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null) const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool) const { finalHtml, relevantHighlights } = useHighlightedContent({ html, markdown, renderedMarkdownHtml, highlights, showHighlights, highlightStyle, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys }) // Key used to force re-mount of markdown preview/render when content changes const contentKey = useMemo(() => { // Prefer selectedUrl as a stable per-article key; fallback to title+length return selectedUrl || `${title || ''}:${(markdown || html || '').length}` }, [selectedUrl, title, markdown, html]) const { contentRef, handleSelectionEnd } = useHighlightInteractions({ onHighlightClick, selectedHighlightId, onTextSelection, onClearSelection }) // Get event store for reading position service const eventStore = Hooks.useEventStore() // Reading position tracking - only for text content that's loaded and long enough // Wait for content to load, check it's not a video, and verify it's long enough to track const isTextContent = useMemo(() => { if (loading) return false if (!markdown && !html) return false if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false if (!shouldTrackReadingProgress(html, markdown)) return false return true }, [loading, markdown, html, selectedUrl]) // Generate article identifier for saving/loading position const articleIdentifier = useMemo(() => { if (!selectedUrl) return null return generateArticleIdentifier(selectedUrl) }, [selectedUrl]) // Callback to save reading position const handleSavePosition = useCallback(async (position: number) => { if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) { console.log('[reading-position] ❌ Cannot save: missing dependencies') return } if (!settings?.syncReadingPosition) { console.log('[reading-position] ⚠️ Save skipped: sync disabled in settings') return } // Check if content is long enough to track reading progress if (!shouldTrackReadingProgress(html, markdown)) { console.log('[reading-position] ⚠️ Save skipped: content too short') return } const scrollTop = window.pageYOffset || document.documentElement.scrollTop try { console.log(`[reading-position] [${new Date().toISOString()}] 🚀 Publishing position ${Math.round(position * 100)}% to relays...`) const factory = new EventFactory({ signer: activeAccount }) await saveReadingPosition( relayPool, eventStore, factory, articleIdentifier, { position, timestamp: Math.floor(Date.now() / 1000), scrollTop } ) console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`) } catch (error) { console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error) } }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown]) // Delay enabling position tracking to ensure content is stable const [isTrackingEnabled, setIsTrackingEnabled] = useState(false) useEffect(() => { if (isTextContent) { // Wait 500ms after content loads before enabling tracking const timer = setTimeout(() => setIsTrackingEnabled(true), 500) return () => clearTimeout(timer) } else { setIsTrackingEnabled(false) } }, [isTextContent, selectedUrl]) const { progressPercentage, saveNow, suppressSavesFor } = useReadingPosition({ enabled: isTrackingEnabled, syncEnabled: settings?.syncReadingPosition !== false, onSave: handleSavePosition, onReadingComplete: () => { // Auto-mark as read when reading is complete (if enabled in settings) if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return if (!isMarkedAsRead) { handleMarkAsRead() } else { // Already archived: still show the success animation for feedback setShowCheckAnimation(true) setTimeout(() => setShowCheckAnimation(false), 600) } } }) // Log sync status when it changes useEffect(() => { }, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage]) // Load saved reading position when article loads (stabilized one-shot restore) const suppressSavesForRef = useRef(suppressSavesFor) useEffect(() => { suppressSavesForRef.current = suppressSavesFor }, [suppressSavesFor]) // Track if we've successfully started restore for this article + tracking state // Use a composite key to ensure we only restore once per article when tracking is enabled const restoreKey = `${articleIdentifier}-${isTrackingEnabled}` const hasAttemptedRestoreRef = useRef(null) useEffect(() => { if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) { console.log('[reading-position] ⏭️ Restore skipped: missing dependencies or not text content') return } if (settings?.syncReadingPosition === false) { console.log('[reading-position] ⏭️ Restore skipped: sync disabled in settings') return } if (!isTrackingEnabled) { console.log('[reading-position] ⏭️ Restore skipped: tracking not yet enabled (waiting for content stability)') return } // Only attempt restore once per article (after tracking is enabled) if (hasAttemptedRestoreRef.current === restoreKey) { console.log('[reading-position] ⏭️ Restore skipped: already attempted for this article') return } console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier) // Mark as attempted using composite key hasAttemptedRestoreRef.current = restoreKey // Suppress saves during restore window (700ms collection + 500ms render + 500ms buffer = 1700ms) if (suppressSavesForRef.current) { suppressSavesForRef.current(1700) } const collector = collectReadingPositionsOnce({ relayPool, eventStore, pubkey: activeAccount.pubkey, articleIdentifier, windowMs: 700 }) collector.onStable((bestPosition) => { if (!bestPosition) { console.log('[reading-position] ℹ️ No position to restore') // No saved position, allow saves immediately if (suppressSavesForRef.current) { suppressSavesForRef.current(0) } return } console.log('[reading-position] 🎯 Stable position received:', Math.round(bestPosition.position * 100) + '%') // Wait for content to be fully rendered setTimeout(() => { const docH = document.documentElement.scrollHeight const winH = window.innerHeight const maxScroll = Math.max(0, docH - winH) const currentTop = window.pageYOffset || document.documentElement.scrollTop const targetTop = bestPosition.position * maxScroll console.log('[reading-position] 📐 Restore calculation:', { docHeight: docH, winHeight: winH, maxScroll, currentTop, targetTop, targetPercent: Math.round(bestPosition.position * 100) + '%' }) // Skip if delta is too small (< 48px or < 5%) const deltaPx = Math.abs(targetTop - currentTop) const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0 if (deltaPx < 48 || deltaPct < 0.05) { console.log('[reading-position] ⏭️ Restore skipped: delta too small (', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)') // Allow saves immediately since no scroll happened if (suppressSavesForRef.current) { suppressSavesForRef.current(0) } return } console.log('[reading-position] 📜 Restoring scroll position (delta:', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)') // Suppress saves for another 1.5s after scroll to avoid saving the restored position if (suppressSavesForRef.current) { suppressSavesForRef.current(1500) } // Perform instant restore (avoid smooth animation oscillation) window.scrollTo({ top: targetTop, behavior: 'auto' }) console.log('[reading-position] ✅ Scroll restored to', Math.round(bestPosition.position * 100) + '%') }, 500) // Give content time to render }) return () => { console.log('[reading-position] 🛑 Stopping restore collector') collector.stop() } }, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) // Save position before unmounting or changing article const saveNowRef = useRef(saveNow) const isTrackingEnabledRef = useRef(isTrackingEnabled) useEffect(() => { saveNowRef.current = saveNow }, [saveNow]) useEffect(() => { isTrackingEnabledRef.current = isTrackingEnabled }, [isTrackingEnabled]) useEffect(() => { return () => { // Only save on unmount if tracking was actually enabled if (saveNowRef.current && isTrackingEnabledRef.current) { saveNowRef.current() } } }, [selectedUrl]) // Close menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node if (articleMenuRef.current && !articleMenuRef.current.contains(target)) { setShowArticleMenu(false) } if (videoMenuRef.current && !videoMenuRef.current.contains(target)) { setShowVideoMenu(false) } if (externalMenuRef.current && !externalMenuRef.current.contains(target)) { setShowExternalMenu(false) } } if (showArticleMenu || showVideoMenu || showExternalMenu) { document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } } }, [showArticleMenu, showVideoMenu, showExternalMenu]) // Check available space and position menu upward if needed useEffect(() => { const checkMenuPosition = (menuRef: React.RefObject, setOpenUpward: (value: boolean) => void) => { if (!menuRef.current) return const menuWrapper = menuRef.current const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement if (!menuElement) return const rect = menuWrapper.getBoundingClientRect() const viewportHeight = window.innerHeight const spaceBelow = viewportHeight - rect.bottom const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet // Open upward if there's not enough space below (with 20px buffer) setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight) } if (showArticleMenu) { checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward) } if (showVideoMenu) { checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward) } if (showExternalMenu) { checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward) } }, [showArticleMenu, showVideoMenu, showExternalMenu]) const readingStats = useMemo(() => { const content = markdown || html || '' if (!content) return null const textContent = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ') return readingTime(textContent) }, [html, markdown]) const hasHighlights = relevantHighlights.length > 0 // Extract plain text for TTS const baseHtml = useMemo(() => { if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : '' return finalHtml || html || '' }, [markdown, renderedMarkdownHtml, finalHtml, html]) const articleText = useMemo(() => { const parts: string[] = [] if (title) parts.push(title) if (summary) parts.push(summary) if (baseHtml) { const div = document.createElement('div') div.innerHTML = baseHtml const txt = (div.textContent || '').replace(/\s+/g, ' ').trim() if (txt) parts.push(txt) } return parts.join('. ') }, [title, summary, baseHtml]) // Determine if we're on a nostr-native article (/a/) or external URL (/r/) const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:') const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type) // Track external video duration (in seconds) for display in header const [videoDurationSec, setVideoDurationSec] = useState(null) // Load YouTube metadata/captions when applicable useEffect(() => { (async () => { try { if (!selectedUrl) return setYtMeta(null) const id = extractYouTubeId(selectedUrl) if (!id) return setYtMeta(null) const locale = navigator?.language?.split('-')[0] || 'en' const data = await getYouTubeMeta(id, locale) if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript }) } catch { setYtMeta(null) } })() }, [selectedUrl]) const formatDuration = (totalSeconds: number): string => { const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = Math.floor(totalSeconds % 60) const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes) const ss = String(seconds).padStart(2, '0') return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` } // Get article links for menu const getArticleLinks = () => { if (!currentArticle) return null const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || '' const activeRelays = relayPool ? getActiveRelayUrls(relayPool) : [] const relayHints = activeRelays.filter(r => !r.includes('localhost') && !r.includes('127.0.0.1') ).slice(0, 3) const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag, relays: relayHints }) // Check for source URL in 'r' tags const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1] return { portal: getNostrUrl(naddr), native: `nostr:${naddr}`, naddr, sourceUrl, borisUrl: `${window.location.origin}/a/${naddr}` } } const articleLinks = getArticleLinks() const handleMenuToggle = () => { setShowArticleMenu(!showArticleMenu) } const toggleVideoMenu = () => setShowVideoMenu(v => !v) const handleOpenPortal = () => { if (articleLinks) { window.open(articleLinks.portal, '_blank', 'noopener,noreferrer') } setShowArticleMenu(false) } const handleOpenNative = () => { if (articleLinks) { window.location.href = articleLinks.native } setShowArticleMenu(false) } const handleShareBoris = async () => { try { if (!articleLinks) return if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise }).share) { await (navigator as { share: (d: { title?: string; url?: string }) => Promise }).share({ title: title || 'Article', url: articleLinks.borisUrl }) } else { await navigator.clipboard.writeText(articleLinks.borisUrl) } } catch (e) { console.warn('Share failed', e) } finally { setShowArticleMenu(false) } } const handleShareOriginal = async () => { try { if (!articleLinks?.sourceUrl) return if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise }).share) { await (navigator as { share: (d: { title?: string; url?: string }) => Promise }).share({ title: title || 'Article', url: articleLinks.sourceUrl }) } else { await navigator.clipboard.writeText(articleLinks.sourceUrl) } } catch (e) { console.warn('Share failed', e) } finally { setShowArticleMenu(false) } } const handleCopyBoris = async () => { try { if (!articleLinks) return await navigator.clipboard.writeText(articleLinks.borisUrl) } catch (e) { console.warn('Copy failed', e) } finally { setShowArticleMenu(false) } } const handleCopyOriginal = async () => { try { if (!articleLinks?.sourceUrl) return await navigator.clipboard.writeText(articleLinks.sourceUrl) } catch (e) { console.warn('Copy failed', e) } finally { setShowArticleMenu(false) } } const handleOpenSearch = () => { // For regular notes (kind:1), open via /e/ path if (currentArticle?.kind === 1) { const borisUrl = `${window.location.origin}/e/${currentArticle.id}` window.open(borisUrl, '_blank', 'noopener,noreferrer') } else if (articleLinks) { // For articles, use search portal window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer') } setShowArticleMenu(false) } // Video actions const handleOpenVideoExternal = () => { if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer') setShowVideoMenu(false) } const handleOpenVideoNative = () => { if (!selectedUrl) return const native = buildNativeVideoUrl(selectedUrl) if (native) { window.location.href = native } else { window.location.href = selectedUrl } setShowVideoMenu(false) } const handleCopyVideoUrl = async () => { try { if (selectedUrl) await navigator.clipboard.writeText(selectedUrl) } catch (e) { console.warn('Clipboard copy failed', e) } finally { setShowVideoMenu(false) } } const handleShareVideoUrl = async () => { try { if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise }).share) { await (navigator as { share: (d: { title?: string; url?: string }) => Promise }).share({ title: title || 'Video', url: selectedUrl }) } else if (selectedUrl) { await navigator.clipboard.writeText(selectedUrl) } } catch (e) { console.warn('Share failed', e) } finally { setShowVideoMenu(false) } } // External article actions const toggleExternalMenu = () => setShowExternalMenu(v => !v) const handleOpenExternalUrl = () => { if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer') setShowExternalMenu(false) } const handleCopyExternalUrl = async () => { try { if (selectedUrl) await navigator.clipboard.writeText(selectedUrl) } catch (e) { console.warn('Clipboard copy failed', e) } finally { setShowExternalMenu(false) } } const handleShareExternalUrl = async () => { try { if (!selectedUrl) return const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}` if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise }).share) { await (navigator as { share: (d: { title?: string; url?: string }) => Promise }).share({ title: title || 'Article', url: borisUrl }) } else { await navigator.clipboard.writeText(borisUrl) } } catch (e) { console.warn('Share failed', e) } finally { setShowExternalMenu(false) } } const handleSearchExternalUrl = () => { if (selectedUrl) { // If it's a nostr event sentinel, open the event directly on ants.sh if (selectedUrl.startsWith('nostr-event:')) { const eventId = selectedUrl.replace('nostr-event:', '') window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer') } else { window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer') } } setShowExternalMenu(false) } // Check if article is already marked as read when URL/article changes useEffect(() => { const checkReadStatus = async () => { if (!activeAccount || !relayPool || !selectedUrl) { setIsMarkedAsRead(false) return } setIsCheckingReadStatus(true) try { let hasRead = false if (isNostrArticle && currentArticle) { hasRead = await hasMarkedEventAsRead( currentArticle.id, activeAccount.pubkey, relayPool ) // Also check archiveController const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] if (dTag) { try { const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag }) hasRead = hasRead || archiveController.isMarked(naddr) } catch (e) { // Silently ignore encoding errors } } } else { hasRead = await hasMarkedWebsiteAsRead( selectedUrl, activeAccount.pubkey, relayPool ) // Also check archiveController const ctrl = archiveController.isMarked(selectedUrl) hasRead = hasRead || ctrl } setIsMarkedAsRead(hasRead) } catch (error) { console.error('Failed to check read status:', error) } finally { setIsCheckingReadStatus(false) } } checkReadStatus() }, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle]) const handleMarkAsRead = () => { if (!activeAccount || !relayPool) return // Toggle archive state: if already archived, request deletion; else archive if (isMarkedAsRead) { // Optimistically unarchive in UI; background deletion request (NIP-09) setIsMarkedAsRead(false) ;(async () => { try { if (isNostrArticle && currentArticle) { // Send deletion for all matching reactions await unarchiveEvent(currentArticle.id, activeAccount, relayPool) // Also clear controller mark so lists update try { const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] if (dTag) { const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag }) archiveController.unmark(naddr) } } catch (e) { console.warn('[archive][content] encode naddr failed', e) } } else if (selectedUrl) { await unarchiveWebsite(selectedUrl, activeAccount, relayPool) archiveController.unmark(selectedUrl) } } catch (err) { console.warn('[archive][content] unarchive failed', err) } })() return } // Instantly update UI with checkmark animation setIsMarkedAsRead(true) setShowCheckAnimation(true) // Reset animation after it completes setTimeout(() => { setShowCheckAnimation(false) }, 600) // Fire-and-forget: publish in background without blocking UI ;(async () => { try { if (isNostrArticle && currentArticle) { await createEventReaction( currentArticle.id, currentArticle.pubkey, currentArticle.kind, activeAccount, relayPool, { aCoord: (() => { try { const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] if (!dTag) return undefined return `${30023}:${currentArticle.pubkey}:${dTag}` } catch { return undefined } })() } ) // Update archiveController immediately try { const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] if (dTag) { const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag }) archiveController.mark(naddr) } } catch (err) { console.warn('[archive][content] optimistic article mark failed', err) } } else if (selectedUrl) { await createWebsiteReaction( selectedUrl, activeAccount, relayPool ) archiveController.mark(selectedUrl) } } catch (error) { console.error('Failed to mark as read:', error) // Revert UI state on error setIsMarkedAsRead(false) } })() } if (!selectedUrl) { return (

Select a bookmark to read its content.

) } if (loading) { return (
) } const highlightRgb = hexToRgb(highlightColor) return ( <> {/* Reading Progress Indicator - Outside reader for fixed positioning */} {isTextContent && ( = 95} showPercentage={true} isSidebarCollapsed={isSidebarCollapsed} isHighlightsCollapsed={isHighlightsCollapsed} /> )}
{/* Hidden markdown preview to convert markdown to HTML */} {markdown && (
( {alt} ) }} > {processedMarkdown || markdown}
)} {isTextContent && articleText && (
)} {isExternalVideo ? ( <>
setVideoDurationSec(Math.floor(d))} />
{ytMeta?.description && (
{ytMeta.description}
)} {ytMeta?.transcript && (

Transcript

{ytMeta.transcript}
)}
{showVideoMenu && (
)}
{activeAccount && (
)} ) : markdown || html ? ( <> {markdown ? ( renderedMarkdownHtml && finalHtml ? ( ) : (
) ) : ( )} {/* Article menu for external URLs */} {!isNostrArticle && !isExternalVideo && selectedUrl && (
{showExternalMenu && (
{/* Only show "Open Original" for actual external URLs, not nostr events */} {!selectedUrl?.startsWith('nostr-event:') && ( )}
)}
)} {/* Article menu for nostr-native articles */} {isNostrArticle && currentArticle && articleLinks && (
{showArticleMenu && (
{articleLinks.sourceUrl && ( )} {articleLinks.sourceUrl && ( )}
)}
)} {/* Archive button */} {activeAccount && (
)} {/* Author info card for nostr-native articles */} {isNostrArticle && currentArticle && (
)} ) : (

No readable content found for this URL.

)}
) } export default ContentPanel