Browse Source

speed up feeds

imwald
Silberengel 1 month ago
parent
commit
60016ed571
  1. 139
      src/components/NoteList/index.tsx
  2. 37
      src/hooks/useProfileTimeline.tsx
  3. 91
      src/providers/NostrProvider/index.tsx
  4. 55
      src/services/client.service.ts

139
src/components/NoteList/index.tsx

@ -1795,7 +1795,36 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -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

37
src/hooks/useProfileTimeline.tsx

@ -9,6 +9,7 @@ import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' @@ -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({ @@ -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({ @@ -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)
})()
}

91
src/providers/NostrProvider/index.tsx

@ -130,6 +130,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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<string | null>(null)
const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null)
const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0)
/**
@ -186,6 +188,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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<Event | undefined> => {
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 }) { @@ -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 }) { @@ -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)) {
@ -629,12 +662,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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 }) { @@ -683,6 +715,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
)
}
}
lastNetworkHydrateAccountPubkeyRef.current = account.pubkey
return controller
}
const promise = init()

55
src/services/client.service.ts

@ -1900,6 +1900,61 @@ class ClientService extends EventTarget { @@ -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<NEvent[]> {
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<string>()
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 }[],
{

Loading…
Cancel
Save