/** * Comprehensive relay list builder utility * Handles all relay selection requirements: * - Filters blocked relays * - Includes local relays from kind 10432 * - Handles author's outboxes/inboxes * - Handles user's outboxes/inboxes * - Includes relay hints * - Includes seen relays */ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { prependAggrForEventLookupRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { canonicalRelaySessionKey, httpIndexRelayBasesInUrlBatch, isKind10243HttpRelayTagUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url' import { buildPersonalRelayKeySet, sanitizeRelayUrlsForFetch, isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal' import { getCacheRelayUrls } from './private-relays' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import client from '@/services/client.service' import logger from '@/lib/logger' import type { Event } from 'nostr-tools' /** Max author NIP-65 read / write URLs merged into comprehensive read lists (shared with viewer first). */ export const AUTHOR_NIP65_RELAY_CAP = 2 /** * Relays for logged-in account session network hydrate (NostrProvider). * Uses the viewer's cached mailbox / favorites plus {@link PROFILE_RELAY_URLS} — not {@link FAST_READ_RELAY_URLS}, * which are blocked under the personal-relay read policy and caused empty/slow startup merges. */ export function buildAccountSessionNetworkHydrateRelayUrls(options: { relayListEvent?: Event | null cacheRelayListEvent?: Event | null httpRelayListEvent?: Event | null favoriteRelaysEvent?: Event | null blockedRelays?: string[] cap?: number }): string[] { const blocked = options.blockedRelays ?? [] const seen = new Set() const out: string[] = [] const push = (raw: string | undefined) => { if (!raw) return const n = normalizeAnyRelayUrl(raw) || normalizeUrl(raw) || raw.trim() if (!n) return const key = relayKey(n) if (!key || seen.has(key)) return seen.add(key) out.push(n) } if (options.relayListEvent) { const rl = getRelayListFromEvent(options.relayListEvent, blocked) for (const u of [...rl.read, ...rl.write, ...(rl.httpRead ?? []), ...(rl.httpWrite ?? [])]) { push(u) } } if (options.cacheRelayListEvent) { const crl = getRelayListFromEvent(options.cacheRelayListEvent) for (const u of [...crl.read, ...crl.write]) push(u) } if (options.httpRelayListEvent) { const hrl = getHttpRelayListFromEvent(options.httpRelayListEvent, blocked) for (const u of [...hrl.httpRead, ...hrl.httpWrite]) push(u) } if (options.favoriteRelaysEvent) { for (const [tag, val] of options.favoriteRelaysEvent.tags) { if (tag === 'relay' && val) push(val) } } for (const u of PROFILE_RELAY_URLS) push(u) const cap = options.cap ?? 16 return out.slice(0, cap) } function relayKey(url: string): string { return canonicalRelaySessionKey(url) } /** * Up to `max` author relays, preferring URLs that also appear on the viewer's NIP-65 read/write lists. */ export function pickAuthorNip65RelaysPreferringViewerOverlap( authorUrls: readonly string[], viewerUrls: readonly string[], max: number ): string[] { if (max <= 0) return [] const viewerKeys = new Set(viewerUrls.map(relayKey).filter(Boolean)) const shared: string[] = [] const authorOnly: string[] = [] const seen = new Set() for (const raw of authorUrls) { const k = relayKey(raw) if (!k || seen.has(k)) continue seen.add(k) if (viewerKeys.has(k)) shared.push(normalizeAnyRelayUrl(raw) || raw.trim()) else authorOnly.push(normalizeAnyRelayUrl(raw) || raw.trim()) } const out: string[] = [] const push = (u: string) => { const k = relayKey(u) if (!k || out.some((x) => relayKey(x) === k)) return out.push(u) } for (const u of [...shared, ...authorOnly]) { if (out.length >= max) break push(u) } return out } function dedupeNormalizedRelayUrls(urls: string[]): string[] { const seen = new Set() const out: string[] = [] for (const u of urls) { if (isKind10243HttpRelayTagUrl(u)) continue const n = normalizeAnyRelayUrl(u) || u.trim() if (!n || seen.has(n)) continue seen.add(n) out.push(n) } return out } /** * Relays to bootstrap Explore replaceable fetches (e.g. kind 10012 batch) before NIP-65 resolves. * PROFILE_FETCH + FAST_READ. */ function exploreDiscoveryBootstrapRelayUrls(): string[] { return dedupeNormalizedRelayUrls([...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) } export interface RelayListBuilderOptions { /** Author's pubkey - will include their outboxes (write relays) */ authorPubkey?: string /** Logged-in user's pubkey - will include their inboxes (read relays) and outboxes (write relays) */ userPubkey?: string /** Explicit relay hints (from bech32 IDs or event tags) */ relayHints?: string[] /** Relays where an event was seen */ seenRelays?: string[] /** Relays where a containing event was found (for embedded events) */ containingEventRelays?: string[] /** Whether to include user's own relays (read/write/local) - for profiles/metadata */ includeUserOwnRelays?: boolean /** Whether to include PROFILE_RELAY_URLS - for profiles/metadata */ includeProfileFetchRelays?: boolean /** Whether to include FAST_READ_RELAY_URLS as fallback */ includeFastReadRelays?: boolean /** * Legacy name: adds {@link FAST_READ_RELAY_URLS} as extra bootstrap mirrors for REQ/read lists * (historically mis-tagged as “fast write”). */ includeFastWriteRelays?: boolean /** Whether to include SEARCHABLE_RELAY_URLS - for search */ includeSearchableRelays?: boolean /** Blocked relays to filter out */ blockedRelays?: string[] /** Whether to include local relays from kind 10432 */ includeLocalRelays?: boolean /** Whether to include user's favorite relays (kind 10012) */ includeFavoriteRelays?: boolean /** * When true with fast-read / searchable / profile-fetch includes: insert `PROFILE_RELAY_URLS`, * `FAST_READ_RELAY_URLS`, and `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before** * author + user NIP-65 lists. Used for batched metadata and embed fetches so public mirrors are not queued * behind broken personal relays under the global connection cap. */ preferPublicReadRelaysEarly?: boolean /** * When set, skips {@link viewerUsesGlobalRelayDefaults} and forces fast-read bootstrap on/off. * Otherwise fast-read is omitted for logged-in users who have favorites or NIP-65 configured. */ useGlobalRelayDefaults?: boolean /** Append the viewer's kind 10243 HTTP index relays when present (not subject to WS-only `addRelay`). */ includeViewerHttpIndexRelays?: boolean } /** * Build comprehensive relay list according to requirements */ export async function buildComprehensiveRelayList(options: RelayListBuilderOptions = {}): Promise { const { authorPubkey, userPubkey, relayHints = [], seenRelays = [], containingEventRelays = [], includeUserOwnRelays = false, includeProfileFetchRelays = false, includeFastReadRelays = true, includeFastWriteRelays = false, includeSearchableRelays = false, blockedRelays = [], includeLocalRelays = true, includeFavoriteRelays = false, preferPublicReadRelaysEarly = false, useGlobalRelayDefaults: useGlobalRelayDefaultsOption, includeViewerHttpIndexRelays = true } = options const relayUrls = new Set() const httpRelayUrls: string[] = [] /** NIP-65 / favorites / 10432 — read-only index relays are only kept when listed here. */ const personalRelayUrls: string[] = [] const trackPersonal = (url: string) => { personalRelayUrls.push(url) } const addRelay = (url: string | undefined) => { if (!url) return // This builder feeds WebSocket REQ/publish lists; kind 10243 HTTP index relays use addHttpRelay. if (isKind10243HttpRelayTagUrl(url)) return const normalized = normalizeAnyRelayUrl(url) if (!normalized) return if (isRelayBlockedByUser(normalized, blockedRelays)) return relayUrls.add(normalized) } /** Hints / NIP-65 lists — no loopback/LAN (viewer cache relays come from kind 10432 only). */ const addRelayFromHints = (url: string | undefined) => { if (!url || !urlIsNonLocalForRemoteViewer(url)) return addRelay(url) } const addHttpRelay = (url: string | undefined) => { if (!url) return const normalized = normalizeHttpRelayUrl(url) if (!normalized || isRelayBlockedByUser(normalized, blockedRelays)) return if (httpRelayUrls.some((u) => relayKey(u) === relayKey(normalized))) return httpRelayUrls.push(normalized) } let viewerRelayListForShare: { read?: string[]; write?: string[]; httpRead?: string[]; httpWrite?: string[] } | null = null if (userPubkey) { try { viewerRelayListForShare = await client.peekRelayListFromStorage(userPubkey) } catch { viewerRelayListForShare = null } } const viewerWsForAuthorOverlap = [ ...(viewerRelayListForShare?.read ?? []), ...(viewerRelayListForShare?.write ?? []) ] let effectiveIncludeFastRead = includeFastReadRelays if (isMetadataRelaysOnlyPolicyActive()) { effectiveIncludeFastRead = false } else if (userPubkey && includeFastReadRelays) { if (useGlobalRelayDefaultsOption !== undefined) { effectiveIncludeFastRead = useGlobalRelayDefaultsOption } else { try { const fav = includeFavoriteRelays && userPubkey ? await client.fetchFavoriteRelaysFromStorage(userPubkey).catch(() => [] as string[]) : [] effectiveIncludeFastRead = viewerUsesGlobalRelayDefaults({ viewerPubkey: userPubkey, favoriteRelayUrls: fav, relayList: viewerRelayListForShare ?? undefined }) } catch { effectiveIncludeFastRead = true } } } // 1. Relay hints (highest priority - explicit hints) relayHints.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints) // 2. Relays where event was seen seenRelays.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints) // 3. Relays where containing event was found (for embedded events) containingEventRelays.filter(urlIsNonLocalForRemoteViewer).forEach(addRelayFromHints) // 3b. Public profile / read relays before user favorites & NIP-65 (batched kind-0 — avoids burning // connection slots on broken personal relays before PROFILE_FETCH + FAST_READ answer). if (preferPublicReadRelaysEarly) { if (includeProfileFetchRelays) { PROFILE_RELAY_URLS.forEach(addRelay) } if (effectiveIncludeFastRead) { FAST_READ_RELAY_URLS.forEach(addRelay) } if (includeSearchableRelays) { SEARCHABLE_RELAY_URLS.forEach(addRelay) } } // 4. Author's outboxes (write relays) - where they publish (IndexedDB + defaults; no network gate) if (authorPubkey) { try { const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) pickAuthorNip65RelaysPreferringViewerOverlap( (authorRelayList.write ?? []).filter(urlIsNonLocalForRemoteViewer), viewerWsForAuthorOverlap, AUTHOR_NIP65_RELAY_CAP ).forEach(addRelayFromHints) pickAuthorNip65RelaysPreferringViewerOverlap( (authorRelayList.read ?? []).filter(urlIsNonLocalForRemoteViewer), viewerWsForAuthorOverlap, AUTHOR_NIP65_RELAY_CAP ).forEach(addRelayFromHints) } catch (error) { logger.warn('[RelayListBuilder] Failed to read author relay list from storage', { error }) } } // 5. User's own relays (for profiles/metadata) if (includeUserOwnRelays && userPubkey) { try { const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userWrite = [...(userRelayList.write || []).slice(0, 10)] userRead.filter(urlIsNonLocalForRemoteViewer).forEach((u) => { trackPersonal(u) addRelayFromHints(u) }) userWrite.filter(urlIsNonLocalForRemoteViewer).forEach((u) => { trackPersonal(u) addRelayFromHints(u) }) // Include local relays from kind 10432 if (includeLocalRelays) { const localRelays = await getCacheRelayUrls(userPubkey) localRelays.forEach((u) => { trackPersonal(u) addRelay(u) }) } // Include favorite relays (kind 10012) if requested if (includeFavoriteRelays) { try { const favoriteRelays = await client.fetchFavoriteRelaysFromStorage(userPubkey) favoriteRelays.forEach((u) => { trackPersonal(u) addRelay(u) }) } catch (error) { logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error }) } } } catch (error) { logger.warn('[RelayListBuilder] Failed to fetch user relay list', { error }) } } else if (userPubkey) { // Even if not including user's own relays, still include user's inboxes for reading try { const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) ;(userRelayList.read ?? []) .slice(0, 10) .filter(urlIsNonLocalForRemoteViewer) .forEach((u) => { trackPersonal(u) addRelayFromHints(u) }) // Include local relays from kind 10432 if enabled if (includeLocalRelays) { const localRelays = await getCacheRelayUrls(userPubkey) localRelays.forEach((u) => { trackPersonal(u) addRelay(u) }) } // Menu / feed “favorite relays” (kind 10012) — same list as the sidebar; not part of NIP-65 alone. if (includeFavoriteRelays) { try { const favoriteRelays = await client.fetchFavoriteRelaysFromStorage(userPubkey) favoriteRelays.forEach((u) => { trackPersonal(u) addRelay(u) }) } catch (error) { logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error }) } } } catch (error) { logger.warn('[RelayListBuilder] Failed to fetch user inboxes', { error }) } } // 6. Profile fetch relays (for profiles/metadata) if (includeProfileFetchRelays) { PROFILE_RELAY_URLS.forEach(addRelay) } // 7. Fast read relays (fallback) if (effectiveIncludeFastRead && !preferPublicReadRelaysEarly) { FAST_READ_RELAY_URLS.forEach(addRelay) } // 8. Extra fast-read bootstrap mirrors (call sites use legacy `includeFastWriteRelays`) if (includeFastWriteRelays && effectiveIncludeFastRead) { FAST_READ_RELAY_URLS.forEach(addRelay) } // 9. Searchable relays (for search) if (includeSearchableRelays && !preferPublicReadRelaysEarly) { SEARCHABLE_RELAY_URLS.forEach(addRelay) } if (includeViewerHttpIndexRelays && userPubkey && viewerRelayListForShare) { const hasHttp = (viewerRelayListForShare.httpRead?.length ?? 0) > 0 || (viewerRelayListForShare.httpWrite?.length ?? 0) > 0 if (hasHttp) { ;[...(viewerRelayListForShare.httpRead ?? []), ...(viewerRelayListForShare.httpWrite ?? [])].forEach( addHttpRelay ) } } const merged = Array.from(relayUrls) const personalKeys = userPubkey ? buildPersonalRelayKeySet(personalRelayUrls) : undefined const ws = sanitizeRelayUrlsForFetch( feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: false }), personalKeys ) if (httpRelayUrls.length === 0) return prependAggrForEventLookupRelayUrls(ws) const seen = new Set(ws.map(relayKey)) const out = [...ws] for (const u of httpRelayUrls) { const k = relayKey(u) if (!k || seen.has(k)) continue seen.add(k) out.push(u) } return prependAggrForEventLookupRelayUrls(out) } /** * Batched kind-0 / profile hydration: {@link PROFILE_RELAY_URLS} plus the logged-in viewer's own relays only. */ export async function buildProfileAndUserRelayList( userPubkey: string | null | undefined, blockedRelays: string[] = [] ): Promise { const profileWs = dedupeNormalizedRelayUrls([...PROFILE_RELAY_URLS]) if (!userPubkey?.trim()) { return feedRelayPolicyUrls([{ source: 'profile-fetch', urls: profileWs }], { operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: true }) } const userStack = await buildComprehensiveRelayList({ userPubkey, includeUserOwnRelays: true, includeProfileFetchRelays: false, includeFastReadRelays: false, includeFastWriteRelays: false, includeSearchableRelays: false, includeFavoriteRelays: true, includeLocalRelays: true, includeViewerHttpIndexRelays: true, blockedRelays }) let httpBases: string[] = [] try { const rl = await client.peekRelayListFromStorage(userPubkey) httpBases = [...(rl?.httpRead ?? []), ...(rl?.httpWrite ?? [])] .map((u) => normalizeHttpRelayUrl(u) || u) .filter(Boolean) } catch { httpBases = [] } const httpPart = httpIndexRelayBasesInUrlBatch(userStack, httpBases) const httpKeys = new Set(httpPart.map((u) => relayKey(u))) const wsPart = userStack.filter((u) => !httpKeys.has(relayKey(u))) const seen = new Set() const mergedWs: string[] = [] for (const u of [...profileWs, ...wsPart]) { const k = relayKey(u) if (!k || seen.has(k)) continue seen.add(k) mergedWs.push(u) } const policyWs = feedRelayPolicyUrls([{ source: 'profile-fetch', urls: mergedWs }], { operation: 'read', blockedRelays, applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: true }) const outSeen = new Set(policyWs.map(relayKey)) const out = [...policyWs] for (const u of httpPart) { const k = relayKey(u) if (!k || outSeen.has(k)) continue outSeen.add(k) out.push(u) } return out } /** * Explore: Following's Favorites (kind 10012 batch) / replaceable discovery. * Bootstrap relays (profile + FAST_READ) plus the viewer's read/write and cache (10432) when logged in. */ export async function buildExploreProfileAndUserRelayList( userPubkey: string | null | undefined ): Promise { const boot = exploreDiscoveryBootstrapRelayUrls() if (!userPubkey) { return boot } let useGlobal = true try { const [fav, peeked] = await Promise.all([ client.fetchFavoriteRelays(userPubkey).catch(() => [] as string[]), client.peekRelayListFromStorage(userPubkey).catch(() => null) ]) useGlobal = viewerUsesGlobalRelayDefaults({ viewerPubkey: userPubkey, favoriteRelayUrls: fav, relayList: peeked ?? undefined }) } catch { useGlobal = true } try { const built = await buildComprehensiveRelayList({ userPubkey, includeUserOwnRelays: true, includeProfileFetchRelays: true, includeFastReadRelays: useGlobal, includeFavoriteRelays: false, includeLocalRelays: true, includeFastWriteRelays: false, includeSearchableRelays: false }) if (!useGlobal) { return built } if (!built.length) return boot return dedupeNormalizedRelayUrls([...boot, ...built]) } catch { return useGlobal ? boot : [] } } /** NIP-10 relay hints from `e` / `E` tags (third value) on the focused event or thread. Omits loopback/LAN — those are only meaningful on the tag author's machine. */ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { const out = new Set() for (const tag of event.tags) { if ((tag[0] === 'e' || tag[0] === 'E') && tag[2]) { const n = normalizeUrl(tag[2]) || tag[2] if (n && urlIsNonLocalForRemoteViewer(n)) out.add(n) } } return [...out] } const POLL_RESULTS_MAX_RELAYS = 40 const POLL_RESULTS_NIP65_READ_SLICE = 16 /** * Relays to REQ poll responses (kind 1068 replies), in priority order: * seen relays, NIP-10 `e`/`E` hints, poll `relay` tags, viewer NIP-65 **read** (inbox), * favorite relays (kind 10012 from props), viewer cache relays (10432), {@link FAST_READ_RELAY_URLS}, * poll author NIP-65 **read** (inbox). */ export async function buildPollResultsReadRelayUrls(options: { pollEvent: Event pollRelayUrls: string[] viewerPubkey: string | null | undefined /** From {@link useFavoriteRelays} — avoids a second kind 10012 fetch. */ viewerFavoriteRelayUrls?: string[] blockedRelays?: string[] }): Promise { const { pollEvent, pollRelayUrls, viewerPubkey, viewerFavoriteRelayUrls = [], blockedRelays = [] } = options const normalizedBlocked = new Set( blockedRelays .map((url) => (normalizeAnyRelayUrl(url) || url).toLowerCase()) .filter(Boolean) ) const ordered: string[] = [] const seenNorm = new Set() const pushLayer = (urls: string[]) => { for (const raw of urls) { if (isKind10243HttpRelayTagUrl(raw)) continue const normalized = normalizeUrl(raw) || raw?.trim() if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) continue if (seenNorm.has(normalized)) continue seenNorm.add(normalized) ordered.push(normalized) } } pushLayer(client.getSeenEventRelayUrls(pollEvent.id)) pushLayer(relayHintsFromEventTags(pollEvent)) pushLayer(pollRelayUrls) let authorReadSlice: string[] = [] let viewerReadSlice: string[] = [] let useGlobalFastRead = true try { const [authorRl, viewerRl] = await Promise.all([ pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null), viewerPubkey ? client.peekRelayListFromStorage(viewerPubkey) : Promise.resolve(null) ]) if (authorRl) { authorReadSlice = userReadRelaysWithHttp(authorRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) } if (viewerRl) { viewerReadSlice = userReadRelaysWithHttp(viewerRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) useGlobalFastRead = viewerUsesGlobalRelayDefaults({ viewerPubkey, favoriteRelayUrls: viewerFavoriteRelayUrls, relayList: viewerRl }) } } catch { /* ignore — poll results still use other layers */ } pushLayer(viewerReadSlice) if (viewerPubkey) { pushLayer(viewerFavoriteRelayUrls) try { const localRelays = await getCacheRelayUrls(viewerPubkey) pushLayer(localRelays) } catch { /* ignore */ } } if (useGlobalFastRead) { pushLayer([...FAST_READ_RELAY_URLS]) } pushLayer(authorReadSlice) return feedRelayPolicyUrls([{ source: 'fallback', urls: ordered }], { operation: 'read', blockedRelays, maxRelays: POLL_RESULTS_MAX_RELAYS, applySocialKindBlockedFilter: false, allowThirdPartyLocalRelays: true }) } /** * Build relay list for reading replies/comments: thread hints, author/user NIP-65, favorites, cache — * then default favorite relays only when global bootstrap applies (signed-out or no configured stack). */ export type BuildReplyReadRelayListOptions = { /** When true (e.g. Explore single-relay page), query only thread hints + author/user NIP-65 — no favorite/fast-read bootstrap layer. */ relayAuthoritative?: boolean } export async function buildReplyReadRelayList( opAuthorPubkey: string | undefined, userPubkey: string | undefined, blockedRelays: string[] = [], threadRelayHints: string[] = [], options?: BuildReplyReadRelayListOptions ): Promise { if (options?.relayAuthoritative) { const scoped = await buildComprehensiveRelayList({ authorPubkey: opAuthorPubkey, userPubkey, relayHints: threadRelayHints, includeUserOwnRelays: Boolean(userPubkey), includeFastReadRelays: false, useGlobalRelayDefaults: false, includeSearchableRelays: false, includeLocalRelays: true, includeFavoriteRelays: false, preferPublicReadRelaysEarly: false, includeProfileFetchRelays: false, blockedRelays }) return prependAggrForEventLookupRelayUrls(scoped) } let useGlobal = true if (userPubkey) { try { const [fav, rl] = await Promise.all([ client.fetchFavoriteRelaysFromStorage(userPubkey).catch(() => [] as string[]), client.peekRelayListFromStorage(userPubkey) ]) useGlobal = viewerUsesGlobalRelayDefaults({ viewerPubkey: userPubkey, favoriteRelayUrls: fav, relayList: rl ?? undefined }) } catch { useGlobal = true } } const scoped = await buildComprehensiveRelayList({ authorPubkey: opAuthorPubkey, userPubkey, relayHints: threadRelayHints, includeUserOwnRelays: Boolean(userPubkey), includeFastReadRelays: useGlobal, useGlobalRelayDefaults: useGlobal, includeSearchableRelays: false, includeLocalRelays: true, includeFavoriteRelays: Boolean(userPubkey), preferPublicReadRelaysEarly: false, includeProfileFetchRelays: useGlobal, blockedRelays }) return prependAggrForEventLookupRelayUrls( mergeRelayUrlLayers([scoped, defaultFavoriteRelaysForViewer(useGlobal)], blockedRelays) ) }