From 061a7c67e96429636b09af8ace5f2c08b0b0cc2a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 17 May 2026 15:23:19 +0200 Subject: [PATCH] fix slow-loading notes --- src/PageManager.tsx | 19 ++- src/hooks/index.tsx | 1 + src/hooks/useFetchThreadContextEvent.tsx | 178 ++++++++++++++++++++ src/lib/thread-context-relays.test.ts | 42 +++++ src/lib/thread-context-relays.ts | 42 +++++ src/pages/secondary/NotePage/index.tsx | 20 +-- src/services/navigation-event-store.test.ts | 32 ++++ src/services/navigation-event-store.ts | 32 +++- 8 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useFetchThreadContextEvent.tsx create mode 100644 src/lib/thread-context-relays.test.ts create mode 100644 src/lib/thread-context-relays.ts create mode 100644 src/services/navigation-event-store.test.ts diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 70c09825..db94ae30 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -472,7 +472,7 @@ export function useSmartNoteNavigation() { navigationEventStore.clear() if (event) { - navigationEventStore.setEvent(event) + navigationEventStore.setEvent(event, noteId) client.addEventToCache(event) } // Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons. @@ -545,7 +545,7 @@ export function useSmartNoteNavigationOptional() { const { noteId } = parsed navigationEventStore.clear() if (event) { - navigationEventStore.setEvent(event) + navigationEventStore.setEvent(event, noteId) client.addEventToCache(event) } if (relatedEvents?.length) { @@ -2564,9 +2564,20 @@ function findAndCreateComponent(url: string, index: number) { logger.component('PageManager', 'Decoded URL parameter', { url: params.url }) } - logger.component('PageManager', 'Creating component with params', { params, index }) + const noteRouteId = typeof params.id === 'string' ? params.id : undefined + const initialEvent = noteRouteId ? navigationEventStore.peekEvent(noteRouteId) : undefined + logger.component('PageManager', 'Creating component with params', { + params, + index, + hasInitialEvent: !!initialEvent + }) try { - const component = cloneSecondaryRouteElement(element, { ...params, index, ref }) + const component = cloneSecondaryRouteElement(element, { + ...params, + index, + ref, + ...(initialEvent ? { initialEvent } : {}) + }) logger.component('PageManager', 'Component created successfully', { hasComponent: !!component }) return { component, ref } } catch (error) { diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index b4fe9094..e02edc8f 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,6 +1,7 @@ export * from './useNearViewport' export * from './useFetchCalendarRsvps' export * from './useFetchEvent' +export * from './useFetchThreadContextEvent' export * from './useFetchFollowings' export * from './useFetchNip05' export * from './useFetchProfile' diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx new file mode 100644 index 00000000..1df3aed7 --- /dev/null +++ b/src/hooks/useFetchThreadContextEvent.tsx @@ -0,0 +1,178 @@ +import { SEARCHABLE_RELAY_URLS } from '@/constants' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' +import { useNostr } from '@/providers/NostrProvider' +import { useReply } from '@/providers/ReplyProvider' +import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event' +import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays' +import client, { eventService } from '@/services/client.service' +import { navigationEventStore } from '@/services/navigation-event-store' +import { Event } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +export type ThreadContextRole = 'parent' | 'root' + +export function useFetchThreadContextEvent( + eventId: string | undefined, + contextEvent: Event | undefined, + role: ThreadContextRole, + initialEvent?: Event +) { + const { pubkey: viewerPubkey } = useNostr() + const { blockedRelays } = useFavoriteRelays() + const { isEventDeleted } = useDeletedEvent() + const { addReplies } = useReply() + const [error, setError] = useState(null) + const [event, setEvent] = useState(initialEvent) + const [isFetching, setIsFetching] = useState(!initialEvent) + const [refetchToken, setRefetchToken] = useState(0) + const searchableAttemptedRef = useRef(false) + + const refetch = useCallback(() => { + searchableAttemptedRef.current = false + setRefetchToken((n) => n + 1) + }, []) + + const targetTag = useMemo(() => { + if (!contextEvent) return undefined + return role === 'parent' ? getParentETag(contextEvent) : getRootETag(contextEvent) + }, [contextEvent, role]) + + const blockedKey = useMemo( + () => [...blockedRelays].map((u) => u).sort().join('\0'), + [blockedRelays] + ) + + useEffect(() => { + searchableAttemptedRef.current = false + }, [eventId]) + + useEffect(() => { + let cancelled = false + + if (!eventId || !contextEvent) { + setIsFetching(false) + setEvent(initialEvent) + return () => { + cancelled = true + } + } + + const skipShortcuts = refetchToken > 0 + + const initialMatches = + initialEvent && + (initialEvent.id === eventId || + (() => { + try { + return getNoteBech32Id(initialEvent) === eventId + } catch { + return false + } + })()) + if (!skipShortcuts && initialMatches && initialEvent) { + if (!isEventDeleted(initialEvent)) { + setEvent(initialEvent) + addReplies([initialEvent]) + setIsFetching(false) + } + return () => { + cancelled = true + } + } + + if (!skipShortcuts) { + const navigationEvent = navigationEventStore.peekEvent(eventId) + if (navigationEvent && !isEventDeleted(navigationEvent)) { + setEvent(navigationEvent) + addReplies([navigationEvent]) + setIsFetching(false) + return () => { + cancelled = true + } + } + } + + setEvent(undefined) + setError(null) + setIsFetching(true) + + const fetchWithFallback = async () => { + try { + const relayUrls = await buildThreadContextFetchRelayUrls( + contextEvent, + targetTag, + viewerPubkey ?? undefined, + blockedRelays + ) + const opts = relayUrls.length ? { relayHints: relayUrls } : undefined + let fetchedEvent = skipShortcuts + ? await eventService.fetchEventForceRetry(eventId, opts) + : await eventService.fetchEvent(eventId, opts) + + if ( + !fetchedEvent && + !searchableAttemptedRef.current && + SEARCHABLE_RELAY_URLS.length > 0 + ) { + searchableAttemptedRef.current = true + fetchedEvent = await client.fetchEventWithExternalRelays( + eventId, + SEARCHABLE_RELAY_URLS + ) + if (fetchedEvent) { + client.addEventToCache(fetchedEvent) + } + } + + if (cancelled) return + if (fetchedEvent && !isEventDeleted(fetchedEvent)) { + setEvent(fetchedEvent) + addReplies([fetchedEvent]) + } else { + setEvent(undefined) + } + } catch (err) { + if (!cancelled) { + setError(err as Error) + setEvent(undefined) + } + } finally { + if (!cancelled) { + setIsFetching(false) + } + } + } + + void fetchWithFallback() + + return () => { + cancelled = true + setIsFetching(false) + } + }, [ + eventId, + contextEvent, + targetTag, + initialEvent, + isEventDeleted, + addReplies, + refetchToken, + viewerPubkey, + blockedKey, + role + ]) + + useEffect(() => { + if (event && isEventDeleted(event)) { + setEvent(undefined) + } + }, [isEventDeleted, event]) + + useEffect(() => { + if (!eventId || event !== undefined) return undefined + return eventService.subscribeWhenSessionHasEvent(eventId, refetch) + }, [eventId, event, refetch]) + + return { isFetching, error, event, refetch } +} diff --git a/src/lib/thread-context-relays.test.ts b/src/lib/thread-context-relays.test.ts new file mode 100644 index 00000000..14045459 --- /dev/null +++ b/src/lib/thread-context-relays.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { getParentEventHexId, getRootEventHexId } from '@/lib/event' +import { pubkeyFromThreadETag, relayHintsFromThreadETag } from '@/lib/thread-context-relays' + +/** Damus-style NIP-10 reply (user report: parent missing on note page). */ +const DAMUS_REPLY_TAGS: string[][] = [ + [ + 'e', + '1dae240e0fe68c331cd9f0923f756c148fb7cf0344a7229c7831b676d6102e71', + 'wss://theforest.nostr1.com/', + 'root', + 'b133bfc57bed61c391d4e8f953b906c7f1709c438d91c75fb6daf79449d5789d' + ], + [ + 'e', + 'c89a8525b4ef8fd3dd149824e6d4f854de8b3120d26286942fd2aabce2d72304', + 'wss://relay.mostr.pub', + 'reply', + 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319' + ] +] + +describe('NIP-10 Damus reply thread tags', () => { + const reply = { kind: 1, tags: DAMUS_REPLY_TAGS } as import('nostr-tools').Event + + it('resolves parent and root hex ids', () => { + expect(getParentEventHexId(reply)).toBe( + 'c89a8525b4ef8fd3dd149824e6d4f854de8b3120d26286942fd2aabce2d72304' + ) + expect(getRootEventHexId(reply)).toBe( + '1dae240e0fe68c331cd9f0923f756c148fb7cf0344a7229c7831b676d6102e71' + ) + }) + + it('reads relay hint and parent author from reply e tag', () => { + const parentTag = DAMUS_REPLY_TAGS[1] + expect(relayHintsFromThreadETag(parentTag)).toEqual(['wss://relay.mostr.pub/']) + expect(pubkeyFromThreadETag(parentTag)).toBe( + 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319' + ) + }) +}) diff --git a/src/lib/thread-context-relays.ts b/src/lib/thread-context-relays.ts new file mode 100644 index 00000000..62404ff8 --- /dev/null +++ b/src/lib/thread-context-relays.ts @@ -0,0 +1,42 @@ +import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' +import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { normalizeUrl } from '@/lib/url' +import type { Event } from 'nostr-tools' + +/** NIP-10 optional pubkey on an `e` tag (field 4 when field 3 is a marker). */ +export function pubkeyFromThreadETag(tag: string[] | undefined): string | undefined { + if (!tag) return undefined + const markerOrPubkey = tag[3] + const pubkeyField = tag[4] + const candidates = [pubkeyField, markerOrPubkey] + for (const v of candidates) { + if (typeof v === 'string' && /^[0-9a-f]{64}$/i.test(v)) { + return v.toLowerCase() + } + } + return undefined +} + +/** Relay hint from a single `e` / `E` tag (third field). */ +export function relayHintsFromThreadETag(tag: string[] | undefined): string[] { + if (!tag?.[2] || typeof tag[2] !== 'string') return [] + const n = normalizeUrl(tag[2]) || tag[2] + if (!n || !urlIsNonLocalForRemoteViewer(n)) return [] + return [n] +} + +/** + * Relays to REQ a thread parent/root: tag-specific hint first, then all `e` hints on the reply, + * plus author NIP-65 when the referenced note's author pubkey is on the tag. + */ +export async function buildThreadContextFetchRelayUrls( + contextEvent: Event, + targetTag: string[] | undefined, + viewerPubkey: string | undefined, + blockedRelays: string[] = [] +): Promise { + const tagHints = relayHintsFromThreadETag(targetTag) + const threadRelayHints = [...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)])] + const opAuthorPubkey = pubkeyFromThreadETag(targetTag) + return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints) +} diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 57fe6515..332a60a3 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -11,7 +11,12 @@ import UserAvatar from '@/components/UserAvatar' import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' -import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks' +import { + useFetchEvent, + useFetchProfile, + useFetchThreadContextEvent, + useNip84HighlightTargetEvents +} from '@/hooks' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNostr } from '@/providers/NostrProvider' import noteStatsService from '@/services/note-stats.service' @@ -29,7 +34,6 @@ import { toNote, toNoteList } from '@/lib/link' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { stripMarkupForPreview } from '@/lib/parent-reply-blurb' import { tagNameEquals } from '@/lib/tag' -import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' import type { Event } from 'nostr-tools' @@ -141,14 +145,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: () => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined), [finalEvent] ) - const threadRelayHints = useMemo( - () => (finalEvent ? relayHintsFromEventTags(finalEvent) : []), - [finalEvent] - ) - const parentRootFetchOpts = useMemo( - () => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined), - [threadRelayHints] - ) const rootInitialEvent = useMemo(() => { if (!finalEvent) return undefined const rootHex = getRootEventHexId(finalEvent)?.toLowerCase() @@ -163,9 +159,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: return client.peekSessionCachedEvent(parentHex) }, [finalEvent]) const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } = - useFetchEvent(rootEventId, rootInitialEvent, parentRootFetchOpts) + useFetchThreadContextEvent(rootEventId, finalEvent, 'root', rootInitialEvent) const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = - useFetchEvent(parentEventId, parentInitialEvent, parentRootFetchOpts) + useFetchThreadContextEvent(parentEventId, finalEvent, 'parent', parentInitialEvent) const selfHex = finalEvent?.id?.toLowerCase() const rootEventForStrip = diff --git a/src/services/navigation-event-store.test.ts b/src/services/navigation-event-store.test.ts new file mode 100644 index 00000000..566dbfa9 --- /dev/null +++ b/src/services/navigation-event-store.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { nip19 } from 'nostr-tools' +import { candidateKeysForNoteUrlId, navigationEventStore } from './navigation-event-store' + +describe('navigationEventStore', () => { + it('aliases hex and nevent url ids', () => { + const id = 'c89a8525b4ef8fd3dd149824e6d4f854de8b3120d26286942fd2aabce2d72304' + const nevent = nip19.neventEncode({ id }) + const event = { + id, + kind: 1, + pubkey: 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319', + created_at: 1, + tags: [], + content: 'hello', + sig: 'x'.repeat(128) + } + navigationEventStore.clear() + navigationEventStore.setEvent(event as import('nostr-tools').Event, nevent) + expect(navigationEventStore.peekEvent(nevent)?.id).toBe(id) + expect(navigationEventStore.peekEvent(id)?.content).toBe('hello') + navigationEventStore.clear() + }) + + it('decodes nevent keys for lookup', () => { + const id = '88687efb89dc05a72a2505f61aa4f87579e87b43aa632b911dae2cdf916b621e' + const nevent = nip19.neventEncode({ id }) + const keys = candidateKeysForNoteUrlId(nevent) + expect(keys).toContain(nevent) + expect(keys.map((k) => k.toLowerCase())).toContain(id) + }) +}) diff --git a/src/services/navigation-event-store.ts b/src/services/navigation-event-store.ts index 8fcf4789..3643035f 100644 --- a/src/services/navigation-event-store.ts +++ b/src/services/navigation-event-store.ts @@ -6,15 +6,21 @@ import { getNoteBech32Id } from '@/lib/event' import { Event, nip19 } from 'nostr-tools' /** URL paths use bech32 (nevent1…, naddr1…); lookups must match the `id` passed to `useFetchEvent`. */ -function candidateKeysForNoteUrlId(eventId: string): string[] { - const keys = [eventId] - if (/^[a-f0-9]{64}$/i.test(eventId)) return keys +export function candidateKeysForNoteUrlId(eventId: string): string[] { + const trimmed = eventId.trim() + if (!trimmed) return [] + const keys = [trimmed] + if (/^[a-f0-9]{64}$/i.test(trimmed)) return keys try { - const decoded = nip19.decode(eventId) + const decoded = nip19.decode(trimmed) if (decoded.type === 'nevent') { keys.push(decoded.data.id) } else if (decoded.type === 'note') { keys.push(decoded.data) + } else if (decoded.type === 'naddr') { + keys.push( + `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier ?? ''}` + ) } } catch { /* not bech32 */ @@ -26,18 +32,26 @@ class NavigationEventStore { private eventMap = new Map() /** - * Store an event for navigation (hex id + same bech32 form as {@link toNote} / the URL). + * Store an event for navigation (hex id + bech32 forms + optional URL segment from {@link parseNoteUrl}). */ - setEvent(event: Event): void { - this.eventMap.set(event.id, event) + setEvent(event: Event, navigatedNoteId?: string): void { + const keys = new Set([event.id.toLowerCase()]) + if (navigatedNoteId?.trim()) { + for (const k of candidateKeysForNoteUrlId(navigatedNoteId)) { + keys.add(k) + } + } try { const urlId = getNoteBech32Id(event) - if (urlId !== event.id) { - this.eventMap.set(urlId, event) + for (const k of candidateKeysForNoteUrlId(urlId)) { + keys.add(k) } } catch { /* ignore */ } + for (const key of keys) { + if (key) this.eventMap.set(key, event) + } } /**