From f4f283c2453fb3e970f444dd80494c7de9b8ab2f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 16 May 2026 10:32:12 +0200 Subject: [PATCH] bug-fix --- src/PageManager.tsx | 7 +- src/components/Embedded/EmbeddedNote.tsx | 57 ++++--- src/components/Note/index.tsx | 6 +- src/components/NoteCard/MainNoteCard.tsx | 5 +- src/components/NoteList/index.tsx | 200 ++--------------------- src/components/ReplyNoteList/index.tsx | 20 ++- src/constants.ts | 4 + src/hooks/useEmojiInfosForEvent.ts | 41 ++--- src/lib/nip30-author-emojis.ts | 82 +++++++++- src/pages/secondary/NotePage/index.tsx | 13 +- src/services/client-events.service.ts | 113 ++++++++++++- src/services/client.service.ts | 15 +- 12 files changed, 301 insertions(+), 262 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 412915ce..50733cd8 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -474,7 +474,7 @@ export function useSmartNoteNavigation() { navigationEventStore.setEvent(event) client.addEventToCache(event) } - // Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching + // Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons. if (relatedEvents?.length) { for (const ev of relatedEvents) { if (ev && ev !== event) { @@ -483,6 +483,11 @@ export function useSmartNoteNavigation() { } } } + if (event) { + client.prefetchEmbeddedEventsForParents( + [event, ...(relatedEvents ?? []).filter((ev) => ev && ev !== event)] + ) + } // Build contextual URL based on current page const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 9819ebbf..99dfa837 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -302,21 +302,25 @@ function EmbeddedNoteFetched({ } const runParallelFetch = async () => { - const { fetchRelayOpts: opts, wideRelaysStatic: wide0 } = embedFetchCtxRef.current + const { fetchRelayOpts: opts } = embedFetchCtxRef.current const hex = hexEventIdFromNoteId(noteKey) - const primary = client.fetchEvent(noteKey, opts) - const wide = runWidePass(wide0) - const idb = - hex && /^[0-9a-f]{64}$/i.test(hex) - ? indexedDb.getEventFromPublicationStore(hex.toLowerCase()).catch(() => undefined) - : Promise.resolve(undefined) - const [p, w, db] = await Promise.all([primary, wide, idb]) + const isUsable = (e: Event) => + !isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e) + const chosen = await firstResolvedUsableEmbedEvent( + [ + () => client.fetchEvent(noteKey, opts), + () => + hex && /^[0-9a-f]{64}$/i.test(hex) + ? indexedDb + .getEventFromPublicationStore(hex.toLowerCase()) + .catch(() => undefined) + : Promise.resolve(undefined) + ], + isUsable + ) if (cancelled) return - const chosen = pickUsableEvent([p, w, db], isEventDeletedRef.current) if (chosen) { resolve(chosen) - setIsFetching(false) - return } setIsFetching(false) } @@ -619,15 +623,28 @@ async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event) return dedupeRelayUrls(hintRelays) } -function pickUsableEvent( - candidates: (Event | undefined)[], - isEventDeleted: (e: Event) => boolean -): Event | undefined { - for (const e of candidates) { - if (!e || isEventDeleted(e) || shouldDropEventOnIngest(e)) continue - return e - } - return undefined +/** 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 }) { diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 3d4eb1ed..2299da9f 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -32,7 +32,7 @@ import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' import { isCalendarEventKind } from '@/lib/calendar-event' import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { getWebBookmarkArticleUrl, @@ -269,6 +269,10 @@ export default function Note({ const noteTranslation = useNoteTranslation(event.id) const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation]) + useLayoutEffect(() => { + client.prefetchEmbeddedEventsForParents([event]) + }, [event.id]) + const reactionDisplay = useNotificationReactionDisplay(event) const webReactionParentUrl = useMemo( () => diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 2e7b4d5a..8b64d720 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -108,7 +108,10 @@ export default function MainNoteCard({ )} - + | null>(null) const lastEventsForTimelinePrefetchRef = useRef([]) /** * {@link client.subscribeTimeline} resolves asynchronously; cleanup used to only close via @@ -2860,36 +2857,12 @@ const NoteList = forwardRef( // Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+ setLoading(false) - // Defer profile + embed prefetch: streaming timelines fire onEvents often; starting - // fetchProfilesForPubkeys on every update spams relays (multi-second each) and cancels hooks. - if (timelinePrefetchDebounceRef.current) { - clearTimeout(timelinePrefetchDebounceRef.current) + // Embeds: fetch with each timeline batch (parent relay hints), not on a debounced follow-up. + if (narrowed.length > 0) { + client.prefetchEmbeddedEventsForParents(narrowed, { + relayHintsOnly: relayAuthoritativeFeedOnlyRef.current + }) } - timelinePrefetchDebounceRef.current = setTimeout(() => { - timelinePrefetchDebounceRef.current = null - if (!effectActive) return - if (relayAuthoritativeFeedOnlyRef.current) return - const evs = lastEventsForTimelinePrefetchRef.current - if (evs.length === 0) return - - const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(evs.slice(0, 50)) - const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id)) - const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p)) - if (hexIdsToFetch.length > 0 || nip19ToFetch.length > 0) { - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p)) - const run = async () => { - try { - await client.prefetchHexEventIds(hexIdsToFetch) - await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p))) - } catch { - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p)) - } - } - void run() - } - }, 450) } else if (eosed) { setLoading(false) } @@ -3126,10 +3099,6 @@ const NoteList = forwardRef( clearTimeout(kindlessEoseTimeoutRef.current) kindlessEoseTimeoutRef.current = null } - if (timelinePrefetchDebounceRef.current) { - clearTimeout(timelinePrefetchDebounceRef.current) - timelinePrefetchDebounceRef.current = null - } const syncClose = timelineEstablishedCloserRef.current timelineEstablishedCloserRef.current = null syncClose?.() @@ -3945,32 +3914,8 @@ const NoteList = forwardRef( // CRITICAL: Prefetch profiles for newly loaded events (optimized to reduce stuttering) // Only prefetch if we're not currently loading to avoid blocking scroll if (toAppend.length > 0 && !loadingRef.current) { - // Use requestIdleCallback if available, otherwise setTimeout with longer delay - const schedulePrefetch = (callback: () => void) => { - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(callback, { timeout: 500 }) - } else { - setTimeout(callback, 300) - } - } - - schedulePrefetch(() => { - const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents(toAppend.slice(0, 30)) - const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id)) - const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p)) - if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p)) - const run = async () => { - try { - await client.prefetchHexEventIds(hexIdsToFetch) - await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p))) - } catch { - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p)) - } - } - void run() + client.prefetchEmbeddedEventsForParents(toAppend.slice(0, 30), { + relayHintsOnly: relayAuthoritativeFeedOnlyRef.current }) } } catch { @@ -4096,130 +4041,15 @@ const NoteList = forwardRef( } }, [timelineSubscriptionKey]) - // CRITICAL: Prefetch embedded events (referenced in e tags, a tags, and content) - // This ensures embedded events are ready before user scrolls to them - const prefetchedEventIdsRef = useRef>(new Set()) - const prefetchEmbeddedEventsTimeoutRef = useRef(null) - - const mergePrefetchTargetsFromEvents = useCallback((evts: Event[]) => { - const hex = new Set() - const nip19 = new Set() - for (const e of evts) { - const t = collectEmbeddedEventPrefetchTargets(e) - t.hexIds.forEach((id) => hex.add(id)) - t.nip19Pointers.forEach((p) => nip19.add(p)) - } - return { hexIds: Array.from(hex), nip19Pointers: Array.from(nip19) } - }, []) - - // CRITICAL: Prefetch embedded events for visible events - useEffect(() => { - // Throttle embedded event prefetching to reduce frequency during rapid scrolling - // Clear any existing timeout - if (prefetchEmbeddedEventsTimeoutRef.current) { - clearTimeout(prefetchEmbeddedEventsTimeoutRef.current) - } - - // Debounce embedded event prefetching by 400ms to reduce frequency during rapid scrolling - prefetchEmbeddedEventsTimeoutRef.current = setTimeout(() => { - const visibleTargets = mergePrefetchTargetsFromEvents(clientFilteredEvents.slice(0, 40)) - const upcomingTargets = mergePrefetchTargetsFromEvents(events.slice(0, 80)) - const hexIds = Array.from( - new Set([...visibleTargets.hexIds, ...upcomingTargets.hexIds]) - ) - const nip19Pointers = Array.from( - new Set([...visibleTargets.nip19Pointers, ...upcomingTargets.nip19Pointers]) - ) - - const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id)) - const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p)) - if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return - - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p)) - - const scheduleFetch = (callback: () => void) => { - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(callback, { timeout: 500 }) - } else { - setTimeout(callback, 0) - } - } - - scheduleFetch(() => { - const run = async () => { - try { - await client.prefetchHexEventIds(hexIdsToFetch) - await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p))) - } catch { - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p)) - } - } - void run() - }) - }, 400) // Debounce by 400ms to reduce frequency during rapid scrolling - - return () => { - if (prefetchEmbeddedEventsTimeoutRef.current) { - clearTimeout(prefetchEmbeddedEventsTimeoutRef.current) - prefetchEmbeddedEventsTimeoutRef.current = null - } - } - }, [clientFilteredEvents, events, mergePrefetchTargetsFromEvents]) - - // Also prefetch when loading more events (scrolling down) - // Throttled to reduce frequency during rapid scrolling - const prefetchNewEventsTimeoutRef = useRef(null) + // Eager embed prefetch for visible rows (deduped in EventService; ingest also prefetches on add). useEffect(() => { - if (loading || !hasMore) return - - // Clear any existing timeout - if (prefetchNewEventsTimeoutRef.current) { - clearTimeout(prefetchNewEventsTimeoutRef.current) - } - - // Debounce embedded-event prefetch for newly revealed rows (profiles use NoteFeed batcher above) - prefetchNewEventsTimeoutRef.current = setTimeout(() => { - const { hexIds, nip19Pointers } = mergePrefetchTargetsFromEvents( - events.slice(showCount, showCount + 50) - ) - const hexIdsToFetch = hexIds.filter((id) => !prefetchedEventIdsRef.current.has(id)) - const nip19ToFetch = nip19Pointers.filter((p) => !prefetchedEventIdsRef.current.has(p)) - if (hexIdsToFetch.length === 0 && nip19ToFetch.length === 0) return - - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.add(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.add(p)) - - const scheduleFetch = (callback: () => void) => { - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(callback, { timeout: 500 }) - } else { - setTimeout(callback, 0) - } - } - - scheduleFetch(() => { - const run = async () => { - try { - await client.prefetchHexEventIds(hexIdsToFetch) - await Promise.all(nip19ToFetch.map((p) => client.fetchEvent(p))) - } catch { - hexIdsToFetch.forEach((id) => prefetchedEventIdsRef.current.delete(id)) - nip19ToFetch.forEach((p) => prefetchedEventIdsRef.current.delete(p)) - } - } - void run() - }) - }, 400) // Debounce by 400ms to reduce frequency during rapid scrolling - - return () => { - if (prefetchNewEventsTimeoutRef.current) { - clearTimeout(prefetchNewEventsTimeoutRef.current) - prefetchNewEventsTimeoutRef.current = null - } - } - }, [events.length, showCount, loading, hasMore, mergePrefetchTargetsFromEvents]) + if (loading) return + const slice = clientFilteredEvents.slice(0, Math.max(showCount, 40)) + if (slice.length === 0) return + client.prefetchEmbeddedEventsForParents(slice, { + relayHintsOnly: relayAuthoritativeFeedOnlyRef.current + }) + }, [clientFilteredEvents, showCount, loading]) const showNewEvents = () => { flushPendingNewEventsIntoTimeline() diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 7904b1c0..62faf7cb 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -2,8 +2,7 @@ import { E_TAG_FILTER_BLOCKED_RELAY_URLS, ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS, - NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, - THREAD_BACKLINK_STREAM_KINDS + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT } from '@/constants' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { @@ -223,8 +222,8 @@ function moveReportsToEndPreserveOrder(events: NEvent[]): NEvent[] { return [...non, ...rep] } -/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link THREAD_BACKLINK_STREAM_KINDS}. */ -const EA_THREAD_TAIL_REFERENCE_KINDS = new Set(THREAD_BACKLINK_STREAM_KINDS) +/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only); matches {@link NOTE_STATS_OP_REFERENCE_KINDS}. */ +const EA_THREAD_TAIL_REFERENCE_KINDS = new Set(NOTE_STATS_OP_REFERENCE_KINDS) function isWebThreadTailKind(kind: number): boolean { return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) @@ -351,7 +350,7 @@ function isKind1QuoteOnlyOfEaRoot(evt: NEvent, root: TRootInfo): boolean { return kind1QuotesThreadRoot(evt, root) } -/** E/A roots: #q-only kind 1 + relay “reply” rows for {@link THREAD_BACKLINK_STREAM_KINDS} belong in backlinks tail, not the chronological middle. */ +/** E/A roots: #q-only kind 1 + relay “reply” rows for {@link NOTE_STATS_OP_REFERENCE_KINDS} belong in backlinks tail, not the chronological middle. */ function isEaThreadTailBacklinkCandidate(evt: NEvent, root: TRootInfo): boolean { if (root.type !== 'E' && root.type !== 'A') return false if (isKind1QuoteOnlyOfEaRoot(evt, root)) return true @@ -1616,7 +1615,16 @@ function ReplyNoteList({ }, 1500) }, []) - const visibleFeed = mergedFeed.slice(0, showCount) + /** Paginate replies only; always show the backlinks tail (quotes, highlights, bookmarks, …). */ + const visibleFeed = useMemo(() => { + const backlinks: NEvent[] = [] + const main: NEvent[] = [] + for (const item of mergedFeed) { + if (quoteUiIdSet.has(item.id)) backlinks.push(item) + else main.push(item) + } + return [...main.slice(0, showCount), ...backlinks] + }, [mergedFeed, showCount, quoteUiIdSet]) const shouldShowFeedItem = useCallback( (item: NEvent) => { diff --git a/src/constants.ts b/src/constants.ts index 6c26ebc6..f7736258 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -264,6 +264,10 @@ export const PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS = 12_000 export const SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS = 5_000 export const SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS = 28_000 +/** Parent-tag / seen-on relay hints only — before big-relay fan-out ({@link EventService._fetchEvent}). */ +export const HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS = 2_000 +export const HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 5_000 + /** Wide REQ for embeds / explicit external lists ({@link EventService.fetchEventWithExternalRelays}). */ export const EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS = 14_000 export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000 diff --git a/src/hooks/useEmojiInfosForEvent.ts b/src/hooks/useEmojiInfosForEvent.ts index d3f2ca53..2ee947a3 100644 --- a/src/hooks/useEmojiInfosForEvent.ts +++ b/src/hooks/useEmojiInfosForEvent.ts @@ -1,18 +1,21 @@ import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' import { + EMPTY_AUTHOR_NIP30_EMOJIS, fetchAuthorNip30EmojiInfos, - fetchAuthorNip30EmojiInfosFromIndexedDb + fetchAuthorNip30EmojiInfosFromIndexedDb, + getAuthorNip30EmojiCache, + subscribeAuthorNip30EmojiCache } from '@/lib/nip30-author-emojis' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { TEmoji } from '@/types' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { type Event } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useSyncExternalStore } from 'react' /** Event `emoji` tags override the same shortcode from the author's kind 0. */ export function mergeEmojiInfosEventOverridesAuthor( - fromAuthor: TEmoji[], - fromEvent: TEmoji[] + fromAuthor: readonly TEmoji[], + fromEvent: readonly TEmoji[] ): TEmoji[] { const m = new Map() for (const e of fromAuthor) m.set(e.shortcode, e) @@ -51,31 +54,17 @@ export function useEmojiInfosForEvent(event: Event | undefined | null): TEmoji[] const pubkey = event?.pubkey?.trim().toLowerCase() ?? '' const validPk = /^[0-9a-f]{64}$/.test(pubkey) - const [fromAuthor, setFromAuthor] = useState([]) + const fromAuthor = useSyncExternalStore( + (onStoreChange) => + validPk && needsLookup ? subscribeAuthorNip30EmojiCache(pubkey, onStoreChange) : () => {}, + () => (validPk && needsLookup ? getAuthorNip30EmojiCache(pubkey) : EMPTY_AUTHOR_NIP30_EMOJIS), + () => EMPTY_AUTHOR_NIP30_EMOJIS + ) useEffect(() => { - if (!needsLookup || !validPk) { - setFromAuthor([]) - return - } - let cancelled = false - let fullResolved = false - void fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey).then((infos) => { - if (cancelled || fullResolved) return - setFromAuthor(infos) - }) + if (!needsLookup || !validPk) return + void fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey) void fetchAuthorNip30EmojiInfos(pubkey) - .then((infos) => { - if (cancelled) return - fullResolved = true - setFromAuthor(infos) - }) - .catch(() => { - fullResolved = true - }) - return () => { - cancelled = true - } }, [needsLookup, validPk, pubkey]) return useMemo( diff --git a/src/lib/nip30-author-emojis.ts b/src/lib/nip30-author-emojis.ts index 27f7a5e4..1bc1d5d0 100644 --- a/src/lib/nip30-author-emojis.ts +++ b/src/lib/nip30-author-emojis.ts @@ -89,16 +89,78 @@ async function loadAuthorNip30FromIndexedDbUncached(pubkey: string): Promise>() const inflightAuthorEmojiIdb = new Map>() +/** Shared author inventory so every mounted note row updates when NIP-30 emoji loads. */ +const authorEmojiCache = new Map() +const authorEmojiListeners = new Map void>>() + +/** Stable empty snapshot for {@link useSyncExternalStore} (must not allocate `[]` per read). */ +export const EMPTY_AUTHOR_NIP30_EMOJIS: readonly TEmoji[] = [] + +function authorEmojiListsEqual(a: readonly TEmoji[], b: readonly TEmoji[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i].shortcode !== b[i].shortcode || a[i].url !== b[i].url) return false + } + return true +} + +function publishAuthorEmojiCache(pk: string, infos: TEmoji[]) { + if (infos.length === 0) return + const prev = authorEmojiCache.get(pk) + if (prev && authorEmojiListsEqual(prev, infos)) return + authorEmojiCache.set(pk, infos) + authorEmojiListeners.get(pk)?.forEach((fn) => fn()) +} + +export function getAuthorNip30EmojiCache(pubkey: string): readonly TEmoji[] { + const pk = pubkey.trim().toLowerCase() + return authorEmojiCache.get(pk) ?? EMPTY_AUTHOR_NIP30_EMOJIS +} + +export function subscribeAuthorNip30EmojiCache(pubkey: string, onStoreChange: () => void): () => void { + const pk = pubkey.trim().toLowerCase() + let set = authorEmojiListeners.get(pk) + if (!set) { + set = new Set() + authorEmojiListeners.set(pk, set) + } + set.add(onStoreChange) + return () => { + set!.delete(onStoreChange) + if (set!.size === 0) authorEmojiListeners.delete(pk) + } +} + +/** Start NIP-30 emoji inventory loads for authors (deduped; updates {@link getAuthorNip30EmojiCache}). */ +export function prefetchAuthorNip30EmojisForPubkeys(pubkeys: readonly string[]): void { + for (const raw of pubkeys) { + const pk = raw.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) continue + if (authorEmojiCache.has(pk)) continue + void fetchAuthorNip30EmojiInfosFromIndexedDb(pk).then((infos) => publishAuthorEmojiCache(pk, infos)) + void fetchAuthorNip30EmojiInfos(pk).then((infos) => publishAuthorEmojiCache(pk, infos)) + } +} + export function fetchAuthorNip30EmojiInfos(pubkey: string): Promise { const pk = pubkey.trim().toLowerCase() if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) + const cached = authorEmojiCache.get(pk) + if (cached?.length) return Promise.resolve(cached) + const existing = inflightAuthorEmoji.get(pk) if (existing) return existing - const p = loadAuthorNip30EmojiInfosUncached(pk).finally(() => { - if (inflightAuthorEmoji.get(pk) === p) inflightAuthorEmoji.delete(pk) - }) + const p = loadAuthorNip30EmojiInfosUncached(pk) + .then((infos) => { + publishAuthorEmojiCache(pk, infos) + return infos + }) + .finally(() => { + if (inflightAuthorEmoji.get(pk) === p) inflightAuthorEmoji.delete(pk) + }) inflightAuthorEmoji.set(pk, p) return p } @@ -108,12 +170,20 @@ export function fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey: string): Promise const pk = pubkey.trim().toLowerCase() if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) + const cached = authorEmojiCache.get(pk) + if (cached?.length) return Promise.resolve(cached) + const existing = inflightAuthorEmojiIdb.get(pk) if (existing) return existing - const p = loadAuthorNip30FromIndexedDbUncached(pk).finally(() => { - if (inflightAuthorEmojiIdb.get(pk) === p) inflightAuthorEmojiIdb.delete(pk) - }) + const p = loadAuthorNip30FromIndexedDbUncached(pk) + .then((infos) => { + publishAuthorEmojiCache(pk, infos) + return infos + }) + .finally(() => { + if (inflightAuthorEmojiIdb.get(pk) === p) inflightAuthorEmojiIdb.delete(pk) + }) inflightAuthorEmojiIdb.set(pk, p) return p } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index cbe7380c..57fe6515 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -17,7 +17,6 @@ import { useNostr } from '@/providers/NostrProvider' import noteStatsService from '@/services/note-stats.service' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { - collectEmbeddedEventPrefetchTargets, getParentBech32Id, getParentETag, getParentEventHexId, @@ -35,7 +34,7 @@ import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' import type { Event } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools' -import { forwardRef, useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react' +import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useState, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns' import { @@ -228,14 +227,10 @@ 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(() => { + /** Resolve nostr embeds with the open note (parent relay hints), before embed cards mount. */ + useLayoutEffect(() => { 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) - } + client.prefetchEmbeddedEventsForParents([finalEvent]) }, [finalEvent?.id]) const getNoteTypeTitle = (kind: number): string => { diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 227f6651..e188fb3d 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -3,6 +3,8 @@ import { ExtendedKind, EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS, EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS, + HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS, + HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS, isDocumentRelayKind, NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS, @@ -10,6 +12,7 @@ import { } from '@/constants' import logger from '@/lib/logger' import { + collectEmbeddedEventPrefetchTargets, getParentATag, getParentETag, getQuotedReferenceFromQTags, @@ -21,7 +24,8 @@ import { isReplyNoteEvent, isReplaceableEvent, kind1QuotesThreadRoot, - normalizeReplaceableCoordinateString + normalizeReplaceableCoordinateString, + relayHintWssUrlsFromEvent } from '@/lib/event' import { getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import type { Event as NEvent, Filter } from 'nostr-tools' @@ -95,6 +99,21 @@ async function buildComprehensiveRelayListForEvents( const PREFETCH_HEX_IDS_CHUNK = 48 +/** Parent kinds that often embed `nostr:…` notes — prefetch targets on ingest with the parent. */ +const EMBEDDED_NOTE_PREFETCH_ON_INGEST_KINDS = new Set([ + kinds.ShortTextNote, + kinds.LongFormArticle, + kinds.Highlights, + kinds.Repost, + ExtendedKind.GENERIC_REPOST, + ExtendedKind.PUBLICATION_CONTENT, + ExtendedKind.WIKI_ARTICLE, + ExtendedKind.WIKI_ARTICLE_MARKDOWN, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ExtendedKind.DISCUSSION +]) + /** Cap session LRU scan per note-stats target — cache iterates newest-first; avoids O(session)×batch stalls. */ const NOTE_STATS_SESSION_PREMERGE_SCAN_MAX = 6000 @@ -433,11 +452,71 @@ export class EventService { return this.fetchEvent(eventId, opts) } + private readonly embeddedPrefetchHexScheduled = new Set() + private readonly embeddedPrefetchNip19Scheduled = new Set() + + /** + * Resolve embed targets from parent notes immediately (same relay hints as the parent). + * Dedupes across feed batches / Note mounts; notifies session waiters when hits land. + */ + prefetchEmbeddedEventsForParents( + parents: readonly NEvent[], + opts?: { relayHintsOnly?: boolean } + ): void { + if (parents.length === 0) return + + const hexSet = new Set() + const nip19Set = new Set() + const relayHintSet = new Set() + for (const parent of parents) { + const { hexIds, nip19Pointers } = collectEmbeddedEventPrefetchTargets(parent) + for (const id of hexIds) hexSet.add(id) + for (const p of nip19Pointers) nip19Set.add(p) + for (const url of relayHintWssUrlsFromEvent(parent)) { + const n = normalizeUrl(url) + if (n) relayHintSet.add(n) + } + } + + const hexIds = [...hexSet].filter((id) => { + if (this.getSessionEventIfAllowed(id)) return false + if (this.embeddedPrefetchHexScheduled.has(id)) return false + this.embeddedPrefetchHexScheduled.add(id) + return true + }) + const nip19Pointers = [...nip19Set].filter((p) => { + if (this.embeddedPrefetchNip19Scheduled.has(p)) return false + this.embeddedPrefetchNip19Scheduled.add(p) + return true + }) + if (hexIds.length === 0 && nip19Pointers.length === 0) return + + const relayHints = [...relayHintSet] + const fetchOpts = relayHints.length > 0 ? { relayHints } : undefined + + void (async () => { + try { + if (hexIds.length > 0) { + await this.prefetchHexEventIds(hexIds, { relayHints, relayHintsOnly: opts?.relayHintsOnly }) + } + await Promise.all( + nip19Pointers.map((pointer) => this.fetchEvent(pointer, fetchOpts)) + ) + } catch { + for (const id of hexIds) this.embeddedPrefetchHexScheduled.delete(id) + for (const p of nip19Pointers) this.embeddedPrefetchNip19Scheduled.delete(p) + } + })() + } + /** * Batch-prefetch events by hex id into session cache (single REQ per chunk). * Used by feeds so embedded notes resolve without N parallel fetches. */ - async prefetchHexEventIds(rawIds: readonly string[]): Promise { + async prefetchHexEventIds( + rawIds: readonly string[], + opts?: { relayHints?: string[]; relayHintsOnly?: boolean } + ): Promise { const hexIds = [ ...new Set( rawIds @@ -455,7 +534,13 @@ export class EventService { toFetch = toFetch.filter((id) => !this.getSessionEventIfAllowed(id)) if (toFetch.length === 0) return - const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], []) + const hints = (opts?.relayHints ?? []) + .map((u) => normalizeUrl(u)) + .filter((u): u is string => Boolean(u)) + const relayUrls = + opts?.relayHintsOnly && hints.length > 0 + ? [...new Set(hints)] + : await buildComprehensiveRelayListForEvents(undefined, hints, hints, hints) if (!relayUrls.length) return for (let i = 0; i < toFetch.length; i += PREFETCH_HEX_IDS_CHUNK) { @@ -466,8 +551,8 @@ export class EventService { undefined, { immediateReturn: false, - eoseTimeout: 2500, - globalTimeout: 12000 + eoseTimeout: hints.length > 0 ? 1800 : 2500, + globalTimeout: hints.length > 0 ? 8000 : 12000 } ) for (const ev of events) { @@ -581,6 +666,9 @@ export class EventService { void client.prefetchAuthorCoreReplaceables([pk.toLowerCase()]) } } + if (EMBEDDED_NOTE_PREFETCH_ON_INGEST_KINDS.has(cleanEvent.kind)) { + this.prefetchEmbeddedEventsForParents([cleanEvent as NEvent]) + } this.notifySessionEventWaiters(id) this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent) @@ -1156,6 +1244,21 @@ export class EventService { } } + if (relays.length > 0) { + const hintedEvents = await this.queryService.query(relays, filter, undefined, { + immediateReturn: true, + eoseTimeout: HINTED_EVENT_FETCH_EOSE_TIMEOUT_MS, + globalTimeout: HINTED_EVENT_FETCH_GLOBAL_TIMEOUT_MS + }) + const hinted = hintedEvents + .filter((e) => !shouldDropEventOnIngest(e, ingestOpts)) + .sort((a, b) => b.created_at - a.created_at)[0] + if (hinted) { + this.addEventToCache(hinted, ingestOpts) + return hinted + } + } + // Try big relays first (uses user's inboxes + defaults) if (filter.ids?.length) { const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 2a6c483d..402bac74 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3433,8 +3433,19 @@ class ClientService extends EventTarget { } /** Batch-prefetch by hex id into session cache (feed embeds). */ - async prefetchHexEventIds(hexIds: readonly string[]): Promise { - return this.eventService.prefetchHexEventIds(hexIds) + async prefetchHexEventIds( + hexIds: readonly string[], + opts?: { relayHints?: string[]; relayHintsOnly?: boolean } + ): Promise { + return this.eventService.prefetchHexEventIds(hexIds, opts) + } + + /** Prefetch nostr embeds referenced by parent notes (with parent relay hints). */ + prefetchEmbeddedEventsForParents( + parents: readonly NEvent[], + opts?: { relayHintsOnly?: boolean } + ): void { + this.eventService.prefetchEmbeddedEventsForParents(parents, opts) } async fetchEventWithExternalRelays(eventId: string, externalRelays: string[]): Promise {