Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
3e4f5a1142
  1. 151
      src/hooks/useProfileTimeline.tsx
  2. 13
      src/services/client-events.service.ts
  3. 7
      src/services/client.service.ts
  4. 87
      src/services/indexed-db.service.ts

151
src/hooks/useProfileTimeline.tsx

@ -1,7 +1,7 @@
import { useDeletedEvent } from '@/providers/DeletedEventProvider' 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 { 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 { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
@ -223,36 +223,52 @@ export function useProfileTimeline({
if (mem?.events?.length) { if (mem?.events?.length) {
mem.events.forEach((e) => pool.set(e.id, e)) mem.events.forEach((e) => pool.set(e.id, e))
setEvents(mem.events) setEvents(mem.events)
setIsLoading(true)
} else { } else {
try { try {
const pk = normalizeHexPubkey(pubkey) const pk = normalizeHexPubkey(pubkey)
const primeKinds = new Set(kinds)
for (const e of latestEventsRef.current) { for (const e of latestEventsRef.current) {
if (!primeKinds.has(e.kind)) continue
if (normalizeHexPubkey(e.pubkey) === pk) pool.set(e.id, e) if (normalizeHexPubkey(e.pubkey) === pk) pool.set(e.id, e)
} }
if (!cancelled && pool.size > 0) flushPool()
} catch { } catch {
/* ignore malformed pubkeys */ /* ignore malformed pubkeys */
} }
} }
setIsLoading(true)
} }
const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k))
const socialKinds = kinds.some(isSocialKindBlockedKind) const socialKinds = kinds.some(isSocialKindBlockedKind)
const emptyAuthor = { read: [] as string[], write: [] as string[] } const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] }
const provisionalFeedUrls = buildProfilePageReadRelayUrls(
favoriteRelays,
blockedRelays,
emptyAuthor,
socialKinds,
includeAuthorLocalRelays,
kinds
)
const idbDocKinds = kinds.filter((k) => isDocumentRelayKind(k)) 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 peoples kind-1 notes; we previously only prefetched relays for document tabs.
*/
let prefetchedAuthorRelays: typeof emptyAuthor = emptyAuthor
if (idbDocKinds.length > 0) { if (idbDocKinds.length > 0) {
try { try {
const pkNorm = normalizeHexPubkey(pubkey) 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.getCachedPublicationStoreEventsForProfileAuthor(pkNorm, idbDocKinds, limit),
indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, {
kinds: idbDocKinds, kinds: idbDocKinds,
@ -261,18 +277,73 @@ export function useProfileTimeline({
}) })
]) ])
if (!cancelled) { if (!cancelled) {
prefetchedAuthorRelays = authorRl
for (const e of fromPubStore) { for (const e of fromPubStore) {
pool.set(e.id, e) pool.set(e.id, e)
} }
for (const e of fromArchive) { for (const e of fromArchive) {
pool.set(e.id, e) 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 { } 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<typeof buildSubRequests>) => { const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => {
if (cancelled || subRequests.length === 0) return if (cancelled || subRequests.length === 0) return
@ -308,29 +379,57 @@ export function useProfileTimeline({
const provisionalSubs = buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds) const provisionalSubs = buildSubRequests([provisionalFeedUrls], pubkey, kinds, limit, hasCalendarKinds)
void (async () => { void (async () => {
let pkForReq = pubkey
try {
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 { try {
const disk = await client.getTimelineDiskSnapshotEvents( const [disk, longFormRows] = await Promise.all([
client.getTimelineDiskSnapshotEvents(
provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }>
) ),
longFormPrefetch
])
if (!cancelled && disk.length > 0) { if (!cancelled && disk.length > 0) {
for (const e of disk) { for (const e of disk) {
pool.set(e.id, e) pool.set(e.id, e)
} }
flushPool() flushPool()
} }
if (!cancelled && longFormRows.length > 0) {
for (const e of longFormRows) {
pool.set(e.id, e)
}
flushPool()
}
} catch { } catch {
/* disk snapshot is best-effort */ /* disk snapshot is best-effort */
} }
try {
await startWave(provisionalSubs) 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 () => { void (async () => {
const authorRl = await client.fetchRelayList(pubkey).catch(() => ({ const authorRl = prefetchedAuthorRelays
read: [] as string[],
write: [] as string[],
httpRead: [] as string[],
httpWrite: [] as string[]
}))
if (cancelled) return if (cancelled) return
const fullFeedUrls = buildProfilePageReadRelayUrls( const fullFeedUrls = buildProfilePageReadRelayUrls(
favoriteRelays, favoriteRelays,
@ -356,7 +455,11 @@ export function useProfileTimeline({
} catch { } catch {
/* optional */ /* optional */
} }
try {
await startWave(deltaSubs) await startWave(deltaSubs)
} finally {
if (!cancelled) flushPool()
}
})() })()
} }

13
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 logger from '@/lib/logger'
import { import {
getParentATag, getParentATag,
@ -525,22 +525,19 @@ export class EventService {
this.notifySessionEventWaiters(id) this.notifySessionEventWaiters(id)
this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent)
if ( if (isReplaceableEvent(cleanEvent.kind) && isDocumentRelayKind(cleanEvent.kind)) {
cleanEvent.kind === ExtendedKind.PUBLICATION || // Long-form (30023), wiki, and publication replaceables — same store as profile “Articles” tab.
cleanEvent.kind === ExtendedKind.PUBLICATION_CONTENT
) {
// Keep publication replaceables durable for profile/publication builder cache hits.
void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => { void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error)) const err = error instanceof Error ? error : new Error(String(error))
const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message) const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message)
if (q) { if (q) {
logger.debug('[EventService] Skipped publication IndexedDB persist (storage quota)', { logger.debug('[EventService] Skipped document replaceable IndexedDB persist (storage quota)', {
kind: cleanEvent.kind, kind: cleanEvent.kind,
eventId: id eventId: id
}) })
return return
} }
logger.warn('[EventService] Failed to persist publication event to IndexedDB', { logger.warn('[EventService] Failed to persist document replaceable to IndexedDB', {
kind: cleanEvent.kind, kind: cleanEvent.kind,
eventId: id, eventId: id,
errorMessage: err.message, errorMessage: err.message,

7
src/services/client.service.ts

@ -1954,7 +1954,7 @@ class ClientService extends EventTarget {
) )
const merged: NEvent[] = [] const merged: NEvent[] = []
const eventIdSet = new Set<string>() const eventIdSet = new Set<string>()
for (const { urls, filter } of subRequests) { const shardReads = subRequests.map(async ({ urls, filter }) => {
let relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
if (!navigator.onLine) { if (!navigator.onLine) {
relays = relays.filter((url) => isLocalNetworkUrl(url)) relays = relays.filter((url) => isLocalNetworkUrl(url))
@ -1968,7 +1968,7 @@ class ClientService extends EventTarget {
const key = this.generateTimelineKey(relays, filter as Filter) const key = this.generateTimelineKey(relays, filter as Filter)
try { try {
const st = await indexedDb.getTimelinePersistedState(key) 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 hexIds = st.refs.map((r) => r[0])
const list = await indexedDb.getArchivedEventsByIds(hexIds) const list = await indexedDb.getArchivedEventsByIds(hexIds)
for (const ev of list) { for (const ev of list) {
@ -1988,7 +1988,8 @@ class ClientService extends EventTarget {
} catch (err) { } catch (err) {
logger.debug('[ClientService] Timeline disk snapshot shard read failed', { 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) merged.sort((a, b) => b.created_at - a.created_at)
return merged.slice(0, mergedTimelineLimit) return merged.slice(0, mergedTimelineLimit)
} }

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

@ -1371,24 +1371,30 @@ class IndexedDbService {
} }
const kindSet = new Set(allowedKinds) const kindSet = new Set(allowedKinds)
const max = Math.min(Math.max(limit, 1), 500) 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) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const request = store.openCursor() const request = store.openCursor()
const results: Event[] = [] const results: Event[] = []
let scanned = 0
request.onsuccess = () => { request.onsuccess = () => {
const cursor = (request as IDBRequest<IDBCursorWithValue>).result const cursor = (request as IDBRequest<IDBCursorWithValue>).result
if (!cursor || results.length >= max) { if (!cursor || scanned >= scanBudget) {
transaction.commit() transaction.commit()
resolve(results) results.sort((a, b) => b.created_at - a.created_at)
resolve(results.slice(0, max))
return return
} }
scanned += 1
const item = cursor.value as TValue<Event> | undefined const item = cursor.value as TValue<Event> | undefined
if (item?.value) { if (item?.value) {
const event = item.value as Event 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) 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<Event[]> { async getArchivedEventsByIds(ids: string[]): Promise<Event[]> {
const uniq = [...new Set(ids.map((x) => x.toLowerCase()))].filter((x) => /^[0-9a-f]{64}$/.test(x)) const uniq = [...new Set(ids.map((x) => x.toLowerCase()))].filter((x) => /^[0-9a-f]{64}$/.test(x))
if (uniq.length === 0) return [] if (uniq.length === 0) return []
await this.initPromise await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const out: Event[] = [] const byId = new Map<string, Event>()
const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) const store = tx.objectStore(StoreNames.EVENT_ARCHIVE)
let pending = uniq.length let remaining = uniq.length
const doneOne = () => { let loggedErr = false
pending -= 1
if (pending === 0) { const finishOne = () => {
remaining -= 1
if (remaining !== 0) return
try {
tx.commit() 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) { for (const id of uniq) {
const get = store.get(id) const req = store.get(id)
get.onsuccess = () => { req.onsuccess = () => {
const row = get.result as TArchivedEventRow | undefined try {
if (row?.value) out.push(row.value) const row = req.result as TArchivedEventRow | undefined
doneOne() if (row?.value) byId.set(id, row.value)
} catch (e) {
if (!loggedErr) {
loggedErr = true
logger.warn('[IndexedDB] getArchivedEventsByIds row read failed', { e })
} }
get.onerror = (e) => { }
tx.commit() finishOne()
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). * 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. * 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( async scanEventArchiveByAuthorPubkey(
authorPubkey: string, authorPubkey: string,
@ -2951,20 +2986,24 @@ class IndexedDbService {
const kindSet = options.kinds?.length ? new Set(options.kinds) : null const kindSet = options.kinds?.length ? new Set(options.kinds) : null
const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000) const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000)
const maxMatches = Math.min(Math.max(options.maxMatches, 1), 2000) 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 await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const out: Event[] = [] const buf: Event[] = []
let scanned = 0 let scanned = 0
const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) const store = tx.objectStore(StoreNames.EVENT_ARCHIVE)
const req = store.openCursor() const req = store.openCursor()
req.onsuccess = () => { req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null const cursor = req.result as IDBCursorWithValue | null
if (!cursor || scanned >= maxRows || out.length >= maxMatches) { if (!cursor || scanned >= maxRows) {
tx.commit() tx.commit()
resolve(out) buf.sort((a, b) => b.created_at - a.created_at)
resolve(buf.slice(0, maxMatches))
return return
} }
scanned += 1 scanned += 1
@ -2976,7 +3015,11 @@ class IndexedDbService {
ev.pubkey?.toLowerCase() === pk && ev.pubkey?.toLowerCase() === pk &&
(!kindSet || kindSet.has(ev.kind)) (!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() cursor.continue()
} }

Loading…
Cancel
Save