From b8f566bf11e8fb8074457cdc2afa723aa917c571 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 15 May 2026 07:45:06 +0200 Subject: [PATCH] bug-fixes --- package-lock.json | 4 +- package.json | 2 +- src/components/Embedded/EmbeddedNote.tsx | 10 +- src/components/NoteList/index.tsx | 4 +- src/components/ReplyNoteList/index.tsx | 2 +- src/constants.ts | 32 ++++- src/hooks/useFetchProfile.tsx | 44 +++++- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/lib/async-timeout.test.ts | 22 +++ src/lib/async-timeout.ts | 31 +++++ src/services/client-events.service.ts | 19 ++- .../client-replaceable-events.service.ts | 127 +++++++++++------- src/services/client.service.ts | 39 +++++- 14 files changed, 273 insertions(+), 67 deletions(-) create mode 100644 src/lib/async-timeout.test.ts create mode 100644 src/lib/async-timeout.ts diff --git a/package-lock.json b/package-lock.json index d6e53e89..9c193b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.9.0", + "version": "23.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.9.0", + "version": "23.10.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index efe63746..4535eb80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.9.0", + "version": "23.10.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index dbc2c9a6..9819ebbf 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -211,6 +211,7 @@ function EmbeddedNoteFetched({ showFull: boolean allowLiveEmbeds: boolean }) { + const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() const { addReplies } = useReply() const { favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -397,8 +398,13 @@ function EmbeddedNoteFetched({ onClick={(e) => e.stopPropagation()} data-embedded-note-loading > - - +

+ {t('embeddedNoteFetchMiss', { + defaultValue: + 'This note is not in local storage and was not returned by the relays we queried. Retries run in the background; you can also open it in another client.' + })} +

+ ) } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 662e8642..10cd0468 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -193,8 +193,8 @@ type TFeedClientAuthorMode = 'everyone' | 'me' | 'npub' const FEED_FILTER_KIND_MIN = 0 const FEED_FILTER_KIND_MAX = 40_000 -/** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */ -const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 +/** Debounce rapid timeline updates so profile batches do not stack on every streaming EVENT. */ +const FEED_PROFILE_BATCH_DEBOUNCE_MS = 400 /** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */ const FEED_PROFILE_CHUNK = 80 diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 8723b3ff..74eee0d1 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -88,7 +88,7 @@ function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_TH return out } /** Short debounce so thread / detail headers populate avatars quickly after events arrive. */ -const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 16 +const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 400 const THREAD_PROFILE_CHUNK = 80 function partitionZapReceipts(items: NEvent[]) { diff --git a/src/constants.ts b/src/constants.ts index d48fe82e..7ed55e51 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -234,19 +234,45 @@ export const ACCOUNT_SESSION_HYDRATE_WALL_MS = 60_000 * Batched kind-0 queries (ReplaceableEventService) over many relays (inbox, favorites, cache, defaults). * Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried. */ -export const METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS = 16000 +export const METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS = 18000 /** After all relays EOSE, wait this long before closing so slow EVENTs still land (slot queue + TLS). */ -export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 2800 +export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 4000 /** * Max `authors` per REQ for batched kind-0; large arrays are split so relays return more complete rows. */ export const METADATA_BATCH_AUTHORS_CHUNK = 22 +/** + * Hard wall on {@link ReplaceableEventService.fetchProfilesForPubkeys} (feed / thread batch avatars). + * On timeout, callers get session/IndexedDB rows plus {@link TProfile.batchPlaceholder} for gaps. + */ +export const FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS = 14_000 + +/** + * After this delay while a pubkey stays in the feed’s `pendingPubkeys` set, {@link useFetchProfile} + * may run a per-pubkey fetch. Must exceed {@link FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS} so we do not + * stack batch + N individual profile REQs on the same refresh. + */ +export const FEED_PROFILE_PENDING_BATCH_ESCAPE_MS = FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS + 4_000 + +/** Network-only cap on {@link ReplaceableEventService.fetchReplaceableEventsFromProfileFetchRelays} `loadMany`. */ +export const PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS = 12_000 + +/** + * Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader). + */ +export const SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS = 5_000 +export const SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS = 28_000 + +/** Wide REQ for embeds / explicit external lists ({@link EventService.fetchEventWithExternalRelays}). */ +export const EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS = 14_000 +export const EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS = 40_000 + /** * useFetchProfile: outer Promise.race on fetchProfileEvent and wait-for-shared-promise timeouts. * Must be greater than {@link METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS} so the batch can finish first. */ -export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 20000 +export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 22_000 /** * Public Blossom (BUD) upload bases: presets in post settings and merged after the user’s diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index c3129c34..a9f27c0e 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -1,4 +1,4 @@ -import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' +import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getProfileFromEvent } from '@/lib/event-metadata' import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' @@ -12,6 +12,10 @@ import { kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import logger from '@/lib/logger' +function feedProfileBatchRetryStaggerMs(pubkeyLower: string): number { + return (parseInt(pubkeyLower.slice(0, 8), 16) % 40) * 400 +} + function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null { if (skipCache) return null const pk = pubkey.toLowerCase() @@ -375,8 +379,46 @@ export function useFetchProfile(id?: string, skipCache = false) { initializedPubkeysRef.current.add(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey) }) + const pendingEscapeTimer = window.setTimeout(() => { + if (pendingCancelled.current) return + const s2 = eventService.getSessionMetadataForPubkey(pkL) + if (s2) { + const q = getProfileFromEvent(s2) + setProfile(q) + setIsFetching(false) + setError(null) + processingPubkeyRef.current = extractedPubkey + initializedPubkeysRef.current.add(extractedPubkey) + effectRunCountRef.current.delete(extractedPubkey) + return + } + void checkProfile(extractedPubkey, pendingCancelled) + }, FEED_PROFILE_PENDING_BATCH_ESCAPE_MS) return () => { pendingCancelled.current = true + window.clearTimeout(pendingEscapeTimer) + if (processingPubkeyRef.current === extractedPubkey) { + processingPubkeyRef.current = null + } + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + checkIntervalRef.current = null + } + if (extractedPubkey) { + effectRunCountRef.current.delete(extractedPubkey) + } + } + } + if (fromBatch?.batchPlaceholder) { + const placeholderCancelled = { current: false } + const staggerMs = feedProfileBatchRetryStaggerMs(pkL) + const placeholderTimer = window.setTimeout(() => { + if (placeholderCancelled.current) return + void checkProfile(extractedPubkey, placeholderCancelled) + }, staggerMs) + return () => { + placeholderCancelled.current = true + window.clearTimeout(placeholderTimer) if (processingPubkeyRef.current === extractedPubkey) { processingPubkeyRef.current = null } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 4826284d..0bc91c48 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -521,6 +521,8 @@ export default { embeddedNoteInvalidHex: "Keine gültige Hex-Event-ID (es werden genau 64 hexadezimale Zeichen erwartet).", embeddedNoteInvalidBech32: "Keine gültige Nostr-ID (Bech32 konnte nicht gelesen werden). Tippfehler oder abgeschnittene Adresse?", embeddedNoteInvalidWrongKind: "Dies ist eine {{type}}-Adresse. Eingebettete Notizen brauchen note1, nevent1, naddr1 oder 64 Zeichen Hex.", + embeddedNoteFetchMiss: + "Diese Notiz liegt nicht lokal vor und kam von den abgefragten Relays nicht zurück. Es wird im Hintergrund erneut versucht; du kannst sie auch in einem anderen Client öffnen.", "The note was not found on your relays or default relays.": "The note was not found on your relays or default relays.", "Try searching author's relays": "Try searching author's relays", "Searching external relays...": "Searching external relays...", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e1a4dd5c..8c096bc9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -528,6 +528,8 @@ export default { embeddedNoteInvalidHex: "This is not a valid hex event id (expected exactly 64 hexadecimal characters).", embeddedNoteInvalidBech32: "This is not a valid Nostr id (bech32 decode failed). It may be mistyped or truncated.", embeddedNoteInvalidWrongKind: "This is a {{type}} id. Embedded notes must use note1, nevent1, naddr1, or 64-character hex.", + embeddedNoteFetchMiss: + "This note is not in local storage and was not returned by the relays we queried. Retries run in the background; you can also open it in another client.", "The note was not found on your relays or default relays.": "The note was not found on your relays or default relays.", "Try searching author's relays": "Try searching author's relays", "Searching external relays...": "Searching external relays...", diff --git a/src/lib/async-timeout.test.ts b/src/lib/async-timeout.test.ts new file mode 100644 index 00000000..c7663fd5 --- /dev/null +++ b/src/lib/async-timeout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { isPromiseTimeoutError, racePromiseWithTimeout } from './async-timeout' + +describe('racePromiseWithTimeout', () => { + it('resolves when promise finishes before deadline', async () => { + await expect(racePromiseWithTimeout(Promise.resolve(42), 500)).resolves.toBe(42) + }) + + it('rejects with PromiseTimeoutError when deadline elapses first', async () => { + await expect( + racePromiseWithTimeout(new Promise(() => {}), 20, 'slow') + ).rejects.toMatchObject({ name: 'PromiseTimeoutError', message: 'slow' }) + }) + + it('isPromiseTimeoutError identifies timeout errors', async () => { + try { + await racePromiseWithTimeout(new Promise(() => {}), 10) + } catch (err) { + expect(isPromiseTimeoutError(err)).toBe(true) + } + }) +}) diff --git a/src/lib/async-timeout.ts b/src/lib/async-timeout.ts new file mode 100644 index 00000000..4f5b05dc --- /dev/null +++ b/src/lib/async-timeout.ts @@ -0,0 +1,31 @@ +export class PromiseTimeoutError extends Error { + constructor(message = 'Promise timed out') { + super(message) + this.name = 'PromiseTimeoutError' + } +} + +export function isPromiseTimeoutError(err: unknown): err is PromiseTimeoutError { + return err instanceof PromiseTimeoutError +} + +/** Rejects with {@link PromiseTimeoutError} when `promise` does not settle within `ms`. */ +export async function racePromiseWithTimeout( + promise: Promise, + ms: number, + label?: string +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new PromiseTimeoutError(label ?? `Timed out after ${ms}ms`)) + }, ms) + }) + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index e2381582..bfd6ce08 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -1,8 +1,12 @@ import { AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS, ExtendedKind, + EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS, + EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS, isDocumentRelayKind, - NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, + SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS, + SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' import logger from '@/lib/logger' import { @@ -79,6 +83,7 @@ async function buildComprehensiveRelayListForEvents( relayHints, seenRelays, containingEventRelays, + includeProfileFetchRelays: true, includeFastReadRelays: true, includeSearchableRelays: true, includeLocalRelays: true, @@ -522,8 +527,8 @@ export class EventService { : undefined /** User-driven “try everywhere”: wait for EOSE-ish completion so slower relays (e.g. nos.lol) can answer. */ const events = await this.queryService.query(externalRelays, filter, undefined, { - eoseTimeout: 12_000, - globalTimeout: 35_000, + eoseTimeout: EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS, + globalTimeout: EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS, immediateReturn: false }) @@ -1246,8 +1251,8 @@ export class EventService { const events = await this.queryService.query(relayUrls, filter, undefined, { immediateReturn: useFastSingleHitQuery, - eoseTimeout: useFastSingleHitQuery ? 2500 : 500, - globalTimeout: useFastSingleHitQuery ? 20_000 : 10000 + eoseTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS : 500, + globalTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS : 10000 }) const event = events @@ -1293,8 +1298,8 @@ export class EventService { undefined, { immediateReturn: isSingleEventFetch, - eoseTimeout: isSingleEventFetch ? 2500 : 500, - globalTimeout: isSingleEventFetch ? 20_000 : 10000 + eoseTimeout: isSingleEventFetch ? SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS : 500, + globalTimeout: isSingleEventFetch ? SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS : 10000 } ) diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 6f93c55d..e90c077a 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -1,10 +1,12 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, + FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, MAX_CONCURRENT_RELAY_CONNECTIONS, METADATA_BATCH_AUTHORS_CHUNK, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, + PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS, PROFILE_FETCH_RELAY_URLS, READ_ONLY_RELAY_URLS, RECOMMENDED_BLOSSOM_SERVERS @@ -26,6 +28,7 @@ import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { isPromiseTimeoutError, racePromiseWithTimeout } from '@/lib/async-timeout' export class ReplaceableEventService { /** Limits parallel Step 2/3 profile network work (relay list + wide metadata REQ). */ @@ -346,9 +349,26 @@ export class ReplaceableEventService { const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined) if (stillMissing.length > 0) { - const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany( - stillMissing.map(({ pubkey }) => ({ pubkey, kind })) - ) + let newEvents: (NEvent | Error | null | undefined)[] + try { + newEvents = await racePromiseWithTimeout( + this.replaceableEventFromBigRelaysDataloader.loadMany( + stillMissing.map(({ pubkey }) => ({ pubkey, kind })) + ), + PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS, + 'replaceableEventFromBigRelaysDataloader.loadMany' + ) + } catch (err) { + if (isPromiseTimeoutError(err)) { + logger.warn('[ReplaceableEventService] Profile batch network load timed out', { + missingCount: stillMissing.length, + kind + }) + newEvents = stillMissing.map(() => undefined) + } else { + throw err + } + } newEvents.forEach((event, idx) => { if (event && !(event instanceof Error)) { const { index } = stillMissing[idx]! @@ -1028,6 +1048,64 @@ export class ReplaceableEventService { async fetchProfilesForPubkeys(pubkeys: string[]): Promise { const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64))) if (deduped.length === 0) return [] + try { + return await racePromiseWithTimeout( + this.fetchProfilesForPubkeysBody(deduped), + FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, + 'fetchProfilesForPubkeys' + ) + } catch (err) { + if (!isPromiseTimeoutError(err)) throw err + logger.warn('[ReplaceableEventService] fetchProfilesForPubkeys exceeded wall timeout', { + pubkeyCount: deduped.length + }) + return this.fetchProfilesForPubkeysLocalFallback(deduped) + } + } + + private async fetchProfilesForPubkeysLocalFallback(pubkeys: string[]): Promise { + const events: (NEvent | undefined)[] = [] + for (const pubkey of pubkeys) { + const pkLower = pubkey.toLowerCase() + let ev: NEvent | undefined = client.eventService.getSessionMetadataForPubkey(pkLower) + if (ev && shouldDropEventOnIngest(ev)) ev = undefined + if (!ev) { + try { + const row = await indexedDb.getReplaceableEvent(pkLower, kinds.Metadata) + if (row && !shouldDropEventOnIngest(row)) ev = row as NEvent + } catch { + /* ignore */ + } + } + events.push(ev) + } + return this.profilesFromMetadataEvents(pubkeys, events) + } + + private async profilesFromMetadataEvents( + pubkeys: string[], + events: (NEvent | undefined)[] + ): Promise { + const profiles: TProfile[] = [] + for (let i = 0; i < pubkeys.length; i++) { + const ev = events[i] + if (ev) { + await this.indexProfile(ev) + profiles.push(getProfileFromEvent(ev)) + } else { + const pubkey = pubkeys[i]! + profiles.push({ + pubkey, + npub: pubkeyToNpub(pubkey) ?? '', + username: formatPubkey(pubkey), + batchPlaceholder: true + }) + } + } + return profiles + } + + private async fetchProfilesForPubkeysBody(deduped: string[]): Promise { let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata) const gapIdx: number[] = [] for (let i = 0; i < deduped.length; i++) { @@ -1080,48 +1158,7 @@ export class ReplaceableEventService { ) } - const MAX_METADATA_GAP_FILL_NETWORK = 48 - const GAP_FILL_NETWORK_PARALLEL = 4 - const stillGap: number[] = [] - for (let i = 0; i < deduped.length; i++) { - if (!events[i]) stillGap.push(i) - } - const cappedNetwork = stillGap.slice(0, MAX_METADATA_GAP_FILL_NETWORK) - for (let off = 0; off < cappedNetwork.length; off += GAP_FILL_NETWORK_PARALLEL) { - const slice = cappedNetwork.slice(off, off + GAP_FILL_NETWORK_PARALLEL) - await Promise.allSettled( - slice.map(async (idx) => { - const pubkey = deduped[idx]! - try { - const ev = await this.fetchProfileEvent(pubkey, false) - if (ev && !shouldDropEventOnIngest(ev)) { - events[idx] = ev - } - } catch { - /* ignore */ - } - }) - ) - } - - const profiles: TProfile[] = [] - for (let i = 0; i < deduped.length; i++) { - const ev = events[i] - if (ev) { - await this.indexProfile(ev) - profiles.push(getProfileFromEvent(ev)) - } else { - const pubkey = deduped[i]! - profiles.push({ - pubkey, - npub: pubkeyToNpub(pubkey) ?? '', - username: formatPubkey(pubkey), - /** Lets {@link useFetchProfile} retry per-pubkey when batch REQ missed kind 0. */ - batchPlaceholder: true - }) - } - } - return profiles + return this.profilesFromMetadataEvents(deduped, events) } /** diff --git a/src/services/client.service.ts b/src/services/client.service.ts index ab6b9b8e..0a29e616 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -323,6 +323,8 @@ async function mapPoolWithConcurrency( /** Many features call `fetchRelayLists` in parallel; each timeout used to emit an identical WARN. */ let fetchRelayListBudgetWarnLastMs = 0 const FETCH_RELAY_LIST_BUDGET_WARN_MIN_INTERVAL_MS = 60_000 +/** Background kind-10002 refresh per pubkey set — avoids re-REQ on every embed/publish lookup. */ +const REFRESH_RELAY_LIST_BG_MIN_INTERVAL_MS = 5 * 60_000 class ClientService extends EventTarget { static instance: ClientService @@ -352,6 +354,9 @@ class ClientService extends EventTarget { private timelinePersistTimers = new Map>() /** In-flight {@link fetchRelayList} dedupe: key = viewer pubkey + target pubkey (sanitization depends on viewer). */ private relayListRequestCache = new Map>() + /** Dedupe {@link refreshRelayListsFromNetwork} — was firing on every cached lookup and stacking REQs. */ + private refreshRelayListsBgInFlight = new Set() + private refreshRelayListsBgLastAtMs = new Map() private userIndex = new FlexSearch.Index({ tokenize: 'forward' }) @@ -4225,19 +4230,47 @@ class ClientService extends EventTarget { pubkeys: string[], storedKind10002: (NEvent | null | undefined)[] ): void { + const missingPubkeys: string[] = [] + const storedForMissing: (NEvent | null | undefined)[] = [] + for (let i = 0; i < pubkeys.length; i++) { + if (storedKind10002[i] != null) continue + missingPubkeys.push(pubkeys[i]!) + storedForMissing.push(storedKind10002[i]) + } + if (missingPubkeys.length === 0) return + + const key = missingPubkeys + .map((pk) => pk.toLowerCase()) + .sort() + .join('\x1e') + const now = Date.now() + if (now - (this.refreshRelayListsBgLastAtMs.get(key) ?? 0) < REFRESH_RELAY_LIST_BG_MIN_INTERVAL_MS) { + return + } + if (this.refreshRelayListsBgInFlight.has(key)) return + + this.refreshRelayListsBgInFlight.add(key) + this.refreshRelayListsBgLastAtMs.set(key, now) + void (async () => { try { const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - pubkeys, + missingPubkeys, kinds.RelayList ) await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - pubkeys, + missingPubkeys, ExtendedKind.HTTP_RELAY_LIST ) - await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedKind10002) + await this.fetchCacheRelayEventsFromMultipleSources( + missingPubkeys, + relayEvents, + storedForMissing + ) } catch { /* best-effort */ + } finally { + this.refreshRelayListsBgInFlight.delete(key) } })() }