diff --git a/src/components/Library/LibraryPublicationGrid.tsx b/src/components/Library/LibraryPublicationGrid.tsx
index af8cb86e..733b7f99 100644
--- a/src/components/Library/LibraryPublicationGrid.tsx
+++ b/src/components/Library/LibraryPublicationGrid.tsx
@@ -2,6 +2,7 @@ import PublicationCard from '@/components/Note/PublicationCard'
import { Skeleton } from '@/components/ui/skeleton'
import { libraryPublicationGridColumnClass, usePanelMode } from '@/hooks/usePanelMode'
import type { LibraryPublicationEntry } from '@/lib/library-publication-index'
+import { eventTagAddress } from '@/lib/publication-index'
import { isBooklistNip32Label } from '@/lib/nip32-label'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -137,7 +138,7 @@ export default function LibraryPublicationGrid({
{entries.map((entry) => (
{
- if (!needsLoad) return
- const el = sectionElRef.current
- if (!el) return
-
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- if (!entry.isIntersecting) continue
- onRequestLoad(node.ref, node.indexEvent)
- onReadAhead()
- }
- },
- { rootMargin: '720px 0px 480px 0px', threshold: 0 }
- )
-
- observer.observe(el)
- return () => observer.disconnect()
- }, [needsLoad, node.ref, node.indexEvent, onRequestLoad, onReadAhead])
+ if (!needsLoad || !isNear) return
+ onRequestLoad(node.ref, node.indexEvent)
+ onReadAhead()
+ }, [needsLoad, isNear, node.ref, node.indexEvent, onRequestLoad, onReadAhead])
return (
{
- document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
- }, [])
+ const scrollToSection = useCallback(
+ (id: string) => {
+ if (!readingStarted) return
+ document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ },
+ [readingStarted]
+ )
if (entries.length === 0) return null
diff --git a/src/hooks/useProgressivePublicationContent.tsx b/src/hooks/useProgressivePublicationContent.tsx
index 9f4a5bdf..ab9f24c3 100644
--- a/src/hooks/useProgressivePublicationContent.tsx
+++ b/src/hooks/useProgressivePublicationContent.tsx
@@ -1,3 +1,4 @@
+import { isMobileBrowserProfile } from '@/lib/client-platform'
import { indexPublicationEvents } from '@/lib/publication-asciidoc-assembler'
import {
collectPendingPublicationSectionLoads,
@@ -8,8 +9,11 @@ import { publicationRefKey, type PublicationSectionRef } from '@/lib/publication
import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
-const INITIAL_PREFETCH_COUNT = 3
-const READ_AHEAD_COUNT = 1
+const READ_AHEAD_COUNT = 2
+
+function initialPrefetchCount(): number {
+ return isMobileBrowserProfile() ? 8 : 5
+}
export function useProgressivePublicationContent(
rootIndex: Event,
@@ -113,7 +117,7 @@ export function useProgressivePublicationContent(
failedRef.current,
inFlightRef.current
)
- for (const task of pending.slice(0, INITIAL_PREFETCH_COUNT)) {
+ for (const task of pending.slice(0, initialPrefetchCount())) {
if (cancelled) return
await loadSection(task.ref, task.indexEvent)
}
@@ -134,6 +138,11 @@ export function useProgressivePublicationContent(
prefetchTasks(pending.slice(0, READ_AHEAD_COUNT))
}, [prefetchTasks, rootIndex])
+ useEffect(() => {
+ if (!enabled) return
+ readAhead()
+ }, [enabled, fetched, failedKeys, readAhead])
+
return {
fetched,
failedKeys,
diff --git a/src/lib/library-index-idb-cache.ts b/src/lib/library-index-idb-cache.ts
index 44d74a12..7eca27f9 100644
--- a/src/lib/library-index-idb-cache.ts
+++ b/src/lib/library-index-idb-cache.ts
@@ -1,4 +1,3 @@
-import { ExtendedKind } from '@/constants'
import {
approxLibraryIndexEventBytes,
getLibraryIndexCacheBudget
@@ -12,6 +11,13 @@ import {
import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools'
+type PersistLibraryIndexCacheOptions = {
+ /** When false, merge rows only — skip reconcile so partial batches cannot wipe unrelated cache rows. */
+ reconcile?: boolean
+}
+
+let persistQueue: Promise = Promise.resolve()
+
export async function loadLibraryIndexCacheEvents(): Promise {
try {
const cached = await indexedDb.getLibraryPublicationIndexCacheEvents()
@@ -22,8 +28,9 @@ export async function loadLibraryIndexCacheEvents(): Promise {
if (structural.length < cached.length) {
void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {})
}
- if (normalized.length !== cached.length) {
- void persistLibraryIndexCacheEvents(normalized).catch(() => {})
+ const hasLegacyKeys = await indexedDb.libraryPublicationIndexCacheHasLegacyKeys()
+ if (normalized.length !== cached.length || hasLegacyKeys) {
+ void persistLibraryIndexCacheEvents(normalized, { reconcile: true }).catch(() => {})
}
return normalized
} catch (e) {
@@ -36,21 +43,33 @@ export async function loadLibraryIndexCacheEvents(): Promise {
}
}
-export async function persistLibraryIndexCacheEvents(events: Event[]): Promise {
+export async function persistLibraryIndexCacheEvents(
+ events: Event[],
+ options?: PersistLibraryIndexCacheOptions
+): Promise {
const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events))
const normalized = publicationIndexMapValues(map)
if (normalized.length === 0) return
- try {
- const budget = getLibraryIndexCacheBudget()
- await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget)
- await indexedDb.reconcileLibraryPublicationIndexCache(map)
- } catch (e) {
- if (import.meta.env.DEV) {
- logger.warn('[Library] index IDB write failed', {
- message: e instanceof Error ? e.message : String(e)
- })
+
+ const reconcile = options?.reconcile !== false
+ const run = async () => {
+ try {
+ const budget = getLibraryIndexCacheBudget()
+ await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget)
+ if (reconcile) {
+ await indexedDb.reconcileLibraryPublicationIndexCache(map)
+ }
+ } catch (e) {
+ if (import.meta.env.DEV) {
+ logger.warn('[Library] index IDB write failed', {
+ message: e instanceof Error ? e.message : String(e)
+ })
+ }
}
}
+
+ persistQueue = persistQueue.then(run, run)
+ return persistQueue
}
export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts
index 1fabca6b..309e40f3 100644
--- a/src/lib/library-publication-index.ts
+++ b/src/lib/library-publication-index.ts
@@ -631,7 +631,7 @@ export async function fetchLibraryIndexEvents(
)
indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, firstPageNetwork)
validMerged = publicationIndexMapValues(indexMap)
- void persistLibraryIndexCacheEvents(validMerged)
+ void persistLibraryIndexCacheEvents(validMerged, { reconcile: false })
emitProgress()
if (import.meta.env.DEV) {
@@ -1901,13 +1901,12 @@ export async function searchLibraryPublicationsOnRelays(
const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat())
const valid = filterValidIndexEvents(networkEvents)
- if (valid.length > 0) {
- void persistLibraryIndexCacheEvents(valid)
- }
-
const mergedIndex = publicationIndexMapValues(
mergePublicationIndexMaps(buildStructuralPublicationIndexMap(context.indexEvents ?? []), valid)
)
+ if (valid.length > 0 || mergedIndex.length > 0) {
+ void persistLibraryIndexCacheEvents(mergedIndex)
+ }
const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index 507b6f8d..0367ccd1 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -237,7 +237,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet = new Set([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */
-const DB_VERSION = 40
+const DB_VERSION = 41
/** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
@@ -261,6 +261,66 @@ type TLibraryPublicationIndexCacheRow = {
approxBytes: number
}
+function approxLibraryPublicationIndexRowBytes(ev: Event): number {
+ try {
+ return new Blob([JSON.stringify(ev)]).size
+ } catch {
+ return 2048
+ }
+}
+
+/** v41: re-key library index rows from event id to kind:pubkey:d; dedupe by address. */
+function migrateLibraryPublicationIndexCacheToAddressKeys(transaction: IDBTransaction): void {
+ const store = transaction.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX)
+ const rows: TLibraryPublicationIndexCacheRow[] = []
+
+ const readReq = store.openCursor()
+ readReq.onsuccess = () => {
+ const cursor = readReq.result
+ if (cursor) {
+ rows.push(cursor.value as TLibraryPublicationIndexCacheRow)
+ cursor.continue()
+ return
+ }
+
+ const byAddress = new Map()
+ for (const row of rows) {
+ const ev = row?.value
+ if (!ev || ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
+ const addr = eventTagAddress(ev)
+ if (!addr) continue
+
+ const existing = byAddress.get(addr)
+ if (!existing) {
+ byAddress.set(addr, {
+ key: addr,
+ value: ev,
+ addedAt: row.addedAt ?? Date.now(),
+ lastAccessAt: row.lastAccessAt ?? row.addedAt ?? Date.now(),
+ approxBytes: row.approxBytes ?? approxLibraryPublicationIndexRowBytes(ev)
+ })
+ continue
+ }
+
+ const winner = pickNewerPublicationIndexEvent(existing.value, ev)
+ byAddress.set(addr, {
+ key: addr,
+ value: winner,
+ addedAt: Math.min(existing.addedAt, row.addedAt ?? existing.addedAt),
+ lastAccessAt: Math.max(existing.lastAccessAt, row.lastAccessAt ?? row.addedAt ?? 0),
+ approxBytes: approxLibraryPublicationIndexRowBytes(winner)
+ })
+ }
+
+ const clearReq = store.clear()
+ clearReq.onsuccess = () => {
+ for (const row of byAddress.values()) {
+ store.put(row)
+ }
+ }
+ }
+}
+
/** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */
function ensureMissingObjectStores(db: IDBDatabase): void {
for (const storeName of Object.values(StoreNames)) {
@@ -536,6 +596,12 @@ class IndexedDbService {
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
}
}
+ if (event.oldVersion < 41) {
+ const tx = (event.target as IDBOpenDBRequest).transaction
+ if (tx && db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
+ migrateLibraryPublicationIndexCacheToAddressKeys(tx)
+ }
+ }
ensureMissingObjectStores(db)
}
}
@@ -3731,6 +3797,45 @@ class IndexedDbService {
return toDelete.length
}
+ /** True when any row is keyed by event id instead of kind:pubkey:d address. */
+ async libraryPublicationIndexCacheHasLegacyKeys(): Promise {
+ await this.initPromise
+ if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return false
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
+ const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
+ req.onsuccess = () => {
+ const cursor = req.result as IDBCursorWithValue | null
+ if (!cursor) {
+ tx.commit()
+ resolve(false)
+ return
+ }
+ const rowKey = cursor.key as string
+ const row = cursor.value as TLibraryPublicationIndexCacheRow
+ const ev = row?.value
+ const addr = ev ? eventTagAddress(ev) : null
+ if (
+ !ev ||
+ ev.kind !== ExtendedKind.PUBLICATION ||
+ !isStructuralPublicationIndex(ev) ||
+ !addr ||
+ rowKey !== addr
+ ) {
+ tx.commit()
+ resolve(true)
+ return
+ }
+ cursor.continue()
+ }
+ req.onerror = (e) => {
+ tx.commit()
+ reject(idbEventToError(e))
+ }
+ })
+ }
+
async getLibraryPublicationIndexCacheEvents(): Promise {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []
@@ -3880,15 +3985,14 @@ class IndexedDbService {
const ev = row?.value
const addr = ev ? eventTagAddress(ev) : null
const canon = addr ? canonical.get(addr) : undefined
- if (
+ const invalid =
!ev ||
ev.kind !== ExtendedKind.PUBLICATION ||
!isStructuralPublicationIndex(ev) ||
!addr ||
- rowKey !== addr ||
- !canon ||
- canon.id !== ev.id
- ) {
+ rowKey !== addr
+ const superseded = Boolean(canon && canon.id !== ev?.id)
+ if (invalid || superseded) {
toDelete.push(rowKey)
}
cursor.continue()