You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

771 lines
25 KiB

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 (
<EmbeddedNoteInvalid
className={className}
noteId={noteId}
validation={validation}
/>
)
}
return (
<EmbeddedNoteContent
noteId={noteId}
className={className}
containingEvent={containingEvent}
showFull={showFull}
/>
)
}
function EmbeddedNoteInvalid({
noteId,
className,
validation
}: {
noteId: string
className?: string
validation: Exclude<EmbeddedNoteIdValidation, { valid: true }>
}) {
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 (
<div
className={cn('not-prose max-w-full text-left p-3 border border-destructive/30 rounded-lg bg-destructive/5', className)}
onClick={(e) => e.stopPropagation()}
data-embedded-note-invalid
>
<div className="flex flex-col gap-2 text-muted-foreground">
<div className="text-sm font-medium text-destructive">{t('Invalid embedded note reference')}</div>
<p className="text-xs leading-relaxed">{message}</p>
{validation.reason !== 'empty' && !isNsecLike && (
<pre className="text-[10px] font-mono whitespace-pre-wrap break-all rounded bg-muted/50 p-2 text-foreground/80">
{preview}
</pre>
)}
<ClientSelect className="w-full" originalNoteId={trimmed || undefined} />
</div>
</div>
)
}
function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; className?: string }) {
const { t } = useTranslation()
const trimmed = noteId.trim()
const njump = `https://njump.me/${trimmed}`
return (
<div
className={cn('not-prose max-w-full rounded-lg border p-3 text-left', className)}
onClick={(e) => e.stopPropagation()}
data-live-embed-suppressed
>
<p className="mb-2 text-xs text-muted-foreground">{t('liveStreamEmbedSuppressed')}</p>
<ExternalLink url={njump} className="text-sm break-all" />
<ClientSelect className="mt-2 w-full" originalNoteId={trimmed || undefined} />
</div>
)
}
/**
* 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<Event | undefined>(undefined)
const [isFetching, setIsFetching] = useState(true)
const eventRef = useRef<Event | undefined>(undefined)
const retryIntervalRef = useRef<ReturnType<typeof setInterval> | 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 <EmbeddedNoteSkeleton className={className} />
}
if (!finalEvent) {
return (
<div
className={cn('not-prose max-w-full text-left p-3 border rounded-lg border-dashed', className)}
onClick={(e) => e.stopPropagation()}
data-embedded-note-loading
>
<p className="text-xs text-muted-foreground mb-2">
{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.'
})}
</p>
<ClientSelect className="w-full" originalNoteId={noteId.trim() || undefined} />
</div>
)
}
if (
!allowLiveEmbeds &&
LIVE_ACTIVITY_KINDS.includes(finalEvent.kind as (typeof LIVE_ACTIVITY_KINDS)[number])
) {
return <SuppressedLiveStreamEmbed noteId={noteId} className={className} />
}
// 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 (
<div
data-embedded-note
data-bookstr
className="not-prose max-w-full"
onClick={(e) => e.stopPropagation()}
>
<EmbeddedBookstrEvent event={finalEvent} originalNoteId={noteId} className={className} />
</div>
)
}
// NIP-52 calendar notes (kinds 31922 / 31923) – render as calendar card
if (isCalendarEventKind(finalEvent.kind)) {
return (
<div
data-embedded-note
className="not-prose max-w-full"
onClick={(e) => e.stopPropagation()}
>
<EmbeddedCalendarEvent event={finalEvent} className={className} />
</div>
)
}
if (!isRenderableNoteKind(finalEvent.kind)) {
return (
<div
data-embedded-note
data-embedded-unsupported
className="not-prose max-w-full"
onClick={(e) => e.stopPropagation()}
>
<UnknownNote
event={finalEvent}
showAuthorSummary
className={cn('my-0 p-2 sm:p-3 border rounded-lg w-full', className)}
/>
</div>
)
}
// Otherwise, render as regular embedded note
return (
<div
data-embedded-note
className="not-prose max-w-full"
onClick={(e) => e.stopPropagation()}
>
<MainNoteCard
className={cn('w-full', className)}
event={finalEvent}
embedded
showFull={showFull}
originalNoteId={noteId}
/>
</div>
)
}
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 (
<EmbeddedNoteFetched
noteId={noteId}
className={className}
containingEvent={containingEvent}
showFull={showFull}
allowLiveEmbeds={allowLiveEmbeds}
/>
)
}
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<string[]> {
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<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
return Promise.race([
promise.catch(() => undefined),
new Promise<undefined>((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<Event | undefined>>,
isUsable: (e: Event) => boolean
): Promise<Event | undefined> {
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 (
<div
className={cn('not-prose max-w-full text-left p-2 sm:p-3 border rounded-lg', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
</div>
)
}
/**
* Render a single bookstr event directly (no searching needed)
*/
function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) {
const [parsedContent, setParsedContent] = useState<string | null>(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 (
<div
className={cn('border rounded-lg p-3 bg-muted/30 clickable', className)}
data-event-id={event.id}
onClick={(e) => {
// 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 */}
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">
{bookName}
{chapterNum && ` ${chapterNum}`}
{verseNum && `:${verseNum}`}
{version && ` (${version.toUpperCase()})`}
</h4>
</div>
{/* Content */}
<div className="flex gap-2 text-sm leading-relaxed items-baseline">
{/* Verse number on the left - only show verse number, not chapter:verse */}
<span className="font-semibold text-muted-foreground shrink-0 min-w-[2.5rem] text-right">
{verseNum || null}
</span>
{/* Content on the right */}
<span className="flex-1" dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
)
}