import { Skeleton } from '@/components/ui/skeleton' import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants' import { useFetchEvent } from '@/hooks' import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' import client from '@/services/client.service' import { useTranslation } from 'react-i18next' import { useEffect, useState } from 'react' import { Event, nip19 } from 'nostr-tools' import ClientSelect from '../ClientSelect' import MainNoteCard from '../NoteCard/MainNoteCard' import { Button } from '../ui/button' import { EmbeddedCalendarEvent } from './EmbeddedCalendarEvent' import { Search } from 'lucide-react' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' import { contentParserService } from '@/services/content-parser.service' import { useSmartNoteNavigation } from '@/PageManager' import { toNote } from '@/lib/link' export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) { const { event, isFetching } = useFetchEvent(noteId) const [retryEvent, setRetryEvent] = useState(undefined) const [isRetrying, setIsRetrying] = useState(false) const [retryCount, setRetryCount] = useState(0) const maxRetries = 3 // If the first fetch fails, try a force retry (max 3 attempts) useEffect(() => { if (!isFetching && !event && !isRetrying && retryCount < maxRetries) { setIsRetrying(true) setRetryCount(prev => prev + 1) client.fetchEventForceRetry(noteId) .then((retryResult: any) => { if (retryResult) { setRetryEvent(retryResult) } }) .catch((error: any) => { logger.warn('EmbeddedNote retry failed', { attempt: retryCount + 1, maxRetries, noteId, error }) }) .finally(() => { setIsRetrying(false) }) } }, [isFetching, event, noteId, isRetrying, retryCount]) const finalEvent = event || retryEvent const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries) if (finalIsFetching) { return } if (!finalEvent) { return } // Check if this event has bookstr tags (at least "book" tag) const bookMetadata = extractBookMetadata(finalEvent) const hasBookstrTags = !!bookMetadata.book // If it has bookstr tags, render directly as bookstr content (no need to search) if (hasBookstrTags) { return (
e.stopPropagation()}>
) } // NIP-52 calendar event (scheduled video call) – render as calendar card if (finalEvent.kind === ExtendedKind.CALENDAR_EVENT_TIME || finalEvent.kind === ExtendedKind.CALENDAR_EVENT_DATE) { return (
e.stopPropagation()}>
) } // Otherwise, render as regular embedded note return (
e.stopPropagation()}>
) } function EmbeddedNoteSkeleton({ className }: { className?: string }) { return (
e.stopPropagation()} >
) } function EmbeddedNoteNotFound({ noteId, className, onEventFound }: { noteId: string className?: string onEventFound?: (event: Event) => void }) { const { t } = useTranslation() const [isSearchingExternal, setIsSearchingExternal] = useState(false) const [triedExternal, setTriedExternal] = useState(false) const [externalRelays, setExternalRelays] = useState([]) const [hexEventId, setHexEventId] = useState(null) // Calculate which external relays would be tried when user clicks "Try external relays". // The client's initial fetch now uses: (1) user's relays or BIG, (2) bech32 hints + author read+write, (3) SEARCHABLE. // We treat BIG + FAST_READ as "already tried"; external = (hints + author read+write + seenOn + SEARCHABLE) minus those. useEffect(() => { const getExternalRelays = async () => { const alreadyTriedRelaysSet = new Set() ;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => { const normalized = normalizeUrl(url) if (normalized) alreadyTriedRelaysSet.add(normalized) }) let hintRelays: string[] = [] let extractedHexEventId: string | null = null if (!/^[0-9a-f]{64}$/.test(noteId)) { try { const { type, data } = nip19.decode(noteId) if (type === 'nevent') { extractedHexEventId = data.id if (data.relays) hintRelays.push(...data.relays) if (data.author) { const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] })) hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) } } else if (type === 'naddr') { if (data.relays) hintRelays.push(...data.relays) const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) } else if (type === 'note') { extractedHexEventId = data } } catch (err) { logger.error('Failed to parse external relays', { error: err, noteId }) } } else { extractedHexEventId = noteId } setHexEventId(extractedHexEventId) // Get relays where this event was seen const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] hintRelays.push(...seenOn) // Normalize all hint relays const normalizedHints = hintRelays .map(url => normalizeUrl(url)) .filter((url): url is string => Boolean(url)) // Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback) // Normalize SEARCHABLE_RELAY_URLS for comparison const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS .map(url => normalizeUrl(url)) .filter((url): url is string => Boolean(url)) // Combine all potential relays (hints + searchable) const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays]) // Filter out relays that were already tried const externalRelays = Array.from(allPotentialRelays).filter( relay => !alreadyTriedRelaysSet.has(relay) ) // Deduplicate final relay list setExternalRelays(externalRelays) logger.debug('External relays calculated', { noteId, hintRelaysCount: normalizedHints.length, searchableRelaysCount: normalizedSearchableRelays.length, alreadyTriedCount: alreadyTriedRelaysSet.size, externalRelaysCount: externalRelays.length, externalRelays: externalRelays.slice(0, 10) // Log first 10 }) } getExternalRelays() }, [noteId]) const handleTryExternalRelays = async () => { if (!hexEventId || isSearchingExternal) return if (externalRelays.length === 0) { logger.warn('No external relays to search', { noteId, hexEventId }) setTriedExternal(true) return } setIsSearchingExternal(true) try { logger.info('Searching external relays', { noteId, hexEventId, relayCount: externalRelays.length, relays: externalRelays.slice(0, 5) // Log first 5 relays }) const event = await client.fetchEventWithExternalRelays(hexEventId, externalRelays) if (event) { logger.info('Event found on external relay', { noteId, hexEventId }) if (onEventFound) { onEventFound(event) } } else { logger.info('Event not found on external relays', { noteId, hexEventId, relayCount: externalRelays.length }) } } catch (error) { logger.error('External relay fetch failed', { error, noteId, hexEventId, externalRelays }) } finally { setIsSearchingExternal(false) setTriedExternal(true) } } const hasExternalRelays = externalRelays.length > 0 return (
{t('Note not found')}
{!triedExternal && hasExternalRelays && (
{t('Show relays')}
{externalRelays.map((relay, i) => (
{relay}
))}
)} {!triedExternal && !hasExternalRelays && (
{t('No external relay hints available')}
)} {triedExternal && (
{t('Note could not be found anywhere')}
)}
) } /** * Render a single bookstr event directly (no searching needed) */ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) { const [parsedContent, setParsedContent] = useState(null) const bookMetadata = extractBookMetadata(event) const { navigateToNote } = useSmartNoteNavigation() useEffect(() => { const parseContent = async () => { try { const result = await contentParserService.parseContent(event.content, { eventKind: ExtendedKind.PUBLICATION_CONTENT }) setParsedContent(result.html) } catch (err) { logger.warn('Error parsing bookstr event content', { error: err, eventId: event.id.substring(0, 8) }) setParsedContent(event.content) } } parseContent() }, [event]) const chapterNum = bookMetadata.chapter const verseNum = bookMetadata.verse const version = bookMetadata.version const bookName = bookMetadata.book ? bookMetadata.book .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') : '' const content = parsedContent || event.content return (
{ // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) { return } e.stopPropagation() client.addEventToCache(event) const noteUrl = toNote(originalNoteId ?? event) navigateToNote(noteUrl) }} > {/* Header */}

{bookName} {chapterNum && ` ${chapterNum}`} {verseNum && `:${verseNum}`} {version && ` (${version.toUpperCase()})`}

{/* Content */}
{/* Verse number on the left - only show verse number, not chapter:verse */} {verseNum || null} {/* Content on the right */}
) }