From e2bece4645536b1ed188089c6f35da2feae26628 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 14:10:26 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 150 +++++++++++++++++- .../primary/SpellsPage/fauxSpellFeeds.ts | 53 ++++++- .../primary/SpellsPage/useSpellsPageFeed.ts | 21 +-- src/services/indexed-db.service.ts | 55 +++++++ 4 files changed, 267 insertions(+), 12 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 5a7283a6..9b91c1b3 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -551,6 +551,39 @@ function eventMatchesSubRequestFilter(event: Event, filter: Filter): boolean { return true } +/** Same as {@link eventMatchesSubRequestFilter} plus `since` / `until` / substring `search` (local warm-up only). */ +function eventMatchesSubRequestFilterWithWindow(event: Event, filter: Filter): boolean { + if (typeof filter.since === 'number' && event.created_at < filter.since) return false + if (typeof filter.until === 'number' && event.created_at > filter.until) return false + const searchRaw = typeof filter.search === 'string' ? filter.search.trim() : '' + if (searchRaw.length > 0) { + const needle = searchRaw.toLowerCase() + const hay = `${event.content ?? ''} ${(event.tags ?? []).flat().join(' ')}`.toLowerCase() + if (!hay.includes(needle)) return false + } + return eventMatchesSubRequestFilter(event, filter) +} + +function unionKindsForSpellLocalWarmup( + shardFilters: Filter[], + fallbackKinds: readonly number[] +): number[] { + const kindUnion = new Set() + for (const f of shardFilters) { + const kk = Array.isArray(f.kinds) ? f.kinds : [] + for (const k of kk) kindUnion.add(k as number) + } + if (kindUnion.size > 0) return Array.from(kindUnion).sort((a, b) => a - b) + return fallbackKinds.length > 0 ? [...fallbackKinds] : [kinds.ShortTextNote] +} + +function tightestSinceFromSpellFilters(shardFilters: Filter[]): number | undefined { + const sinceCandidates = shardFilters + .map((f) => (typeof f.since === 'number' ? f.since : undefined)) + .filter((n): n is number => n !== undefined) + return sinceCandidates.length > 0 ? Math.max(...sinceCandidates) : undefined +} + const NoteList = forwardRef( ( { @@ -1935,7 +1968,114 @@ const NoteList = forwardRef( setLoading(!!oneShotFetch) } else { let primedFromDisk = false - if (!oneShotFetch && mappedSubRequests.length > 0) { + let spellLocalMergeBase: Event[] = [] + const isSpellPageLocalWarmup = + hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0 + + if (isSpellPageLocalWarmup) { + const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) + const matchesSpellLocal = (ev: Event) => + shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f)) + const kindsForScan = unionKindsForSpellLocalWarmup( + shardFilters, + effectiveShowKindsRef.current + ) + const sinceTightest = tightestSinceFromSpellFilters(shardFilters) + const localLayerCap = Math.min( + FEED_FULL_SEARCH_MERGE_CAP, + Math.max(eventCapEarly, 200) + ) + const sessionScanCap = Math.min(800, localLayerCap * 4) + + const sessionHits = client + .getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan) + .filter(matchesSpellLocal) + .sort((a, b) => b.created_at - a.created_at) + + if (!timelineEffectStale() && sessionHits.length > 0) { + const narrowedS = narrowLiveBatch(sessionHits) + if (narrowedS.length > 0) { + const mergedS = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) + ) + if (mergedS.length > 0) { + spellLocalMergeBase = mergedS + setEvents(mergedS) + lastEventsForTimelinePrefetchRef.current = mergedS + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'spell_local_session', + mergedCount: mergedS.length + } + primedFromDisk = true + } + } + } + + try { + const [diskRaw, fromPub, fromArch] = await Promise.all([ + client.getTimelineDiskSnapshotEvents( + mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + ), + indexedDb.getCachedPublicationEventsByKinds(localLayerCap * 2, kindsForScan), + indexedDb.scanEventArchiveByKinds({ + kinds: kindsForScan, + since: sinceTightest, + maxRowsScanned: 10_000, + maxMatches: localLayerCap * 2 + }) + ]) + if (!timelineEffectStale()) { + const seen = new Set() + const combinedRaw: Event[] = [] + for (const ev of diskRaw) { + if (seen.has(ev.id)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of fromPub) { + if (seen.has(ev.id)) continue + if (!matchesSpellLocal(ev)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of fromArch) { + if (seen.has(ev.id)) continue + if (!matchesSpellLocal(ev)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + combinedRaw.sort((a, b) => b.created_at - a.created_at) + if (combinedRaw.length > 0) { + const diskNarrowed = narrowLiveBatch(combinedRaw) + if (diskNarrowed.length > 0) { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: + spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot', + mergedCount: merged.length + } + primedFromDisk = true + } + } + } + } + } catch { + /* spell local + disk snapshot is best-effort */ + } + } else if (!oneShotFetch && mappedSubRequests.length > 0) { try { const diskRaw = await client.getTimelineDiskSnapshotEvents( mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> @@ -2569,7 +2709,8 @@ const NoteList = forwardRef( withKindFilter, onSingleRelayKindlessEmpty, mapLiveSubRequestsForTimeline, - progressiveWarmupQuery + progressiveWarmupQuery, + hostPrimaryPageName ]) useEffect(() => { @@ -2882,6 +3023,8 @@ const NoteList = forwardRef( const hasMoreRef = useRef(hasMore) const timelineKeyRef = useRef(timelineKey) const blankFeedHiddenAtRef = useRef(null) + /** Avoid subscribe storms when the tab stays empty (dead relays): visibility resume used to call `refresh()` every few seconds. */ + const blankFeedVisibilityResumeRetryAtRef = useRef(0) useEffect(() => { showCountRef.current = showCount @@ -2981,6 +3124,9 @@ const NoteList = forwardRef( if (loadingRef.current) return if (eventsRef.current.length > 0) return if (!subRequestsRef.current.length) return + const now = Date.now() + if (now - blankFeedVisibilityResumeRetryAtRef.current < 45_000) return + blankFeedVisibilityResumeRetryAtRef.current = now logger.info('[NoteList] Blank feed — auto-retry after tab resume', { hiddenMs }) refresh() } diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 12ee1d0e..2c10d23d 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -12,14 +12,16 @@ import { DEFAULT_FEED_SHOW_KINDS, ExtendedKind, + FAST_READ_RELAY_URLS, PROFILE_MEDIA_TAB_KINDS, READ_ONLY_RELAY_URLS } from '@/constants' import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { normalizeTopic } from '@/lib/discussion-topics' import { userIdToPubkey } from '@/lib/pubkey' -import { normalizeAnyRelayUrl } from '@/lib/url' +import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import type { TFeedSubRequest } from '@/types' import { type Event, type Filter } from 'nostr-tools' @@ -77,6 +79,55 @@ const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4 * ({@link FAUX_SPELL_MAX_RELAYS}); appending read-only at the end dropped mirrors whenever inbox+favorites * filled the cap. */ +/** + * {@link buildPrioritizedReadRelayUrls} merges inbox → favorites → FAST_READ under {@link FAUX_SPELL_MAX_RELAYS}. + * Long NIP-65 read lists can fill the cap before FAST_READ is reached, so every REQ shard was only dead/private + * relays — live faux feeds (media, etc.) stayed empty while the console showed only connection refused. + */ +export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string[] { + const fast = dedupeNormalizeRelayUrlsOrdered( + FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] + ) + const fastNormSet = new Set() + for (const u of fast) { + const n = normalizeAnyRelayUrl(u) || u.trim() + if (n) fastNormSet.add(n) + } + let out = dedupeNormalizeRelayUrlsOrdered(urls) + if (!out.length) return fast.slice(0, FAUX_SPELL_MAX_RELAYS) + + const fastCount = () => + out.reduce((n, u) => { + const k = normalizeAnyRelayUrl(u) || u.trim() + return n + (k && fastNormSet.has(k) ? 1 : 0) + }, 0) + + while (fastCount() < 2) { + let addedOne = false + for (const fr of fast) { + const fn = normalizeAnyRelayUrl(fr) || fr.trim() + if (!fn || out.some((u) => (normalizeAnyRelayUrl(u) || u.trim()) === fn)) continue + while (out.length >= FAUX_SPELL_MAX_RELAYS) { + let dropped = false + for (let i = out.length - 1; i >= 0; i--) { + const kn = normalizeAnyRelayUrl(out[i]!) || out[i]!.trim() + if (kn && !fastNormSet.has(kn)) { + out.splice(i, 1) + dropped = true + break + } + } + if (!dropped) break + } + out.push(fr) + addedOne = true + break + } + if (!addedOne) break + } + return dedupeNormalizeRelayUrlsOrdered(out).slice(0, FAUX_SPELL_MAX_RELAYS) +} + export function appendCuratedReadOnlyRelays(curated: string[], blockedRelays: string[]): string[] { const blocked = new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b)) const seen = new Set() diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 813271da..670409a3 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -33,7 +33,8 @@ import { FAUX_SPELL_EVENT_LIMIT, MEDIA_SPELL_KINDS, NOTIFICATION_SPELL_KINDS, - applyFauxSpellCapsToSubRequests + applyFauxSpellCapsToSubRequests, + ensureFauxSpellRelayStackTouchesFastRead } from './fauxSpellFeeds' import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' import type { TFeedSubRequest } from '@/types' @@ -348,14 +349,16 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { selectedFauxSpell === 'media' || selectedFauxSpell === 'bookmarks' || selectedFauxSpell === 'interests' - const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( - favoriteRelays, - blockedRelays, - userReadRelaysWithHttp(relayList), - { - userWriteRelays: relayList?.write ?? [], - applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined - } + const feedUrls = ensureFauxSpellRelayStackTouchesFastRead( + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { + userWriteRelays: relayList?.write ?? [], + applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined + } + ) ) if (selectedFauxSpell === 'notifications') { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index caf2b32c..b82a6bb7 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1456,6 +1456,61 @@ class IndexedDbService { }) } + /** + * Iterate {@link StoreNames.PUBLICATION_EVENTS} and return up to `limit` events whose kind is in `allowedKinds`, + * newest {@link Event.created_at} first. Used for spell feeds and similar: show cached rows before relay REQ. + */ + async getCachedPublicationEventsByKinds( + limit: number, + allowedKinds: number[], + options?: { scanBudget?: number } + ): Promise { + await this.initPromise + if ( + !this.db || + !this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS) || + allowedKinds.length === 0 || + limit <= 0 + ) { + return [] + } + const kindSet = new Set(allowedKinds) + const scanBudget = Math.min(Math.max(options?.scanBudget ?? 12_000, 200), 50_000) + const collectCap = Math.min(4000, Math.max(limit * 4, limit + 80)) + + 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 || scanned >= scanBudget || results.length >= collectCap) { + transaction.commit() + results.sort((a, b) => b.created_at - a.created_at) + resolve(results.slice(0, limit)) + return + } + scanned += 1 + const item = cursor.value as TValue | undefined + if (item?.value) { + const event = item.value as Event + if (kindSet.has(event.kind)) { + results.push(event) + } + } + cursor.continue() + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + /** * Publication store + hot {@link StoreNames.EVENT_ARCHIVE}: events whose kind is allowed and content or any tag * value matches the query (case-insensitive). Used to show local hits before NIP-50 relay results.