mirror of
https://github.com/dergigi/boris.git
synced 2025-12-27 11:34:50 +01:00
- Add three-dot menu button at end of articles (before Mark as Read) - Right-aligned menu with two options: - Open on Nostr (using nostr gateway/portal) - Open with Native App (using nostr: URI scheme) - Add 'Open with Native App' option to highlight card menus - Menu only appears for nostr-native articles (kind:30023) - Styled consistently with highlight card menus - Click outside to close menu functionality
414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
|
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, faCheck, faEllipsisH, faExternalLinkAlt, faMobileAlt } 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'
|
|
|
|
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<string>
|
|
settings?: UserSettings
|
|
relayPool?: RelayPool | null
|
|
activeAccount?: IAccount | null
|
|
currentArticle?: NostrEvent | null
|
|
// For highlight creation
|
|
onTextSelection?: (text: string) => void
|
|
onClearSelection?: () => void
|
|
}
|
|
|
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
|
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 articleMenuRef = useRef<HTMLDivElement>(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) => {
|
|
if (articleMenuRef.current && !articleMenuRef.current.contains(event.target as Node)) {
|
|
setShowArticleMenu(false)
|
|
}
|
|
}
|
|
|
|
if (showArticleMenu) {
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
}
|
|
}
|
|
}, [showArticleMenu])
|
|
|
|
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:')
|
|
|
|
// 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 handleOpenPortal = () => {
|
|
if (articleLinks) {
|
|
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
|
|
}
|
|
setShowArticleMenu(false)
|
|
}
|
|
|
|
const handleOpenNative = () => {
|
|
if (articleLinks) {
|
|
window.location.href = articleLinks.native
|
|
}
|
|
setShowArticleMenu(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 (
|
|
<div className="reader empty">
|
|
<p>Select a bookmark to read its content.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="reader loading">
|
|
<div className="loading-spinner">
|
|
<FontAwesomeIcon icon={faSpinner} spin />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const highlightRgb = hexToRgb(highlightColor)
|
|
|
|
return (
|
|
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
|
{/* Hidden markdown preview to convert markdown to HTML */}
|
|
{markdown && (
|
|
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
|
components={{
|
|
img: ({ src, alt, ...props }) => (
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
{...props}
|
|
/>
|
|
)
|
|
}}
|
|
>
|
|
{processedMarkdown || markdown}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
|
|
<ReaderHeader
|
|
title={title}
|
|
image={image}
|
|
summary={summary}
|
|
published={published}
|
|
readingTimeText={readingStats ? readingStats.text : null}
|
|
hasHighlights={hasHighlights}
|
|
highlightCount={relevantHighlights.length}
|
|
settings={settings}
|
|
highlights={relevantHighlights}
|
|
highlightVisibility={highlightVisibility}
|
|
/>
|
|
{markdown || html ? (
|
|
<>
|
|
{markdown ? (
|
|
renderedMarkdownHtml && finalHtml ? (
|
|
<div
|
|
ref={contentRef}
|
|
className="reader-markdown"
|
|
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
|
onMouseUp={handleSelectionEnd}
|
|
onTouchEnd={handleSelectionEnd}
|
|
/>
|
|
) : (
|
|
<div className="reader-markdown">
|
|
<div className="loading-spinner">
|
|
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div
|
|
ref={contentRef}
|
|
className="reader-html"
|
|
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
|
onMouseUp={handleSelectionEnd}
|
|
onTouchEnd={handleSelectionEnd}
|
|
/>
|
|
)}
|
|
|
|
{/* Article menu for nostr-native articles */}
|
|
{isNostrArticle && currentArticle && articleLinks && (
|
|
<div className="article-menu-container">
|
|
<div className="article-menu-wrapper" ref={articleMenuRef}>
|
|
<button
|
|
className="article-menu-btn"
|
|
onClick={handleMenuToggle}
|
|
title="More options"
|
|
>
|
|
<FontAwesomeIcon icon={faEllipsisH} />
|
|
</button>
|
|
|
|
{showArticleMenu && (
|
|
<div className="article-menu">
|
|
<button
|
|
className="article-menu-item"
|
|
onClick={handleOpenPortal}
|
|
>
|
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
|
<span>Open on Nostr</span>
|
|
</button>
|
|
<button
|
|
className="article-menu-item"
|
|
onClick={handleOpenNative}
|
|
>
|
|
<FontAwesomeIcon icon={faMobileAlt} />
|
|
<span>Open with Native App</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mark as Read button */}
|
|
{activeAccount && (
|
|
<div className="mark-as-read-container">
|
|
<button
|
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
|
onClick={handleMarkAsRead}
|
|
disabled={isMarkedAsRead || isCheckingReadStatus}
|
|
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
|
>
|
|
<FontAwesomeIcon
|
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheck : faBooks}
|
|
spin={isCheckingReadStatus}
|
|
/>
|
|
<span>
|
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Author info card for nostr-native articles */}
|
|
{isNostrArticle && currentArticle && (
|
|
<div className="author-card-container">
|
|
<AuthorCard authorPubkey={currentArticle.pubkey} />
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="reader empty">
|
|
<p>No readable content found for this URL.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ContentPanel
|