Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
b8f566bf11
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 10
      src/components/Embedded/EmbeddedNote.tsx
  4. 4
      src/components/NoteList/index.tsx
  5. 2
      src/components/ReplyNoteList/index.tsx
  6. 32
      src/constants.ts
  7. 44
      src/hooks/useFetchProfile.tsx
  8. 2
      src/i18n/locales/de.ts
  9. 2
      src/i18n/locales/en.ts
  10. 22
      src/lib/async-timeout.test.ts
  11. 31
      src/lib/async-timeout.ts
  12. 19
      src/services/client-events.service.ts
  13. 123
      src/services/client-replaceable-events.service.ts
  14. 39
      src/services/client.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.9.0", "version": "23.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.9.0", "version": "23.10.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

10
src/components/Embedded/EmbeddedNote.tsx

@ -211,6 +211,7 @@ function EmbeddedNoteFetched({
showFull: boolean showFull: boolean
allowLiveEmbeds: boolean allowLiveEmbeds: boolean
}) { }) {
const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply() const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -397,8 +398,13 @@ function EmbeddedNoteFetched({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
data-embedded-note-loading data-embedded-note-loading
> >
<EmbeddedNoteSkeleton className="border-0 p-0 shadow-none" /> <p className="text-xs text-muted-foreground mb-2">
<ClientSelect className="w-full mt-3" originalNoteId={noteId.trim() || undefined} /> {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.'
})}
</p>
<ClientSelect className="w-full" originalNoteId={noteId.trim() || undefined} />
</div> </div>
) )
} }

4
src/components/NoteList/index.tsx

