You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

743 lines
25 KiB

/**
* 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<string>()
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<string>()
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<string>()
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<string[]> {
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<string>()
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<string[]> {
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<string>()
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<string[]> {
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<string>()
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<string[]> {
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<string>()
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<string[]> {
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)
)
}