import { Skeleton } from '@/components/ui/skeleton' import ExternalLink from '@/components/ExternalLink' import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, ExtendedKind } from '@/constants' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities' import { isCalendarEventKind } from '@/lib/calendar-event' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' import client, { eventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import nip66Service from '@/services/nip66.service' import { navigationEventStore } from '@/services/navigation-event-store' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { getAggrAwareSearchRelayUrls, syncViewerRelayStackNostrLandAggrEligible, urlsForViewerNostrLandAggrEligibilitySync } from '@/lib/nostr-land-relay-eligibility' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useIsEventDeleted } from '@/providers/DeletedEventProvider' import { useReply } from '@/providers/ReplyProvider' import { useTranslation } from 'react-i18next' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { relayHintWssUrlsFromEvent } from '@/lib/event' import { Event, nip19 } from 'nostr-tools' import ClientSelect from '../ClientSelect' import MainNoteCard from '../NoteCard/MainNoteCard' import UnknownNote from '../Note/UnknownNote' import { EmbeddedCalendarEvent } from './EmbeddedCalendarEvent' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' import { contentParserService } from '@/services/content-parser.service' import { useSmartNoteNavigationOptional } from '@/PageManager' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { toNote } from '@/lib/link' import { type EmbeddedNoteIdValidation, validateEmbeddedNotePointer } from './embeddedNotePointer' import { useSuppressEmbeddedNoteId } from '@/contexts/suppress-embedded-note-context' /** Embedded `noteId` is often raw hex from parsers; must accept A–F and normalize for REQ `ids`. */ function hexEventIdFromNoteId(noteId: string): string | null { const trimmed = noteId.trim() if (/^[0-9a-f]{64}$/i.test(trimmed)) { return trimmed.toLowerCase() } try { const { type, data } = nip19.decode(noteId) if (type === 'note') return data if (type === 'nevent') return data.id return null } catch { return null } } /** For naddr (replaceable events), return coordinate kind:pubkey:identifier for suppression matching. */ function coordinateFromNoteId(noteId: string): string | null { try { const { type, data } = nip19.decode(noteId.trim()) if (type === 'naddr' && data) { const id = data.identifier ?? '' return `${data.kind}:${data.pubkey}:${id}`.toLowerCase() } return null } catch { return null } } /** True if `fetchEventWithExternalRelays(noteId, …)` can build a REQ filter (hex, note, nevent, naddr). */ function canSearchOnExternalRelays(noteId: string): boolean { if (hexEventIdFromNoteId(noteId)) return true try { return nip19.decode(noteId.trim()).type === 'naddr' } catch { return false } } export function EmbeddedNote({ noteId, className, containingEvent, showFull = false }: { noteId: string className?: string containingEvent?: Event /** True = full long-form/publication body; false = compact card (use inside articles). */ showFull?: boolean }) { const suppress = useSuppressEmbeddedNoteId() const embeddedHexId = useMemo(() => hexEventIdFromNoteId(noteId), [noteId]) const embeddedCoordinate = useMemo(() => coordinateFromNoteId(noteId), [noteId]) const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId]) if (suppress) { if (embeddedHexId && embeddedHexId === suppress.hexId.toLowerCase()) return null if (suppress.coordinate && embeddedCoordinate && embeddedCoordinate === suppress.coordinate.toLowerCase()) return null } if (!validation.valid) { return ( ) } return ( ) } function EmbeddedNoteInvalid({ noteId, className, validation }: { noteId: string className?: string validation: Exclude }) { const { t } = useTranslation() const trimmed = noteId.trim() const isNsecLike = /^nsec1/i.test(trimmed) || validation.decodedType === 'nsec' const preview = trimmed.length > 96 ? `${trimmed.slice(0, 96)}…` : trimmed || '—' let message: string switch (validation.reason) { case 'empty': message = t('embeddedNoteInvalidEmpty') break case 'invalid_hex': message = t('embeddedNoteInvalidHex') break case 'wrong_nip19_type': message = t('embeddedNoteInvalidWrongKind', { type: validation.decodedType ?? 'unknown' }) break case 'invalid_bech32': default: message = t('embeddedNoteInvalidBech32') break } return (
e.stopPropagation()} data-embedded-note-invalid >
{t('Invalid embedded note reference')}

