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)
}
})()
}