@ -193,8 +193,8 @@ type TFeedClientAuthorMode = 'everyone' | 'me' | 'npub'
const FEED_FILTER_KIND_MIN = 0 const FEED_FILTER_KIND_MIN = 0
const FEED_FILTER_KIND_MAX = 40_000 const FEED_FILTER_KIND_MAX = 40_000
/** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */ /** Debounce rapid timeline updates so profile batches do not stack on every streaming EVENT. */
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 const FEED_PROFILE_BATCH_DEBOUNCE_MS = 400
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */ /** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
const FEED_PROFILE_CHUNK = 80 const FEED_PROFILE_CHUNK = 80

2
src/components/ReplyNoteList/index.tsx

@ -88,7 +88,7 @@ function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_TH
return out return out
} }
/** Short debounce so thread / detail headers populate avatars quickly after events arrive. */ /** 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 const THREAD_PROFILE_CHUNK = 80
function partitionZapReceipts(items: NEvent[]) { function partitionZapReceipts(items: NEvent[]) {

32
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). * 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. * 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). */ /** 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. * 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 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 feeds `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. * 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. * 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 users * Public Blossom (BUD) upload bases: presets in post settings and merged after the users

44
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 { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import logger from '@/lib/logger' 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 { function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null {
if (skipCache) return null if (skipCache) return null
const pk = pubkey.toLowerCase() const pk = pubkey.toLowerCase()
@ -375,8 +379,46 @@ export function useFetchProfile(id?: string, skipCache = false) {
initializedPubkeysRef.current.add(extractedPubkey) initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(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 () => { return () => {
pendingCancelled.current = true 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) { if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null processingPubkeyRef.current = null
} }

2
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).", 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?", 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.", 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.", "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", "Try searching author's relays": "Try searching author's relays",
"Searching external relays...": "Searching external relays...", "Searching external relays...": "Searching external relays...",

2
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).", 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.", 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.", 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.", "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", "Try searching author's relays": "Try searching author's relays",
"Searching external relays...": "Searching external relays...", "Searching external relays...": "Searching external relays...",

22
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<number>(() => {}), 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)
}
})
})

31
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<T>(
promise: Promise<T>,
ms: number,
label?: string
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(new PromiseTimeoutError(label ?? `Timed out after ${ms}ms`))
}, ms)
})
])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}

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

@ -1,8 +1,12 @@
import { import {
AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS, AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS,
ExtendedKind, ExtendedKind,
EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS,
EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS,
isDocumentRelayKind, 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' } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
@ -79,6 +83,7 @@ async function buildComprehensiveRelayListForEvents(
relayHints, relayHints,
seenRelays, seenRelays,
containingEventRelays, containingEventRelays,
includeProfileFetchRelays: true,
includeFastReadRelays: true, includeFastReadRelays: true,
includeSearchableRelays: true, includeSearchableRelays: true,
includeLocalRelays: true, includeLocalRelays: true,
@ -522,8 +527,8 @@ export class EventService {
: undefined : undefined
/** User-driven “try everywhere”: wait for EOSE-ish completion so slower relays (e.g. nos.lol) can answer. */ /** 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, { const events = await this.queryService.query(externalRelays, filter, undefined, {
eoseTimeout: 12_000, eoseTimeout: EXTERNAL_RELAY_EVENT_FETCH_EOSE_TIMEOUT_MS,
globalTimeout: 35_000, globalTimeout: EXTERNAL_RELAY_EVENT_FETCH_GLOBAL_TIMEOUT_MS,
immediateReturn: false immediateReturn: false
}) })
@ -1246,8 +1251,8 @@ export class EventService {
const events = await this.queryService.query(relayUrls, filter, undefined, { const events = await this.queryService.query(relayUrls, filter, undefined, {
immediateReturn: useFastSingleHitQuery, immediateReturn: useFastSingleHitQuery,
eoseTimeout: useFastSingleHitQuery ? 2500 : 500, eoseTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS : 500,
globalTimeout: useFastSingleHitQuery ? 20_000 : 10000 globalTimeout: useFastSingleHitQuery ? SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS : 10000
}) })
const event = events const event = events
@ -1293,8 +1298,8 @@ export class EventService {
undefined, undefined,
{ {
immediateReturn: isSingleEventFetch, immediateReturn: isSingleEventFetch,
eoseTimeout: isSingleEventFetch ? 2500 : 500, eoseTimeout: isSingleEventFetch ? SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS : 500,
globalTimeout: isSingleEventFetch ? 20_000 : 10000 globalTimeout: isSingleEventFetch ? SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS : 10000
} }
) )

123
src/services/client-replaceable-events.service.ts

@ -1,10 +1,12 @@
import { import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS,
MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_RELAY_CONNECTIONS,
METADATA_BATCH_AUTHORS_CHUNK, METADATA_BATCH_AUTHORS_CHUNK,
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS,
PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS,
PROFILE_FETCH_RELAY_URLS, PROFILE_FETCH_RELAY_URLS,
READ_ONLY_RELAY_URLS, READ_ONLY_RELAY_URLS,
RECOMMENDED_BLOSSOM_SERVERS RECOMMENDED_BLOSSOM_SERVERS
@ -26,6 +28,7 @@ import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { isPromiseTimeoutError, racePromiseWithTimeout } from '@/lib/async-timeout'
export class ReplaceableEventService { export class ReplaceableEventService {
/** Limits parallel Step 2/3 profile network work (relay list + wide metadata REQ). */ /** 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) const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined)
if (stillMissing.length > 0) { if (stillMissing.length > 0) {
const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany( let newEvents: (NEvent | Error | null | undefined)[]
try {
newEvents = await racePromiseWithTimeout(
this.replaceableEventFromBigRelaysDataloader.loadMany(
stillMissing.map(({ pubkey }) => ({ pubkey, kind })) 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) => { newEvents.forEach((event, idx) => {
if (event && !(event instanceof Error)) { if (event && !(event instanceof Error)) {
const { index } = stillMissing[idx]! const { index } = stillMissing[idx]!
@ -1028,6 +1048,64 @@ export class ReplaceableEventService {
async fetchProfilesForPubkeys(pubkeys: string[]): Promise<TProfile[]> { async fetchProfilesForPubkeys(pubkeys: string[]): Promise<TProfile[]> {
const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64))) const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64)))
if (deduped.length === 0) return [] 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<TProfile[]> {
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<TProfile[]> {
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<TProfile[]> {
let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata) let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata)
const gapIdx: number[] = [] const gapIdx: number[] = []
for (let i = 0; i < deduped.length; i++) { for (let i = 0; i < deduped.length; i++) {
@ -1080,48 +1158,7 @@ export class ReplaceableEventService {
) )
} }
const MAX_METADATA_GAP_FILL_NETWORK = 48 return this.profilesFromMetadataEvents(deduped, events)
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
} }
/** /**

39
src/services/client.service.ts

@ -323,6 +323,8 @@ async function mapPoolWithConcurrency<T, R>(
/** Many features call `fetchRelayLists` in parallel; each timeout used to emit an identical WARN. */ /** Many features call `fetchRelayLists` in parallel; each timeout used to emit an identical WARN. */
let fetchRelayListBudgetWarnLastMs = 0 let fetchRelayListBudgetWarnLastMs = 0
const FETCH_RELAY_LIST_BUDGET_WARN_MIN_INTERVAL_MS = 60_000 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 { class ClientService extends EventTarget {
static instance: ClientService static instance: ClientService
@ -352,6 +354,9 @@ class ClientService extends EventTarget {
private timelinePersistTimers = new Map<string, ReturnType<typeof setTimeout>>() private timelinePersistTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** In-flight {@link fetchRelayList} dedupe: key = viewer pubkey + target pubkey (sanitization depends on viewer). */ /** In-flight {@link fetchRelayList} dedupe: key = viewer pubkey + target pubkey (sanitization depends on viewer). */
private relayListRequestCache = new Map<string, Promise<TRelayList>>() private relayListRequestCache = new Map<string, Promise<TRelayList>>()
/** Dedupe {@link refreshRelayListsFromNetwork} — was firing on every cached lookup and stacking REQs. */
private refreshRelayListsBgInFlight = new Set<string>()
private refreshRelayListsBgLastAtMs = new Map<string, number>()
private userIndex = new FlexSearch.Index({ private userIndex = new FlexSearch.Index({
tokenize: 'forward' tokenize: 'forward'
}) })
@ -4225,19 +4230,47 @@ class ClientService extends EventTarget {
pubkeys: string[], pubkeys: string[],
storedKind10002: (NEvent | null | undefined)[] storedKind10002: (NEvent | null | undefined)[]
): void { ): 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 () => { void (async () => {
try { try {
const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys, missingPubkeys,
kinds.RelayList kinds.RelayList
) )
await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys, missingPubkeys,
ExtendedKind.HTTP_RELAY_LIST ExtendedKind.HTTP_RELAY_LIST
) )
await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedKind10002) await this.fetchCacheRelayEventsFromMultipleSources(
missingPubkeys,
relayEvents,
storedForMissing
)
} catch { } catch {
/* best-effort */ /* best-effort */
} finally {
this.refreshRelayListsBgInFlight.delete(key)
} }
})() })()
} }

Loading…
Cancel
Save