{message}

{validation.reason !== 'empty' && !isNsecLike && (
            {preview}
          
)}
) } function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; className?: string }) { const { t } = useTranslation() const trimmed = noteId.trim() const njump = `https://njump.me/${trimmed}` return (
e.stopPropagation()} data-live-embed-suppressed >

{t('liveStreamEmbedSuppressed')}

) } /** * Fetches and renders an embedded note: wide-relay REQ immediately in parallel with the normal * fetch path (no “try external” gate — embeds are always worth the index-relay fan-out). */ function EmbeddedNoteFetched({ noteId, className, containingEvent, showFull, allowLiveEmbeds }: { noteId: string className?: string containingEvent?: Event showFull: boolean allowLiveEmbeds: boolean }) { const { t } = useTranslation() const isEventDeleted = useIsEventDeleted() const { addReplies } = useReply() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { inboxRelayUrls } = useViewerInboxRelayUrls() const [event, setEvent] = useState(undefined) const [isFetching, setIsFetching] = useState(true) const eventRef = useRef(undefined) const retryIntervalRef = useRef | null>(null) eventRef.current = event const relayHintsFromParent = useMemo( () => relayHintWssUrlsFromEvent(containingEvent), [containingEvent?.id] ) const menuRelayUrls = useMemo( () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) .map((url) => normalizeUrl(url)) .filter((url): url is string => Boolean(url)), [favoriteRelays, blockedRelays] ) useEffect(() => { syncViewerRelayStackNostrLandAggrEligible( urlsForViewerNostrLandAggrEligibilitySync({ favoriteRelayUrls: favoriteRelays, relayListRead: inboxRelayUrls }) ) }, [favoriteRelays, inboxRelayUrls]) const wideRelaysStatic = useMemo( () => buildEmbedWideRelayUrlsStatic( menuRelayUrls, relayHintsFromParent, inboxRelayUrls ), [menuRelayUrls, relayHintsFromParent, inboxRelayUrls] ) const fetchRelayOpts = useMemo( () => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined), [relayHintsFromParent] ) const resolveAndSet = useCallback( (ev: Event | undefined) => { if (!ev || isEventDeleted(ev) || shouldDropEventOnIngest(ev)) return false if (retryIntervalRef.current) { clearInterval(retryIntervalRef.current) retryIntervalRef.current = null } client.addEventToCache(ev) setEvent(ev) addReplies([ev]) return true }, [addReplies, isEventDeleted] ) /** Latest relay lists without listing them as effect deps (favorites context churn would re-run the effect and `setEvent(undefined)` wiped loaded embeds → loop / “crash”). */ const embedFetchCtxRef = useRef({ fetchRelayOpts: undefined as { relayHints?: string[] } | undefined, wideRelaysStatic: [] as string[] }) embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic } const resolveAndSetRef = useRef(resolveAndSet) resolveAndSetRef.current = resolveAndSet const isEventDeletedRef = useRef(isEventDeleted) isEventDeletedRef.current = isEventDeleted const containingEventRef = useRef(containingEvent) containingEventRef.current = containingEvent useEffect(() => { let cancelled = false const noteKey = noteId.trim() eventRef.current = undefined setEvent(undefined) setIsFetching(true) const resolve = (ev: Event | undefined) => resolveAndSetRef.current(ev) const tryShortcuts = (): boolean => { const nav = navigationEventStore.peekEvent(noteKey) if (nav && resolve(nav)) return true const peek = client.peekSessionCachedEvent(noteKey) if (peek && resolve(peek)) return true return false } const runWidePass = async (relayUrls: string[]) => { if (!canSearchOnExternalRelays(noteKey) || relayUrls.length === 0) return undefined const ev = await client.fetchEventWithExternalRelays(noteKey, relayUrls) return ev } const runParallelFetch = async () => { const { fetchRelayOpts: opts, wideRelaysStatic: wideUrls } = embedFetchCtxRef.current const hex = hexEventIdFromNoteId(noteKey) const isUsable = (e: Event) => !isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e) try { const chosen = await firstResolvedUsableEmbedEvent( [ () => promiseWithTimeout(client.fetchEvent(noteKey, opts), 12_000), () => hex && /^[0-9a-f]{64}$/i.test(hex) ? indexedDb .getEventFromPublicationStore(hex.toLowerCase()) .catch(() => undefined) : Promise.resolve(undefined), () => runWidePass(wideUrls) ], isUsable ) if (cancelled) return if (chosen) { resolve(chosen) } } finally { if (!cancelled) setIsFetching(false) } } if (tryShortcuts()) { setIsFetching(false) } else { void runParallelFetch() } void (async () => { if (tryShortcuts() || eventRef.current) return const extra = await loadAsyncEmbedRelayHints(noteKey, containingEventRef.current) if (cancelled || eventRef.current) return const wide0 = embedFetchCtxRef.current.wideRelaysStatic const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra])) const ev = await runWidePass( feedRelayPolicyUrls([{ source: 'fallback', urls: wideMerged }], { operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: false }) ) if (cancelled || !ev) return resolve(ev) if (!cancelled) setIsFetching(false) })() if (eventRef.current) { return () => { cancelled = true if (retryIntervalRef.current) { clearInterval(retryIntervalRef.current) retryIntervalRef.current = null } setIsFetching(false) } } retryIntervalRef.current = setInterval(() => { if (cancelled || eventRef.current) return void (async () => { const opts = embedFetchCtxRef.current.fetchRelayOpts const ev = await client.fetchEventForceRetry(noteKey, opts) if (!cancelled && ev) resolve(ev) })() }, 8000) return () => { cancelled = true if (retryIntervalRef.current) { clearInterval(retryIntervalRef.current) retryIntervalRef.current = null } setIsFetching(false) } /** Only the embed pointer and parent note identity — not relay arrays / callbacks (avoids wipe+refetch loops). */ }, [noteId, containingEvent?.id]) useEffect(() => { if (!noteId.trim() || event !== undefined) return undefined const id = noteId.trim() return eventService.subscribeWhenSessionHasEvent(id, () => { const peek = client.peekSessionCachedEvent(id) if (peek) resolveAndSetRef.current(peek) }) }, [noteId, event]) const finalEvent = event if (isFetching && !finalEvent) { return } if (!finalEvent) { return (
e.stopPropagation()} data-embedded-note-loading >

{t('embeddedNoteFetchMiss', { defaultValue: 'This note is not in local storage and was not returned by the relays we queried. Retries run in the background; you can also open it in another client.' })}

) } if ( !allowLiveEmbeds && LIVE_ACTIVITY_KINDS.includes(finalEvent.kind as (typeof LIVE_ACTIVITY_KINDS)[number]) ) { 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 notes (kinds 31922 / 31923) – render as calendar card if (isCalendarEventKind(finalEvent.kind)) { return (
e.stopPropagation()} >
) } if (!isRenderableNoteKind(finalEvent.kind)) { return (
e.stopPropagation()} >
) } // Otherwise, render as regular embedded note return (
e.stopPropagation()} >
) } function EmbeddedNoteContent({ noteId, className, containingEvent, showFull = false }: { noteId: string className?: string containingEvent?: Event showFull?: boolean }) { /** Embeds are contextual to the parent note; home kind picker must not hide NIP-53 live cards here. */ const allowLiveEmbeds = true return ( ) } function dedupeRelayUrls(urls: readonly string[]): string[] { return [...new Set(urls.map((u) => (normalizeUrl(u?.trim()) || '') as string).filter(Boolean))] } /** Prefer relays that usually hold / index replaceables so REQ opens useful targets first. */ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { const score = (u: string) => { const x = u.toLowerCase() if (x.includes('nos.lol')) return 0 if (x.includes('nostr.land')) return 1 if (x.includes('relay.damus.io')) return 2 if (x.includes('relay.primal.net')) return 3 if (x.includes('nostr.wine')) return 4 return 30 } return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) } /** Static + menu favorites + viewer inboxes: REQ on embed mount; always include the nostr.land aggregator. */ function buildEmbedWideRelayUrlsStatic( menuRelayUrls: string[], relayHintsFromParent: string[], viewerInboxRelayUrls: string[] ): string[] { return sanitizeRelayUrlsForFetch( feedRelayPolicyUrls( [ { source: 'fallback', urls: preferPublicIndexRelaysFirst( dedupeRelayUrls([ ...getAggrAwareSearchRelayUrls(), ...relayHintsFromParent, ...viewerInboxRelayUrls, ...nip66Service.getSearchableRelayUrls(), ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS, ...menuRelayUrls ]) ) } ], { operation: 'read', applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: false } ) ) } /** NIP-65 / nevent relays / seen-on — merged into a second wide REQ if the first pass missed. */ async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event): Promise { const hintRelays: string[] = [] const resolvedHexId = (() => { const h = hexEventIdFromNoteId(noteId) if (h) return h try { const { type, data } = nip19.decode(noteId.trim()) if (type === 'nevent') return data.id if (type === 'note') return data } catch { return null } return null })() if (containingEvent) { try { const containingAuthorRelayList = await client .fetchRelayList(containingEvent.pubkey) .catch(() => ({ read: [] as string[], write: [] as string[] })) hintRelays.push( ...(containingAuthorRelayList.read ?? []).slice(0, 12), ...(containingAuthorRelayList.write ?? []).slice(0, 12) ) } catch (err) { logger.debug('[EmbeddedNote] containing author relays failed', { error: err }) } } try { const { type, data } = nip19.decode(noteId.trim()) if (type === 'nevent') { 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, 12), ...(authorRelayList.write ?? []).slice(0, 12) ) } } 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, 12), ...(authorRelayList.write ?? []).slice(0, 12) ) } } catch { /* invalid */ } if (resolvedHexId) { hintRelays.push(...client.getSeenEventRelayUrls(resolvedHexId)) } return dedupeRelayUrls(hintRelays) } function promiseWithTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise.catch(() => undefined), new Promise((resolve) => { setTimeout(() => resolve(undefined), ms) }) ]) } /** Resolve as soon as any fetch path returns a usable event (do not wait for slow wide-relay fan-out). */ function firstResolvedUsableEmbedEvent( tasks: Array<() => Promise>, isUsable: (e: Event) => boolean ): Promise { if (tasks.length === 0) return Promise.resolve(undefined) return new Promise((resolve) => { let settled = 0 let resolved = false const finish = (ev: Event | undefined) => { settled++ if (!resolved && ev && isUsable(ev)) { resolved = true resolve(ev) return } if (settled === tasks.length && !resolved) resolve(undefined) } for (const run of tasks) { void run().then(finish).catch(() => finish(undefined)) } }) } function EmbeddedNoteSkeleton({ className }: { className?: string }) { return (
e.stopPropagation()} >
) } /** * 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 } = useSmartNoteNavigationOptional() 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() const noteUrl = toNote( originalNoteId ?? event, typeof originalNoteId === 'string' && /^[0-9a-f]{64}$/i.test(originalNoteId.trim()) ? event : undefined ) navigateToNote(noteUrl, event, getCachedThreadContextEvents(event)) }} > {/* 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 */}
) }