mirror of
https://github.com/dergigi/boris.git
synced 2025-12-28 20:14:36 +01:00
- Detect and decode nostr: URIs (npub, nprofile, naddr, note, nevent) in comments - Render profiles as clickable links with shortened pubkeys (@abc12345...) - Render blog posts (kind:30023) as clickable article links - Shorten other event identifiers to prevent layout breaks - Add monospace styling for shortened nostr IDs - Maintains DRY principles by extending existing CommentContent component
589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
|
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
|
import { Highlight } from '../types/highlights'
|
|
import { useEventModel } from 'applesauce-react/hooks'
|
|
import { Models, IEventStore } from 'applesauce-core'
|
|
import { RelayPool } from 'applesauce-relay'
|
|
import { Hooks } from 'applesauce-react'
|
|
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
|
import { RELAYS } from '../config/relays'
|
|
import { areAllRelaysLocal } from '../utils/helpers'
|
|
import { nip19 } from 'nostr-tools'
|
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
|
import { createDeletionRequest } from '../services/deletionService'
|
|
import ConfirmDialog from './ConfirmDialog'
|
|
import { getNostrUrl } from '../config/nostrGateways'
|
|
import CompactButton from './CompactButton'
|
|
import { HighlightCitation } from './HighlightCitation'
|
|
|
|
// Helper to detect if a URL is an image
|
|
const isImageUrl = (url: string): boolean => {
|
|
try {
|
|
const urlObj = new URL(url)
|
|
const pathname = urlObj.pathname.toLowerCase()
|
|
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Helper to render a nostr identifier
|
|
const renderNostrId = (nostrUri: string, index: number): JSX.Element => {
|
|
try {
|
|
// Remove nostr: prefix
|
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
const decoded = nip19.decode(identifier)
|
|
|
|
switch (decoded.type) {
|
|
case 'npub': {
|
|
const pubkey = decoded.data
|
|
return (
|
|
<a
|
|
key={index}
|
|
href={`/p/${nip19.npubEncode(pubkey)}`}
|
|
className="highlight-comment-link"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{pubkey.slice(0, 8)}...
|
|
</a>
|
|
)
|
|
}
|
|
case 'nprofile': {
|
|
const { pubkey } = decoded.data
|
|
const npub = nip19.npubEncode(pubkey)
|
|
return (
|
|
<a
|
|
key={index}
|
|
href={`/p/${npub}`}
|
|
className="highlight-comment-link"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{pubkey.slice(0, 8)}...
|
|
</a>
|
|
)
|
|
}
|
|
case 'naddr': {
|
|
const { kind, pubkey, identifier } = decoded.data
|
|
// Check if it's a blog post (kind:30023)
|
|
if (kind === 30023) {
|
|
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
|
return (
|
|
<a
|
|
key={index}
|
|
href={`/a/${naddr}`}
|
|
className="highlight-comment-link"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{identifier || 'Article'}
|
|
</a>
|
|
)
|
|
}
|
|
// For other kinds, show shortened identifier
|
|
return (
|
|
<span key={index} className="highlight-comment-nostr-id">
|
|
nostr:{identifier.slice(0, 12)}...
|
|
</span>
|
|
)
|
|
}
|
|
case 'note': {
|
|
const eventId = decoded.data
|
|
return (
|
|
<span key={index} className="highlight-comment-nostr-id">
|
|
note:{eventId.slice(0, 12)}...
|
|
</span>
|
|
)
|
|
}
|
|
case 'nevent': {
|
|
const { id } = decoded.data
|
|
return (
|
|
<span key={index} className="highlight-comment-nostr-id">
|
|
event:{id.slice(0, 12)}...
|
|
</span>
|
|
)
|
|
}
|
|
default:
|
|
// Fallback for unrecognized types
|
|
return (
|
|
<span key={index} className="highlight-comment-nostr-id">
|
|
{identifier.slice(0, 20)}...
|
|
</span>
|
|
)
|
|
}
|
|
} catch (error) {
|
|
// If decoding fails, show shortened identifier
|
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
|
return (
|
|
<span key={index} className="highlight-comment-nostr-id">
|
|
{identifier.slice(0, 20)}...
|
|
</span>
|
|
)
|
|
}
|
|
}
|
|
|
|
// Component to render comment with links, inline images, and nostr identifiers
|
|
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
|
// Pattern to match both http(s) URLs and nostr: URIs
|
|
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
|
|
const parts = text.split(urlPattern)
|
|
|
|
return (
|
|
<>
|
|
{parts.map((part, index) => {
|
|
// Handle nostr: URIs
|
|
if (part.startsWith('nostr:')) {
|
|
return renderNostrId(part, index)
|
|
}
|
|
|
|
// Handle http(s) URLs
|
|
if (part.match(/^https?:\/\//)) {
|
|
if (isImageUrl(part)) {
|
|
return (
|
|
<img
|
|
key={index}
|
|
src={part}
|
|
alt="Comment attachment"
|
|
className="highlight-comment-image"
|
|
loading="lazy"
|
|
/>
|
|
)
|
|
} else {
|
|
return (
|
|
<a
|
|
key={index}
|
|
href={part}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="highlight-comment-link"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{part}
|
|
</a>
|
|
)
|
|
}
|
|
}
|
|
|
|
return <span key={index}>{part}</span>
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
interface HighlightWithLevel extends Highlight {
|
|
level?: 'mine' | 'friends' | 'nostrverse'
|
|
}
|
|
|
|
interface HighlightItemProps {
|
|
highlight: HighlightWithLevel
|
|
onSelectUrl?: (url: string) => void
|
|
isSelected?: boolean
|
|
onHighlightClick?: (highlightId: string) => void
|
|
relayPool?: RelayPool | null
|
|
eventStore?: IEventStore | null
|
|
onHighlightUpdate?: (highlight: Highlight) => void
|
|
onHighlightDelete?: (highlightId: string) => void
|
|
showCitation?: boolean
|
|
}
|
|
|
|
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|
highlight,
|
|
// onSelectUrl is not used but kept in props for API compatibility
|
|
isSelected,
|
|
onHighlightClick,
|
|
relayPool,
|
|
eventStore,
|
|
onHighlightUpdate,
|
|
onHighlightDelete,
|
|
showCitation = true
|
|
}) => {
|
|
const itemRef = useRef<HTMLDivElement>(null)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
|
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
|
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [showMenu, setShowMenu] = useState(false)
|
|
|
|
const activeAccount = Hooks.useActiveAccount()
|
|
|
|
// Resolve the profile of the user who made the highlight
|
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
|
|
|
// Get display name for the user
|
|
const getUserDisplayName = () => {
|
|
if (profile?.name) return profile.name
|
|
if (profile?.display_name) return profile.display_name
|
|
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
|
}
|
|
|
|
// Update offline indicator when highlight prop changes
|
|
useEffect(() => {
|
|
if (highlight.isOfflineCreated && !isSyncing) {
|
|
setShowOfflineIndicator(true)
|
|
}
|
|
}, [highlight.isOfflineCreated, isSyncing])
|
|
|
|
// Listen to sync state changes
|
|
useEffect(() => {
|
|
const unsubscribe = onSyncStateChange((eventId, syncingState) => {
|
|
if (eventId === highlight.id) {
|
|
setIsSyncing(syncingState)
|
|
// When sync completes successfully, update highlight to show all relays
|
|
if (!syncingState) {
|
|
setShowOfflineIndicator(false)
|
|
|
|
// Update the highlight with all relays after successful sync
|
|
if (onHighlightUpdate && highlight.isLocalOnly) {
|
|
const updatedHighlight = {
|
|
...highlight,
|
|
publishedRelays: RELAYS,
|
|
isLocalOnly: false,
|
|
isOfflineCreated: false
|
|
}
|
|
onHighlightUpdate(updatedHighlight)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return unsubscribe
|
|
}, [highlight, onHighlightUpdate])
|
|
|
|
useEffect(() => {
|
|
if (isSelected && itemRef.current) {
|
|
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}
|
|
}, [isSelected])
|
|
|
|
// Close menu when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setShowMenu(false)
|
|
}
|
|
}
|
|
|
|
if (showMenu) {
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
}
|
|
}
|
|
}, [showMenu])
|
|
|
|
const handleItemClick = () => {
|
|
if (onHighlightClick) {
|
|
onHighlightClick(highlight.id)
|
|
}
|
|
}
|
|
|
|
const getHighlightLinks = () => {
|
|
// Encode the highlight event itself (kind 9802) as a nevent
|
|
// Get non-local relays for the hint
|
|
const relayHints = RELAYS.filter(r =>
|
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
|
).slice(0, 3) // Include up to 3 relay hints
|
|
|
|
const nevent = nip19.neventEncode({
|
|
id: highlight.id,
|
|
relays: relayHints,
|
|
author: highlight.pubkey,
|
|
kind: 9802
|
|
})
|
|
|
|
return {
|
|
portal: getNostrUrl(nevent),
|
|
native: `nostr:${nevent}`
|
|
}
|
|
}
|
|
|
|
const highlightLinks = getHighlightLinks()
|
|
|
|
// Handle rebroadcast to all relays
|
|
const handleRebroadcast = async (e: React.MouseEvent) => {
|
|
e.stopPropagation() // Prevent triggering highlight selection
|
|
|
|
if (!relayPool || !eventStore || isRebroadcasting) return
|
|
|
|
setIsRebroadcasting(true)
|
|
|
|
try {
|
|
// Get the event from the event store
|
|
const event = eventStore.getEvent(highlight.id)
|
|
if (!event) {
|
|
console.error('Event not found in store:', highlight.id)
|
|
return
|
|
}
|
|
|
|
// Publish to all configured relays - let the relay pool handle connection state
|
|
const targetRelays = RELAYS
|
|
|
|
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
|
|
|
|
await relayPool.publish(targetRelays, event)
|
|
|
|
console.log('✅ Rebroadcast successful!')
|
|
|
|
// Update the highlight with new relay info
|
|
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
|
const updatedHighlight = {
|
|
...highlight,
|
|
publishedRelays: targetRelays,
|
|
isLocalOnly,
|
|
isOfflineCreated: false
|
|
}
|
|
|
|
// Notify parent of the update
|
|
if (onHighlightUpdate) {
|
|
onHighlightUpdate(updatedHighlight)
|
|
}
|
|
|
|
// Update local state
|
|
setShowOfflineIndicator(false)
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to rebroadcast:', error)
|
|
} finally {
|
|
setIsRebroadcasting(false)
|
|
}
|
|
}
|
|
|
|
// Determine relay indicator icon and tooltip
|
|
const getRelayIndicatorInfo = () => {
|
|
// Show spinner if manually rebroadcasting OR auto-syncing
|
|
if (isRebroadcasting || isSyncing) {
|
|
return {
|
|
icon: faSpinner,
|
|
tooltip: isRebroadcasting ? 'rebroadcasting...' : 'syncing...',
|
|
spin: true
|
|
}
|
|
}
|
|
|
|
// Always show relay list, use plane icon for local-only
|
|
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
|
|
|
// Show highlighter icon with relay info if available
|
|
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
|
const relayNames = highlight.publishedRelays.map(url =>
|
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
|
)
|
|
return {
|
|
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
|
tooltip: relayNames.join('\n'),
|
|
spin: false
|
|
}
|
|
}
|
|
|
|
if (highlight.seenOnRelays && highlight.seenOnRelays.length > 0) {
|
|
const relayNames = highlight.seenOnRelays.map(url =>
|
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
|
)
|
|
return {
|
|
icon: faHighlighter,
|
|
tooltip: relayNames.join('\n'),
|
|
spin: false
|
|
}
|
|
}
|
|
|
|
// Fallback: show all relays we queried (where this was likely fetched from)
|
|
const relayNames = RELAYS.map(url =>
|
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
|
)
|
|
return {
|
|
icon: faHighlighter,
|
|
tooltip: relayNames.join('\n'),
|
|
spin: false
|
|
}
|
|
}
|
|
|
|
const relayIndicator = getRelayIndicatorInfo()
|
|
|
|
// Check if current user can delete this highlight
|
|
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!activeAccount || !relayPool) {
|
|
console.warn('Cannot delete: no account or relay pool')
|
|
return
|
|
}
|
|
|
|
setIsDeleting(true)
|
|
setShowDeleteConfirm(false)
|
|
|
|
try {
|
|
await createDeletionRequest(
|
|
highlight.id,
|
|
9802, // kind for highlights
|
|
'Deleted by user',
|
|
activeAccount,
|
|
relayPool
|
|
)
|
|
|
|
console.log('✅ Highlight deletion request published')
|
|
|
|
// Notify parent to remove this highlight from the list
|
|
if (onHighlightDelete) {
|
|
onHighlightDelete(highlight.id)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete highlight:', error)
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
const handleCancelDelete = () => {
|
|
setShowDeleteConfirm(false)
|
|
}
|
|
|
|
const handleMenuToggle = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setShowMenu(!showMenu)
|
|
}
|
|
|
|
const handleOpenPortal = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const handleOpenNative = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
window.location.href = highlightLinks.native
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const handleMenuDeleteClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setShowMenu(false)
|
|
setShowDeleteConfirm(true)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={itemRef}
|
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
|
data-highlight-id={highlight.id}
|
|
onClick={handleItemClick}
|
|
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
|
>
|
|
<div className="highlight-header">
|
|
<CompactButton
|
|
className="highlight-timestamp"
|
|
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
window.location.href = highlightLinks.native
|
|
}}
|
|
>
|
|
{formatDateCompact(highlight.created_at)}
|
|
</CompactButton>
|
|
</div>
|
|
|
|
<CompactButton
|
|
className="highlight-quote-button"
|
|
icon={faQuoteLeft}
|
|
title="Quote"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
|
|
{/* relay indicator lives in footer for consistent padding/alignment */}
|
|
|
|
<div className="highlight-content">
|
|
<blockquote className="highlight-text">
|
|
{highlight.content}
|
|
</blockquote>
|
|
|
|
{showCitation && (
|
|
<HighlightCitation
|
|
highlight={highlight}
|
|
relayPool={relayPool}
|
|
/>
|
|
)}
|
|
|
|
{highlight.comment && (
|
|
<div className="highlight-comment">
|
|
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
|
<div className="highlight-comment-text">
|
|
<CommentContent text={highlight.comment} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
<div className="highlight-footer">
|
|
<div className="highlight-footer-left">
|
|
{relayIndicator && (
|
|
<CompactButton
|
|
className="highlight-relay-indicator"
|
|
icon={relayIndicator.icon}
|
|
spin={relayIndicator.spin}
|
|
title={relayIndicator.tooltip}
|
|
onClick={handleRebroadcast}
|
|
disabled={!relayPool || !eventStore}
|
|
/>
|
|
)}
|
|
|
|
<span className="highlight-author">
|
|
{getUserDisplayName()}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="highlight-menu-wrapper" ref={menuRef}>
|
|
<CompactButton
|
|
icon={faEllipsisH}
|
|
onClick={handleMenuToggle}
|
|
title="More options"
|
|
/>
|
|
|
|
{showMenu && (
|
|
<div className="highlight-menu">
|
|
<button
|
|
className="highlight-menu-item"
|
|
onClick={handleOpenPortal}
|
|
>
|
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
|
<span>Open on Nostr</span>
|
|
</button>
|
|
<button
|
|
className="highlight-menu-item"
|
|
onClick={handleOpenNative}
|
|
>
|
|
<FontAwesomeIcon icon={faMobileAlt} />
|
|
<span>Open with Native App</span>
|
|
</button>
|
|
{canDelete && (
|
|
<button
|
|
className="highlight-menu-item highlight-menu-item-danger"
|
|
onClick={handleMenuDeleteClick}
|
|
disabled={isDeleting}
|
|
>
|
|
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
|
<span>Delete</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
isOpen={showDeleteConfirm}
|
|
title="Delete Highlight?"
|
|
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
|
confirmText="Delete"
|
|
cancelText="Cancel"
|
|
variant="danger"
|
|
onConfirm={handleConfirmDelete}
|
|
onCancel={handleCancelDelete}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|