diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 1693e726..08fe4694 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -35,7 +35,13 @@ import { useTranslation } from 'react-i18next' import Emoji from '../Emoji' import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' import { formatCount } from './utils' -import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { + type RelayStatus, + showPublishingError, + showPublishingFeedback, + showSimplePublishSuccess +} from '@/lib/publishing-feedback' +import { LoginRequiredError } from '@/lib/nostr-errors' import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed' export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { @@ -177,7 +183,29 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; } } } catch (error) { + if (error instanceof LoginRequiredError) { + return + } logger.error('Like failed', { error, eventId: event.id }) + if (error instanceof AggregateError && (error as AggregateError & { relayStatuses?: RelayStatus[] }).relayStatuses) { + const relayStatuses = (error as AggregateError & { relayStatuses: RelayStatus[] }).relayStatuses + const successCount = relayStatuses.filter((s) => s.success).length + showPublishingFeedback( + { + success: successCount > 0, + relayStatuses, + successCount, + totalCount: relayStatuses.length + }, + { + message: + successCount > 0 ? t('Reaction published to some relays') : t('Failed to publish reaction'), + duration: 6000 + } + ) + } else { + showPublishingError(error instanceof Error ? error.message : t('Failed to publish reaction')) + } } finally { setLiking(false) clearTimeout(timer) diff --git a/src/components/VideoPlayer/index.tsx b/src/components/VideoPlayer/index.tsx index 873525f5..0c4e99bc 100644 --- a/src/components/VideoPlayer/index.tsx +++ b/src/components/VideoPlayer/index.tsx @@ -1,6 +1,6 @@ import { isImwaldElectron } from '@/lib/client-platform' import { isHlsPlaylistUrl } from '@/lib/url' -import { cn, isInViewport } from '@/lib/utils' +import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import mediaManager from '@/services/media-manager.service' import Hls from 'hls.js' @@ -97,24 +97,52 @@ export default function VideoPlayer({ if (!video || !container) return + /** + * Mobile: `threshold: 1` + a second `isInViewport` (full element inside innerHeight) caused + * play/pause thrash as the toolbar/resizes and subpixel layout toggled visibility. That produced + * a buffering spinner loop (Loader2) and stutter. Use fractional visibility + debounced pause. + */ + const PLAY_AFTER_VISIBLE_RATIO = 0.35 + const PAUSE_BELOW_RATIO = 0.12 + const PLAY_DELAY_MS = 200 + const PAUSE_DELAY_MS = 450 + + let playTimer: ReturnType | undefined + let pauseTimer: ReturnType | undefined + const observer = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting) { - setTimeout(() => { - if (isInViewport(container)) { - mediaManager.autoPlay(video) - } - }, 200) - } else { - mediaManager.pause(video) + const ratio = entry.intersectionRatio + if (ratio >= PLAY_AFTER_VISIBLE_RATIO) { + if (pauseTimer !== undefined) { + clearTimeout(pauseTimer) + pauseTimer = undefined + } + if (playTimer !== undefined) return + playTimer = setTimeout(() => { + playTimer = undefined + mediaManager.autoPlay(video) + }, PLAY_DELAY_MS) + } else if (ratio <= PAUSE_BELOW_RATIO) { + if (playTimer !== undefined) { + clearTimeout(playTimer) + playTimer = undefined + } + if (pauseTimer !== undefined) return + pauseTimer = setTimeout(() => { + pauseTimer = undefined + mediaManager.pause(video) + }, PAUSE_DELAY_MS) } }, - { threshold: 1 } + { threshold: [0, 0.1, 0.2, 0.35, 0.5, 0.75, 1] } ) observer.observe(container) return () => { + if (playTimer !== undefined) clearTimeout(playTimer) + if (pauseTimer !== undefined) clearTimeout(pauseTimer) observer.unobserve(container) } }, [autoplay, src, hlsMode]) diff --git a/src/constants.ts b/src/constants.ts index a596a49c..de9f4288 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -107,9 +107,10 @@ export const MAX_PUBLISH_RELAYS = 20 export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 /** - * Cap how long we wait on NIP-65 / inbox relay-list fetches before publishing. - * Without this, a stuck `fetchRelayList` / `fetchRelayLists` can leave republish toasts loading forever - * (the 30s publish timeout only runs after targets are resolved). + * Cap how long we wait on NIP-65 / inbox relay-list resolution (including `fetchRelayLists` network phase + * and kind-10432 fetch) before publishing or falling back to IndexedDB-only merge. + * Without this, a stuck `fetchReplaceableEventsFromProfileFetchRelays` can block the UI even when kind + * 10002 is already in IndexedDB (the 30s publish timeout only runs after targets are resolved). */ export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 12_000 diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 0d722a65..880341d0 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1398,6 +1398,7 @@ export default { 'Failed to pin note': 'Failed to pin note', 'Failed to publish post': 'Failed to publish post', 'Failed to publish reply': 'Failed to publish reply', + 'Failed to publish reaction': 'Reaktion konnte nicht veröffentlicht werden', 'Failed to publish thread': 'Failed to publish thread', 'Failed to publish to some relays. Please try again or use different relays.': 'Failed to publish to some relays. Please try again or use different relays.', @@ -1642,6 +1643,7 @@ export default { 'Rate limited. Please wait before trying again.': 'Rate limited. Please wait before trying again.', 'Reaction published': 'Reaction published', + 'Reaction published to some relays': 'Reaktion auf einigen Relays veröffentlicht', 'Reaction removed': 'Reaction removed', 'Read full article': 'Read full article', 'Reading group entry': 'Reading group entry', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fdeaa646..6af3ede0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1395,6 +1395,7 @@ export default { 'Failed to pin note': 'Failed to pin note', 'Failed to publish post': 'Failed to publish post', 'Failed to publish reply': 'Failed to publish reply', + 'Failed to publish reaction': 'Failed to publish reaction', 'Failed to publish thread': 'Failed to publish thread', 'Failed to publish to some relays. Please try again or use different relays.': 'Failed to publish to some relays. Please try again or use different relays.', @@ -1702,6 +1703,7 @@ export default { 'Rate limited. Please wait before trying again.': 'Rate limited. Please wait before trying again.', 'Reaction published': 'Reaction published', + 'Reaction published to some relays': 'Reaction published to some relays', 'Reaction removed': 'Reaction removed', 'Read full article': 'Read full article', 'Reading group entry': 'Reading group entry', diff --git a/src/services/client.service.ts b/src/services/client.service.ts index ea58be26..f7f1184b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -653,7 +653,12 @@ class ClientService extends EventTarget { .slice(0, MAX_PUBLISH_RELAYS) } - private async capPublishRelayUrlsForPublish( + /** + * Same ordering as {@link prioritizePublishUrlList} but bounded so relay-list / inbox fetches + * cannot block publishing indefinitely. Used from {@link determineTargetRelays} (before + * {@link publishEvent}) and from {@link capPublishRelayUrlsForPublish}. + */ + private async prioritizePublishUrlListWithTimeout( relayUrls: string[], event: NEvent, favoriteRelayUrls: string[] = [] @@ -675,6 +680,72 @@ class ClientService extends EventTarget { ]) } + private async capPublishRelayUrlsForPublish( + relayUrls: string[], + event: NEvent, + favoriteRelayUrls: string[] = [] + ): Promise { + return this.prioritizePublishUrlListWithTimeout(relayUrls, event, favoriteRelayUrls) + } + + private emptyRelayListForPublish(): TRelayList { + return { + write: [], + read: [], + originalRelays: [], + httpRead: [], + httpWrite: [], + httpOriginalRelays: [] + } + } + + /** Bounded wait so NIP-65 fetch cannot block publishing (reactions, replies without relay picker, etc.). */ + private async fetchRelayListWithPublishTimeout(pubkey: string): Promise { + const empty = this.emptyRelayListForPublish() + try { + return await Promise.race([ + this.fetchRelayList(pubkey), + new Promise((resolve) => + setTimeout(() => { + logger.warn('[DetermineTargetRelays] fetchRelayList timed out; using empty outbox', { + pubkeySlice: pubkey.slice(0, 12) + }) + resolve(empty) + }, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) + ) + ]) + } catch (err) { + logger.warn('[DetermineTargetRelays] fetchRelayList failed, using fallback relays', { + pubkeySlice: pubkey.slice(0, 12), + error: err instanceof Error ? err.message : String(err) + }) + return empty + } + } + + private async fetchRelayListsWithPublishTimeout(pubkeys: string[]): Promise { + if (pubkeys.length === 0) return [] + try { + return await Promise.race([ + this.fetchRelayLists(pubkeys), + new Promise((resolve) => + setTimeout(() => { + logger.warn('[DetermineTargetRelays] fetchRelayLists timed out; skipping context inbox merge', { + pubkeyCount: pubkeys.length + }) + resolve(pubkeys.map(() => this.emptyRelayListForPublish())) + }, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) + ) + ]) + } catch (err) { + logger.warn('[DetermineTargetRelays] fetchRelayLists failed', { + pubkeyCount: pubkeys.length, + error: err instanceof Error ? err.message : String(err) + }) + return pubkeys.map(() => this.emptyRelayListForPublish()) + } + } + /** * Determine which relays to publish an event to. * Fallbacks (used when user relay list is empty or fetch fails): @@ -703,7 +774,7 @@ class ClientService extends EventTarget { // For Report events, always include user's write relays first, then add seen relays if they're write-capable if (event.kind === kinds.Report) { // Start with user's write relays (outboxes) - these are the primary targets for reports - const relayList = await this.fetchRelayList(event.pubkey) + const relayList = await this.fetchRelayListWithPublishTimeout(event.pubkey) const reportHttpWrites = (relayList?.httpWrite ?? []) .map((url) => normalizeHttpRelayUrl(url) || url) .filter((u): u is string => !!u) @@ -757,7 +828,19 @@ class ClientService extends EventTarget { event.kind === ExtendedKind.PUBLIC_MESSAGE || event.kind === ExtendedKind.CALENDAR_EVENT_RSVP ) { - const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[], httpWrite: [] as string[], httpRead: [] as string[] })) + const recipientPubkeys = Array.from( + new Set( + event.tags.filter((t) => t[0] === 'p' && t[1] && isValidPubkey(t[1])).map((t) => t[1] as string) + ) + ).filter((p) => p !== event.pubkey) + const recipientListsPromise = + recipientPubkeys.length > 0 + ? this.fetchRelayListsWithPublishTimeout(recipientPubkeys) + : Promise.resolve([] as TRelayList[]) + const [authorRelayList, recipientRelayLists] = await Promise.all([ + this.fetchRelayListWithPublishTimeout(event.pubkey), + recipientListsPromise + ]) const authorHttpWrites = (authorRelayList?.httpWrite ?? []) .map((url) => normalizeHttpRelayUrl(url)) .filter((url): url is string => !!url) @@ -768,20 +851,12 @@ class ClientService extends EventTarget { if (authorWrite.length === 0) { authorWrite = [...FAST_WRITE_RELAY_URLS] } - const recipientPubkeys = Array.from( - new Set( - event.tags.filter((t) => t[0] === 'p' && t[1] && isValidPubkey(t[1])).map((t) => t[1] as string) - ) - ).filter((p) => p !== event.pubkey) let recipientRead: string[] = [] - if (recipientPubkeys.length > 0) { - const recipientRelayLists = await this.fetchRelayLists(recipientPubkeys) - recipientRead = recipientRelayLists.flatMap((rl) => [ - ...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)), - ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)) - ]) - recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead) - } + recipientRead = recipientRelayLists.flatMap((rl) => [ + ...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)), + ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)) + ]) + recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead) let pubRelays = mergeRelayPriorityLayers( [relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], blockedRelayUrls, @@ -818,20 +893,13 @@ class ClientService extends EventTarget { if (event.kind === ExtendedKind.SPELL) { let spellRelayList: TRelayList | undefined try { - spellRelayList = await this.fetchRelayList(event.pubkey) + spellRelayList = await this.fetchRelayListWithPublishTimeout(event.pubkey) } catch (err) { logger.warn('[DetermineTargetRelays] fetchRelayList failed for spell', { pubkey: event.pubkey, error: err instanceof Error ? err.message : String(err) }) - spellRelayList = { - write: [], - read: [], - originalRelays: [], - httpRead: [], - httpWrite: [], - httpOriginalRelays: [] - } + spellRelayList = this.emptyRelayListForPublish() } const spellHttpWrites = (spellRelayList?.httpWrite ?? []) .map((url) => normalizeHttpRelayUrl(url)) @@ -862,25 +930,26 @@ class ClientService extends EventTarget { const bootstrapExtras: string[] = [...(additionalRelayUrls ?? [])] let authorInboxFromContext: string[] = [] - if ( + const shouldMergeContextInboxes = !specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist, ExtendedKind.FOLLOW_SET].includes(event.kind) - ) { - const ctxPubkeys = this.collectReplyAndMentionPubkeys(event) - if (ctxPubkeys.length > 0) { - const relayLists = await this.fetchRelayLists(ctxPubkeys) - relayLists.forEach((relayList) => { - for (const u of relayList.httpRead ?? []) { - const n = normalizeHttpRelayUrl(u) || u - if (n) authorInboxFromContext.push(n) - } - for (const u of relayList.read ?? []) { - const n = normalizeUrl(u) || u - if (n) authorInboxFromContext.push(n) - } - }) + const ctxPubkeys = shouldMergeContextInboxes ? this.collectReplyAndMentionPubkeys(event) : [] + const relayListsPromise = + ctxPubkeys.length > 0 + ? this.fetchRelayListsWithPublishTimeout(ctxPubkeys) + : Promise.resolve([] as TRelayList[]) + const relayListPromise = this.fetchRelayListWithPublishTimeout(event.pubkey) + const [relayLists, relayList] = await Promise.all([relayListsPromise, relayListPromise]) + relayLists.forEach((rl) => { + for (const u of rl.httpRead ?? []) { + const n = normalizeHttpRelayUrl(u) || u + if (n) authorInboxFromContext.push(n) } - } + for (const u of rl.read ?? []) { + const n = normalizeUrl(u) || u + if (n) authorInboxFromContext.push(n) + } + }) if ( [ kinds.RelayList, @@ -918,34 +987,9 @@ class ClientService extends EventTarget { event.kind === ExtendedKind.FAVORITE_RELAYS || event.kind === kinds.Relaysets ) { - logger.debug('[DetermineTargetRelays] Fetching user relay list for event publication', { - pubkey: event.pubkey, - kind: event.kind - }) - } - let relayList: TRelayList | undefined - try { - relayList = await this.fetchRelayList(event.pubkey) - } catch (err) { - logger.warn('[DetermineTargetRelays] fetchRelayList failed, using fallback relays', { + logger.debug('[DetermineTargetRelays] User relay list resolved for publication', { pubkey: event.pubkey, - error: err instanceof Error ? err.message : String(err) - }) - relayList = { - write: [], - read: [], - originalRelays: [], - httpRead: [], - httpWrite: [], - httpOriginalRelays: [] - } - } - if ( - event.kind === kinds.RelayList || - event.kind === ExtendedKind.FAVORITE_RELAYS || - event.kind === kinds.Relaysets - ) { - logger.debug('[DetermineTargetRelays] User relay list fetched', { + kind: event.kind, hasRelayList: !!relayList, writeRelayCount: relayList?.write?.length ?? 0, readRelayCount: relayList?.read?.length ?? 0, @@ -1000,7 +1044,7 @@ class ClientService extends EventTarget { if (specifiedRelayUrls?.length) { const checkedCount = specifiedRelayUrls.length - relays = await this.prioritizePublishUrlList(relays, event, favoriteRelayUrls ?? []) + relays = await this.prioritizePublishUrlListWithTimeout(relays, event, favoriteRelayUrls ?? []) if (checkedCount > relays.length) { logger.info('[Publish] Relay picker: checked count exceeds per-publish cap (stage 1)', { checkedInRelayPicker: checkedCount, @@ -3330,39 +3374,28 @@ class ClientService extends EventTarget { return requestPromise } - async fetchRelayLists(pubkeys: string[]): Promise { - // First check IndexedDB for offline/quick access (prioritizes cache relays for offline use) - const storedRelayEvents = await Promise.all( - pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)) - ) - const storedCacheRelayEvents = await Promise.all( - pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) - ) - const storedHttpRelayEvents = await Promise.all( - pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST)) - ) - - // Then fetch from relays (will update cache if newer) - const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(pubkeys, kinds.RelayList) - const httpRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - pubkeys, - ExtendedKind.HTTP_RELAY_LIST - ) - - // Fetch cache relays from multiple sources: FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, and user's inboxes/outboxes - const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedRelayEvents) - + /** + * Merge NIP-65 (10002), HTTP relay list (10243), and cache relays (10432) from network and/or IndexedDB. + * Network arrays may be sparse/undefined per index; stored* always filled from IDB reads. + */ + private mergeRelayListsBundle( + pubkeys: string[], + relayEvents: (NEvent | null | undefined)[], + httpRelayEvents: (NEvent | null | undefined)[], + cacheRelayEvents: (NEvent | null | undefined)[], + storedRelayEvents: (NEvent | null | undefined)[], + storedHttpRelayEvents: (NEvent | null | undefined)[], + storedCacheRelayEvents: (NEvent | null | undefined)[] + ): TRelayList[] { return pubkeys.map((targetPubkey, index) => { const isOwnRelayList = this.pubkey != null && hexPubkeysEqual(this.pubkey, userIdToPubkey(targetPubkey)) - // Use stored cache relay event if available (for offline), otherwise use fetched one const storedCacheEvent = storedCacheRelayEvents[index] const cacheEvent = cacheRelayEvents[index] || storedCacheEvent const httpRelayEvent = httpRelayEvents[index] || storedHttpRelayEvents[index] - // Use stored relay event if no network event (for offline), otherwise use fetched one const storedRelayEvent = storedRelayEvents[index] const relayEvent = relayEvents[index] || storedRelayEvent @@ -3388,27 +3421,22 @@ class ClientService extends EventTarget { ...emptyHttp } - // Merge kind 10432 (cache relays) only for the logged-in user — never use someone else's local relays. if (isOwnRelayList && cacheEvent) { const cacheRelayList = getRelayListFromEvent(cacheEvent) - // Merge read relays - cache relays first, then others (for offline priority) const mergedRead = [...cacheRelayList.read, ...relayList.read] const mergedWrite = [...cacheRelayList.write, ...relayList.write] const mergedOriginalRelays = new Map() - // Add cache relay original relays first (prioritized) - cacheRelayList.originalRelays.forEach(relay => { + cacheRelayList.originalRelays.forEach((relay) => { mergedOriginalRelays.set(relay.url, relay) }) - // Then add regular relay original relays - relayList.originalRelays.forEach(relay => { + relayList.originalRelays.forEach((relay) => { if (!mergedOriginalRelays.has(relay.url)) { mergedOriginalRelays.set(relay.url, relay) } }) - // Deduplicate while preserving order (cache relays first) return mergeKind10243({ write: Array.from(new Set(mergedWrite)), read: Array.from(new Set(mergedRead)), @@ -3417,7 +3445,6 @@ class ClientService extends EventTarget { }) } - // If no merged cache path, return original relay list or default (with own cache as fallback only) if (!relayEvent) { if (isOwnRelayList && storedCacheEvent) { const cacheRelayList = getRelayListFromEvent(storedCacheEvent) @@ -3444,6 +3471,119 @@ class ClientService extends EventTarget { }) } + /** Background refresh so UI/publish can use IDB immediately while relays catch up. */ + private refreshRelayListsFromNetwork( + pubkeys: string[], + storedKind10002: (NEvent | null | undefined)[] + ): void { + void (async () => { + try { + const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + pubkeys, + kinds.RelayList + ) + await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + pubkeys, + ExtendedKind.HTTP_RELAY_LIST + ) + await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedKind10002) + } catch { + /* best-effort */ + } + })() + } + + async fetchRelayLists(pubkeys: string[]): Promise { + if (pubkeys.length === 0) return [] + + const storedRelayEvents = await Promise.all( + pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)) + ) + const storedCacheRelayEvents = await Promise.all( + pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) + ) + const storedHttpRelayEvents = await Promise.all( + pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST)) + ) + + const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS + const allHaveKind10002 = pubkeys.every((_, i) => storedRelayEvents[i] != null) + + const networkBundle = async (): Promise<{ + relayEvents: (NEvent | null | undefined)[] + httpRelayEvents: (NEvent | null | undefined)[] + cacheRelayEvents: (NEvent | null | undefined)[] + }> => { + const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + pubkeys, + kinds.RelayList + ) + const httpRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + pubkeys, + ExtendedKind.HTTP_RELAY_LIST + ) + const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources( + pubkeys, + relayEvents, + storedRelayEvents + ) + return { relayEvents, httpRelayEvents, cacheRelayEvents } + } + + if (allHaveKind10002) { + this.refreshRelayListsFromNetwork(pubkeys, storedRelayEvents) + logger.debug( + '[FetchRelayLists] Kind 10002 present in IndexedDB for all pubkeys; merging locally, network refresh in background', + { count: pubkeys.length } + ) + const cacheRelayEvents = await Promise.race([ + this.fetchCacheRelayEventsFromMultipleSources(pubkeys, storedRelayEvents, storedRelayEvents), + new Promise<(NEvent | null | undefined)[]>((resolve) => + setTimeout(() => resolve(storedCacheRelayEvents.map((e) => e ?? undefined)), budgetMs) + ) + ]) + return this.mergeRelayListsBundle( + pubkeys, + storedRelayEvents.map((e) => e ?? undefined), + storedHttpRelayEvents.map((e) => e ?? undefined), + cacheRelayEvents, + storedRelayEvents, + storedHttpRelayEvents, + storedCacheRelayEvents + ) + } + + const raced = await Promise.race([ + networkBundle(), + new Promise((resolve) => setTimeout(() => resolve(null), budgetMs)) + ]) + if (raced != null) { + return this.mergeRelayListsBundle( + pubkeys, + raced.relayEvents, + raced.httpRelayEvents, + raced.cacheRelayEvents, + storedRelayEvents, + storedHttpRelayEvents, + storedCacheRelayEvents + ) + } + + logger.warn('[FetchRelayLists] Network relay-list fetch exceeded budget; using IndexedDB / empty network layer only', { + pubkeyCount: pubkeys.length + }) + const cacheRelayEvents = storedCacheRelayEvents.map((e) => e ?? undefined) + return this.mergeRelayListsBundle( + pubkeys, + pubkeys.map(() => undefined), + pubkeys.map(() => undefined), + cacheRelayEvents, + storedRelayEvents, + storedHttpRelayEvents, + storedCacheRelayEvents + ) + } + async forceUpdateRelayListEvent(pubkey: string) { await this.replaceableEventService.fetchReplaceableEvent(pubkey, kinds.RelayList) } @@ -3471,11 +3611,18 @@ class ClientService extends EventTarget { return storedCacheRelayEvents } - // Fetch from PROFILE_FETCH_RELAY_URLS - const cacheRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( - pubkeysToFetch, - ExtendedKind.CACHE_RELAYS - ) + const cacheRelayEvents = await Promise.race([ + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( + pubkeysToFetch, + ExtendedKind.CACHE_RELAYS + ), + new Promise<(NEvent | undefined)[]>((resolve) => + setTimeout( + () => resolve(pubkeysToFetch.map(() => undefined)), + PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS + ) + ) + ]) // Map results back to original pubkey order return pubkeys.map((pubkey, index) => {