Browse Source

fix mobile

imwald
Silberengel 1 week ago
parent
commit
534781a4c5
  1. 3
      src/components/Library/LibraryPublicationGrid.tsx
  2. 34
      src/components/Note/PublicationIndexBody.tsx
  3. 15
      src/hooks/useProgressivePublicationContent.tsx
  4. 45
      src/lib/library-index-idb-cache.ts
  5. 9
      src/lib/library-publication-index.ts
  6. 116
      src/services/indexed-db.service.ts

3
src/components/Library/LibraryPublicationGrid.tsx

@ -2,6 +2,7 @@ import PublicationCard from '@/components/Note/PublicationCard'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { libraryPublicationGridColumnClass, usePanelMode } from '@/hooks/usePanelMode' import { libraryPublicationGridColumnClass, usePanelMode } from '@/hooks/usePanelMode'
import type { LibraryPublicationEntry } from '@/lib/library-publication-index' import type { LibraryPublicationEntry } from '@/lib/library-publication-index'
import { eventTagAddress } from '@/lib/publication-index'
import { isBooklistNip32Label } from '@/lib/nip32-label' import { isBooklistNip32Label } from '@/lib/nip32-label'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -137,7 +138,7 @@ export default function LibraryPublicationGrid({
<div className={cn('grid gap-4', gridCols)}> <div className={cn('grid gap-4', gridCols)}>
{entries.map((entry) => ( {entries.map((entry) => (
<div <div
key={entry.event.id} key={eventTagAddress(entry.event) ?? entry.event.id}
className={cn( className={cn(
'flex min-w-0 flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden' 'flex min-w-0 flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden'
)} )}

34
src/components/Note/PublicationIndexBody.tsx

@ -3,6 +3,7 @@ import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import NoteOptions from '@/components/NoteOptions' import NoteOptions from '@/components/NoteOptions'
import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, LIBRARY_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, LIBRARY_RELAY_URLS } from '@/constants'
import { useProgressivePublicationContent } from '@/hooks/useProgressivePublicationContent' import { useProgressivePublicationContent } from '@/hooks/useProgressivePublicationContent'
import { useNearViewport } from '@/hooks/useNearViewport'
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler'
import { publicationRefKey } from '@/lib/publication-section-fetch' import { publicationRefKey } from '@/lib/publication-section-fetch'
import { import {
@ -100,26 +101,13 @@ function PublicationSectionNodeView({
const isMissing = Boolean(refKey && failedKeys.has(refKey)) const isMissing = Boolean(refKey && failedKeys.has(refKey))
const isLoading = Boolean(refKey && loadingKeys.has(refKey)) const isLoading = Boolean(refKey && loadingKeys.has(refKey))
const needsLoad = Boolean(refKey && !node.event && !isMissing && !isLoading) const needsLoad = Boolean(refKey && !node.event && !isMissing && !isLoading)
const isNear = useNearViewport(sectionElRef, { enabled: needsLoad, marginPx: 480 })
useEffect(() => { useEffect(() => {
if (!needsLoad) return if (!needsLoad || !isNear) return
const el = sectionElRef.current onRequestLoad(node.ref, node.indexEvent)
if (!el) return onReadAhead()
}, [needsLoad, isNear, node.ref, node.indexEvent, onRequestLoad, onReadAhead])
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])
return ( return (
<section <section
@ -179,9 +167,13 @@ function PublicationTableOfContents({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const scrollToSection = useCallback((id: string) => { const scrollToSection = useCallback(
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) (id: string) => {
}, []) if (!readingStarted) return
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
[readingStarted]
)
if (entries.length === 0) return null if (entries.length === 0) return null

15
src/hooks/useProgressivePublicationContent.tsx

@ -1,3 +1,4 @@
import { isMobileBrowserProfile } from '@/lib/client-platform'
import { indexPublicationEvents } from '@/lib/publication-asciidoc-assembler' import { indexPublicationEvents } from '@/lib/publication-asciidoc-assembler'
import { import {
collectPendingPublicationSectionLoads, collectPendingPublicationSectionLoads,
@ -8,8 +9,11 @@ import { publicationRefKey, type PublicationSectionRef } from '@/lib/publication
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
const INITIAL_PREFETCH_COUNT = 3 const READ_AHEAD_COUNT = 2
const READ_AHEAD_COUNT = 1
function initialPrefetchCount(): number {
return isMobileBrowserProfile() ? 8 : 5
}
export function useProgressivePublicationContent( export function useProgressivePublicationContent(
rootIndex: Event, rootIndex: Event,
@ -113,7 +117,7 @@ export function useProgressivePublicationContent(
failedRef.current, failedRef.current,
inFlightRef.current inFlightRef.current
) )
for (const task of pending.slice(0, INITIAL_PREFETCH_COUNT)) { for (const task of pending.slice(0, initialPrefetchCount())) {
if (cancelled) return if (cancelled) return
await loadSection(task.ref, task.indexEvent) await loadSection(task.ref, task.indexEvent)
} }
@ -134,6 +138,11 @@ export function useProgressivePublicationContent(
prefetchTasks(pending.slice(0, READ_AHEAD_COUNT)) prefetchTasks(pending.slice(0, READ_AHEAD_COUNT))
}, [prefetchTasks, rootIndex]) }, [prefetchTasks, rootIndex])
useEffect(() => {
if (!enabled) return
readAhead()
}, [enabled, fetched, failedKeys, readAhead])
return { return {
fetched, fetched,
failedKeys, failedKeys,

45
src/lib/library-index-idb-cache.ts

@ -1,4 +1,3 @@
import { ExtendedKind } from '@/constants'
import { import {
approxLibraryIndexEventBytes, approxLibraryIndexEventBytes,
getLibraryIndexCacheBudget getLibraryIndexCacheBudget
@ -12,6 +11,13 @@ import {
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' 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<void> = Promise.resolve()
export async function loadLibraryIndexCacheEvents(): Promise<Event[]> { export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
try { try {
const cached = await indexedDb.getLibraryPublicationIndexCacheEvents() const cached = await indexedDb.getLibraryPublicationIndexCacheEvents()
@ -22,8 +28,9 @@ export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
if (structural.length < cached.length) { if (structural.length < cached.length) {
void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {}) void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {})
} }
if (normalized.length !== cached.length) { const hasLegacyKeys = await indexedDb.libraryPublicationIndexCacheHasLegacyKeys()
void persistLibraryIndexCacheEvents(normalized).catch(() => {}) if (normalized.length !== cached.length || hasLegacyKeys) {
void persistLibraryIndexCacheEvents(normalized, { reconcile: true }).catch(() => {})
} }
return normalized return normalized
} catch (e) { } catch (e) {
@ -36,21 +43,33 @@ export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
} }
} }
export async function persistLibraryIndexCacheEvents(events: Event[]): Promise<void> { export async function persistLibraryIndexCacheEvents(
events: Event[],
options?: PersistLibraryIndexCacheOptions
): Promise<void> {
const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events)) const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events))
const normalized = publicationIndexMapValues(map) const normalized = publicationIndexMapValues(map)
if (normalized.length === 0) return if (normalized.length === 0) return
try {
const budget = getLibraryIndexCacheBudget() const reconcile = options?.reconcile !== false
await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget) const run = async () => {
await indexedDb.reconcileLibraryPublicationIndexCache(map) try {
} catch (e) { const budget = getLibraryIndexCacheBudget()
if (import.meta.env.DEV) { await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget)
logger.warn('[Library] index IDB write failed', { if (reconcile) {
message: e instanceof Error ? e.message : String(e) 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 }> { export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {

9
src/lib/library-publication-index.ts

@ -631,7 +631,7 @@ export async function fetchLibraryIndexEvents(
) )
indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, firstPageNetwork) indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, firstPageNetwork)
validMerged = publicationIndexMapValues(indexMap) validMerged = publicationIndexMapValues(indexMap)
void persistLibraryIndexCacheEvents(validMerged) void persistLibraryIndexCacheEvents(validMerged, { reconcile: false })
emitProgress() emitProgress()
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -1901,13 +1901,12 @@ export async function searchLibraryPublicationsOnRelays(
const settled = await Promise.all(batches) const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat()) const networkEvents = dedupeEventsById(settled.flat())
const valid = filterValidIndexEvents(networkEvents) const valid = filterValidIndexEvents(networkEvents)
if (valid.length > 0) {
void persistLibraryIndexCacheEvents(valid)
}
const mergedIndex = publicationIndexMapValues( const mergedIndex = publicationIndexMapValues(
mergePublicationIndexMaps(buildStructuralPublicationIndexMap(context.indexEvents ?? []), valid) mergePublicationIndexMaps(buildStructuralPublicationIndexMap(context.indexEvents ?? []), valid)
) )
if (valid.length > 0 || mergedIndex.length > 0) {
void persistLibraryIndexCacheEvents(mergedIndex)
}
const indexByAddress = buildIndexByAddress(mergedIndex) const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress) const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT const engagement = context.engagement ?? EMPTY_ENGAGEMENT

116
src/services/indexed-db.service.ts

@ -237,7 +237,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */ /** 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). */ /** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000 const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
@ -261,6 +261,66 @@ type TLibraryPublicationIndexCacheRow = {
approxBytes: number 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<string, TLibraryPublicationIndexCacheRow>()
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). */ /** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */
function ensureMissingObjectStores(db: IDBDatabase): void { function ensureMissingObjectStores(db: IDBDatabase): void {
for (const storeName of Object.values(StoreNames)) { for (const storeName of Object.values(StoreNames)) {
@ -536,6 +596,12 @@ class IndexedDbService {
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false }) 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) ensureMissingObjectStores(db)
} }
} }
@ -3731,6 +3797,45 @@ class IndexedDbService {
return toDelete.length return toDelete.length
} }
/** True when any row is keyed by event id instead of kind:pubkey:d address. */
async libraryPublicationIndexCacheHasLegacyKeys(): Promise<boolean> {
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<Event[]> { async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
await this.initPromise await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return [] if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []
@ -3880,15 +3985,14 @@ class IndexedDbService {
const ev = row?.value const ev = row?.value
const addr = ev ? eventTagAddress(ev) : null const addr = ev ? eventTagAddress(ev) : null
const canon = addr ? canonical.get(addr) : undefined const canon = addr ? canonical.get(addr) : undefined
if ( const invalid =
!ev || !ev ||
ev.kind !== ExtendedKind.PUBLICATION || ev.kind !== ExtendedKind.PUBLICATION ||
!isStructuralPublicationIndex(ev) || !isStructuralPublicationIndex(ev) ||
!addr || !addr ||
rowKey !== addr || rowKey !== addr
!canon || const superseded = Boolean(canon && canon.id !== ev?.id)
canon.id !== ev.id if (invalid || superseded) {
) {
toDelete.push(rowKey) toDelete.push(rowKey)
} }
cursor.continue() cursor.continue()

Loading…
Cancel
Save