Browse Source

fix slow-loading notes

imwald
Silberengel 4 weeks ago
parent
commit
061a7c67e9
  1. 19
      src/PageManager.tsx
  2. 1
      src/hooks/index.tsx
  3. 178
      src/hooks/useFetchThreadContextEvent.tsx
  4. 42
      src/lib/thread-context-relays.test.ts
  5. 42
      src/lib/thread-context-relays.ts
  6. 20
      src/pages/secondary/NotePage/index.tsx
  7. 32
      src/services/navigation-event-store.test.ts
  8. 32
      src/services/navigation-event-store.ts

19
src/PageManager.tsx

@ -472,7 +472,7 @@ export function useSmartNoteNavigation() {
navigationEventStore.clear() navigationEventStore.clear()
if (event) { if (event) {
navigationEventStore.setEvent(event) navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event) client.addEventToCache(event)
} }
// Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons. // Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons.
@ -545,7 +545,7 @@ export function useSmartNoteNavigationOptional() {
const { noteId } = parsed const { noteId } = parsed
navigationEventStore.clear() navigationEventStore.clear()
if (event) { if (event) {
navigationEventStore.setEvent(event) navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event) client.addEventToCache(event)
} }
if (relatedEvents?.length) { 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', '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 { 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 }) logger.component('PageManager', 'Component created successfully', { hasComponent: !!component })
return { component, ref } return { component, ref }
} catch (error) { } catch (error) {

1
src/hooks/index.tsx

@ -1,6 +1,7 @@
export * from './useNearViewport' export * from './useNearViewport'
export * from './useFetchCalendarRsvps' export * from './useFetchCalendarRsvps'
export * from './useFetchEvent' export * from './useFetchEvent'
export * from './useFetchThreadContextEvent'
export * from './useFetchFollowings' export * from './useFetchFollowings'
export * from './useFetchNip05' export * from './useFetchNip05'
export * from './useFetchProfile' export * from './useFetchProfile'

178
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<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(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 }
}

42
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'
)
})
})

42
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<string[]> {
const tagHints = relayHintsFromThreadETag(targetTag)
const threadRelayHints = [...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)])]
const opAuthorPubkey = pubkeyFromThreadETag(targetTag)
return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints)
}

20
src/pages/secondary/NotePage/index.tsx

@ -11,7 +11,12 @@ import UserAvatar from '@/components/UserAvatar'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' 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 { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import noteStatsService from '@/services/note-stats.service' 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 { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { stripMarkupForPreview } from '@/lib/parent-reply-blurb' import { stripMarkupForPreview } from '@/lib/parent-reply-blurb'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import type { Event } from 'nostr-tools' 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?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined),
[finalEvent] [finalEvent]
) )
const threadRelayHints = useMemo(
() => (finalEvent ? relayHintsFromEventTags(finalEvent) : []),
[finalEvent]
)
const parentRootFetchOpts = useMemo(
() => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined),
[threadRelayHints]
)
const rootInitialEvent = useMemo(() => { const rootInitialEvent = useMemo(() => {
if (!finalEvent) return undefined if (!finalEvent) return undefined
const rootHex = getRootEventHexId(finalEvent)?.toLowerCase() const rootHex = getRootEventHexId(finalEvent)?.toLowerCase()
@ -163,9 +159,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return client.peekSessionCachedEvent(parentHex) return client.peekSessionCachedEvent(parentHex)
}, [finalEvent]) }, [finalEvent])
const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } = const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } =
useFetchEvent(rootEventId, rootInitialEvent, parentRootFetchOpts) useFetchThreadContextEvent(rootEventId, finalEvent, 'root', rootInitialEvent)
const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } =
useFetchEvent(parentEventId, parentInitialEvent, parentRootFetchOpts) useFetchThreadContextEvent(parentEventId, finalEvent, 'parent', parentInitialEvent)
const selfHex = finalEvent?.id?.toLowerCase() const selfHex = finalEvent?.id?.toLowerCase()
const rootEventForStrip = const rootEventForStrip =

32
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)
})
})

32
src/services/navigation-event-store.ts

@ -6,15 +6,21 @@ import { getNoteBech32Id } from '@/lib/event'
import { Event, nip19 } from 'nostr-tools' import { Event, nip19 } from 'nostr-tools'
/** URL paths use bech32 (nevent1…, naddr1…); lookups must match the `id` passed to `useFetchEvent`. */ /** URL paths use bech32 (nevent1…, naddr1…); lookups must match the `id` passed to `useFetchEvent`. */
function candidateKeysForNoteUrlId(eventId: string): string[] { export function candidateKeysForNoteUrlId(eventId: string): string[] {
const keys = [eventId] const trimmed = eventId.trim()
if (/^[a-f0-9]{64}$/i.test(eventId)) return keys if (!trimmed) return []
const keys = [trimmed]
if (/^[a-f0-9]{64}$/i.test(trimmed)) return keys
try { try {
const decoded = nip19.decode(eventId) const decoded = nip19.decode(trimmed)
if (decoded.type === 'nevent') { if (decoded.type === 'nevent') {
keys.push(decoded.data.id) keys.push(decoded.data.id)
} else if (decoded.type === 'note') { } else if (decoded.type === 'note') {
keys.push(decoded.data) keys.push(decoded.data)
} else if (decoded.type === 'naddr') {
keys.push(
`${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier ?? ''}`
)
} }
} catch { } catch {
/* not bech32 */ /* not bech32 */
@ -26,18 +32,26 @@ class NavigationEventStore {
private eventMap = new Map<string, Event>() private eventMap = new Map<string, Event>()
/** /**
* 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 { setEvent(event: Event, navigatedNoteId?: string): void {
this.eventMap.set(event.id, event) const keys = new Set<string>([event.id.toLowerCase()])
if (navigatedNoteId?.trim()) {
for (const k of candidateKeysForNoteUrlId(navigatedNoteId)) {
keys.add(k)
}
}
try { try {
const urlId = getNoteBech32Id(event) const urlId = getNoteBech32Id(event)
if (urlId !== event.id) { for (const k of candidateKeysForNoteUrlId(urlId)) {
this.eventMap.set(urlId, event) keys.add(k)
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
for (const key of keys) {
if (key) this.eventMap.set(key, event)
}
} }
/** /**

Loading…
Cancel
Save