From 60016ed571b1c2b4fd0b6fafe0b1835d3ee4636c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 6 May 2026 23:36:58 +0200 Subject: [PATCH] speed up feeds --- src/components/NoteList/index.tsx | 139 ++++++++++++++++++++++---- src/hooks/useProfileTimeline.tsx | 37 ++++++- src/providers/NostrProvider/index.tsx | 95 ++++++++++++------ src/services/client.service.ts | 55 ++++++++++ 4 files changed, 269 insertions(+), 57 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 3408b203..b2e7404e 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1795,7 +1795,36 @@ const NoteList = forwardRef( if (!relayCapabilityReady && !oneShotFetch) { setLoading(true) - return () => {} + let diskPrimeCancelled = false + const primeDiskWhileAwaitingRelayProbe = async () => { + try { + const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) + .map((req) => + isOfflineRef.current + ? { ...req, urls: req.urls.filter((u) => isLocalNetworkUrl(u)) } + : req + ) + .filter((req) => req.urls.length > 0) + if (mapped.length === 0) return + const disk = await client.getTimelineDiskSnapshotEvents( + mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return + const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT + const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays)) + if (merged.length > 0) { + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setLoading(false) + } + } catch { + /* best-effort */ + } + } + void primeDiskWhileAwaitingRelayProbe() + return () => { + diskPrimeCancelled = true + } } const prevSubKey = prevSubRequestsKeyForTimelineRef.current @@ -1848,27 +1877,6 @@ const NoteList = forwardRef( !userPulledRefresh ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) : undefined const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length) - if (!keepExistingTimelineEvents) { - if (restoredFromSession && sessionSnap) { - feedPaintSessionPendingRef.current = true - const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap) - setEvents(restored) - lastEventsForTimelinePrefetchRef.current = restored - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(!!oneShotFetch) - } else { - if (!keepRowsVisible) setLoading(true) - setEvents([]) - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - } - } else if (!keepRowsVisible) { - setLoading(true) - } - setHasMore(true) - consecutiveEmptyRef.current = 0 // Reset counter on refresh - const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const mappedSubRequests = mapLiveSubRequestsForTimeline(subRequestsRef.current) @@ -1927,6 +1935,64 @@ const NoteList = forwardRef( return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind)) } + const eventCapEarly = allowKindlessRelayExplore + ? RELAY_EXPLORE_LIMIT + : areAlgoRelays + ? ALGO_LIMIT + : LIMIT + + if (!keepExistingTimelineEvents) { + if (restoredFromSession && sessionSnap) { + feedPaintSessionPendingRef.current = true + const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap) + setEvents(restored) + lastEventsForTimelinePrefetchRef.current = restored + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(!!oneShotFetch) + } else { + let primedFromDisk = false + if (!oneShotFetch && mappedSubRequests.length > 0) { + try { + const diskRaw = await client.getTimelineDiskSnapshotEvents( + mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + if (!timelineEffectStale() && diskRaw.length > 0) { + const diskNarrowed = narrowLiveBatch(diskRaw) + if (diskNarrowed.length > 0) { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], diskNarrowed, eventCapEarly, areAlgoRelays) + ) + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'disk_snapshot', + mergedCount: merged.length + } + primedFromDisk = true + } + } + } catch { + /* disk snapshot is best-effort */ + } + } + if (!primedFromDisk) { + if (!keepRowsVisible) setLoading(true) + setEvents([]) + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + } + } + } else if (!keepRowsVisible) { + setLoading(true) + } + setHasMore(true) + consecutiveEmptyRef.current = 0 // Reset counter on refresh + if (oneShotFetch) { setHasMore(false) try { @@ -1951,6 +2017,35 @@ const NoteList = forwardRef( if (warmQOneShot) setProgressiveLayersSearching(false) return undefined } + if (!warmQOneShot && mappedSubRequests.length > 0) { + try { + const diskRaw = await client.getTimelineDiskSnapshotEvents( + mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + if (!timelineEffectStale() && diskRaw.length > 0) { + const capDisk = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP + const narrowed = narrowLiveBatch(diskRaw) + if (narrowed.length > 0) { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowed, capDisk, areAlgoRelays) + ) + if (merged.length > 0) { + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setLoading(false) + feedRelayReturnedAnyEventRef.current = true + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'disk_snapshot_one_shot', + mergedCount: merged.length + } + } + } + } + } catch { + /* best-effort */ + } + } const firstRelayGraceResolved = oneShotFirstRelayGraceMs === undefined ? FIRST_RELAY_RESULT_GRACE_MS diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 4b9eb9ae..7863d27f 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -9,6 +9,7 @@ import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import indexedDb from '@/services/indexed-db.service' +import type { TSubRequestFilter } from '@/types' type ProfileTimelineMemoryEntry = { events: Event[] @@ -278,9 +279,23 @@ export function useProfileTimeline({ return } - void startWave( - buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds) - ) + const provisionalSubs = buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds) + void (async () => { + try { + const disk = await client.getTimelineDiskSnapshotEvents( + provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + if (!cancelled && disk.length > 0) { + for (const e of disk) { + pool.set(e.id, e) + } + flushPool() + } + } catch { + /* disk snapshot is best-effort */ + } + await startWave(provisionalSubs) + })() void (async () => { const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ @@ -300,7 +315,21 @@ export function useProfileTimeline({ ) const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) if (cancelled || deltaUrls.length === 0) return - await startWave(buildSubRequests([deltaUrls], pubkey, kinds, limit, hasCalendarKinds)) + const deltaSubs = buildSubRequests([deltaUrls], pubkey, kinds, limit, hasCalendarKinds) + try { + const diskDelta = await client.getTimelineDiskSnapshotEvents( + deltaSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + if (!cancelled && diskDelta.length > 0) { + for (const e of diskDelta) { + pool.set(e.id, e) + } + flushPool() + } + } catch { + /* optional */ + } + await startWave(deltaSubs) })() } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index d3528918..ff37a24d 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -130,6 +130,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const accountHydrationGenerationRef = useRef(0) /** When true, next hydrate run performs a full network merge without clearing UI state from IndexedDB first. */ const forceNextAccountNetworkHydrateRef = useRef(false) + /** Last account pubkey for which we cleared session UI; avoids nulling relay/profile on same-account rehydrate. */ + const lastNetworkHydrateAccountPubkeyRef = useRef(null) const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null) const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0) /** @@ -186,6 +188,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const init = async () => { if (!account) { accountHydrationGenerationRef.current += 1 + lastNetworkHydrateAccountPubkeyRef.current = null setIsAccountSessionHydrating(false) forceNextAccountNetworkHydrateRef.current = false setRelayList(null) @@ -207,7 +210,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { forceNextAccountNetworkHydrateRef.current = false } - if (!userForcedAccountNetworkHydrate) { + const prevHydratedPk = lastNetworkHydrateAccountPubkeyRef.current + const switchedToDifferentAccount = + prevHydratedPk != null && prevHydratedPk !== account.pubkey + if (switchedToDifferentAccount) { setRelayList(null) setProfile(null) setProfileEvent(null) @@ -457,16 +463,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null if (relayListEvent) { client.updateRelayListCache(relayListEvent) - await indexedDb.putReplaceableEvent(relayListEvent) } + await Promise.all([ + relayListEvent ? indexedDb.putReplaceableEvent(relayListEvent).catch(() => {}) : Promise.resolve(), + cacheRelayListEvent ? indexedDb.putReplaceableEvent(cacheRelayListEvent).catch(() => {}) : Promise.resolve(), + httpRelayListEventFetched + ? indexedDb.putReplaceableEvent(httpRelayListEventFetched).catch(() => {}) + : Promise.resolve() + ]) if (cacheRelayListEvent) { - await indexedDb.putReplaceableEvent(cacheRelayListEvent) setCacheRelayListEvent(cacheRelayListEvent) } else { setCacheRelayListEvent(null) } if (httpRelayListEventFetched) { - await indexedDb.putReplaceableEvent(httpRelayListEventFetched) setHttpRelayListEvent(httpRelayListEventFetched) } else { setHttpRelayListEvent(null) @@ -510,16 +520,45 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { (e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST ) const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList) + + const safePutReplaceable = async (evt: Event | undefined): Promise => { + if (!evt) return undefined + try { + return await indexedDb.putReplaceableEvent(evt) + } catch { + return evt + } + } + + const [ + resolvedProfilePut, + resolvedFollowPut, + resolvedMutePut, + resolvedBookmarkPut, + resolvedInterestPut, + resolvedFavoritePut, + resolvedBlockedPut, + resolvedUserEmojiPut + ] = await Promise.all([ + safePutReplaceable(profileEvent), + safePutReplaceable(followListEvent), + safePutReplaceable(muteListEvent), + safePutReplaceable(bookmarkListEvent), + safePutReplaceable(interestListEvent), + safePutReplaceable(favoriteRelaysEvent), + safePutReplaceable(blockedRelaysEvent), + safePutReplaceable(userEmojiListEvent) + ]) + if (profileEvent) { - let resolvedProfileEvent = profileEvent + const resolvedProfileEvent = resolvedProfilePut ?? profileEvent try { - const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) - resolvedProfileEvent = updatedProfileEvent await replaceableEventService.updateReplaceableEventCache(resolvedProfileEvent) } catch (e) { - // IDB write failed (e.g. tombstone or store error) — still apply the fetched event in memory - logger.warn('[NostrProvider] putReplaceableEvent failed for profile; using fetched event in memory', { error: e }) - try { await replaceableEventService.updateReplaceableEventCache(profileEvent) } catch {} + logger.warn('[NostrProvider] replaceableEventService cache update failed for profile', { error: e }) + try { + await replaceableEventService.updateReplaceableEventCache(profileEvent) + } catch {} } setProfileEvent(resolvedProfileEvent) setProfile(getProfileFromEvent(resolvedProfileEvent)) @@ -531,8 +570,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }) } if (followListEvent) { - const updatedFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent) - if (updatedFollowListEvent.id === followListEvent.id) { + if (resolvedFollowPut && resolvedFollowPut.id === followListEvent.id) { setFollowListEvent(followListEvent) } } else { @@ -582,37 +620,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }) } if (muteListEvent) { - const updatedMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent) - if (updatedMuteListEvent.id === muteListEvent.id) { + if (resolvedMutePut && resolvedMutePut.id === muteListEvent.id) { setMuteListEvent(muteListEvent) } } if (bookmarkListEvent) { - const updateBookmarkListEvent = await indexedDb.putReplaceableEvent(bookmarkListEvent) - if (updateBookmarkListEvent.id === bookmarkListEvent.id) { + if (resolvedBookmarkPut && resolvedBookmarkPut.id === bookmarkListEvent.id) { setBookmarkListEvent(bookmarkListEvent) } } if (interestListEvent) { - const updatedInterestListEvent = await indexedDb.putReplaceableEvent(interestListEvent) - if (updatedInterestListEvent.id === interestListEvent.id) { + if (resolvedInterestPut && resolvedInterestPut.id === interestListEvent.id) { setInterestListEvent(interestListEvent) } } if (favoriteRelaysEvent) { - const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) - if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) { - setFavoriteRelaysEvent(updatedFavoriteRelaysEvent) + if (resolvedFavoritePut && resolvedFavoritePut.id === favoriteRelaysEvent.id) { + setFavoriteRelaysEvent(favoriteRelaysEvent) } } if (blockedRelaysEvent) { - const updatedBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent) - if (updatedBlockedRelaysEvent.id === blockedRelaysEvent.id) { - setBlockedRelaysEvent(updatedBlockedRelaysEvent) - + if (resolvedBlockedPut && resolvedBlockedPut.id === blockedRelaysEvent.id) { + setBlockedRelaysEvent(resolvedBlockedPut) + // Update blockedRelays array and re-filter relay list const newBlockedRelays: string[] = [] - updatedBlockedRelaysEvent.tags.forEach(([tagName, tagValue]) => { + resolvedBlockedPut.tags.forEach(([tagName, tagValue]) => { if (tagName === 'relay' && tagValue) { const normalizedUrl = normalizeUrl(tagValue) if (normalizedUrl && !newBlockedRelays.includes(normalizedUrl)) { @@ -620,7 +653,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } }) - + // Re-filter relay list with updated blocked relays if (relayListEvent) { const updatedRelayList = getRelayListFromEvent(relayListEvent, newBlockedRelays) @@ -629,12 +662,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } if (blossomServerListEvent) { - await client.updateBlossomServerListEventCache(blossomServerListEvent) + void client.updateBlossomServerListEventCache(blossomServerListEvent) } if (userEmojiListEvent) { - const updatedUserEmojiListEvent = await indexedDb.putReplaceableEvent(userEmojiListEvent) - if (updatedUserEmojiListEvent.id === userEmojiListEvent.id) { - setUserEmojiListEvent(updatedUserEmojiListEvent) + if (resolvedUserEmojiPut && resolvedUserEmojiPut.id === userEmojiListEvent.id) { + setUserEmojiListEvent(userEmojiListEvent) } } @@ -683,6 +715,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { ) } } + lastNetworkHydrateAccountPubkeyRef.current = account.pubkey return controller } const promise = init() diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 64f91283..c4f5e154 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1900,6 +1900,61 @@ class ClientService extends EventTarget { return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } + /** + * Load the last persisted timeline rows from IndexedDB for each shard (same keys as live subscribe), + * without opening relay subscriptions. Used for stale-while-revalidate first paint on feeds. + */ + async getTimelineDiskSnapshotEvents( + subRequests: { urls: string[]; filter: TSubRequestFilter }[] + ): Promise { + if (!subRequests.length) return [] + const mergedTimelineLimit = Math.max( + 500, + ...subRequests.map(({ filter }) => + typeof filter.limit === 'number' && filter.limit > 0 ? filter.limit : 0 + ) + ) + const merged: NEvent[] = [] + const eventIdSet = new Set() + for (const { urls, filter } of subRequests) { + let relays = Array.from(new Set(urls)) + if (!navigator.onLine) { + relays = relays.filter((url) => isLocalNetworkUrl(url)) + } + if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) { + relays = relayUrlsStripExtendedTagReqBlocked(relays) + if (relays.length === 0 && navigator.onLine) { + relays = relayUrlsStripExtendedTagReqBlocked([...FAST_READ_RELAY_URLS]) + } + } + const key = this.generateTimelineKey(relays, filter as Filter) + try { + const st = await indexedDb.getTimelinePersistedState(key) + if (!st?.refs?.length) continue + const hexIds = st.refs.map((r) => r[0]) + const list = await indexedDb.getArchivedEventsByIds(hexIds) + for (const ev of list) { + if (shouldDropEventOnIngest(ev)) continue + if (eventIdSet.has(ev.id)) continue + eventIdSet.add(ev.id) + merged.push(ev) + } + for (const refId of hexIds) { + if (eventIdSet.has(refId)) continue + const sess = this.eventService.peekSessionCachedEvent(refId) + if (sess && !shouldDropEventOnIngest(sess)) { + eventIdSet.add(refId) + merged.push(sess) + } + } + } catch (err) { + logger.debug('[ClientService] Timeline disk snapshot shard read failed', { err }) + } + } + merged.sort((a, b) => b.created_at - a.created_at) + return merged.slice(0, mergedTimelineLimit) + } + async subscribeTimeline( subRequests: { urls: string[]; filter: TSubRequestFilter }[], {