From 534781a4c5ed5340a34b8955b16a86ca0704d7f6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 8 Jun 2026 09:49:09 +0200 Subject: [PATCH] fix mobile --- .../Library/LibraryPublicationGrid.tsx | 3 +- src/components/Note/PublicationIndexBody.tsx | 34 ++--- .../useProgressivePublicationContent.tsx | 15 ++- src/lib/library-index-idb-cache.ts | 45 +++++-- src/lib/library-publication-index.ts | 9 +- src/services/indexed-db.service.ts | 116 +++++++++++++++++- 6 files changed, 173 insertions(+), 49 deletions(-) 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()