From 3e4f5a114219435d9a2491bee344a385801d4c3e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 11:33:37 +0200 Subject: [PATCH] bug-fixes --- src/hooks/useProfileTimeline.tsx | 157 +++++++++++++++++++++----- src/services/client-events.service.ts | 13 +-- src/services/client.service.ts | 7 +- src/services/indexed-db.service.ts | 87 ++++++++++---- 4 files changed, 204 insertions(+), 60 deletions(-) diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 7af2c1ec..97d408d5 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -1,7 +1,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' -import client from '@/services/client.service' +import client, { eventService } from '@/services/client.service' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Event } from 'nostr-tools' +import { Event, kinds as nostrKinds, type Filter } from 'nostr-tools' import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' @@ -223,36 +223,52 @@ export function useProfileTimeline({ if (mem?.events?.length) { mem.events.forEach((e) => pool.set(e.id, e)) setEvents(mem.events) + setIsLoading(true) } else { try { const pk = normalizeHexPubkey(pubkey) + const primeKinds = new Set(kinds) for (const e of latestEventsRef.current) { + if (!primeKinds.has(e.kind)) continue if (normalizeHexPubkey(e.pubkey) === pk) pool.set(e.id, e) } + if (!cancelled && pool.size > 0) flushPool() } catch { /* ignore malformed pubkeys */ } } - setIsLoading(true) } const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) const socialKinds = kinds.some(isSocialKindBlockedKind) - const emptyAuthor = { read: [] as string[], write: [] as string[] } - const provisionalFeedUrls = buildProfilePageReadRelayUrls( - favoriteRelays, - blockedRelays, - emptyAuthor, - socialKinds, - includeAuthorLocalRelays, - kinds - ) - + const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] } const idbDocKinds = kinds.filter((k) => isDocumentRelayKind(k)) + /** + * Author NIP-65 read/write relays must feed the **first** REQ for every profile tab. Favorites-only + * misses most people’s kind-1 notes; we previously only prefetched relays for document tabs. + */ + let prefetchedAuthorRelays: typeof emptyAuthor = emptyAuthor + if (idbDocKinds.length > 0) { try { const pkNorm = normalizeHexPubkey(pubkey) - const [fromPubStore, fromArchive] = await Promise.all([ + const fromSession = eventService.listSessionEventsAuthoredBy(pkNorm, { + kinds: idbDocKinds, + limit + }) + if (!cancelled) { + for (const e of fromSession) { + pool.set(e.id, e as Event) + } + if (fromSession.length) flushPool() + } + const [authorRl, fromPubStore, fromArchive] = await Promise.all([ + client.fetchRelayList(pubkey).catch(() => ({ + read: [] as string[], + write: [] as string[], + httpRead: [] as string[], + httpWrite: [] as string[] + })), indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkNorm, idbDocKinds, limit), indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { kinds: idbDocKinds, @@ -261,19 +277,74 @@ export function useProfileTimeline({ }) ]) if (!cancelled) { + prefetchedAuthorRelays = authorRl for (const e of fromPubStore) { pool.set(e.id, e) } for (const e of fromArchive) { pool.set(e.id, e) } - if (fromPubStore.length || fromArchive.length) flushPool() + const hadDisk = fromPubStore.length > 0 || fromArchive.length > 0 + if (hadDisk) flushPool() + else if (!isCacheFresh && !mem?.events?.length && fromSession.length === 0) { + setIsLoading(true) + } } } catch { - /* IDB optional */ + if (!cancelled) { + prefetchedAuthorRelays = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) + } + if (!cancelled && !isCacheFresh && !mem?.events?.length) { + setIsLoading(true) + } + } + } else { + try { + const pkNorm = normalizeHexPubkey(pubkey) + const fromSession = eventService.listSessionEventsAuthoredBy(pkNorm, { kinds, limit }) + if (!cancelled) { + for (const e of fromSession) { + pool.set(e.id, e as Event) + } + if (fromSession.length) flushPool() + } + const [authorRl, fromArchiveSocial] = await Promise.all([ + client.fetchRelayList(pubkey).catch(() => emptyAuthor), + indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { + kinds, + maxRowsScanned: 16_000, + maxMatches: limit + }) + ]) + if (!cancelled) { + prefetchedAuthorRelays = authorRl + for (const e of fromArchiveSocial) { + pool.set(e.id, e) + } + if (fromArchiveSocial.length) flushPool() + else if (!isCacheFresh && !mem?.events?.length && fromSession.length === 0) { + setIsLoading(true) + } + } + } catch { + if (!cancelled) { + prefetchedAuthorRelays = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) + } + if (!cancelled && !isCacheFresh && !mem?.events?.length) { + setIsLoading(true) + } } } + const provisionalFeedUrls = buildProfilePageReadRelayUrls( + favoriteRelays, + blockedRelays, + prefetchedAuthorRelays, + socialKinds, + includeAuthorLocalRelays, + kinds + ) + const startWave = async (subRequests: ReturnType) => { if (cancelled || subRequests.length === 0) return try { @@ -308,29 +379,57 @@ export function useProfileTimeline({ const provisionalSubs = buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds) void (async () => { + let pkForReq = pubkey try { - const disk = await client.getTimelineDiskSnapshotEvents( - provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> - ) + pkForReq = normalizeHexPubkey(pubkey) + } catch { + /* use raw pubkey */ + } + const longFormPrefetch = + idbDocKinds.includes(nostrKinds.LongFormArticle) && provisionalFeedUrls.length > 0 + ? client.fetchEvents( + provisionalFeedUrls, + { + authors: [pkForReq], + kinds: [nostrKinds.LongFormArticle], + limit + } as Filter, + { cache: true, eoseTimeout: 4500, globalTimeout: 14_000, replaceableRace: true } + ).catch(() => [] as Event[]) + : Promise.resolve([] as Event[]) + + try { + const [disk, longFormRows] = await Promise.all([ + client.getTimelineDiskSnapshotEvents( + provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> + ), + longFormPrefetch + ]) if (!cancelled && disk.length > 0) { for (const e of disk) { pool.set(e.id, e) } flushPool() } + if (!cancelled && longFormRows.length > 0) { + for (const e of longFormRows) { + pool.set(e.id, e) + } + flushPool() + } } catch { /* disk snapshot is best-effort */ } - await startWave(provisionalSubs) + try { + await startWave(provisionalSubs) + } finally { + /** Subscriptions are live; sync UI even if the merged layer was slow to emit (empty feed is valid). */ + if (!cancelled) flushPool() + } })() void (async () => { - const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ - read: [] as string[], - write: [] as string[], - httpRead: [] as string[], - httpWrite: [] as string[] - })) + const authorRl = prefetchedAuthorRelays if (cancelled) return const fullFeedUrls = buildProfilePageReadRelayUrls( favoriteRelays, @@ -356,7 +455,11 @@ export function useProfileTimeline({ } catch { /* optional */ } - await startWave(deltaSubs) + try { + await startWave(deltaSubs) + } finally { + if (!cancelled) flushPool() + } })() } diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 59abf4c7..6af0aca6 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { ExtendedKind, isDocumentRelayKind } from '@/constants' import logger from '@/lib/logger' import { getParentATag, @@ -525,22 +525,19 @@ export class EventService { this.notifySessionEventWaiters(id) this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent) - if ( - cleanEvent.kind === ExtendedKind.PUBLICATION || - cleanEvent.kind === ExtendedKind.PUBLICATION_CONTENT - ) { - // Keep publication replaceables durable for profile/publication builder cache hits. + if (isReplaceableEvent(cleanEvent.kind) && isDocumentRelayKind(cleanEvent.kind)) { + // Long-form (30023), wiki, and publication replaceables — same store as profile “Articles” tab. void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)) const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message) if (q) { - logger.debug('[EventService] Skipped publication IndexedDB persist (storage quota)', { + logger.debug('[EventService] Skipped document replaceable IndexedDB persist (storage quota)', { kind: cleanEvent.kind, eventId: id }) return } - logger.warn('[EventService] Failed to persist publication event to IndexedDB', { + logger.warn('[EventService] Failed to persist document replaceable to IndexedDB', { kind: cleanEvent.kind, eventId: id, errorMessage: err.message, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 3c008b19..3b62beba 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1954,7 +1954,7 @@ class ClientService extends EventTarget { ) const merged: NEvent[] = [] const eventIdSet = new Set() - for (const { urls, filter } of subRequests) { + const shardReads = subRequests.map(async ({ urls, filter }) => { let relays = Array.from(new Set(urls)) if (!navigator.onLine) { relays = relays.filter((url) => isLocalNetworkUrl(url)) @@ -1968,7 +1968,7 @@ class ClientService extends EventTarget { const key = this.generateTimelineKey(relays, filter as Filter) try { const st = await indexedDb.getTimelinePersistedState(key) - if (!st?.refs?.length) continue + if (!st?.refs?.length) return const hexIds = st.refs.map((r) => r[0]) const list = await indexedDb.getArchivedEventsByIds(hexIds) for (const ev of list) { @@ -1988,7 +1988,8 @@ class ClientService extends EventTarget { } catch (err) { logger.debug('[ClientService] Timeline disk snapshot shard read failed', { err }) } - } + }) + await Promise.all(shardReads) merged.sort((a, b) => b.created_at - a.created_at) return merged.slice(0, mergedTimelineLimit) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 5cad992e..caf2b32c 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1371,24 +1371,30 @@ class IndexedDbService { } const kindSet = new Set(allowedKinds) const max = Math.min(Math.max(limit, 1), 500) + /** Cursor order is not chronological; scan enough rows then sort newest-first for the tab. */ + const scanBudget = Math.min(12_000, Math.max(800, max * 40)) + const collectCap = Math.min(2000, Math.max(max * 4, max + 50)) return new Promise((resolve, reject) => { const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) const request = store.openCursor() const results: Event[] = [] + let scanned = 0 request.onsuccess = () => { const cursor = (request as IDBRequest).result - if (!cursor || results.length >= max) { + if (!cursor || scanned >= scanBudget) { transaction.commit() - resolve(results) + results.sort((a, b) => b.created_at - a.created_at) + resolve(results.slice(0, max)) return } + scanned += 1 const item = cursor.value as TValue | undefined if (item?.value) { const event = item.value as Event - if (kindSet.has(event.kind) && event.pubkey?.toLowerCase() === pk) { + if (kindSet.has(event.kind) && event.pubkey?.toLowerCase() === pk && results.length < collectCap) { results.push(event) } } @@ -2906,33 +2912,58 @@ class IndexedDbService { }) } + /** + * Batch-read archive rows by event id. Best-effort: never rejects (avoids hung timelines when one `get` + * fails or the transaction aborts mid-batch); logs the first error only. + */ async getArchivedEventsByIds(ids: string[]): Promise { const uniq = [...new Set(ids.map((x) => x.toLowerCase()))].filter((x) => /^[0-9a-f]{64}$/.test(x)) if (uniq.length === 0) return [] await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] - return new Promise((resolve, reject) => { - const out: Event[] = [] + return new Promise((resolve) => { + const byId = new Map() const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) - let pending = uniq.length - const doneOne = () => { - pending -= 1 - if (pending === 0) { + let remaining = uniq.length + let loggedErr = false + + const finishOne = () => { + remaining -= 1 + if (remaining !== 0) return + try { tx.commit() - resolve(out) + } catch { + /* commit() unsupported or invalid state — transaction may auto-finish */ + } + const ordered: Event[] = [] + for (const id of uniq) { + const ev = byId.get(id) + if (ev) ordered.push(ev) } + resolve(ordered) } + for (const id of uniq) { - const get = store.get(id) - get.onsuccess = () => { - const row = get.result as TArchivedEventRow | undefined - if (row?.value) out.push(row.value) - doneOne() + const req = store.get(id) + req.onsuccess = () => { + try { + const row = req.result as TArchivedEventRow | undefined + if (row?.value) byId.set(id, row.value) + } catch (e) { + if (!loggedErr) { + loggedErr = true + logger.warn('[IndexedDB] getArchivedEventsByIds row read failed', { e }) + } + } + finishOne() } - get.onerror = (e) => { - tx.commit() - reject(idbEventToError(e)) + req.onerror = (ev) => { + if (!loggedErr) { + loggedErr = true + logger.warn('[IndexedDB] getArchivedEventsByIds request failed', { err: idbEventToError(ev) }) + } + finishOne() } } }) @@ -2941,6 +2972,10 @@ class IndexedDbService { /** * Scan {@link StoreNames.EVENT_ARCHIVE} for events authored by `pubkey` (bounded scan). * Used for client-side aggregates (e.g. interaction map) from disk cache without a new relay REQ. + * + * Cursor order is **event id**, not `created_at`. Never stop at `maxMatches` while scanning — that would + * keep the first N random-key hits (often old) and drop brand-new notes. Instead keep a working buffer of + * the newest rows seen so far, trim periodically, then return the top `maxMatches` by time. */ async scanEventArchiveByAuthorPubkey( authorPubkey: string, @@ -2951,20 +2986,24 @@ class IndexedDbService { const kindSet = options.kinds?.length ? new Set(options.kinds) : null const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000) const maxMatches = Math.min(Math.max(options.maxMatches, 1), 2000) + /** When buffer grows this large, sort by time and shrink so we keep the best candidates while scanning. */ + const workingCap = Math.min(4000, Math.max(maxMatches * 10, 240)) + const keepAfterTrim = Math.min(workingCap, Math.max(maxMatches * 3, maxMatches + 40)) await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] return new Promise((resolve, reject) => { - const out: Event[] = [] + const buf: Event[] = [] let scanned = 0 const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) const req = store.openCursor() req.onsuccess = () => { const cursor = req.result as IDBCursorWithValue | null - if (!cursor || scanned >= maxRows || out.length >= maxMatches) { + if (!cursor || scanned >= maxRows) { tx.commit() - resolve(out) + buf.sort((a, b) => b.created_at - a.created_at) + resolve(buf.slice(0, maxMatches)) return } scanned += 1 @@ -2976,7 +3015,11 @@ class IndexedDbService { ev.pubkey?.toLowerCase() === pk && (!kindSet || kindSet.has(ev.kind)) ) { - out.push(ev) + buf.push(ev) + if (buf.length >= workingCap) { + buf.sort((a, b) => b.created_at - a.created_at) + buf.length = keepAfterTrim + } } cursor.continue() }