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 ( e.stopPropagation()} > @{pubkey.slice(0, 8)}... ) } case 'nprofile': { const { pubkey } = decoded.data const npub = nip19.npubEncode(pubkey) return ( e.stopPropagation()} > @{pubkey.slice(0, 8)}... ) } 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 ( e.stopPropagation()} > {identifier || 'Article'} ) } // For other kinds, show shortened identifier return ( nostr:{identifier.slice(0, 12)}... ) } case 'note': { const eventId = decoded.data return ( note:{eventId.slice(0, 12)}... ) } case 'nevent': { const { id } = decoded.data return ( event:{id.slice(0, 12)}... ) } default: // Fallback for unrecognized types return ( {identifier.slice(0, 20)}... ) } } catch (error) { // If decoding fails, show shortened identifier const identifier = nostrUri.replace(/^nostr:/, '') return ( {identifier.slice(0, 20)}... ) } } // 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 ( Comment attachment ) } else { return ( e.stopPropagation()} > {part} ) } } return {part} })} ) } 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 = ({ highlight, // onSelectUrl is not used but kept in props for API compatibility isSelected, onHighlightClick, relayPool, eventStore, onHighlightUpdate, onHighlightDelete, showCitation = true }) => { const itemRef = useRef(null) const menuRef = useRef(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 ( <>
{ e.stopPropagation() window.location.href = highlightLinks.native }} > {formatDateCompact(highlight.created_at)}
e.stopPropagation()} /> {/* relay indicator lives in footer for consistent padding/alignment */}
{highlight.content}
{showCitation && ( )} {highlight.comment && (
)}
{relayIndicator && ( )} {getUserDisplayName()}
{showMenu && (
{canDelete && ( )}
)}
) }