From e3b692e4f48b4da84433830747f647799f8ea303 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 5 May 2026 22:48:29 +0200 Subject: [PATCH] make embedded events appear faster --- src/components/Embedded/EmbeddedNote.tsx | 225 +++++++++-------- src/components/LogoutDialog/index.tsx | 4 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 10 +- src/hooks/useFetchEvent.tsx | 23 +- src/lib/event.ts | 33 ++- src/lib/relay-list-builder.ts | 24 +- src/pages/secondary/NotePage/index.tsx | 11 + src/services/client-events.service.ts | 236 ++++++++++++++---- src/services/client-query.service.ts | 28 ++- src/services/client.service.ts | 8 +- 10 files changed, 418 insertions(+), 184 deletions(-) diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 79d14ead..2e23f192 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -9,9 +9,11 @@ import { normalizeUrl } from '@/lib/url' import { cn } from '@/lib/utils' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' +import nip66Service from '@/services/nip66.service' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useTranslation } from 'react-i18next' import { useEffect, useMemo, useState } from 'react' +import { relayHintWssUrlsFromEvent } from '@/lib/event' import { Event, nip19 } from 'nostr-tools' import ClientSelect from '../ClientSelect' import MainNoteCard from '../NoteCard/MainNoteCard' @@ -80,6 +82,7 @@ export function EmbeddedNote({ noteId: string className?: string containingEvent?: Event + /** True = full long-form/publication body; false = compact card (use inside articles). */ showFull?: boolean }) { const suppress = useSuppressEmbeddedNoteId() @@ -199,47 +202,33 @@ function EmbeddedNoteFetched({ showFull: boolean allowLiveEmbeds: boolean }) { - 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 relayHints = useMemo( + () => relayHintWssUrlsFromEvent(containingEvent), + [containingEvent?.id] + ) + const fetchRelayOpts = useMemo( + () => (relayHints.length > 0 ? { relayHints } : undefined), + [relayHints] + ) + const { event, isFetching } = useFetchEvent(noteId, undefined, fetchRelayOpts) + /** Filled when “Try external relays” / IndexedDB recovery finds the event after the hook missed. */ + const [resolvedEvent, setResolvedEvent] = useState(undefined) - const finalEvent = event || retryEvent - const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries) + const finalEvent = event || resolvedEvent - if (finalIsFetching) { + if (isFetching && !finalEvent) { return } if (!finalEvent) { - return + return ( + + ) } if ( @@ -340,6 +329,24 @@ function EmbeddedNoteContent({ ) } +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)) +} + function EmbeddedNoteSkeleton({ className }: { className?: string }) { return (
([]) - const [hexEventId, setHexEventId] = useState(null) + const [asyncHintRelays, setAsyncHintRelays] = useState([]) const [externalSearchDetail, setExternalSearchDetail] = useState< null | 'unparseable' | 'no_relays' | 'searched' >(null) - // Relays for "Try external relays": hints + searchable + FAST_READ. - // Initial embed fetch uses short per-relay timeouts; this pass uses longer timeouts (see fetchEventWithExternalRelays). - // We intentionally include FAST_READ again so slow/default relays get a second chance. + const resolvedHexId = useMemo(() => { + 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 { + /* plain hex handled above */ + } + return null + }, [noteId]) + + /** Always available immediately: static searchable + fast-read + favorites + NIP-66 search-capable relays. */ + const coreExternalRelays = useMemo( + () => + preferPublicIndexRelaysFirst( + dedupeRelayUrls([ + ...nip66Service.getSearchableRelayUrls(), + ...SEARCHABLE_RELAY_URLS, + ...FAST_READ_RELAY_URLS, + ...menuRelayUrls, + ]) + ), + [menuRelayUrls] + ) + + const externalRelays = useMemo( + () => preferPublicIndexRelaysFirst(dedupeRelayUrls([...asyncHintRelays, ...coreExternalRelays])), + [asyncHintRelays, coreExternalRelays] + ) + + // Extra hints (parent tags, NIP-65, nevent/naddr relay lists, “seen on”) — merged on top of {@link coreExternalRelays}. useEffect(() => { - const getExternalRelays = async () => { - let hintRelays: string[] = [] - let extractedHexEventId: string | null = null + let cancelled = false + const loadHints = async () => { + const hintRelays: string[] = [] - // 1. Extract relay hints from containing event (e, a, q tags - 3rd position) if (containingEvent) { for (const tag of containingEvent.tags) { if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { const hint = tag[2] - if (hint.startsWith('wss://') || hint.startsWith('ws://')) { - hintRelays.push(hint) - } + if (hint.startsWith('wss://') || hint.startsWith('ws://')) hintRelays.push(hint) } } - - // Also get containing event author's relays try { - const containingAuthorRelayList = await client.fetchRelayList(containingEvent.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) - hintRelays.push(...(containingAuthorRelayList.read ?? []).slice(0, 10), ...(containingAuthorRelayList.write ?? []).slice(0, 10)) + const containingAuthorRelayList = await client + .fetchRelayList(containingEvent.pubkey) + .catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push( + ...(containingAuthorRelayList.read ?? []).slice(0, 10), + ...(containingAuthorRelayList.write ?? []).slice(0, 10) + ) } catch (err) { logger.debug('Failed to fetch containing event author relays', { error: err }) } } - // 2. Hex id (any case) or bech32; hints from nevent/naddr for extra relays - const quickHex = hexEventIdFromNoteId(noteId) - if (quickHex) { - extractedHexEventId = quickHex - } try { - const { type, data } = nip19.decode(noteId) + const { type, data } = nip19.decode(noteId.trim()) 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, 10), ...(authorRelayList.write ?? []).slice(0, 10)) + const authorRelayList = await client + .fetchRelayList(data.author) + .catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push( + ...(authorRelayList.read ?? []).slice(0, 10), + ...(authorRelayList.write ?? []).slice(0, 10) + ) } } 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, 10), ...(authorRelayList.write ?? []).slice(0, 10)) - } else if (type === 'note') { - extractedHexEventId = data + const authorRelayList = await client + .fetchRelayList(data.pubkey) + .catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push( + ...(authorRelayList.read ?? []).slice(0, 10), + ...(authorRelayList.write ?? []).slice(0, 10) + ) } } catch { - // Plain hex ids are not valid bech32 — already handled via quickHex + /* invalid bech32 */ } - - setHexEventId(extractedHexEventId) - - // 3. Get relays where this embedded 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)) - - const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS - .map(url => normalizeUrl(url)) - .filter((url): url is string => Boolean(url)) - - const normalizedFastRead = FAST_READ_RELAY_URLS - .map(url => normalizeUrl(url)) - .filter((url): url is string => Boolean(url)) - - const externalRelays = Array.from( - new Set([ - ...normalizedHints, - ...menuRelayUrls, - ...normalizedSearchableRelays, - ...normalizedFastRead - ]) - ) - setExternalRelays(externalRelays) + const seenOn = resolvedHexId ? client.getSeenEventRelayUrls(resolvedHexId) : [] + hintRelays.push(...seenOn) - logger.debug('External relays calculated', { - noteId, - hintRelaysCount: normalizedHints.length, - searchableRelaysCount: normalizedSearchableRelays.length, - fastReadRelaysCount: normalizedFastRead.length, - externalRelaysCount: externalRelays.length, - externalRelays: externalRelays.slice(0, 10) - }) + if (!cancelled) { + setAsyncHintRelays(dedupeRelayUrls(hintRelays)) + logger.debug('External relay hints merged', { + noteId, + hintCount: hintRelays.length, + totalRelays: dedupeRelayUrls([...hintRelays, ...coreExternalRelays]).length + }) + } } - getExternalRelays() - // containingEvent supplies e/a/q relay hints + author NIP-65 list — must rerun when parent loads - }, [noteId, containingEvent?.id, menuRelayUrls]) + void loadHints() + return () => { + cancelled = true + } + }, [noteId, containingEvent?.id, resolvedHexId, coreExternalRelays]) const handleTryExternalRelays = async () => { if (isSearchingExternal) return @@ -505,7 +518,7 @@ function EmbeddedNoteNotFound({ setExternalSearchDetail(null) let found: Event | undefined try { - const idHex = hexEventId ?? hexEventIdFromNoteId(noteId) + const idHex = resolvedHexId ?? hexEventIdFromNoteId(noteId) if (idHex) { const fromDb = await indexedDb.getEventFromPublicationStore(idHex) if (fromDb) { diff --git a/src/components/LogoutDialog/index.tsx b/src/components/LogoutDialog/index.tsx index 00c9536b..236f9773 100644 --- a/src/components/LogoutDialog/index.tsx +++ b/src/components/LogoutDialog/index.tsx @@ -18,7 +18,7 @@ import { DrawerTitle } from '@/components/ui/drawer' import { useNostr } from '@/providers/NostrProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useTranslation } from 'react-i18next' export default function LogoutDialog({ @@ -29,7 +29,7 @@ export default function LogoutDialog({ setOpen: (open: boolean) => void }) { const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() + const { isSmallScreen = false } = useScreenSizeOptional() ?? {} const { account, switchAccount } = useNostr() const handleLogout = () => { diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index e6110653..210d4a9b 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -2646,7 +2646,7 @@ function parseMarkdownContentLegacy( // Embedded events should be block-level and fill width parts.push(
- +
) } @@ -3567,7 +3567,7 @@ function parseMarkdownContentMarked( } return (
- +
) } @@ -3733,7 +3733,7 @@ function parseMarkdownContentMarked( } return (
- +
) } @@ -3782,7 +3782,7 @@ function parseMarkdownContentMarked( } else { nodes.push(
- +
) } @@ -4096,7 +4096,7 @@ function parseMarkdownContentMarked( } else { nodes.push(
- +
) } diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx index 9ac00d13..2aa6fd15 100644 --- a/src/hooks/useFetchEvent.tsx +++ b/src/hooks/useFetchEvent.tsx @@ -6,7 +6,11 @@ import { navigationEventStore } from '@/services/navigation-event-store' import { Event } from 'nostr-tools' import { useCallback, useEffect, useState } from 'react' -export function useFetchEvent(eventId?: string, initialEvent?: Event) { +export function useFetchEvent( + eventId?: string, + initialEvent?: Event, + fetchOpts?: { relayHints?: string[] } +) { const { isEventDeleted } = useDeletedEvent() const { addReplies } = useReply() const [error, setError] = useState(null) @@ -18,6 +22,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { setRefetchToken((n) => n + 1) }, []) + /** Content-based key so a new `relayHints` array with the same URLs does not restart the fetch. */ + const relayHintsSerialized = fetchOpts?.relayHints?.join('\0') ?? '' + useEffect(() => { let cancelled = false @@ -75,10 +82,10 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { try { // First load: DataLoader dedupes. Refetches (incl. session-waiter) clear a prior undefined so // timeline-cached events resolve after the embed mounted first. - const fetchedEvent = - skipShortcuts - ? await eventService.fetchEventForceRetry(eventId) - : await eventService.fetchEvent(eventId) + const opts = fetchOpts?.relayHints?.length ? fetchOpts : undefined + const fetchedEvent = skipShortcuts + ? await eventService.fetchEventForceRetry(eventId, opts) + : await eventService.fetchEvent(eventId, opts) if (cancelled) return if (fetchedEvent && !isEventDeleted(fetchedEvent)) { setEvent(fetchedEvent) @@ -99,8 +106,12 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { return () => { cancelled = true + // If deps change (e.g. embed relay hints) or Strict Mode re-runs the effect while a fetch is + // still in flight, `finally` skips `setIsFetching(false)` when `cancelled` — without this, + // loading can stay true forever and embeds show an endless skeleton. + setIsFetching(false) } - }, [eventId, initialEvent, isEventDeleted, addReplies, refetchToken]) + }, [eventId, initialEvent, isEventDeleted, addReplies, refetchToken, relayHintsSerialized]) useEffect(() => { if (event && isEventDeleted(event)) { diff --git a/src/lib/event.ts b/src/lib/event.ts index 47b30818..8bf066d7 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -1,7 +1,7 @@ import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { muteSetHas } from '@/lib/mute-set' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' -import { cleanUrl } from '@/lib/url' +import { cleanUrl, normalizeUrl } from '@/lib/url' import client from '@/services/client.service' import { TImetaInfo } from '@/types' import { LRUCache } from 'lru-cache' @@ -616,6 +616,37 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): { } } +/** + * `wss://` / `ws://` hints from `e`/`a`/`q` third field, `relays` tags, and relays that delivered the parent event. + * Used to resolve embedded notes from the same context (e.g. long-form body) before the generic relay fan-out. + */ +export function relayHintWssUrlsFromEvent(event: Event | undefined): string[] { + if (!event) return [] + const hints: string[] = [] + for (const tag of event.tags) { + if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { + const hint = tag[2] + if (hint.startsWith('wss://') || hint.startsWith('ws://')) hints.push(hint) + } + } + const relaysTag = event.tags.find((t) => t[0] === 'relays') + if (relaysTag) { + for (let i = 1; i < relaysTag.length; i++) { + const u = relaysTag[i] + if (typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://'))) hints.push(u) + } + } + try { + hints.push(...client.getSeenEventRelayUrls(event.id)) + } catch { + /* ignore */ + } + const normalized = hints + .map((u) => normalizeUrl(u)) + .filter((u): u is string => Boolean(u)) + return [...new Set(normalized)] +} + function getEmbeddedPubkeys(event: Event) { const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id) if (cache) return cache diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 81bdb170..dcf06462 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -64,6 +64,13 @@ export interface RelayListBuilderOptions { includeLocalRelays?: boolean /** Whether to include user's favorite relays (kind 10012) */ includeFavoriteRelays?: boolean + /** + * When true with fast-read / searchable includes: insert `FAST_READ_RELAY_URLS` and + * `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before** author + user + * NIP-65 lists. Used for single-event / embed fetches so public mirrors (e.g. nos.lol) are not + * queued behind dozens of personal relays under the global connection cap. + */ + preferPublicReadRelaysEarly?: boolean } /** @@ -83,7 +90,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio includeSearchableRelays = false, blockedRelays = [], includeLocalRelays = true, - includeFavoriteRelays = false + includeFavoriteRelays = false, + preferPublicReadRelaysEarly = false } = options const relayUrls = new Set() @@ -114,6 +122,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio // 3. Relays where containing event was found (for embedded events) containingEventRelays.forEach(addRelay) + // 3b. Public read / index relays before author + user NIP-65 expansion (embed + fetchEvent). + if (preferPublicReadRelaysEarly) { + if (includeFastReadRelays) { + FAST_READ_RELAY_URLS.forEach(addRelay) + } + if (includeSearchableRelays) { + SEARCHABLE_RELAY_URLS.forEach(addRelay) + } + } + // 4. Author's outboxes (write relays) - where they publish if (authorPubkey) { try { @@ -257,7 +275,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } // 7. Fast read relays (fallback) - if (includeFastReadRelays) { + if (includeFastReadRelays && !preferPublicReadRelaysEarly) { FAST_READ_RELAY_URLS.forEach(addRelay) } @@ -267,7 +285,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } // 9. Searchable relays (for search) - if (includeSearchableRelays) { + if (includeSearchableRelays && !preferPublicReadRelaysEarly) { SEARCHABLE_RELAY_URLS.forEach(addRelay) } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 230626eb..33584f6c 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { + collectEmbeddedEventPrefetchTargets, getParentBech32Id, getParentETag, getParentEventHexId, @@ -191,6 +192,16 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: void client.fetchProfilesForPubkeys([pk]) }, [finalEvent?.id, finalEvent?.pubkey]) + /** Warm session cache so markdown/embed cards resolve before each {@link EmbeddedNote} mounts. */ + useEffect(() => { + if (!finalEvent) return + const { hexIds, nip19Pointers } = collectEmbeddedEventPrefetchTargets(finalEvent) + if (hexIds.length > 0) void client.prefetchHexEventIds(hexIds) + for (const pointer of nip19Pointers) { + void client.fetchEvent(pointer) + } + }, [finalEvent?.id]) + const getNoteTypeTitle = (kind: number): string => { switch (kind) { case 1: // kinds.ShortTextNote diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 9519c099..9cccad60 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -31,6 +31,26 @@ import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' +/** NIP-33 / NIP-01 `a` coordinate: `::`. */ +function parseReplaceableAtagCoordinate(atag: string): { + kind: number + pubkey: string + identifier: string +} | null { + const s = atag.trim() + const i0 = s.indexOf(':') + if (i0 < 0) return null + const kind = Number.parseInt(s.slice(0, i0), 10) + if (!Number.isFinite(kind)) return null + const rest = s.slice(i0 + 1) + const i1 = rest.indexOf(':') + if (i1 < 0) return null + const pubkey = rest.slice(0, i1).toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pubkey)) return null + const identifier = rest.slice(i1 + 1) + return { kind, pubkey, identifier } +} + /** * Build comprehensive relay list for event-by-id fetch: user's inboxes (+ cache), **favorite relays * (kind 10012, same as sidebar menu)**, relay hints, author outboxes/inboxes when known, @@ -51,7 +71,8 @@ async function buildComprehensiveRelayListForEvents( includeFastReadRelays: true, includeSearchableRelays: true, includeLocalRelays: true, - includeFavoriteRelays: Boolean(client.pubkey) + includeFavoriteRelays: Boolean(client.pubkey), + preferPublicReadRelaysEarly: true }) } @@ -70,6 +91,8 @@ export class EventService { private sessionMetadataByPubkey = new Map() /** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */ private sessionEventWaiters = new Map void>>() + /** Waiters keyed like {@link replaceableWaiterKey} — naddr embeds have no hex id until a REQ returns. */ + private sessionReplaceableWaiters = new Map void>>() private eventDataLoader: DataLoader private fetchEventFromBigRelaysDataloader: DataLoader @@ -128,7 +151,7 @@ export class EventService { if (!isReplaceableEvent(ev.kind)) continue if (ev.kind !== kind || ev.pubkey.toLowerCase() !== pk) continue const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? '' - if (d === identifier) return ev + if (d === (identifier ?? '')) return ev } return undefined } @@ -145,6 +168,21 @@ export class EventService { } } + private notifyReplaceableCoordinateWaiters(ev: NEvent): void { + if (!isReplaceableEvent(ev.kind)) return + const dTag = ev.tags.find((t) => t[0] === 'd')?.[1] ?? '' + const key = `${ev.kind}:${ev.pubkey.toLowerCase()}:${dTag}` + const waiters = this.sessionReplaceableWaiters.get(key) + if (!waiters?.size) return + for (const cb of [...waiters]) { + try { + cb() + } catch (e) { + logger.warn('[EventService] replaceable session waiter failed', { key, e }) + } + } + } + /** * Read parent/root (or any) event from the session cache without removing it. * Accepts hex, note1, nevent1, or naddr1 (replaceable match in session LRU only). @@ -161,7 +199,7 @@ export class EventService { return this.getSessionEventIfMatchingNaddr({ pubkey: data.pubkey, kind: data.kind, - identifier: data.identifier + identifier: data.identifier ?? '' }) } } catch { @@ -171,35 +209,69 @@ export class EventService { } /** - * When an event with this id is added to the session cache, invoke `callback` (and when already cached). - * Only supports hex, note1, and nevent1 (not naddr). + * When a matching event is added to the session cache, invoke `callback` (and when already cached). + * Supports hex / note1 / nevent1 and **naddr1** (replaceable coordinate: kind + pubkey + `d`). */ subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void { const hex = this.resolveHexWaiterKey(eventId) - if (!hex) return () => {} + if (hex) { + if (this.getSessionEventIfAllowed(hex)) { + queueMicrotask(() => callback()) + } - if (this.getSessionEventIfAllowed(hex)) { - queueMicrotask(() => callback()) + let set = this.sessionEventWaiters.get(hex) + if (!set) { + set = new Set() + this.sessionEventWaiters.set(hex, set) + } + set.add(callback) + return () => { + set!.delete(callback) + if (set!.size === 0) { + this.sessionEventWaiters.delete(hex) + } + } } - let set = this.sessionEventWaiters.get(hex) - if (!set) { - set = new Set() - this.sessionEventWaiters.set(hex, set) - } - set.add(callback) - return () => { - set!.delete(callback) - if (set!.size === 0) { - this.sessionEventWaiters.delete(hex) + try { + const { type, data } = nip19.decode(eventId.trim()) + if (type === 'naddr') { + const identifier = data.identifier ?? '' + if ( + this.getSessionEventIfMatchingNaddr({ + pubkey: data.pubkey, + kind: data.kind, + identifier + }) + ) { + queueMicrotask(() => callback()) + } + const key = `${data.kind}:${data.pubkey.toLowerCase()}:${identifier}` + let rset = this.sessionReplaceableWaiters.get(key) + if (!rset) { + rset = new Set() + this.sessionReplaceableWaiters.set(key, rset) + } + rset.add(callback) + return () => { + rset!.delete(callback) + if (rset!.size === 0) { + this.sessionReplaceableWaiters.delete(key) + } + } } + } catch { + /* invalid bech32 */ } + + return () => {} } /** - * Fetch single event by ID (hex, note1, nevent1, naddr1) + * Fetch single event by ID (hex, note1, nevent1, naddr1). + * Optional `relayHints` (e.g. from the parent article’s tags) are merged first so REQ targets the same relays that likely hold the embed. */ - async fetchEvent(id: string): Promise { + async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise { const trimmed = id.trim() let hexId: string | undefined if (/^[0-9a-f]{64}$/i.test(trimmed)) { @@ -218,7 +290,7 @@ export class EventService { const fromSession = this.getSessionEventIfMatchingNaddr({ pubkey: data.pubkey, kind: data.kind, - identifier: data.identifier + identifier: data.identifier ?? '' }) if (fromSession) return fromSession break @@ -246,6 +318,10 @@ export class EventService { this.eventDataLoader.clear(hexId) } } + if (opts?.relayHints?.length) { + const hinted = await this._fetchEvent(trimmed, opts.relayHints) + if (hinted && !shouldDropEventOnIngest(hinted)) return hinted + } const loaded = await this.eventDataLoader.load(hexId ?? trimmed) if (hexId) { const fromSessionAfter = this.getSessionEventIfAllowed(hexId) @@ -284,9 +360,9 @@ export class EventService { /** * Force retry fetch event */ - async fetchEventForceRetry(eventId: string): Promise { + async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise { this.clearDataloaderCacheForFetchId(eventId) - return this.fetchEvent(eventId) + return this.fetchEvent(eventId, opts) } /** @@ -345,10 +421,13 @@ export class EventService { if (type === 'note') return { ids: [data], limit: 1 } if (type === 'nevent') return { ids: [data.id], limit: 1 } if (type === 'naddr') { + const pk = data.pubkey.toLowerCase() + const ident = data.identifier ?? '' + /** NIP-33 coordinate query; `#a` alone often misses — many relays index `authors` + `#d`. */ return { kinds: [data.kind], - authors: [data.pubkey], - '#d': [data.identifier], + authors: [pk], + '#d': [ident], limit: 1 } } @@ -378,7 +457,9 @@ export class EventService { const logKey = 'ids' in filter && filter.ids?.[0] ? filter.ids[0].slice(0, 8) - : `${filter.kinds?.[0]}:${(filter.authors?.[0] ?? '').slice(0, 8)}` + : Array.isArray(filter['#a']) && filter['#a'][0] + ? String(filter['#a'][0]).slice(0, 40) + : `${filter.kinds?.[0]}:${(filter.authors?.[0] ?? '').slice(0, 8)}` logger.debug('fetchEventWithExternalRelays: Starting search', { noteIdKey: logKey, @@ -387,10 +468,11 @@ export class EventService { }) const startTime = Date.now() + /** User-driven “try everywhere”: wait for EOSE-ish completion so slower relays (e.g. nos.lol) can answer. */ const events = await this.queryService.query(externalRelays, filter, undefined, { - eoseTimeout: 10000, - globalTimeout: 20000, - immediateReturn: true + eoseTimeout: 12_000, + globalTimeout: 35_000, + immediateReturn: false }) const duration = Date.now() - startTime @@ -401,7 +483,10 @@ export class EventService { durationMs: duration }) - return events[0] + const usable = events + .filter((e) => !shouldDropEventOnIngest(e)) + .sort((a, b) => b.created_at - a.created_at) + return usable[0] } /** @@ -427,6 +512,7 @@ export class EventService { } } this.notifySessionEventWaiters(id) + this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent) if ( cleanEvent.kind === ExtendedKind.PUBLICATION || @@ -722,6 +808,7 @@ export class EventService { this.sessionMetadataByPubkey.clear() this.eventCacheMap.clear() this.sessionEventWaiters.clear() + this.sessionReplaceableWaiters.clear() this.fetchEventFromBigRelaysDataloader.clearAll() invalidateArchiveFootprintCache() logger.info('[EventService] In-memory caches cleared') @@ -730,10 +817,19 @@ export class EventService { /** * Private: Fetch event by ID (internal implementation) */ - private async _fetchEvent(id: string): Promise { + private async _fetchEvent(id: string, extraRelayHints?: string[]): Promise { let filter: Filter | undefined let relays: string[] = [] - + if (extraRelayHints?.length) { + relays = [ + ...new Set( + extraRelayHints + .map((u) => normalizeUrl(u)) + .filter((u): u is string => Boolean(u)) + ) + ] + } + if (/^[0-9a-f]{64}$/i.test(id)) { filter = { ids: [id.toLowerCase()], limit: 1 } } else { @@ -744,19 +840,20 @@ export class EventService { break case 'nevent': filter = { ids: [data.id], limit: 1 } - if (data.relays) relays = [...data.relays] + if (data.relays) relays = [...new Set([...relays, ...data.relays])] break - case 'naddr': + case 'naddr': { + const pk = data.pubkey.toLowerCase() + const ident = data.identifier ?? '' filter = { - authors: [data.pubkey], kinds: [data.kind], + authors: [pk], + '#d': [ident], limit: 1 } - if (data.identifier) { - filter['#d'] = [data.identifier] - } - if (data.relays) relays = [...data.relays] + if (data.relays) relays = [...new Set([...relays, ...data.relays])] break + } } } @@ -816,6 +913,28 @@ export class EventService { if (sess) return sess } + if (filter.authors?.length === 1 && filter.kinds?.length === 1 && Array.isArray(filter['#d'])) { + const ident = filter['#d'][0] ?? '' + const sessAddr = this.getSessionEventIfMatchingNaddr({ + pubkey: filter.authors[0]!, + kind: filter.kinds[0]!, + identifier: ident + }) + if (sessAddr) return sessAddr + } + + if (Array.isArray(filter['#a']) && filter['#a'][0]) { + const parsed = parseReplaceableAtagCoordinate(String(filter['#a'][0])) + if (parsed) { + const sessA = this.getSessionEventIfMatchingNaddr({ + pubkey: parsed.pubkey, + kind: parsed.kind, + identifier: parsed.identifier + }) + if (sessA) return sessA + } + } + return undefined } @@ -831,9 +950,13 @@ export class EventService { // Get seen relays if we have an event ID const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : [] - // Get author pubkey - const authorPubkey = filter.authors?.length === 1 ? filter.authors[0] : undefined - + const parsedAtag = + Array.isArray(filter['#a']) && typeof filter['#a'][0] === 'string' + ? parseReplaceableAtagCoordinate(filter['#a'][0] as string) + : null + const authorPubkey = + filter.authors?.length === 1 ? filter.authors[0] : parsedAtag?.pubkey + // Build comprehensive relay list const relayUrls = await buildComprehensiveRelayListForEvents(authorPubkey, relayHints, seenRelays, []) @@ -852,22 +975,25 @@ export class EventService { hasSeen: seenRelays.length > 0 }) - const isSingleEventById = filter.ids && filter.ids.length === 1 && filter.limit === 1 - - // For single-event fetches, always use immediateReturn to return ASAP - // This is especially important for non-replaceable events (not in 10000-19999 or 30000-39999 ranges) + const isSingleEventById = Boolean(filter.ids && filter.ids.length === 1 && filter.limit === 1) + /** Replaceable coordinate: `#a` (preferred) or legacy `authors` + `#d`. */ + const isReplaceableCoordinateFetch = + filter.limit === 1 && + filter.kinds?.length === 1 && + ((Array.isArray(filter['#a']) && filter['#a'].length >= 1) || + (filter.authors?.length === 1 && Array.isArray(filter['#d']) && filter['#d'].length >= 1)) + const useFastSingleHitQuery = isSingleEventById || isReplaceableCoordinateFetch + const events = await this.queryService.query(relayUrls, filter, undefined, { - immediateReturn: isSingleEventById, // Return immediately when found - eoseTimeout: isSingleEventById ? 1500 : 500, - globalTimeout: isSingleEventById ? 12000 : 10000 + immediateReturn: useFastSingleHitQuery, + eoseTimeout: useFastSingleHitQuery ? 2500 : 500, + globalTimeout: useFastSingleHitQuery ? 20_000 : 10000 }) - + const event = events .filter((e) => !shouldDropEventOnIngest(e)) .sort((a, b) => b.created_at - a.created_at)[0] - - // For non-replaceable events, we've already returned immediately via immediateReturn - // But log it for debugging + if (event && isSingleEventById && !isReplaceableEvent(event.kind)) { logger.debug('[EventService] Non-replaceable event returned immediately', { eventId: event.id.substring(0, 8), @@ -910,8 +1036,8 @@ export class EventService { undefined, { immediateReturn: isSingleEventFetch, - eoseTimeout: isSingleEventFetch ? 1500 : 500, - globalTimeout: isSingleEventFetch ? 12000 : 10000 + eoseTimeout: isSingleEventFetch ? 2500 : 500, + globalTimeout: isSingleEventFetch ? 20_000 : 10000 } ) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 8ba9a57d..72afff3d 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -112,6 +112,24 @@ function sanitizeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] { return splitFiltersByMaxKindCount(sanitized) } +/** True for single-replaceable REQ (`#a` coordinate or legacy `authors` + `#d`). */ +function filterHasReplaceableCoordinate(f: Filter): boolean { + if ((f.limit ?? 0) !== 1 || !f.kinds?.length) return false + const a = (f as Record)['#a'] + if (Array.isArray(a) && a.length > 0 && typeof a[0] === 'string' && String(a[0]).includes(':')) { + return true + } + if (f.authors?.length === 1) { + const d = (f as Record)['#d'] + return Array.isArray(d) && d.length > 0 + } + return false +} + +function someFilterHasReplaceableCoordinate(filters: Filter[]): boolean { + return filters.some(filterHasReplaceableCoordinate) +} + export interface QueryOptions { eoseTimeout?: number globalTimeout?: number @@ -439,12 +457,18 @@ export class QueryService { const filters = sanitizedFilters const maxLimit = Math.max(...filters.map((f) => (f.limit ?? 0) as number), 0) const isSingleEventFetch = maxLimit === 1 - const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0) + const hasIdFilter = filters.some((f) => f.ids && f.ids.length > 0) + const hasReplaceableCoordFilter = someFilterHasReplaceableCoordinate(filters) // For immediateReturn: return as soon as we find the event // This is critical for non-replaceable events (not in 10000-19999 or 30000-39999 ranges) // which should be rendered ASAP - if (immediateReturn && hasIdFilter && isSingleEventFetch && events.length > 0) { + if ( + immediateReturn && + (hasIdFilter || hasReplaceableCoordFilter) && + isSingleEventFetch && + events.length > 0 + ) { resolveWithEvents() return } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index bdce4f31..5db8c112 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3068,14 +3068,14 @@ class ClientService extends EventTarget { * (4) if still missing and filter has authors: author's read+write again in tryHarderToFetchEvent * (5) SEARCHABLE_RELAY_URLS as final fallback. Author relays are used so embedded notes load from the author's relays. */ - async fetchEvent(id: string): Promise { - return this.eventService.fetchEvent(id) + async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise { + return this.eventService.fetchEvent(id, opts) } // Legacy fetchEvent implementation removed - now delegated to EventService - async fetchEventForceRetry(eventId: string): Promise { - return this.eventService.fetchEventForceRetry(eventId) + async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise { + return this.eventService.fetchEventForceRetry(eventId, opts) } /** Batch-prefetch by hex id into session cache (feed embeds). */