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

13
src/services/client-events.service.ts

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

7
src/services/client.service.ts

@ -1954,7 +1954,7 @@ class ClientService extends EventTarget { @@ -1954,7 +1954,7 @@ class ClientService extends EventTarget {
)
const merged: NEvent[] = []
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))
if (!navigator.onLine) {
relays = relays.filter((url) => isLocalNetworkUrl(url))
@ -1968,7 +1968,7 @@ class ClientService extends EventTarget { @@ -1968,7 +1968,7 @@ class ClientService extends EventTarget {
const key = this.generateTimelineKey(relays, filter as Filter)
try {
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 list = await indexedDb.getArchivedEventsByIds(hexIds)
for (const ev of list) {
@ -1988,7 +1988,8 @@ class ClientService extends EventTarget { @@ -1988,7 +1988,8 @@ class ClientService extends EventTarget {
} catch (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)
return merged.slice(0, mergedTimelineLimit)
}

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

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

Loading…
Cancel
Save