import React, { useMemo, useState, useEffect, useRef } 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import 'prismjs/themes/prism-tomorrow.css' import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons' import { nip19 } from 'nostr-tools' import { getNostrUrl } from '../config/nostrGateways' import { RELAYS } from '../config/relays' import { RelayPool } from 'applesauce-relay' 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 AuthorCard from './AuthorCard' import { faBooks } from '../icons/customIcons' import { classifyUrl } from '../utils/helpers' 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 } 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 }) => { 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 articleMenuRef = useRef(null) const videoMenuRef = useRef(null) const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool) const { finalHtml, relevantHighlights } = useHighlightedContent({ html, markdown, renderedMarkdownHtml, highlights, showHighlights, highlightStyle, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys }) const { contentRef, handleSelectionEnd } = useHighlightInteractions({ onHighlightClick, selectedHighlightId, onTextSelection, onClearSelection }) // 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 (showArticleMenu || showVideoMenu) { document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } } }, [showArticleMenu, showVideoMenu]) 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 // 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) 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 relayHints = RELAYS.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 }) return { portal: getNostrUrl(naddr), native: `nostr:${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) } // Video actions const buildNativeVideoUrl = (url: string): string | null => { try { const u = new URL(url) const host = u.hostname if (host.includes('youtube.com')) { const id = u.searchParams.get('v') return id ? `youtube://watch?v=${id}` : `youtube://${u.pathname}${u.search}` } if (host === 'youtu.be') { const id = u.pathname.replace('/', '') return id ? `youtube://watch?v=${id}` : 'youtube://' } if (host.includes('vimeo.com')) { const id = u.pathname.split('/').filter(Boolean)[0] return id ? `vimeo://app.vimeo.com/videos/${id}` : 'vimeo://' } if (host.includes('dailymotion.com') || host === 'dai.ly') { // dailymotion.com/video/ or dai.ly/ const parts = u.pathname.split('/').filter(Boolean) const id = host === 'dai.ly' ? parts[0] : (parts[1] || '') return id ? `dailymotion://video/${id}` : 'dailymotion://' } return null } catch { return null } } 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) } } // 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 ) } else { hasRead = await hasMarkedWebsiteAsRead( selectedUrl, activeAccount.pubkey, relayPool ) } 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 || isMarkedAsRead) { 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 ) console.log('✅ Marked nostr article as read') } else if (selectedUrl) { await createWebsiteReaction( selectedUrl, activeAccount, relayPool ) console.log('✅ Marked website as read') } } 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 (
{/* Hidden markdown preview to convert markdown to HTML */} {markdown && (
( {alt} ) }} > {processedMarkdown || markdown}
)} {isExternalVideo ? ( <>
setVideoDurationSec(Math.floor(d))} />
{showVideoMenu && (
)}
{activeAccount && (
)} ) : markdown || html ? ( <> {markdown ? ( renderedMarkdownHtml && finalHtml ? (
) : (
) ) : (
)} {/* Article menu for nostr-native articles */} {isNostrArticle && currentArticle && articleLinks && (
{showArticleMenu && (
)}
)} {/* Mark as Read button */} {activeAccount && (
)} {/* Author info card for nostr-native articles */} {isNostrArticle && currentArticle && (
)} ) : (

No readable content found for this URL.

)}
) } export default ContentPanel