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.
1782 lines
64 KiB
1782 lines
64 KiB
import { |
|
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS, |
|
DOCUMENT_RELAY_URLS, |
|
ExtendedKind, |
|
FAST_READ_RELAY_URLS, |
|
FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, |
|
MAX_CONCURRENT_RELAY_CONNECTIONS, |
|
METADATA_BATCH_AUTHORS_CHUNK, |
|
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, |
|
PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS, |
|
PROFILE_RELAY_URLS, |
|
RECOMMENDED_BLOSSOM_SERVERS, |
|
isDocumentRelayKind |
|
} from '@/constants' |
|
import { kinds, nip19 } from 'nostr-tools' |
|
import type { Event as NEvent, Filter } from 'nostr-tools' |
|
import DataLoader from 'dataloader' |
|
import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url' |
|
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' |
|
import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges' |
|
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' |
|
import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' |
|
import { TProfile } from '@/types' |
|
import { LRUCache } from 'lru-cache' |
|
import indexedDb from './indexed-db.service' |
|
import type { QueryService } from './client-query.service' |
|
import logger from '@/lib/logger' |
|
import client from './client.service' |
|
import { |
|
buildComprehensiveRelayList, |
|
buildExploreProfileAndUserRelayList, |
|
buildProfileAndUserRelayList |
|
} from '@/lib/relay-list-builder' |
|
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' |
|
import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' |
|
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' |
|
import { |
|
registerProfileBatchPubkeys, |
|
shouldDeferPerPubkeyProfileNetwork, |
|
unregisterProfileBatchPubkeys |
|
} from '@/lib/profile-batch-coordinator' |
|
import { isPromiseTimeoutError, racePromiseWithTimeout } from '@/lib/async-timeout' |
|
import { networkKindsForReplaceableFetch } from '@/lib/replaceable-fetch-kinds' |
|
|
|
export type FetchProfileEventOptions = { |
|
/** |
|
* When false (default), stop after batched profile relays / DataLoader — no per-author |
|
* NIP-65 + expanded-relay REQ (avoids hundreds of parallel 7-relay queries on feed paint). |
|
*/ |
|
allowWideRelayFallback?: boolean |
|
} |
|
|
|
export class ReplaceableEventService { |
|
/** Limits parallel {@link fetchKind0FromProfileRelays} (7-relay REQ per author). */ |
|
private static kind0ProfileRelaySlotsInUse = 0 |
|
private static kind0ProfileRelayWaitQueue: Array<() => void> = [] |
|
private static readonly MAX_CONCURRENT_KIND0_PROFILE_RELAY_REQS = 3 |
|
|
|
/** Limits parallel Step 2/3 profile network work (relay list + wide metadata REQ). */ |
|
private static profileFallbackSlotsInUse = 0 |
|
private static profileFallbackWaitQueue: Array<() => void> = [] |
|
|
|
private static async acquireKind0ProfileRelaySlot(): Promise<void> { |
|
if ( |
|
ReplaceableEventService.kind0ProfileRelaySlotsInUse < |
|
ReplaceableEventService.MAX_CONCURRENT_KIND0_PROFILE_RELAY_REQS |
|
) { |
|
ReplaceableEventService.kind0ProfileRelaySlotsInUse++ |
|
return |
|
} |
|
await new Promise<void>((resolve) => { |
|
ReplaceableEventService.kind0ProfileRelayWaitQueue.push(() => { |
|
ReplaceableEventService.kind0ProfileRelaySlotsInUse++ |
|
resolve() |
|
}) |
|
}) |
|
} |
|
|
|
private static releaseKind0ProfileRelaySlot(): void { |
|
ReplaceableEventService.kind0ProfileRelaySlotsInUse = Math.max( |
|
0, |
|
ReplaceableEventService.kind0ProfileRelaySlotsInUse - 1 |
|
) |
|
const next = ReplaceableEventService.kind0ProfileRelayWaitQueue.shift() |
|
if (next) next() |
|
} |
|
|
|
private static async acquireProfileFallbackNetworkSlot(): Promise<void> { |
|
if (ReplaceableEventService.profileFallbackSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) { |
|
ReplaceableEventService.profileFallbackSlotsInUse++ |
|
return |
|
} |
|
await new Promise<void>((resolve) => { |
|
ReplaceableEventService.profileFallbackWaitQueue.push(() => { |
|
ReplaceableEventService.profileFallbackSlotsInUse++ |
|
resolve() |
|
}) |
|
}) |
|
} |
|
|
|
private static releaseProfileFallbackNetworkSlot(): void { |
|
ReplaceableEventService.profileFallbackSlotsInUse = Math.max( |
|
0, |
|
ReplaceableEventService.profileFallbackSlotsInUse - 1 |
|
) |
|
const next = ReplaceableEventService.profileFallbackWaitQueue.shift() |
|
if (next) next() |
|
} |
|
|
|
/** True when kind 10002 exists locally — {@link client.fetchRelayList} would mostly merge IDB anyway. */ |
|
private static async hasRelayListInLocalCache(pubkey: string): Promise<boolean> { |
|
try { |
|
const idb = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList) |
|
if (idb && !shouldDropEventOnIngest(idb)) return true |
|
} catch { |
|
/* ignore */ |
|
} |
|
const hits = client.eventService.listSessionEventsAuthoredBy(pubkey, { |
|
kinds: [kinds.RelayList], |
|
limit: 1 |
|
}) |
|
const ses = hits[0] |
|
return Boolean(ses && !shouldDropEventOnIngest(ses)) |
|
} |
|
|
|
private queryService: QueryService |
|
private onProfileIndexed?: (profileEvent: NEvent) => void | Promise<void> |
|
private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({ |
|
max: 50, |
|
ttl: 1000 * 60 * 60 |
|
}) |
|
/** One in-flight profile replaceables pull per author (avoids stacked REQs when profile UI remounts). */ |
|
private authorReplaceablesRefreshByPubkey = new Map<string, Promise<void>>() |
|
/** Per-author cooldown after a successful profile-view replaceable sweep (avoids reopen loops). */ |
|
private authorProfileViewRefreshNotBeforeMs = new Map<string, number>() |
|
/** Coalesce IDB-hit background refreshes so feed paint does not open one REQ per row per 100ms window. */ |
|
private backgroundRefreshByKey = new Map<string, { pubkey: string; kind: number; d?: string }>() |
|
private backgroundRefreshFlushTimer: ReturnType<typeof setTimeout> | null = null |
|
private replaceableEventFromBigRelaysDataloader: DataLoader< |
|
{ pubkey: string; kind: number }, |
|
NEvent | null, |
|
string |
|
> |
|
private replaceableEventDataLoader: DataLoader< |
|
{ pubkey: string; kind: number; d?: string }, |
|
NEvent | null, |
|
string |
|
> |
|
|
|
constructor(queryService: QueryService, onProfileIndexed?: (profileEvent: NEvent) => void | Promise<void>) { |
|
this.queryService = queryService |
|
this.onProfileIndexed = onProfileIndexed |
|
this.replaceableEventFromBigRelaysDataloader = new DataLoader< |
|
{ pubkey: string; kind: number }, |
|
NEvent | null, |
|
string |
|
>( |
|
this.replaceableEventFromBigRelaysBatchLoadFn.bind(this), |
|
{ |
|
batchScheduleFn: (callback) => setTimeout(callback, 100), // Increased from 50ms to 100ms to better batch rapid scrolling |
|
maxBatchSize: 200, // Reduced from 500 to prevent overwhelming the system during rapid scrolling |
|
cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}` |
|
} |
|
) |
|
this.replaceableEventDataLoader = new DataLoader< |
|
{ pubkey: string; kind: number; d?: string }, |
|
NEvent | null, |
|
string |
|
>( |
|
this.replaceableEventBatchLoadFn.bind(this), |
|
{ |
|
cacheKeyFn: ({ pubkey, kind, d }) => `${kind}:${pubkey}:${d ?? ''}` |
|
} |
|
) |
|
} |
|
|
|
|
|
/** |
|
* Build comprehensive relay list: author's outboxes + user's inboxes + relay hints + defaults |
|
* For profiles/metadata: includes user's own relays (read/write/local) + PROFILE_RELAY_URLS |
|
*/ |
|
private async buildComprehensiveRelayListForAuthor( |
|
authorPubkey: string, |
|
kind: number, |
|
relayHints: string[] = [], |
|
containingEventRelays: string[] = [] |
|
): Promise<string[]> { |
|
const userPubkey = client.pubkey |
|
if (kind === kinds.Metadata) { |
|
const profileStack = await buildProfileAndUserRelayList(userPubkey) |
|
const hintLayer = [...relayHints, ...containingEventRelays] |
|
if (hintLayer.length === 0) return profileStack |
|
return Array.from( |
|
new Set([ |
|
...profileStack, |
|
...hintLayer |
|
.map((u) => normalizeAnyRelayUrl(u) || normalizeUrl(u) || u.trim()) |
|
.filter((u): u is string => !!u && !/^https?:\/\//i.test(u)) |
|
]) |
|
) |
|
} |
|
|
|
const isProfileOrMetadata = kind === kinds.RelayList |
|
return buildComprehensiveRelayList({ |
|
authorPubkey, |
|
userPubkey, |
|
relayHints, |
|
containingEventRelays, |
|
includeUserOwnRelays: isProfileOrMetadata, |
|
includeProfileFetchRelays: isProfileOrMetadata, |
|
includeFastReadRelays: true, |
|
includeLocalRelays: true |
|
}) |
|
} |
|
|
|
/** |
|
* Fetch replaceable event (profile, relay list, etc.) |
|
* Uses DataLoader to batch IndexedDB checks and network fetches |
|
* ALWAYS uses: author's outboxes + user's inboxes + relay hints + defaults |
|
* For profiles/metadata: includes user's own relays (read/write/local) + PROFILE_RELAY_URLS |
|
* |
|
* @param pubkey - Author's pubkey |
|
* @param kind - Event kind |
|
* @param d - Optional d-tag for parameterized replaceable events |
|
* @param containingEventRelays - Optional relays where a containing event was found (for profiles, might be on same relay as event) |
|
*/ |
|
async fetchReplaceableEvent( |
|
pubkey: string, |
|
kind: number, |
|
d?: string, |
|
containingEventRelays: string[] = [] |
|
): Promise<NEvent | undefined> { |
|
try { |
|
if (kind === kinds.Metadata && !d) { |
|
const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) |
|
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey, kind }, |
|
Promise.resolve(sessionEv) |
|
) |
|
return sessionEv |
|
} |
|
} |
|
|
|
// Kind 3 / NIP-65 / 10133: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh. |
|
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList || kind === ExtendedKind.PAYMENT_INFO)) { |
|
let idbEv: NEvent | undefined | null |
|
try { |
|
idbEv = await indexedDb.getReplaceableEvent(pubkey, kind, d) |
|
} catch { |
|
idbEv = undefined |
|
} |
|
const idbOk = idbEv && !shouldDropEventOnIngest(idbEv) ? idbEv : undefined |
|
const sessionHits = client.eventService.listSessionEventsAuthoredBy(pubkey, { |
|
kinds: [kind], |
|
limit: 20 |
|
}) |
|
const ses = sessionHits[0] |
|
const sesOk = ses && !shouldDropEventOnIngest(ses) ? ses : undefined |
|
const pick = !idbOk ? sesOk : !sesOk ? idbOk : sesOk.created_at >= idbOk.created_at ? sesOk : idbOk |
|
if (pick) { |
|
this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(pick)) |
|
void indexedDb.putReplaceableEvent(pick).catch(() => {}) |
|
if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { |
|
this.refreshInBackground(pubkey, kind, d) |
|
} |
|
return pick |
|
} |
|
} |
|
|
|
// If we have containing event relays and this is a profile, we need to use a custom relay list |
|
// Otherwise, use DataLoader (which batches IndexedDB checks and network fetches) |
|
let event: NEvent | undefined |
|
if (containingEventRelays.length > 0 && kind === kinds.Metadata && !d) { |
|
// For profiles with containing event relays (author's relay list), check IndexedDB first, then query directly |
|
try { |
|
const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d) |
|
if (indexedDbCached) { |
|
// Refresh in background |
|
this.refreshInBackground(pubkey, kind, d) |
|
return indexedDbCached |
|
} |
|
} catch (error) { |
|
logger.warn('[ReplaceableEventService] IndexedDB error', { |
|
pubkey, |
|
kind, |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
} |
|
|
|
// Not in IndexedDB, fetch from network with custom relay list |
|
const relayUrls = stripLocalNetworkRelaysForWssReq( |
|
await this.buildComprehensiveRelayListForAuthor(pubkey, kind, containingEventRelays, []) |
|
) |
|
const events = await this.queryService.query( |
|
relayUrls, |
|
{ |
|
authors: [pubkey], |
|
kinds: networkKindsForReplaceableFetch(kind) |
|
}, |
|
undefined, |
|
{ |
|
replaceableRace: kind !== kinds.Metadata, |
|
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
|
} |
|
) |
|
if (kind === kinds.Metadata) { |
|
this.ingestMetadataCoFetchSidecars(events) |
|
} |
|
const sortedEvents = events |
|
.filter((e) => e.kind === kind) |
|
.sort((a, b) => b.created_at - a.created_at) |
|
event = sortedEvents.length > 0 ? sortedEvents[0] : undefined |
|
} else { |
|
// Use DataLoader for batching (IndexedDB checks and network fetches are batched) |
|
const loadedEvent = d |
|
? await this.replaceableEventDataLoader.load({ pubkey, kind, d }) |
|
: await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) |
|
event = loadedEvent || undefined |
|
} |
|
|
|
if (event) { |
|
return event |
|
} |
|
} catch (error) { |
|
// Log errors but don't throw - return undefined so UI can show fallback |
|
if (kind === kinds.Metadata) { |
|
logger.error('[ReplaceableEventService] Error fetching profile', { |
|
pubkey, |
|
error: error instanceof Error ? error.message : String(error), |
|
stack: error instanceof Error ? error.stack : undefined |
|
}) |
|
} else { |
|
logger.warn('[ReplaceableEventService] Error fetching replaceable event', { |
|
pubkey, |
|
kind, |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
} |
|
} |
|
|
|
return undefined |
|
} |
|
|
|
/** |
|
* Refresh event in background (non-blocking). Batched via {@link flushScheduledBackgroundRefresh}. |
|
*/ |
|
private refreshInBackground(pubkey: string, kind: number, d?: string): void { |
|
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) return |
|
const key = d ? `${pubkey}:${kind}:${d}` : `${pubkey}:${kind}` |
|
if (!this.backgroundRefreshByKey.has(key)) { |
|
this.backgroundRefreshByKey.set(key, { pubkey, kind, d }) |
|
} |
|
if (this.backgroundRefreshFlushTimer != null) return |
|
this.backgroundRefreshFlushTimer = setTimeout(() => { |
|
this.backgroundRefreshFlushTimer = null |
|
void this.flushScheduledBackgroundRefresh() |
|
}, 250) |
|
} |
|
|
|
private async flushScheduledBackgroundRefresh(): Promise<void> { |
|
const entries = [...this.backgroundRefreshByKey.values()] |
|
this.backgroundRefreshByKey.clear() |
|
if (entries.length === 0) return |
|
|
|
const withoutD = entries.filter((e) => !e.d) |
|
const withD = entries.filter((e) => e.d) |
|
|
|
if (withoutD.length > 0) { |
|
const eligible = withoutD.filter((e) => !shouldDeferPerPubkeyProfileNetwork(e.pubkey)) |
|
if (eligible.length > 0) { |
|
try { |
|
await this.replaceableEventFromBigRelaysDataloader.loadMany( |
|
eligible.map((e) => ({ pubkey: e.pubkey, kind: e.kind })) |
|
) |
|
} catch { |
|
/* ignore */ |
|
} |
|
} |
|
} |
|
|
|
for (const { pubkey, kind, d } of withD) { |
|
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) continue |
|
try { |
|
await this.replaceableEventDataLoader.load({ pubkey, kind, d }) |
|
} catch { |
|
/* ignore */ |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Batch fetch replaceable events from profile fetch relays. |
|
* Order: IndexedDB, then session LRU for kind 3 / 10002 gaps, then network. |
|
*/ |
|
async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> { |
|
const results: (NEvent | undefined)[] = new Array(pubkeys.length) |
|
const needsIndexedDb: { pubkey: string; index: number }[] = [] |
|
|
|
for (let index = 0; index < pubkeys.length; index++) { |
|
const pubkey = pubkeys[index] |
|
if (kind === kinds.Metadata) { |
|
const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) |
|
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { |
|
results[index] = sessionEv |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey, kind }, |
|
Promise.resolve(sessionEv) |
|
) |
|
continue |
|
} |
|
} |
|
needsIndexedDb.push({ pubkey, index }) |
|
} |
|
|
|
if (needsIndexedDb.length > 0) { |
|
try { |
|
const orderedPubkeys = needsIndexedDb.map((n) => n.pubkey) |
|
const fromIdb = await indexedDb.getManyReplaceableEvents(orderedPubkeys, kind) |
|
fromIdb.forEach((event, i) => { |
|
if (event && !shouldDropEventOnIngest(event)) { |
|
const slot = needsIndexedDb[i] |
|
if (slot) results[slot.index] = event |
|
} |
|
}) |
|
} catch { |
|
await Promise.allSettled( |
|
needsIndexedDb.map(async ({ pubkey, index }) => { |
|
try { |
|
const event = await indexedDb.getReplaceableEvent(pubkey, kind) |
|
if (event && !shouldDropEventOnIngest(event)) { |
|
results[index] = event |
|
} |
|
} catch { |
|
/* ignore */ |
|
} |
|
}) |
|
) |
|
} |
|
} |
|
|
|
if (needsIndexedDb.length > 0 && (kind === kinds.Contacts || kind === kinds.RelayList)) { |
|
for (const { pubkey, index } of needsIndexedDb) { |
|
if (results[index] !== undefined) continue |
|
const hits = client.eventService.listSessionEventsAuthoredBy(pubkey, { |
|
kinds: [kind], |
|
limit: 20 |
|
}) |
|
const ev = hits[0] |
|
if (ev && !shouldDropEventOnIngest(ev)) { |
|
results[index] = ev |
|
this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(ev)) |
|
} |
|
} |
|
} |
|
|
|
const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined) |
|
if (stillMissing.length > 0) { |
|
let newEvents: (NEvent | Error | null | undefined)[] |
|
try { |
|
newEvents = await racePromiseWithTimeout( |
|
this.replaceableEventFromBigRelaysDataloader.loadMany( |
|
stillMissing.map(({ pubkey }) => ({ pubkey, kind })) |
|
), |
|
PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS, |
|
'replaceableEventFromBigRelaysDataloader.loadMany' |
|
) |
|
} catch (err) { |
|
// Never throw: a DataLoader/relay error would abort the whole feed profile batch and skip IDB |
|
// hydration in {@link fetchProfilesForPubkeysBody}. Degrade like timeout — gaps fill from IDB/session below. |
|
if (isPromiseTimeoutError(err)) { |
|
logger.warn('[ReplaceableEventService] Profile batch network load timed out', { |
|
missingCount: stillMissing.length, |
|
kind |
|
}) |
|
} else { |
|
logger.warn('[ReplaceableEventService] Profile batch network load failed', { |
|
missingCount: stillMissing.length, |
|
kind, |
|
error: err instanceof Error ? err.message : String(err) |
|
}) |
|
} |
|
newEvents = stillMissing.map(() => undefined) |
|
} |
|
newEvents.forEach((event, idx) => { |
|
if (event && !(event instanceof Error)) { |
|
const { index } = stillMissing[idx]! |
|
if (index !== undefined) { |
|
results[index] = event ?? undefined |
|
} |
|
} |
|
}) |
|
} |
|
|
|
return results |
|
} |
|
|
|
/** |
|
* Update replaceable event cache |
|
*/ |
|
async updateReplaceableEventCache(event: NEvent): Promise<void> { |
|
// Update DataLoader cache and IndexedDB |
|
await this.updateReplaceableEventFromBigRelaysCache(event) |
|
} |
|
|
|
/** |
|
* Clear replaceable event caches |
|
*/ |
|
clearCaches(): void { |
|
this.replaceableEventFromBigRelaysDataloader.clearAll() |
|
this.replaceableEventDataLoader.clearAll() |
|
} |
|
|
|
/** |
|
* Private: Batch load function for replaceable events from big relays |
|
* Batches IndexedDB checks first, then only fetches missing events from network |
|
*/ |
|
private async replaceableEventFromBigRelaysBatchLoadFn( |
|
params: readonly { pubkey: string; kind: number }[] |
|
): Promise<(NEvent | null)[]> { |
|
const results: (NEvent | null)[] = new Array(params.length).fill(null) |
|
const eventsMap = new Map<string, NEvent>() |
|
|
|
for (let i = 0; i < params.length; i++) { |
|
const { pubkey, kind } = params[i] |
|
if (kind !== kinds.Metadata) continue |
|
const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) |
|
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { |
|
results[i] = sessionEv |
|
eventsMap.set(`${pubkey}:${kind}`, sessionEv) |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey, kind }, |
|
Promise.resolve(sessionEv) |
|
) |
|
} |
|
} |
|
|
|
const idbByKind = new Map<number, { pubkey: string; index: number }[]>() |
|
params.forEach(({ pubkey, kind }, index) => { |
|
if (results[index] != null) return |
|
if (!idbByKind.has(kind)) { |
|
idbByKind.set(kind, []) |
|
} |
|
idbByKind.get(kind)!.push({ pubkey, index }) |
|
}) |
|
|
|
const missingParams: { pubkey: string; kind: number; index: number }[] = [] |
|
|
|
await Promise.allSettled( |
|
Array.from(idbByKind.entries()).map(async ([kind, items]) => { |
|
const pubkeys = items.map((x) => x.pubkey) |
|
try { |
|
const indexedDbEvents = await indexedDb.getManyReplaceableEvents(pubkeys, kind) |
|
|
|
items.forEach(({ pubkey, index }, idx) => { |
|
const event = indexedDbEvents[idx] |
|
if (event && event !== null) { |
|
results[index] = event |
|
eventsMap.set(`${pubkey}:${kind}`, event) |
|
this.refreshInBackground(pubkey, kind) |
|
} else { |
|
missingParams.push({ pubkey, kind, index }) |
|
} |
|
}) |
|
} catch (error) { |
|
logger.warn('[ReplaceableEventService] IndexedDB batch query error', { |
|
kind, |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
for (const { pubkey, index } of items) { |
|
missingParams.push({ pubkey, kind, index }) |
|
} |
|
} |
|
}) |
|
) |
|
|
|
for (let mi = missingParams.length - 1; mi >= 0; mi--) { |
|
const m = missingParams[mi]! |
|
if ( |
|
m.kind !== kinds.Contacts && |
|
m.kind !== kinds.RelayList && |
|
m.kind !== ExtendedKind.PAYMENT_INFO |
|
) { |
|
continue |
|
} |
|
const hits = client.eventService.listSessionEventsAuthoredBy(m.pubkey, { |
|
kinds: [m.kind], |
|
limit: 20 |
|
}) |
|
const sessionEv = hits[0] |
|
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { |
|
results[m.index] = sessionEv |
|
eventsMap.set(`${m.pubkey}:${m.kind}`, sessionEv) |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey: m.pubkey, kind: m.kind }, |
|
Promise.resolve(sessionEv) |
|
) |
|
missingParams.splice(mi, 1) |
|
} |
|
} |
|
|
|
// Step 2: Only fetch missing events from network |
|
if (missingParams.length === 0) { |
|
return results |
|
} |
|
|
|
const networkMissing: { pubkey: string; kind: number; index: number }[] = [] |
|
for (const m of missingParams) { |
|
if (m.kind === kinds.Metadata) { |
|
const ev = client.eventService.getSessionMetadataForPubkey(m.pubkey) |
|
if (ev && !shouldDropEventOnIngest(ev)) { |
|
results[m.index] = ev |
|
eventsMap.set(`${m.pubkey}:${m.kind}`, ev) |
|
continue |
|
} |
|
} |
|
networkMissing.push(m) |
|
} |
|
|
|
if (networkMissing.length > 0) { |
|
// Group missing params by kind for network fetch |
|
const missingGroups = new Map<number, { pubkey: string; index: number }[]>() |
|
networkMissing.forEach(({ pubkey, kind, index }) => { |
|
if (!missingGroups.has(kind)) { |
|
missingGroups.set(kind, []) |
|
} |
|
missingGroups.get(kind)!.push({ pubkey, index }) |
|
}) |
|
|
|
await Promise.allSettled( |
|
Array.from(missingGroups.entries()).map(async ([kind, missingItems]) => { |
|
const pubkeys = missingItems.map(item => item.pubkey) |
|
// ALWAYS use comprehensive relay list: author's outboxes + user's inboxes + defaults |
|
// For profiles/metadata: includes user's own relays (read/write/local) + PROFILE_RELAY_URLS |
|
// For each pubkey, build comprehensive relay list |
|
// CRITICAL FIX: For batch fetches, use default relays instead of fetching relay lists for each author |
|
// Fetching relay lists for hundreds of authors causes infinite loops and browser crashes |
|
// Use PROFILE_RELAY_URLS + FAST_READ_RELAY_URLS for profiles, or FAST_READ_RELAY_URLS for other kinds. |
|
// For metadata with a logged-in user, merge defaults with {@link buildComprehensiveRelayList}: inboxes (read), |
|
// local/cache relays (10432), favorite relays (10012), plus profile + fast read — same idea as favorites feed |
|
// / inbox-scoped discovery without per-author relay list fetches. |
|
// Following's Favorites (Explore): kind 10012 batch uses {@link buildExploreProfileAndUserRelayList} |
|
// (profile + FAST_READ + viewer read/write/local when logged in). |
|
let relayUrls: string[] |
|
if (kind === kinds.Metadata) { |
|
try { |
|
relayUrls = await buildProfileAndUserRelayList(client.pubkey) |
|
} catch { |
|
relayUrls = [...PROFILE_RELAY_URLS] |
|
} |
|
} else if (kind === ExtendedKind.FAVORITE_RELAYS) { |
|
relayUrls = await buildExploreProfileAndUserRelayList(client.pubkey) |
|
} else if (kind === 10001) { |
|
// Pin lists (NIP-51): same pitfall as profile media — FAST_READ alone misses aggr / profile mirrors, |
|
// and 100ms EOSE loses the race when several relays are down. |
|
relayUrls = Array.from( |
|
new Set( |
|
[...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( |
|
(u) => normalizeUrl(u) || u |
|
) |
|
) |
|
).filter(Boolean) |
|
} else if (kind === kinds.Contacts) { |
|
// Contacts (kind 3): aggregators + profile mirrors + fast read. |
|
relayUrls = Array.from( |
|
new Set( |
|
[...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( |
|
(u) => normalizeUrl(u) || u |
|
) |
|
) |
|
).filter(Boolean) |
|
} else if (kind === kinds.RelayList) { |
|
// NIP-65 (10002): aggregators + profile mirrors + fast read. |
|
relayUrls = Array.from( |
|
new Set( |
|
[...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( |
|
(u) => normalizeUrl(u) || u |
|
) |
|
) |
|
).filter(Boolean) |
|
} else if (kind === kinds.Mutelist || kind === kinds.BookmarkList) { |
|
// Mute / bookmark lists: same distribution as contacts; FAST_READ + mirrors. |
|
relayUrls = Array.from( |
|
new Set( |
|
[...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( |
|
(u) => normalizeUrl(u) || u |
|
) |
|
) |
|
).filter(Boolean) |
|
} else if (kind === ExtendedKind.PAYMENT_INFO) { |
|
// NIP-A3 kind 10133: aggregators + profile mirrors + fast read. |
|
relayUrls = Array.from( |
|
new Set( |
|
[...PROFILE_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( |
|
(u) => normalizeUrl(u) || u |
|
) |
|
) |
|
).filter(Boolean) |
|
} else { |
|
relayUrls = [...FAST_READ_RELAY_URLS] |
|
} |
|
relayUrls = prependAggrNostrLandIfViewerEligible( |
|
stripLocalNetworkRelaysForWssReq(relayUrls) |
|
) |
|
// Contacts + NIP-65 need the same patience as pins/payment: 100ms EOSE loses the race on slow relays |
|
// and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author). |
|
const isSlowReplaceableBatch = |
|
kind === kinds.Metadata || |
|
kind === 10001 || |
|
kind === ExtendedKind.PAYMENT_INFO || |
|
kind === kinds.Contacts || |
|
kind === kinds.RelayList || |
|
kind === kinds.Mutelist || |
|
kind === kinds.BookmarkList |
|
const multiAuthorBatch = pubkeys.length > 1 |
|
// replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0 |
|
// (many `authors` in one filter) that stops the subscription while most profiles are still in flight. |
|
// Kind 0: never race — first relay may answer without Damus/mirrors; wait for EOSE window so the |
|
// newest metadata across relays is collected (same as multi-author batches). |
|
// Slow replaceables (10133 payment, pins, contacts, …): never race — a single-author fetch used to |
|
// set replaceableRace=true and close after 100ms EOSE, missing events on profile mirrors. |
|
const useReplaceableRace = |
|
kind === kinds.Metadata || isSlowReplaceableBatch ? false : !multiAuthorBatch |
|
const queryOpts = { |
|
replaceableRace: useReplaceableRace, |
|
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, |
|
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 |
|
} |
|
|
|
let events: NEvent[] |
|
if (kind === kinds.Metadata && pubkeys.length > METADATA_BATCH_AUTHORS_CHUNK) { |
|
const merged: NEvent[] = [] |
|
for (let off = 0; off < missingItems.length; off += METADATA_BATCH_AUTHORS_CHUNK) { |
|
const slice = missingItems.slice(off, off + METADATA_BATCH_AUTHORS_CHUNK) |
|
const chunkPubkeys = slice.map((m) => m.pubkey) |
|
const chunkMulti = chunkPubkeys.length > 1 |
|
const chunkRace = |
|
kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !chunkMulti |
|
const evts = await this.queryService.query( |
|
relayUrls, |
|
{ authors: chunkPubkeys, kinds: networkKindsForReplaceableFetch(kind) }, |
|
undefined, |
|
{ ...queryOpts, replaceableRace: chunkRace } |
|
) |
|
if (kind === kinds.Metadata) { |
|
this.ingestMetadataCoFetchSidecars(evts) |
|
} |
|
merged.push(...evts) |
|
} |
|
events = merged |
|
} else { |
|
events = await this.queryService.query( |
|
relayUrls, |
|
{ |
|
authors: pubkeys, |
|
kinds: networkKindsForReplaceableFetch(kind) |
|
}, |
|
undefined, |
|
queryOpts |
|
) |
|
if (kind === kinds.Metadata) { |
|
this.ingestMetadataCoFetchSidecars(events) |
|
} |
|
} |
|
|
|
// CRITICAL: Limit the number of events processed to prevent memory issues during rapid scrolling |
|
// If we have too many events, only process the most recent ones per pubkey |
|
if (events.length > 1000) { |
|
logger.warn('[ReplaceableEventService] Large batch detected, limiting processing', { |
|
kind, |
|
eventCount: events.length, |
|
pubkeyCount: pubkeys.length |
|
}) |
|
// Group by pubkey and keep only the most recent event per pubkey |
|
const eventsByPubkey = new Map<string, NEvent>() |
|
for (const event of events) { |
|
const key = `${event.pubkey}:${event.kind}` |
|
const existing = eventsByPubkey.get(key) |
|
if (!existing || existing.created_at < event.created_at) { |
|
eventsByPubkey.set(key, event) |
|
} |
|
} |
|
// Convert back to array, but limit to reasonable size |
|
const limitedEvents = Array.from(eventsByPubkey.values()).slice(0, 500) |
|
// Use limited events for processing |
|
for (const event of limitedEvents) { |
|
this.applyNetworkReplaceableEventToBatch(event, kind, missingItems, results, eventsMap) |
|
} |
|
} else { |
|
// Normal processing for smaller batches |
|
for (const event of events) { |
|
this.applyNetworkReplaceableEventToBatch(event, kind, missingItems, results, eventsMap) |
|
} |
|
} |
|
|
|
const idbFill = missingItems.filter(({ index }) => results[index] == null) |
|
if (idbFill.length > 0) { |
|
try { |
|
const order = idbFill.map((m) => m.pubkey) |
|
const late = await indexedDb.getManyReplaceableEvents(order, kind) |
|
late.forEach((ev, j) => { |
|
if (!ev || shouldDropEventOnIngest(ev)) return |
|
const slot = idbFill[j] |
|
if (!slot) return |
|
results[slot.index] = ev |
|
eventsMap.set(`${slot.pubkey}:${kind}`, ev) |
|
}) |
|
} catch { |
|
/* ignore */ |
|
} |
|
} |
|
|
|
}) |
|
) |
|
} |
|
|
|
// Step 3: Persist hits only. Do not write negative cache rows (`value: null`) — optional kinds |
|
// (e.g. 10432 cache relays, 10001 pins) are missing for most pubkeys and would flood IndexedDB. |
|
await Promise.allSettled( |
|
Array.from(eventsMap.values()).map(async (event) => { |
|
await indexedDb.putReplaceableEvent(event) |
|
}) |
|
) |
|
|
|
return results |
|
} |
|
|
|
/** |
|
* Private: Batch load function for replaceable events with d-tag |
|
*/ |
|
private async replaceableEventBatchLoadFn( |
|
params: readonly { pubkey: string; kind: number; d?: string }[] |
|
): Promise<(NEvent | null)[]> { |
|
const results: (NEvent | null)[] = new Array(params.length).fill(null) |
|
const missing: { pubkey: string; kind: number; d: string; index: number }[] = [] |
|
|
|
await Promise.allSettled( |
|
params.map(async ({ pubkey, kind, d }, index) => { |
|
if (!d) { |
|
results[index] = null |
|
return |
|
} |
|
try { |
|
const idb = await indexedDb.getReplaceableEvent(pubkey, kind, d) |
|
if (idb && idb.kind === kind && !shouldDropEventOnIngest(idb)) { |
|
results[index] = idb |
|
return |
|
} |
|
} catch { |
|
/* optional */ |
|
} |
|
const session = client.eventService.findSessionReplaceableByNaddr({ |
|
pubkey, |
|
kind, |
|
identifier: d |
|
}) |
|
if (session && session.kind === kind && !shouldDropEventOnIngest(session)) { |
|
results[index] = session |
|
return |
|
} |
|
missing.push({ pubkey, kind, d, index }) |
|
}) |
|
) |
|
|
|
if (missing.length === 0) { |
|
return results |
|
} |
|
|
|
const eventsMap = new Map<string, NEvent>() |
|
const groups = new Map<string, typeof missing>() |
|
for (const item of missing) { |
|
const key = `${item.kind}:${item.d}` |
|
if (!groups.has(key)) groups.set(key, []) |
|
groups.get(key)!.push(item) |
|
} |
|
|
|
await Promise.allSettled( |
|
Array.from(groups.values()).map(async (items) => { |
|
const { kind, d } = items[0]! |
|
const pubkeys = items.map((item) => item.pubkey) |
|
const relayUrls = stripLocalNetworkRelaysForWssReq( |
|
isDocumentRelayKind(kind) |
|
? [...new Set([...FAST_READ_RELAY_URLS, ...DOCUMENT_RELAY_URLS])] |
|
: [...FAST_READ_RELAY_URLS] |
|
) |
|
|
|
const filter: Filter = { |
|
authors: pubkeys, |
|
kinds: [kind], |
|
'#d': [d] |
|
} |
|
|
|
const events = await this.queryService.query(relayUrls, filter, undefined, { |
|
replaceableRace: true, |
|
eoseTimeout: isDocumentRelayKind(kind) ? 2500 : 100, |
|
globalTimeout: isDocumentRelayKind(kind) ? 8000 : 2000 |
|
}) |
|
|
|
for (const event of events) { |
|
if (event.kind !== kind || shouldDropEventOnIngest(event)) continue |
|
const eventKey = `${event.pubkey}:${event.kind}:${d}` |
|
const existing = eventsMap.get(eventKey) |
|
if (!existing || existing.created_at < event.created_at) { |
|
eventsMap.set(eventKey, event) |
|
} |
|
} |
|
}) |
|
) |
|
|
|
for (const { pubkey, kind, d, index } of missing) { |
|
const eventKey = `${pubkey}:${kind}:${d}` |
|
const event = eventsMap.get(eventKey) |
|
if (event) { |
|
results[index] = event |
|
void indexedDb.putReplaceableEvent(event) |
|
} |
|
} |
|
|
|
return results |
|
} |
|
|
|
/** Persist kind 10133 rows returned alongside a kind-0 REQ (same filter, separate cache slots). */ |
|
private ingestMetadataCoFetchSidecars(events: readonly NEvent[]): void { |
|
for (const event of events) { |
|
if (event.kind !== ExtendedKind.PAYMENT_INFO || shouldDropEventOnIngest(event)) continue |
|
void this.updateReplaceableEventFromBigRelaysCache(event) |
|
} |
|
} |
|
|
|
private applyNetworkReplaceableEventToBatch( |
|
event: NEvent, |
|
requestedKind: number, |
|
missingItems: { pubkey: string; index: number }[], |
|
results: (NEvent | null)[], |
|
eventsMap: Map<string, NEvent> |
|
): void { |
|
const kindKey = `${event.pubkey}:${event.kind}` |
|
const existing = eventsMap.get(kindKey) |
|
if (!existing || existing.created_at < event.created_at) { |
|
eventsMap.set(kindKey, event) |
|
} |
|
if (event.kind !== requestedKind) return |
|
const itemIndex = missingItems.findIndex((item) => item.pubkey === event.pubkey) |
|
if (itemIndex < 0) return |
|
const paramIndex = missingItems[itemIndex]!.index |
|
results[paramIndex] = event |
|
} |
|
|
|
/** |
|
* Private: Update cache for replaceable event from big relays |
|
*/ |
|
private async updateReplaceableEventFromBigRelaysCache(event: NEvent): Promise<void> { |
|
if (!indexedDb.hasReplaceableEventStoreForKind(event.kind)) { |
|
return |
|
} |
|
const d = event.tags.find((t) => t[0] === 'd')?.[1] |
|
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: event.pubkey, kind: event.kind }) |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey: event.pubkey, kind: event.kind }, |
|
Promise.resolve(event) |
|
) |
|
this.replaceableEventDataLoader.clear({ pubkey: event.pubkey, kind: event.kind, d }) |
|
this.replaceableEventDataLoader.prime( |
|
{ pubkey: event.pubkey, kind: event.kind, d }, |
|
Promise.resolve(event) |
|
) |
|
try { |
|
await indexedDb.putReplaceableEvent(event) |
|
} catch { |
|
// Tombstone or validation — in-memory loaders still primed for this session |
|
} |
|
} |
|
|
|
/** |
|
* =========== Profile Methods =========== |
|
*/ |
|
|
|
/** Direct kind-0 REQ on {@link PROFILE_RELAY_URLS} by `authors` (npub / hex lookup — not NIP-50 text). */ |
|
private async fetchKind0FromProfileRelays(pubkey: string): Promise<NEvent | undefined> { |
|
const pk = pubkey.trim().toLowerCase() |
|
if (!/^[0-9a-f]{64}$/.test(pk)) return undefined |
|
if (shouldDeferPerPubkeyProfileNetwork(pk)) return undefined |
|
|
|
await ReplaceableEventService.acquireKind0ProfileRelaySlot() |
|
try { |
|
const relays = prependAggrNostrLandIfViewerEligible( |
|
stripLocalNetworkRelaysForWssReq( |
|
Array.from( |
|
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean)) |
|
) |
|
) |
|
) |
|
if (relays.length === 0) return undefined |
|
|
|
try { |
|
const events = await this.queryService.query( |
|
relays, |
|
{ authors: [pk], kinds: networkKindsForReplaceableFetch(kinds.Metadata), limit: 4 }, |
|
undefined, |
|
{ |
|
replaceableRace: false, |
|
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, |
|
foreground: true, |
|
relayOpSource: 'ReplaceableEventService.fetchKind0FromProfileRelays' |
|
} |
|
) |
|
this.ingestMetadataCoFetchSidecars(events) |
|
const metadataRows = events.filter((e) => e.kind === kinds.Metadata) |
|
if (metadataRows.length === 0) return undefined |
|
const sorted = metadataRows.sort((a, b) => b.created_at - a.created_at) |
|
return sorted[0] |
|
} catch (error) { |
|
logger.warn('[ReplaceableEventService] fetchKind0FromProfileRelays failed', { |
|
pubkey: pk.slice(0, 8), |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
return undefined |
|
} |
|
} finally { |
|
ReplaceableEventService.releaseKind0ProfileRelaySlot() |
|
} |
|
} |
|
|
|
/** |
|
* Fetch profile event by id (hex, npub, nprofile) |
|
*/ |
|
async fetchProfileEvent( |
|
id: string, |
|
_skipCache: boolean = false, |
|
options: FetchProfileEventOptions = {} |
|
): Promise<NEvent | undefined> { |
|
const allowWideRelayFallback = options.allowWideRelayFallback === true |
|
let pubkey: string | undefined |
|
let relays: string[] = [] |
|
if (/^[0-9a-f]{64}$/.test(id)) { |
|
pubkey = id |
|
} else { |
|
try { |
|
const { data, type } = nip19.decode(id) |
|
switch (type) { |
|
case 'npub': |
|
pubkey = data |
|
break |
|
case 'nprofile': |
|
pubkey = data.pubkey |
|
if (data.relays) relays = data.relays |
|
break |
|
} |
|
} catch (error) { |
|
logger.error('[ReplaceableEventService] Failed to decode bech32 ID', { |
|
id, |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
} |
|
} |
|
|
|
if (!pubkey) { |
|
logger.error('[ReplaceableEventService] Invalid id - no pubkey extracted', { id }) |
|
throw new Error('Invalid id') |
|
} |
|
|
|
// Local-first: session LRU, then IndexedDB — before any PROFILE_RELAY / DataLoader / wide relay pass. |
|
if (!_skipCache) { |
|
const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) |
|
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey, kind: kinds.Metadata }, |
|
Promise.resolve(sessionEv) |
|
) |
|
await this.indexProfile(sessionEv) |
|
void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) |
|
if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { |
|
this.refreshInBackground(pubkey, kinds.Metadata) |
|
} |
|
return sessionEv |
|
} |
|
try { |
|
const idbEv = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) |
|
if (idbEv && !shouldDropEventOnIngest(idbEv)) { |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey, kind: kinds.Metadata }, |
|
Promise.resolve(idbEv) |
|
) |
|
await this.indexProfile(idbEv) |
|
if (!shouldDeferPerPubkeyProfileNetwork(pubkey)) { |
|
this.refreshInBackground(pubkey, kinds.Metadata) |
|
} |
|
return idbEv |
|
} |
|
} catch { |
|
/* ignore IDB read errors — fall through to network */ |
|
} |
|
} |
|
|
|
/** When batch or wide relay steps miss, return session row if we had one earlier in the call. */ |
|
let sessionFallback: NEvent | undefined |
|
if (!_skipCache) { |
|
const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) |
|
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { |
|
sessionFallback = sessionEv |
|
} |
|
} |
|
|
|
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) { |
|
return sessionFallback |
|
} |
|
|
|
// Relay hints from bech32 (nprofile, etc.) — highest priority in wide fallback only |
|
const relayHints = relays.length > 0 ? [...relays] : [] |
|
|
|
if (allowWideRelayFallback) { |
|
const fromProfileRelays = await this.fetchKind0FromProfileRelays(pubkey) |
|
if (fromProfileRelays) { |
|
this.replaceableEventFromBigRelaysDataloader.prime( |
|
{ pubkey, kind: kinds.Metadata }, |
|
Promise.resolve(fromProfileRelays) |
|
) |
|
await this.indexProfile(fromProfileRelays) |
|
void indexedDb.putReplaceableEvent(fromProfileRelays).catch(() => {}) |
|
return fromProfileRelays |
|
} |
|
} |
|
|
|
// Step 1: DataLoader (IndexedDB + batched profile relay stack) |
|
// CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions |
|
// DataLoader already uses default relays internally and batches all profile fetches |
|
// We'll use relay hints in Step 2/3 only if Step 1 fails |
|
// fetchReplaceableEvent uses DataLoader which checks IndexedDB first, then queries default relays |
|
// Passing empty array ensures DataLoader is used (batched) - this prevents individual subscriptions |
|
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, []) |
|
|
|
if (profileEvent) { |
|
await this.indexProfile(profileEvent) |
|
return profileEvent |
|
} |
|
|
|
if (!allowWideRelayFallback) { |
|
return sessionFallback |
|
} |
|
|
|
await ReplaceableEventService.acquireProfileFallbackNetworkSlot() |
|
try { |
|
// Step 2: Only after cache + default relays miss — NIP-65 relay list (timeout-capped), then hints + outbox/inbox + defaults. |
|
let authorRelayList: { read?: string[]; write?: string[] } | null = null |
|
try { |
|
const hasLocal10002 = await ReplaceableEventService.hasRelayListInLocalCache(pubkey) |
|
if (hasLocal10002) { |
|
authorRelayList = await client.peekRelayListFromStorage(pubkey) |
|
} else { |
|
const relayListPromise = client.fetchRelayList(pubkey) |
|
const timeoutPromise = new Promise<null>((resolve) => { |
|
setTimeout(() => { |
|
resolve(null) |
|
}, 2800) |
|
}) |
|
authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) |
|
if (authorRelayList == null) { |
|
authorRelayList = await client.peekRelayListFromStorage(pubkey) |
|
} |
|
} |
|
} catch (error) { |
|
logger.error('[ReplaceableEventService] Failed to fetch author relay list', { |
|
pubkey, |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
} |
|
|
|
const authorRelays = authorRelayList |
|
? [ |
|
...(authorRelayList.write || []).slice(0, 10), |
|
...(authorRelayList.read || []).slice(0, 10) |
|
] |
|
: [] |
|
|
|
const expandedRelays = prependAggrNostrLandIfViewerEligible( |
|
stripLocalNetworkRelaysForWssReq( |
|
Array.from( |
|
new Set([ |
|
...relayHints, |
|
...authorRelays, |
|
...PROFILE_RELAY_URLS, |
|
...FAST_READ_RELAY_URLS |
|
]) |
|
) |
|
) |
|
) |
|
|
|
const profileFromExpanded = await this.fetchReplaceableEvent( |
|
pubkey, |
|
kinds.Metadata, |
|
undefined, |
|
expandedRelays |
|
) |
|
if (profileFromExpanded) { |
|
await this.indexProfile(profileFromExpanded) |
|
return profileFromExpanded |
|
} |
|
|
|
// Step 3: Last resort — broad relay query (timeout-bounded in query layer) |
|
try { |
|
const userPubkey = client.pubkey |
|
const comprehensiveRelays = await buildComprehensiveRelayList({ |
|
authorPubkey: pubkey, |
|
userPubkey: userPubkey || undefined, |
|
relayHints: relayHints.length > 0 ? relayHints : undefined, |
|
includeUserOwnRelays: true, |
|
includeFavoriteRelays: true, |
|
includeProfileFetchRelays: true, |
|
includeFastReadRelays: true, |
|
includeFastWriteRelays: false, |
|
includeSearchableRelays: true, |
|
includeLocalRelays: true |
|
}) |
|
|
|
if (comprehensiveRelays.length > 0) { |
|
const relaysForQuery = prependAggrNostrLandIfViewerEligible( |
|
stripLocalNetworkRelaysForWssReq(comprehensiveRelays) |
|
) |
|
const events = await this.queryService.query( |
|
relaysForQuery, |
|
{ |
|
authors: [pubkey], |
|
kinds: networkKindsForReplaceableFetch(kinds.Metadata) |
|
}, |
|
undefined, |
|
{ |
|
replaceableRace: false, |
|
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, |
|
foreground: true |
|
} |
|
) |
|
|
|
this.ingestMetadataCoFetchSidecars(events) |
|
const metadataRows = events.filter((e) => e.kind === kinds.Metadata) |
|
if (metadataRows.length > 0) { |
|
const sortedEvents = metadataRows.sort((a, b) => b.created_at - a.created_at) |
|
const found = sortedEvents[0]! |
|
await this.indexProfile(found) |
|
return found |
|
} |
|
} |
|
} catch (error) { |
|
logger.error('[ReplaceableEventService] Comprehensive search failed', { |
|
pubkey, |
|
error: error instanceof Error ? error.message : String(error) |
|
}) |
|
} |
|
} finally { |
|
ReplaceableEventService.releaseProfileFallbackNetworkSlot() |
|
} |
|
|
|
return sessionFallback |
|
} |
|
|
|
/** |
|
* Fetch profile by id (hex, npub, nprofile) |
|
*/ |
|
async fetchProfile(id: string, skipCache: boolean = false): Promise<TProfile | undefined> { |
|
const profileEvent = await this.fetchProfileEvent(id, skipCache) |
|
if (profileEvent) { |
|
return getProfileFromEvent(profileEvent) |
|
} |
|
|
|
try { |
|
const pubkey = userIdToPubkey(id) |
|
return { pubkey, npub: pubkeyToNpub(pubkey) ?? '', username: formatPubkey(pubkey) } |
|
} catch { |
|
return undefined |
|
} |
|
} |
|
|
|
/** |
|
* Get profile from IndexedDB only |
|
*/ |
|
async getProfileFromIndexedDB(id: string): Promise<TProfile | undefined> { |
|
let pubkey: string | undefined |
|
try { |
|
if (/^[0-9a-f]{64}$/.test(id)) { |
|
pubkey = id |
|
} else { |
|
const { data, type } = nip19.decode(id) |
|
if (type === 'npub') pubkey = data |
|
else if (type === 'nprofile') pubkey = data.pubkey |
|
} |
|
} catch { |
|
return undefined |
|
} |
|
if (!pubkey) return undefined |
|
const event = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) |
|
if (!event || event === null) return undefined |
|
return getProfileFromEvent(event) |
|
} |
|
|
|
/** Fetch profiles for multiple pubkeys (profile mirrors + viewer's own relays only). */ |
|
async fetchProfilesForPubkeys(pubkeys: string[]): Promise<TProfile[]> { |
|
const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64))) |
|
if (deduped.length === 0) return [] |
|
registerProfileBatchPubkeys(deduped) |
|
try { |
|
return await racePromiseWithTimeout( |
|
this.fetchProfilesForPubkeysBody(deduped), |
|
FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, |
|
'fetchProfilesForPubkeys' |
|
) |
|
} catch (err) { |
|
if (isPromiseTimeoutError(err)) { |
|
logger.warn('[ReplaceableEventService] fetchProfilesForPubkeys exceeded wall timeout', { |
|
pubkeyCount: deduped.length |
|
}) |
|
} else { |
|
logger.warn('[ReplaceableEventService] fetchProfilesForPubkeys failed; using session / IndexedDB only', { |
|
pubkeyCount: deduped.length, |
|
error: err instanceof Error ? err.message : String(err) |
|
}) |
|
} |
|
return this.fetchProfilesForPubkeysLocalFallback(deduped) |
|
} finally { |
|
unregisterProfileBatchPubkeys(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) |
|
const gapIdx: number[] = [] |
|
for (let i = 0; i < deduped.length; i++) { |
|
if (!events[i]) gapIdx.push(i) |
|
} |
|
if (gapIdx.length > 0) { |
|
try { |
|
const order = gapIdx.map((i) => deduped[i]!) |
|
const late = await indexedDb.getManyReplaceableEvents(order, kinds.Metadata) |
|
const patched = [...events] |
|
gapIdx.forEach((origIdx, j) => { |
|
const ev = late[j] |
|
if (ev && !shouldDropEventOnIngest(ev)) { |
|
patched[origIdx] = ev |
|
} |
|
}) |
|
events = patched |
|
} catch { |
|
/* ignore */ |
|
} |
|
} |
|
|
|
/** |
|
* Batched kind-0 REQ / DataLoader can miss rows that already exist in session or IndexedDB (ordering, |
|
* timing, or chunk boundaries). Hydrate gaps from local caches first; only then hit the network. |
|
*/ |
|
const gapIndices: number[] = [] |
|
for (let i = 0; i < deduped.length; i++) { |
|
if (!events[i]) gapIndices.push(i) |
|
} |
|
const LOCAL_GAP_CHUNK = 16 |
|
for (let off = 0; off < gapIndices.length; off += LOCAL_GAP_CHUNK) { |
|
const slice = gapIndices.slice(off, off + LOCAL_GAP_CHUNK) |
|
await Promise.allSettled( |
|
slice.map(async (idx) => { |
|
const pubkey = deduped[idx]! |
|
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 */ |
|
} |
|
} |
|
if (ev) events[idx] = ev |
|
}) |
|
) |
|
} |
|
|
|
return this.profilesFromMetadataEvents(deduped, events) |
|
} |
|
|
|
/** |
|
* Index profile for search (calls callback if provided) |
|
*/ |
|
private async indexProfile(profileEvent: NEvent): Promise<void> { |
|
if (this.onProfileIndexed) { |
|
await this.onProfileIndexed(profileEvent) |
|
} |
|
} |
|
|
|
/** |
|
* =========== Follow Methods =========== |
|
*/ |
|
|
|
/** |
|
* Fetch follow list event. |
|
* When relayUrls are provided (e.g. user write + search relays), queries those directly. |
|
* Otherwise uses the default relay set (PROFILE_RELAY_URLS + FAST_READ). |
|
*/ |
|
/** Hard cap: {@link fetchReplaceableEvent} can otherwise wedge the DataLoader chain when relays never answer. */ |
|
private static readonly FETCH_FOLLOW_LIST_REPLACEABLE_TIMEOUT_MS = 14_000 |
|
|
|
async fetchFollowListEvent(pubkey: string, relayUrls?: string[]): Promise<NEvent | undefined> { |
|
if (relayUrls && relayUrls.length > 0) { |
|
const normalized = Array.from( |
|
new Set(relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)) |
|
) |
|
const events = await this.queryService.query( |
|
normalized, |
|
{ authors: [pubkey], kinds: [kinds.Contacts], limit: 1 }, |
|
undefined, |
|
{ replaceableRace: true, eoseTimeout: 1500, globalTimeout: 8000 } |
|
) |
|
const latest = events.sort((a, b) => b.created_at - a.created_at)[0] |
|
return latest |
|
} |
|
const fromNetwork = await Promise.race([ |
|
this.fetchReplaceableEvent(pubkey, kinds.Contacts), |
|
new Promise<undefined>((resolve) => |
|
setTimeout(() => resolve(undefined), ReplaceableEventService.FETCH_FOLLOW_LIST_REPLACEABLE_TIMEOUT_MS) |
|
) |
|
]) |
|
if (fromNetwork) return fromNetwork |
|
try { |
|
const fromIdb = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts) |
|
return fromIdb ?? undefined |
|
} catch { |
|
return undefined |
|
} |
|
} |
|
|
|
/** |
|
* Fetch followings (pubkeys from follow list) |
|
*/ |
|
async fetchFollowings(pubkey: string): Promise<string[]> { |
|
const followListEvent = await this.fetchFollowListEvent(pubkey) |
|
return followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] |
|
} |
|
|
|
/** |
|
* =========== Specialized Replaceable Event Methods =========== |
|
*/ |
|
|
|
/** |
|
* Fetch mute list event |
|
*/ |
|
async fetchMuteListEvent(pubkey: string): Promise<NEvent | undefined> { |
|
return await this.fetchReplaceableEvent(pubkey, kinds.Mutelist) |
|
} |
|
|
|
/** |
|
* Fetch bookmark list event |
|
*/ |
|
async fetchBookmarkListEvent(pubkey: string): Promise<NEvent | undefined> { |
|
return this.fetchReplaceableEvent(pubkey, kinds.BookmarkList) |
|
} |
|
|
|
/** |
|
* Fetch blossom server list event |
|
*/ |
|
async fetchBlossomServerListEvent(pubkey: string): Promise<NEvent | undefined> { |
|
return await this.fetchReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST) |
|
} |
|
|
|
/** |
|
* Fetch blossom server list (URLs) |
|
*/ |
|
async fetchBlossomServerList(pubkey: string): Promise<string[]> { |
|
const evt = await this.fetchBlossomServerListEvent(pubkey) |
|
const fromEvent = evt ? getServersFromServerTags(evt.tags) : [] |
|
const seen = new Set<string>() |
|
const out: string[] = [] |
|
const add = (raw: string) => { |
|
const n = normalizeHttpUrl(raw) |
|
if (!n || seen.has(n)) return |
|
seen.add(n) |
|
out.push(n) |
|
} |
|
fromEvent.forEach(add) |
|
RECOMMENDED_BLOSSOM_SERVERS.forEach(add) |
|
return out |
|
} |
|
|
|
/** |
|
* Fetch interest list event |
|
*/ |
|
async fetchInterestListEvent(pubkey: string): Promise<NEvent | undefined> { |
|
return await this.fetchReplaceableEvent(pubkey, 10015) |
|
} |
|
|
|
/** |
|
* Fetch pin list event |
|
*/ |
|
async fetchPinListEvent(pubkey: string): Promise<NEvent | undefined> { |
|
return await this.fetchReplaceableEvent(pubkey, 10001) |
|
} |
|
|
|
/** |
|
* Fetch payment info event |
|
*/ |
|
async getPaymentInfoFromIndexedDB(pubkey: string): Promise<NEvent | undefined> { |
|
try { |
|
const row = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO) |
|
if (!row || row === null) return undefined |
|
return row as NEvent |
|
} catch { |
|
return undefined |
|
} |
|
} |
|
|
|
async fetchPaymentInfoEvent(pubkey: string): Promise<NEvent | undefined> { |
|
if (shouldDeferPerPubkeyProfileNetwork(pubkey)) { |
|
return this.getPaymentInfoFromIndexedDB(pubkey) |
|
} |
|
return await this.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO) |
|
} |
|
|
|
/** Drop in-memory kind 0 / 10133 loaders so the next read picks up IndexedDB after a profile-view refresh. */ |
|
clearAuthorViewPaymentAndMetadataLoaders(pubkey: string): void { |
|
const pk = pubkey.trim().toLowerCase() |
|
if (!/^[0-9a-f]{64}$/.test(pk)) return |
|
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind: kinds.Metadata }) |
|
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind: ExtendedKind.PAYMENT_INFO }) |
|
} |
|
|
|
/** |
|
* Force refresh profile and payment info: clear in-memory loaders, pull from relays (incl. 10133), persist to IndexedDB. |
|
*/ |
|
async forceRefreshProfileAndPaymentInfoCache(pubkey: string): Promise<void> { |
|
const pk = pubkey.trim().toLowerCase() |
|
if (!/^[0-9a-f]{64}$/.test(pk)) return |
|
for (const kind of AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS) { |
|
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind }) |
|
} |
|
this.replaceableEventDataLoader.clear({ |
|
pubkey: pk, |
|
kind: ExtendedKind.PROFILE_BADGES, |
|
d: LEGACY_PROFILE_BADGES_D_TAG |
|
}) |
|
await this.refreshAuthorPublishedReplaceablesFromRelays(pk) |
|
} |
|
|
|
/** |
|
* Profile view: query a wide relay set for the author's published replaceables (kind 0, contacts, |
|
* NIP-65, mute list, bookmarks, pay, etc.), persist winners to IndexedDB, refresh in-memory loaders, |
|
* then dispatch `ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT` so the session can re-sync UI. |
|
*/ |
|
static readonly AUTHOR_REPLACEABLES_REFRESHED_EVENT = 'jumble:author-replaceables-refreshed' as const |
|
|
|
async refreshAuthorPublishedReplaceablesFromRelays(pubkey: string): Promise<void> { |
|
const pk = pubkey.trim().toLowerCase() |
|
if (!/^[0-9a-f]{64}$/.test(pk)) return |
|
|
|
const notBefore = this.authorProfileViewRefreshNotBeforeMs.get(pk) ?? 0 |
|
if (Date.now() < notBefore) return |
|
|
|
const inFlight = this.authorReplaceablesRefreshByPubkey.get(pk) |
|
if (inFlight) return inFlight |
|
|
|
const run = this.refreshAuthorPublishedReplaceablesFromRelaysBody(pk) |
|
this.authorReplaceablesRefreshByPubkey.set(pk, run) |
|
void run.finally(() => { |
|
if (this.authorReplaceablesRefreshByPubkey.get(pk) === run) { |
|
this.authorReplaceablesRefreshByPubkey.delete(pk) |
|
} |
|
}) |
|
return run |
|
} |
|
|
|
private async refreshAuthorPublishedReplaceablesFromRelaysBody(pk: string): Promise<void> { |
|
await ReplaceableEventService.acquireProfileFallbackNetworkSlot() |
|
try { |
|
let relayUrls: string[] |
|
try { |
|
relayUrls = await buildComprehensiveRelayList({ |
|
authorPubkey: pk, |
|
userPubkey: client.pubkey || undefined, |
|
includeUserOwnRelays: true, |
|
includeFavoriteRelays: true, |
|
includeProfileFetchRelays: true, |
|
includeFastReadRelays: true, |
|
includeFastWriteRelays: false, |
|
includeSearchableRelays: true, |
|
includeLocalRelays: true |
|
}) |
|
} catch { |
|
relayUrls = [] |
|
} |
|
if (relayUrls.length === 0) return |
|
|
|
const events = await this.queryService.query( |
|
relayUrls, |
|
{ authors: [pk], kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS] }, |
|
undefined, |
|
{ |
|
replaceableRace: false, |
|
eoseTimeout: 2500, |
|
globalTimeout: 14_000 |
|
} |
|
) |
|
|
|
const legacyProfileBadgeRows = await this.queryService.query( |
|
relayUrls, |
|
{ |
|
authors: [pk], |
|
kinds: [ExtendedKind.PROFILE_BADGES], |
|
'#d': [LEGACY_PROFILE_BADGES_D_TAG], |
|
limit: 10 |
|
}, |
|
undefined, |
|
{ |
|
replaceableRace: false, |
|
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
|
} |
|
) |
|
|
|
const bestByKind = new Map<number, NEvent>() |
|
for (const e of events) { |
|
if (shouldDropEventOnIngest(e)) continue |
|
const prev = bestByKind.get(e.kind) |
|
if (!prev || e.created_at > prev.created_at) { |
|
bestByKind.set(e.kind, e) |
|
} |
|
} |
|
|
|
const legacyProfileBadges = legacyProfileBadgeRows.filter((e) => !shouldDropEventOnIngest(e)).reduce< |
|
NEvent | undefined |
|
>((best, e) => (!best || e.created_at > best.created_at ? e : best), undefined) |
|
|
|
await Promise.allSettled( |
|
[...Array.from(bestByKind.values()), ...(legacyProfileBadges ? [legacyProfileBadges] : [])].map( |
|
async (ev) => { |
|
try { |
|
await indexedDb.putReplaceableEvent(ev) |
|
} catch { |
|
/* tombstone / validation */ |
|
} |
|
try { |
|
await this.updateReplaceableEventCache(ev) |
|
} catch { |
|
/* ignore */ |
|
} |
|
if (ev.kind === kinds.Metadata) { |
|
await this.indexProfile(ev) |
|
} |
|
}) |
|
) |
|
|
|
this.authorProfileViewRefreshNotBeforeMs.set(pk, Date.now() + 90_000) |
|
|
|
if (typeof window !== 'undefined') { |
|
window.dispatchEvent( |
|
new CustomEvent(ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, { detail: { pubkey: pk } }) |
|
) |
|
} |
|
} finally { |
|
ReplaceableEventService.releaseProfileFallbackNetworkSlot() |
|
} |
|
} |
|
|
|
/** |
|
* =========== Following Favorite Relays =========== |
|
*/ |
|
|
|
/** |
|
* Fetch following favorite relays |
|
*/ |
|
async fetchFollowingFavoriteRelays(pubkey: string, skipCache = false): Promise<[string, string[]][]> { |
|
if (!skipCache) { |
|
const cached = this.followingFavoriteRelaysCache.get(pubkey) |
|
if (cached) { |
|
return cached.catch((err: unknown) => { |
|
this.followingFavoriteRelaysCache.delete(pubkey) |
|
throw err |
|
}) |
|
} |
|
} |
|
const promise = this._fetchFollowingFavoriteRelays(pubkey).catch((err: unknown) => { |
|
this.followingFavoriteRelaysCache.delete(pubkey) |
|
throw err |
|
}) |
|
this.followingFavoriteRelaysCache.set(pubkey, promise) |
|
return promise |
|
} |
|
|
|
private async _fetchFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][]> { |
|
const followings = await this.fetchFollowings(pubkey) |
|
const followingsToProcess = followings.slice(0, 100) |
|
const [favoriteRelaysEvents, relayListEvents] = await Promise.all([ |
|
this.fetchReplaceableEventsFromProfileFetchRelays( |
|
followingsToProcess, |
|
ExtendedKind.FAVORITE_RELAYS |
|
), |
|
this.fetchReplaceableEventsFromProfileFetchRelays(followingsToProcess, kinds.RelayList) |
|
]) |
|
// Group by relay URL: Map<relayUrl, Set<pubkey>> |
|
const relayToUsers = new Map<string, Set<string>>() |
|
|
|
const addFollowingRelay = (followingPk: string, rawUrl: string) => { |
|
const normalizedUrl = |
|
(normalizeUrl(rawUrl) || normalizeAnyRelayUrl(rawUrl) || '').trim() || null |
|
if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) return |
|
if (!relayToUsers.has(normalizedUrl)) relayToUsers.set(normalizedUrl, new Set()) |
|
relayToUsers.get(normalizedUrl)!.add(followingPk) |
|
} |
|
|
|
for (let i = 0; i < followingsToProcess.length; i++) { |
|
const followingPubkey = followingsToProcess[i] |
|
if (!followingPubkey) continue |
|
|
|
const favEv = favoriteRelaysEvents[i] |
|
if (favEv) { |
|
favEv.tags.forEach(([tagName, tagValue]) => { |
|
if (!tagValue) return |
|
if (tagName === 'relay' || tagName === 'r') { |
|
addFollowingRelay(followingPubkey, tagValue) |
|
} |
|
}) |
|
} |
|
|
|
/** NIP-65 kind 10002 — most clients only publish this, not kind 10012 “favorite relays”. */ |
|
const nip65 = relayListEvents[i] |
|
if (nip65) { |
|
const rl = getRelayListFromEvent(nip65) |
|
for (const { url } of rl.originalRelays) { |
|
addFollowingRelay(followingPubkey, url) |
|
} |
|
} |
|
} |
|
|
|
// Convert to array format: [relayUrl, pubkeys[]] |
|
const result: [string, string[]][] = [] |
|
for (const [relayUrl, pubkeys] of relayToUsers.entries()) { |
|
result.push([relayUrl, Array.from(pubkeys)]) |
|
} |
|
|
|
return result |
|
} |
|
}
|
|
|