client.service.ts raw
1 import { DEPLOYMENT, ExtendedKind } from '@/constants'
2 import { Pubkey } from '@/domain'
3 import {
4 compareEvents,
5 getReplaceableCoordinate,
6 getReplaceableCoordinateFromEvent,
7 isReplaceableEvent
8 } from '@/lib/event'
9 import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
10 import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
11 import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
12 import { isSafari } from '@/lib/utils'
13 import { ISigner, TProfile, TPublishOptions, TRelayList, TSubRequestFilter } from '@/types'
14 import { sha256 } from '@noble/hashes/sha2'
15 import DataLoader from 'dataloader'
16 import dayjs from 'dayjs'
17 import { LRUCache } from 'lru-cache'
18 import {
19 EventTemplate,
20 Filter,
21 kinds,
22 matchFilters,
23 Event as NEvent,
24 nip19,
25 SimplePool
26 } from 'nostr-tools'
27 import { AbstractRelay } from 'nostr-tools/abstract-relay'
28 import { createEventCacheActor, EventCacheActor } from '@/actors/event-cache.actor'
29 import { createSubscription } from '@/actors/subscription.actor'
30 import { createTimelineActor, TimelineActor } from '@/actors/timeline.actor'
31 import { createUserIndexActor, UserIndexActor } from '@/actors/user-index.actor'
32 import indexedDb from './indexed-db.service'
33 import storage from './local-storage.service'
34 import managedOutboxService from './managed-outbox.service'
35 import nrcCacheRelayService from './nrc/nrc-cache-relay.service'
36 import relayDiscoveryService from './relay-discovery.service'
37 import relayListCacheService from './relay-list-cache.service'
38 import relayStatsService from './relay-stats.service'
39
40 /**
41 * Bootstrap relays used when no user relays are available yet.
42 * Essential for initial login when we need to fetch the user's relay list.
43 */
44 const BOOTSTRAP_RELAYS = DEPLOYMENT.defaultRelay
45 ? [DEPLOYMENT.defaultRelay]
46 : [
47 'wss://relay.damus.io/',
48 'wss://relay.nostr.band/',
49 'wss://nos.lol/',
50 'wss://purplepag.es/'
51 ]
52
53 class ClientService extends EventTarget {
54 static instance: ClientService
55
56 signer?: ISigner
57 pubkey?: string
58 currentRelays: string[] = []
59 /** After publishEvent resolves, this promise resolves to URLs of relays that failed */
60 lastPublishFailedRelays: Promise<string[]> = Promise.resolve([])
61 private pool: SimplePool
62
63 // Compound timeline key -> array of actor sub-keys
64 private compoundTimelines: Record<string, string[] | undefined> = {}
65 private replaceableEventCacheMap = new Map<string, NEvent>()
66 private eventCacheMap = new Map<string, Promise<NEvent | undefined>>()
67 private eventDataLoader = new DataLoader<string, NEvent | undefined>(
68 (ids) => Promise.all(ids.map((id) => this._fetchEvent(id))),
69 { cacheMap: this.eventCacheMap }
70 )
71 private fetchEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
72 this.fetchEventsFromBigRelays.bind(this),
73 { cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) }
74 )
75
76 // Actors - fix structural races in subscribe/cache/search/timeline
77 private eventCacheActor: EventCacheActor
78 private userIndexActor: UserIndexActor
79 private timelineActor: TimelineActor
80
81 constructor() {
82 super()
83 this.pool = new SimplePool()
84 this.pool.trackRelays = true
85
86 this.eventCacheActor = createEventCacheActor()
87 this.userIndexActor = createUserIndexActor()
88 this.timelineActor = createTimelineActor({
89 eventCacheActor: this.eventCacheActor,
90 pool: this.pool,
91 getSigner: () => this.signer,
92 trackEventSeenOn: (id, relay) => this.trackEventSeenOn(id, relay),
93 addEventToCache: (event) => this.addEventToCache(event)
94 })
95 this.addEventListener('newEvent', (data) => {
96 const evt = (data as CustomEvent<NEvent>).detail
97 this.timelineActor.injectEvent.send(evt)
98 })
99 }
100
101 public static getInstance(): ClientService {
102 if (!ClientService.instance) {
103 ClientService.instance = new ClientService()
104 ClientService.instance.init()
105 }
106 return ClientService.instance
107 }
108
109 async init() {
110 await relayStatsService.init()
111 await indexedDb.iterateProfileEvents(async (profileEvent) => { this.addUsernameToIndex(profileEvent) })
112 }
113
114 /**
115 * Get fallback relays: discovered relays (if cached) or hardcoded bootstrap relays.
116 * This ensures we always have relays available even before discovery has run.
117 */
118 private getFallbackRelays(): string[] {
119 const count = storage.getFallbackRelayCount()
120 const discovered = relayDiscoveryService.getTopRelays(count)
121 return discovered.length > 0 ? discovered : BOOTSTRAP_RELAYS
122 }
123
124 async determineTargetRelays(
125 event: NEvent,
126 { specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {}
127 ) {
128 if (event.kind === kinds.Report) {
129 const targetEventId = event.tags.find(tagNameEquals('e'))?.[1]
130 if (targetEventId) {
131 return this.getSeenEventRelayUrls(targetEventId)
132 }
133 }
134
135 // NRC-only config sync: don't publish config events to relays, only sync via NRC
136 const CONFIG_KINDS = [
137 kinds.Contacts, // 3
138 kinds.Mutelist, // 10000
139 kinds.RelayList, // 10002
140 30002, // Relay sets
141 ExtendedKind.FAVORITE_RELAYS, // 10012
142 30078 // Application data (settings sync)
143 ]
144 if (storage.getNrcOnlyConfigSync() && CONFIG_KINDS.includes(event.kind)) {
145 return [] // No relays - NRC will sync this event to paired devices
146 }
147
148 const relaySet = new Set<string>()
149 if (specifiedRelayUrls?.length) {
150 specifiedRelayUrls.forEach((url) => relaySet.add(url))
151 } else {
152 additionalRelayUrls?.forEach((url) => relaySet.add(url))
153
154 // Get user's relay list
155 const userRelayList = await this.fetchRelayList(event.pubkey)
156
157 // Add user's write relays
158 userRelayList.write.forEach((url) => relaySet.add(url))
159
160 // For events that mention others, add recipients' write relays
161 if (![kinds.Contacts, kinds.Mutelist, ExtendedKind.PINNED_USERS].includes(event.kind)) {
162 const mentions: string[] = []
163 event.tags.forEach(([tagName, tagValue]) => {
164 if (
165 ['p', 'P'].includes(tagName) &&
166 !!tagValue &&
167 Pubkey.isValidHex(tagValue) &&
168 !mentions.includes(tagValue)
169 ) {
170 mentions.push(tagValue)
171 }
172 })
173 if (mentions.length > 0) {
174 // Use cached relay lists for recipients
175 const recipientRelays = await relayListCacheService.getWriteRelaysForRecipients(mentions)
176 recipientRelays.slice(0, 10).forEach((url) => relaySet.add(url))
177 }
178 }
179
180 // For comments on external content, use relay hints from tags
181 if (event.kind === ExtendedKind.COMMENT) {
182 const rootITag = event.tags.find(tagNameEquals('I'))
183 if (rootITag) {
184 // Extract relay hints from e-tags or a-tags in the event
185 event.tags.forEach((tag) => {
186 if ((tag[0] === 'e' || tag[0] === 'a' || tag[0] === 'E' || tag[0] === 'A') && tag[2]?.startsWith('wss://')) {
187 relaySet.add(tag[2])
188 }
189 })
190 }
191 }
192 }
193
194 // If no relays found, fall back to user's current relays (not hardcoded big relays)
195 if (!relaySet.size && this.currentRelays.length > 0) {
196 this.currentRelays.forEach((url) => relaySet.add(url))
197 }
198
199 // Gate through managed outbox service — user's own write relays bypass gating
200 const ownRelays = new Set(this.currentRelays)
201 return managedOutboxService.filterRelayUrls(Array.from(relaySet), 'outbox', ownRelays)
202 }
203
204 async determineRelaysByFilter(filter: Filter) {
205 const ownRelays = new Set(this.currentRelays)
206 if (filter.search) {
207 return storage.getSearchRelays()
208 } else if (filter.authors?.length) {
209 const relayLists = await this.fetchRelayLists(filter.authors)
210 const relays = Array.from(new Set(relayLists.flatMap((list) => list.write.slice(0, 5))))
211 const filtered = managedOutboxService.filterRelayUrls(relays, 'inbox', ownRelays)
212 return filtered.length > 0 ? filtered : this.currentRelays
213 } else if (filter['#p']?.length) {
214 const relayLists = await this.fetchRelayLists(filter['#p'])
215 const relays = Array.from(new Set(relayLists.flatMap((list) => list.read.slice(0, 5))))
216 const filtered = managedOutboxService.filterRelayUrls(relays, 'inbox', ownRelays)
217 return filtered.length > 0 ? filtered : this.currentRelays
218 }
219 // Use current relays, falling back to discovered/bootstrap relays
220 return this.currentRelays.length > 0 ? this.currentRelays : this.getFallbackRelays()
221 }
222
223 async publishEvent(
224 relayUrls: string[],
225 event: NEvent
226 ) {
227 const uniqueRelayUrls = Array.from(new Set(relayUrls))
228 const TIMEOUT_MS = 3_000
229
230 // Track per-relay outcomes: 'success' | Error
231 const results = new Map<string, 'pending' | 'success' | Error>(
232 uniqueRelayUrls.map((url) => [url, 'pending'])
233 )
234
235 // Resolves as soon as ONE relay ACKs, or rejects on timeout / all-failed
236 let allSettledPromise: Promise<unknown>
237
238 await new Promise<void>((resolve, reject) => {
239 let resolved = false
240
241 const timer = setTimeout(() => {
242 if (!resolved) {
243 resolved = true
244 reject(new Error('Publish timed out: no relay acknowledged within 3 seconds'))
245 }
246 }, TIMEOUT_MS)
247
248 const onSuccess = (url: string) => {
249 results.set(url, 'success')
250 relayStatsService.recordPublishSuccess(url)
251 if (!resolved) {
252 resolved = true
253 clearTimeout(timer)
254 this.emitNewEvent(event)
255 resolve()
256 }
257 }
258
259 const onError = (url: string, error: Error) => {
260 results.set(url, error)
261 relayStatsService.recordPublishFailure(url)
262 // If all relays have finished and none succeeded, reject
263 const pending = [...results.values()].filter((v) => v === 'pending')
264 if (pending.length === 0 && !resolved) {
265 resolved = true
266 clearTimeout(timer)
267 const failed = [...results.entries()].filter(([, v]) => v !== 'success')
268 reject(
269 new AggregateError(
270 failed.map(
271 ([u, err]) =>
272 new Error(`${u}: ${err instanceof Error ? err.message : String(err)}`)
273 )
274 )
275 )
276 }
277 }
278
279 allSettledPromise = Promise.allSettled(
280 uniqueRelayUrls.map(async (url) => {
281 // eslint-disable-next-line @typescript-eslint/no-this-alias
282 const that = this
283 const relay = await this.pool
284 .ensureRelay(url, { connectionTimeout: TIMEOUT_MS })
285 .catch((err) => {
286 console.debug(`[publishEvent] Failed to connect to ${url}:`, err?.message || err)
287 return undefined
288 })
289 if (!relay) {
290 onError(url, new Error('Cannot connect to relay'))
291 return
292 }
293
294 relay.publishTimeout = TIMEOUT_MS
295 let hasAuthed = false
296
297 const attemptPublish = async (): Promise<void> => {
298 try {
299 await relay.publish(event)
300 that.trackEventSeenOn(event.id, relay)
301 console.debug(`[publishEvent] Success on ${url}`)
302 onSuccess(url)
303 } catch (error) {
304 if (
305 !hasAuthed &&
306 error instanceof Error &&
307 error.message.startsWith('auth-required') &&
308 !!that.signer
309 ) {
310 console.debug(`[publishEvent] Auth required on ${url}, authenticating...`)
311 try {
312 await relay.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
313 hasAuthed = true
314 return await attemptPublish()
315 } catch (authError) {
316 console.debug(`[publishEvent] Auth failed on ${url}:`, authError)
317 onError(url, authError instanceof Error ? authError : new Error(String(authError)))
318 }
319 } else {
320 console.debug(
321 `[publishEvent] Failed on ${url}:`,
322 error instanceof Error ? error.message : error
323 )
324 onError(url, error instanceof Error ? error : new Error(String(error)))
325 }
326 }
327 }
328
329 return attemptPublish()
330 })
331 )
332 })
333
334 // Store background promise: resolves to failed relay URLs once all relays finish
335 this.lastPublishFailedRelays = allSettledPromise!.then(() => {
336 return [...results.entries()]
337 .filter(([, v]) => v !== 'success')
338 .map(([url]) => url)
339 })
340 }
341
342 emitNewEvent(event: NEvent) {
343 this.dispatchEvent(new CustomEvent('newEvent', { detail: event }))
344 }
345
346 async signHttpAuth(url: string, method: string, description = '') {
347 if (!this.signer) {
348 throw new Error('Please login first to sign the event')
349 }
350 const event = await this.signer?.signEvent({
351 content: description,
352 kind: kinds.HTTPAuth,
353 created_at: dayjs().unix(),
354 tags: [
355 ['u', url],
356 ['method', method]
357 ]
358 })
359 return 'Nostr ' + btoa(JSON.stringify(event))
360 }
361
362 /** =========== Timeline =========== */
363
364 private generateTimelineKey(urls: string[], filter: Filter) {
365 const stableFilter: any = {}
366 Object.entries(filter)
367 .sort()
368 .forEach(([key, value]) => {
369 if (key === 'limit') return
370 if (Array.isArray(value)) {
371 stableFilter[key] = [...value].sort()
372 }
373 stableFilter[key] = value
374 })
375 const paramsStr = JSON.stringify({
376 urls: [...urls].sort(),
377 filter: stableFilter
378 })
379 const encoder = new TextEncoder()
380 const data = encoder.encode(paramsStr)
381 const hashBuffer = sha256(data)
382 const hashArray = Array.from(new Uint8Array(hashBuffer))
383 return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
384 }
385
386 private generateMultipleTimelinesKey(subRequests: { urls: string[]; filter: Filter }[]) {
387 const keys = subRequests.map(({ urls, filter }) => this.generateTimelineKey(urls, filter))
388 const encoder = new TextEncoder()
389 const data = encoder.encode(JSON.stringify(keys.sort()))
390 const hashBuffer = sha256(data)
391 const hashArray = Array.from(new Uint8Array(hashBuffer))
392 return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
393 }
394
395 async subscribeTimeline(
396 subRequests: { urls: string[]; filter: TSubRequestFilter }[],
397 {
398 onEvents,
399 onNew,
400 onClose
401 }: {
402 onEvents: (events: NEvent[], eosed: boolean) => void
403 onNew: (evt: NEvent) => void
404 onClose?: (url: string, reason: string) => void
405 },
406 {
407 startLogin,
408 needSort = true
409 }: {
410 startLogin?: () => void
411 needSort?: boolean
412 } = {}
413 ) {
414 const newEventIdSet = new Set<string>()
415 const requestCount = subRequests.length
416 const threshold = Math.floor(requestCount / 2)
417 let events: NEvent[] = []
418 let eosedCount = 0
419
420 const subs = await Promise.all(
421 subRequests.map(({ urls, filter }) => {
422 return this._subscribeTimeline(
423 urls,
424 filter,
425 {
426 onEvents: (_events, _eosed) => {
427 if (_eosed) {
428 eosedCount++
429 }
430
431 events = this.mergeTimelines(events, _events)
432
433 if (eosedCount >= threshold) {
434 onEvents(events, eosedCount >= requestCount)
435 }
436 },
437 onNew: (evt) => {
438 if (newEventIdSet.has(evt.id)) return
439 newEventIdSet.add(evt.id)
440 onNew(evt)
441 },
442 onClose
443 },
444 { startLogin, needSort }
445 )
446 })
447 )
448
449 const key = this.generateMultipleTimelinesKey(subRequests)
450 this.compoundTimelines[key] = subs.map((sub) => sub.timelineKey)
451
452 return {
453 closer: () => {
454 onEvents = () => {}
455 onNew = () => {}
456 subs.forEach((sub) => {
457 sub.closer()
458 })
459 },
460 timelineKey: key
461 }
462 }
463
464 private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] {
465 if (a.length === 0) return [...b]
466 if (b.length === 0) return [...a]
467
468 const result: NEvent[] = []
469 let i = 0
470 let j = 0
471 while (i < a.length && j < b.length) {
472 const cmp = compareEvents(a[i], b[j])
473 if (cmp > 0) {
474 result.push(a[i])
475 i++
476 } else if (cmp < 0) {
477 result.push(b[j])
478 j++
479 } else {
480 result.push(a[i])
481 i++
482 j++
483 }
484 }
485
486 return result
487 }
488
489 async loadMoreTimeline(key: string, until: number, limit: number) {
490 const subKeys = this.compoundTimelines[key]
491 if (!subKeys) return []
492
493 const results = await Promise.all(
494 subKeys.map((k) => this.timelineActor.loadMore.call({ key: k, until, limit }))
495 )
496
497 const eventIdSet = new Set<string>()
498 const events: NEvent[] = []
499 for (const batch of results) {
500 for (const evt of batch) {
501 if (eventIdSet.has(evt.id)) continue
502 eventIdSet.add(evt.id)
503 events.push(evt)
504 }
505 }
506 return events.sort((a, b) => b.created_at - a.created_at).slice(0, limit)
507 }
508
509 subscribe(
510 urls: string[],
511 filter: Filter | Filter[],
512 {
513 onevent,
514 oneose,
515 onclose: _onclose,
516 startLogin,
517 onAllClose
518 }: {
519 onevent?: (evt: NEvent) => void
520 oneose?: (eosed: boolean) => void
521 onclose?: (url: string, reason: string) => void
522 startLogin?: () => void
523 onAllClose?: (reasons: string[]) => void
524 }
525 ) {
526 void _onclose
527 const relays = Array.from(new Set(urls))
528 const filters = Array.isArray(filter) ? filter : [filter]
529 const _knownIds = new Set<string>()
530
531 // Internal event dispatch for locally published events
532 const handleNewEventFromInternal = (data: Event) => {
533 const customEvent = data as CustomEvent<NEvent>
534 const evt = customEvent.detail
535 if (!matchFilters(filters, evt)) return
536 if (_knownIds.has(evt.id)) return
537 _knownIds.add(evt.id)
538 onevent?.(evt)
539 }
540 this.addEventListener('newEvent', handleNewEventFromInternal)
541
542 const sub = createSubscription({
543 urls: relays,
544 filters,
545 pool: this.pool,
546 signer: this.signer,
547 startLogin,
548 onevent: (evt) => {
549 if (_knownIds.has(evt.id)) return
550 _knownIds.add(evt.id)
551 onevent?.(evt)
552 },
553 oneose: (eosed) => oneose?.(eosed),
554 onclose: (reasons) => onAllClose?.(reasons),
555 onReceived: (id, relay) => this.trackEventSeenOn(id, relay)
556 })
557
558 return {
559 close: () => {
560 this.removeEventListener('newEvent', handleNewEventFromInternal)
561 sub.close()
562 }
563 }
564 }
565
566 private async _subscribeTimeline(
567 urls: string[],
568 filter: TSubRequestFilter,
569 {
570 onEvents,
571 onNew
572 }: {
573 onEvents: (events: NEvent[], eosed: boolean) => void
574 onNew: (evt: NEvent) => void
575 onClose?: (url: string, reason: string) => void
576 },
577 {
578 startLogin,
579 needSort = true
580 }: {
581 startLogin?: () => void
582 needSort?: boolean
583 } = {}
584 ) {
585 return this.timelineActor.subscribe.call({
586 urls,
587 filter,
588 onEvents,
589 onNew,
590 startLogin,
591 needSort
592 })
593 }
594
595 /** =========== Event =========== */
596
597 getSeenEventRelays(eventId: string) {
598 return Array.from(this.pool.seenOn.get(eventId)?.values() || [])
599 }
600
601 getSeenEventRelayUrls(eventId: string) {
602 return this.getSeenEventRelays(eventId).map((relay) => relay.url)
603 }
604
605 getEventHints(eventId: string) {
606 return this.getSeenEventRelayUrls(eventId).filter((url) => !isLocalNetworkUrl(url))
607 }
608
609 getEventHint(eventId: string) {
610 return this.getSeenEventRelayUrls(eventId).find((url) => !isLocalNetworkUrl(url)) ?? ''
611 }
612
613 trackEventSeenOn(eventId: string, relay: AbstractRelay) {
614 let set = this.pool.seenOn.get(eventId)
615 if (!set) {
616 set = new Set()
617 this.pool.seenOn.set(eventId, set)
618 }
619 set.add(relay)
620 }
621
622 private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
623 return await new Promise<NEvent[]>((resolve) => {
624 const events: NEvent[] = []
625 const sub = this.subscribe(urls, filter, {
626 onevent(evt) {
627 onevent?.(evt)
628 events.push(evt)
629 },
630 oneose: (eosed) => {
631 if (eosed) {
632 sub.close()
633 resolve(events)
634 }
635 },
636 onAllClose: () => {
637 resolve(events)
638 }
639 })
640 })
641 }
642
643 /**
644 * Query with NRC cache relay integration
645 *
646 * Query flow:
647 * 1. Check IndexedDB cache first
648 * 2. Query NRC cache relays with 400ms timeout
649 * 3. Query regular relays for remaining events
650 * 4. Push loaded events to cache relays in background
651 */
652 async queryWithCacheRelays(
653 urls: string[],
654 filter: Filter | Filter[],
655 {
656 onevent,
657 skipCache = false
658 }: {
659 onevent?: (evt: NEvent) => void
660 skipCache?: boolean
661 } = {}
662 ): Promise<NEvent[]> {
663 const filters = Array.isArray(filter) ? filter : [filter]
664 const seenIds = new Set<string>()
665 const allEvents: NEvent[] = []
666
667 const addEvent = (evt: NEvent) => {
668 if (!seenIds.has(evt.id)) {
669 seenIds.add(evt.id)
670 allEvents.push(evt)
671 onevent?.(evt)
672 }
673 }
674
675 // Step 1: Check IndexedDB cache
676 if (!skipCache) {
677 try {
678 const cachedEvents = await indexedDb.queryCachedEvents(filters)
679 for (const evt of cachedEvents) {
680 addEvent(evt as NEvent)
681 }
682 if (cachedEvents.length > 0) {
683 console.log(`[ClientService] Found ${cachedEvents.length} events in IndexedDB cache`)
684 }
685 } catch (err) {
686 console.warn('[ClientService] IndexedDB cache query failed:', err)
687 }
688 }
689
690 // Step 2: Query NRC cache relays (400ms timeout)
691 const cacheRelayResult = await nrcCacheRelayService.queryWithTimeout(filters, 400)
692 if (cacheRelayResult.fromCache && cacheRelayResult.events.length > 0) {
693 for (const evt of cacheRelayResult.events) {
694 addEvent(evt as NEvent)
695 }
696 console.log(
697 `[ClientService] Got ${cacheRelayResult.events.length} events from NRC cache relay`
698 )
699 }
700
701 // Step 3: Query regular relays for remaining events
702 const regularEvents = await this.query(urls, filter)
703 const newEvents: NEvent[] = []
704 for (const evt of regularEvents) {
705 if (!seenIds.has(evt.id)) {
706 addEvent(evt)
707 newEvents.push(evt)
708 }
709 }
710
711 // Step 4: Cache new events and push to NRC cache relays in background
712 if (newEvents.length > 0) {
713 // Cache in IndexedDB
714 indexedDb.putCachedEvents(newEvents).catch((err) => {
715 console.warn('[ClientService] Failed to cache events:', err)
716 })
717
718 // Push to NRC cache relays
719 nrcCacheRelayService.queueEventsForPush(newEvents)
720 }
721
722 return allEvents
723 }
724
725 async fetchEvents(
726 urls: string[],
727 filter: Filter | Filter[],
728 {
729 onevent,
730 cache = false,
731 useCacheRelay = false
732 }: {
733 onevent?: (evt: NEvent) => void
734 cache?: boolean
735 useCacheRelay?: boolean
736 } = {}
737 ) {
738 const relays = Array.from(new Set(urls))
739 // Use provided relays, or fall back to current user's relays, or bootstrap relays
740 let targetRelays = relays.length > 0 ? relays : this.currentRelays
741
742 // Fall back to discovered/bootstrap relays if no relays available (essential for initial login)
743 if (targetRelays.length === 0) {
744 targetRelays = this.getFallbackRelays()
745 }
746
747 // Use cache relay integration if enabled
748 if (useCacheRelay) {
749 const events = await this.queryWithCacheRelays(targetRelays, filter, { onevent })
750 if (cache) {
751 events.forEach((evt) => {
752 this.addEventToCache(evt)
753 })
754 }
755 return events
756 }
757
758 // Standard query path
759 const events = await this.query(targetRelays, filter, onevent)
760 if (cache) {
761 events.forEach((evt) => {
762 this.addEventToCache(evt)
763 })
764 }
765 return events
766 }
767
768 async fetchEvent(id: string): Promise<NEvent | undefined> {
769 if (!/^[0-9a-f]{64}$/.test(id)) {
770 let eventId: string | undefined
771 let coordinate: string | undefined
772 const { type, data } = nip19.decode(id)
773 switch (type) {
774 case 'note':
775 eventId = data
776 break
777 case 'nevent':
778 eventId = data.id
779 break
780 case 'naddr':
781 coordinate = getReplaceableCoordinate(data.kind, data.pubkey, data.identifier)
782 break
783 }
784 if (coordinate) {
785 const cache = this.replaceableEventCacheMap.get(coordinate)
786 if (cache) {
787 return cache
788 }
789 const indexedDbCache = await indexedDb.getReplaceableEventByCoordinate(coordinate)
790 if (indexedDbCache) {
791 this.replaceableEventCacheMap.set(coordinate, indexedDbCache)
792 return indexedDbCache
793 }
794 } else if (eventId) {
795 const cache = this.eventCacheMap.get(eventId)
796 if (cache) {
797 return cache
798 }
799 }
800 }
801 return this.eventDataLoader.load(id)
802 }
803
804 addEventToCache(event: NEvent) {
805 this.eventDataLoader.prime(event.id, Promise.resolve(event))
806 this.eventCacheActor.cache.send(event)
807 if (isReplaceableEvent(event.kind)) {
808 const coordinate = getReplaceableCoordinateFromEvent(event)
809 const cachedEvent = this.replaceableEventCacheMap.get(coordinate)
810 if (!cachedEvent || compareEvents(event, cachedEvent) > 0) {
811 this.replaceableEventCacheMap.set(coordinate, event)
812 }
813 }
814 }
815
816 getReplaeableEventFromCache(coordinate: string): NEvent | undefined {
817 return this.replaceableEventCacheMap.get(coordinate)
818 }
819
820 private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
821 const event = await this.fetchEventFromBigRelaysDataloader.load(id)
822 if (event) {
823 return event
824 }
825
826 return this.fetchEventFromRelays(relayUrls, { ids: [id], limit: 1 })
827 }
828
829 private async _fetchEvent(id: string): Promise<NEvent | undefined> {
830 let filter: Filter | undefined
831 let relays: string[] = []
832 let author: string | undefined
833 if (/^[0-9a-f]{64}$/.test(id)) {
834 filter = { ids: [id] }
835 } else {
836 const { type, data } = nip19.decode(id)
837 switch (type) {
838 case 'note':
839 filter = { ids: [data] }
840 break
841 case 'nevent':
842 filter = { ids: [data.id] }
843 if (data.relays) relays = data.relays
844 if (data.author) author = data.author
845 break
846 case 'naddr':
847 filter = {
848 authors: [data.pubkey],
849 kinds: [data.kind],
850 limit: 1
851 }
852 author = data.pubkey
853 if (data.identifier) {
854 filter['#d'] = [data.identifier]
855 }
856 if (data.relays) relays = data.relays
857 }
858 }
859 if (!filter) {
860 throw new Error('Invalid id')
861 }
862
863 let event: NEvent | undefined
864 if (filter.ids?.length) {
865 event = await this.fetchEventById(relays, filter.ids[0])
866 }
867
868 if (!event && author) {
869 const relayList = await this.fetchRelayList(author)
870 event = await this.fetchEventFromRelays(relayList.write.slice(0, 5), filter)
871 }
872
873 // Phase 4: Progressive querying through discovered relays
874 if (!event) {
875 const alreadyTried = new Set<string>([
876 ...this.currentRelays,
877 ...this.getFallbackRelays(),
878 ...relays
879 ])
880
881 const batches = relayDiscoveryService.getRelayBatches(10, 0, 50, alreadyTried)
882
883 for (const batch of batches) {
884 if (batch.length === 0) continue
885
886 const batchEvents = await Promise.race([
887 this.query(batch, filter),
888 new Promise<NEvent[]>((resolve) => setTimeout(() => resolve([]), 5000))
889 ])
890
891 if (batchEvents.length > 0) {
892 event = batchEvents.sort((a, b) => b.created_at - a.created_at)[0]
893 if (event) {
894 this.addEventToCache(event)
895 break
896 }
897 }
898 }
899 }
900
901 if (event && event.id !== id) {
902 this.addEventToCache(event)
903 }
904
905 // Don't cache failed lookups - allow retry on next request
906 if (!event) {
907 this.eventCacheMap.delete(id)
908 }
909
910 return event
911 }
912
913 private async fetchEventFromRelays(relayUrls: string[], filter: Filter) {
914 if (!relayUrls.length) return
915
916 const events = await this.query(relayUrls, filter)
917 return events.sort((a, b) => b.created_at - a.created_at)[0]
918 }
919
920 private async fetchEventsFromBigRelays(ids: readonly string[]) {
921 // Use current relays, falling back to discovered/bootstrap relays
922 const relays = this.currentRelays.length > 0 ? this.currentRelays : this.getFallbackRelays()
923
924 const events = await this.query(relays, {
925 ids: Array.from(new Set(ids)),
926 limit: ids.length
927 })
928 const eventsMap = new Map<string, NEvent>()
929 for (const event of events) {
930 eventsMap.set(event.id, event)
931 }
932
933 return ids.map((id) => eventsMap.get(id))
934 }
935
936 /** =========== Following favorite relays =========== */
937
938 private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
939 max: 10,
940 fetchMethod: this._fetchFollowingFavoriteRelays.bind(this)
941 })
942
943 async fetchFollowingFavoriteRelays(pubkey: string) {
944 return this.followingFavoriteRelaysCache.fetch(pubkey)
945 }
946
947 private async _fetchFollowingFavoriteRelays(pubkey: string) {
948 const fetchNewData = async () => {
949 const followings = await this.fetchFollowings(pubkey)
950 // Fetch from authors' relays instead of hardcoded big relays
951 const events = await this.fetchEvents([], {
952 authors: followings,
953 kinds: [ExtendedKind.FAVORITE_RELAYS, kinds.Relaysets],
954 limit: 1000
955 })
956 const alreadyExistsFavoriteRelaysPubkeySet = new Set<string>()
957 const alreadyExistsRelaySetsPubkeySet = new Set<string>()
958 const uniqueEvents: NEvent[] = []
959 events
960 .sort((a, b) => b.created_at - a.created_at)
961 .forEach((event) => {
962 if (event.kind === ExtendedKind.FAVORITE_RELAYS) {
963 if (alreadyExistsFavoriteRelaysPubkeySet.has(event.pubkey)) return
964 alreadyExistsFavoriteRelaysPubkeySet.add(event.pubkey)
965 } else if (event.kind === kinds.Relaysets) {
966 if (alreadyExistsRelaySetsPubkeySet.has(event.pubkey)) return
967 alreadyExistsRelaySetsPubkeySet.add(event.pubkey)
968 } else {
969 return
970 }
971 uniqueEvents.push(event)
972 })
973
974 const relayMap = new Map<string, Set<string>>()
975 uniqueEvents.forEach((event) => {
976 event.tags.forEach(([tagName, tagValue]) => {
977 if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) {
978 const url = normalizeUrl(tagValue)
979 relayMap.set(url, (relayMap.get(url) || new Set()).add(event.pubkey))
980 }
981 })
982 })
983 const relayMapEntries = Array.from(relayMap.entries())
984 .sort((a, b) => b[1].size - a[1].size)
985 .map(([url, pubkeys]) => [url, Array.from(pubkeys)]) as [string, string[]][]
986
987 indexedDb.putFollowingFavoriteRelays(pubkey, relayMapEntries)
988 return relayMapEntries
989 }
990
991 const cached = await indexedDb.getFollowingFavoriteRelays(pubkey)
992 if (cached) {
993 fetchNewData()
994 return cached
995 }
996 return fetchNewData()
997 }
998
999 /** =========== Followings =========== */
1000
1001 async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
1002 const followings = await this.fetchFollowings(pubkey, false)
1003 for (let i = 0; i * 20 < followings.length; i++) {
1004 if (signal.aborted) return
1005 await Promise.all(
1006 followings
1007 .slice(i * 20, (i + 1) * 20)
1008 .map((pubkey) => this.fetchProfile(pubkey, false, false))
1009 )
1010 await new Promise((resolve) => setTimeout(resolve, 1000))
1011 }
1012 }
1013
1014 /** =========== Profile =========== */
1015
1016 async searchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
1017 const events = await this.query(relayUrls, {
1018 ...filter,
1019 kinds: [kinds.Metadata]
1020 })
1021
1022 const profileEvents = events.sort((a, b) => b.created_at - a.created_at)
1023 await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile)))
1024 profileEvents.forEach((profile) => this.updateProfileEventCache(profile))
1025 return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent))
1026 }
1027
1028 async searchNpubsFromLocal(query: string, limit: number = 100) {
1029 return await this.userIndexActor.search.call({ query, limit })
1030 }
1031
1032 async searchProfilesFromLocal(query: string, limit: number = 100) {
1033 const npubs = await this.searchNpubsFromLocal(query, limit)
1034 const profiles = await Promise.all(npubs.map((npub) => this.fetchProfile(npub)))
1035 return profiles.filter((profile) => !!profile) as TProfile[]
1036 }
1037
1038 private addUsernameToIndex(profileEvent: NEvent) {
1039 this.userIndexActor.addProfile.send(profileEvent)
1040 }
1041
1042 private async _fetchProfileEvent(id: string): Promise<NEvent | undefined> {
1043 let pubkey: string | undefined
1044 let hintRelays: string[] = []
1045 if (/^[0-9a-f]{64}$/.test(id)) {
1046 pubkey = id
1047 } else {
1048 const { data, type } = nip19.decode(id)
1049 switch (type) {
1050 case 'npub':
1051 pubkey = data
1052 break
1053 case 'nprofile':
1054 pubkey = data.pubkey
1055 if (data.relays) hintRelays = data.relays
1056 break
1057 }
1058 }
1059
1060 if (!pubkey) {
1061 throw new Error('Invalid id')
1062 }
1063
1064 // Phase 3: Improved relay selection for profile fetching
1065 // 1. Try relay hints from nprofile first (most specific)
1066 if (hintRelays.length > 0) {
1067 const profileEvent = await this.fetchEventFromRelays(hintRelays.slice(0, 3), {
1068 authors: [pubkey],
1069 kinds: [kinds.Metadata],
1070 limit: 1
1071 })
1072 if (profileEvent) {
1073 this.addUsernameToIndex(profileEvent)
1074 indexedDb.putReplaceableEvent(profileEvent)
1075 return profileEvent
1076 }
1077 }
1078
1079 // 2. Try author's relays from cache/network
1080 const authorRelayList = await relayListCacheService.getRelayList(pubkey)
1081 if (authorRelayList && authorRelayList.write.length > 0) {
1082 const profileEvent = await this.fetchEventFromRelays(authorRelayList.write.slice(0, 5), {
1083 authors: [pubkey],
1084 kinds: [kinds.Metadata],
1085 limit: 1
1086 })
1087 if (profileEvent) {
1088 this.addUsernameToIndex(profileEvent)
1089 indexedDb.putReplaceableEvent(profileEvent)
1090 return profileEvent
1091 }
1092 }
1093
1094 // 3. Fall back to current relays (batched for efficiency)
1095 const profileFromCurrentRelays = await this.replaceableEventFromBigRelaysDataloader.load({
1096 pubkey,
1097 kind: kinds.Metadata
1098 })
1099 if (profileFromCurrentRelays) {
1100 this.addUsernameToIndex(profileFromCurrentRelays)
1101 return profileFromCurrentRelays
1102 }
1103
1104 return undefined
1105 }
1106
1107 private profileDataloader = new DataLoader<string, TProfile | null, string>(async (ids) => {
1108 const results = await Promise.allSettled(ids.map((id) => this._fetchProfile(id)))
1109 return results.map((res) => (res.status === 'fulfilled' ? res.value : null))
1110 })
1111
1112 async fetchProfile(
1113 id: string,
1114 skipCache = false,
1115 updateCacheInBackground = true
1116 ): Promise<TProfile | null> {
1117 if (skipCache) {
1118 return this._fetchProfile(id)
1119 }
1120
1121 const pk = Pubkey.tryFromString(id)
1122 if (!pk) throw new Error('Invalid id')
1123 const localProfileEvent = await indexedDb.getReplaceableEvent(pk.hex, kinds.Metadata)
1124 if (localProfileEvent) {
1125 if (updateCacheInBackground) {
1126 this.profileDataloader.load(id) // update cache in background
1127 }
1128 const localProfile = getProfileFromEvent(localProfileEvent)
1129 return localProfile
1130 }
1131 return await this.profileDataloader.load(id)
1132 }
1133
1134 private async _fetchProfile(id: string): Promise<TProfile | null> {
1135 const profileEvent = await this._fetchProfileEvent(id)
1136 if (profileEvent) {
1137 return getProfileFromEvent(profileEvent)
1138 }
1139
1140 const pk = Pubkey.tryFromString(id)
1141 if (!pk) return null
1142 return { pubkey: pk.hex, npub: pk.npub, username: pk.formatNpub(12) }
1143 }
1144
1145 async updateProfileEventCache(event: NEvent) {
1146 await this.updateReplaceableEventFromBigRelaysCache(event)
1147 }
1148
1149 /** =========== Relay list =========== */
1150
1151 async fetchRelayList(pubkey: string): Promise<TRelayList> {
1152 const [relayList] = await this.fetchRelayLists([pubkey])
1153 return relayList
1154 }
1155
1156 async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> {
1157 const relayEvents = await this.fetchReplaceableEventsFromBigRelays(pubkeys, kinds.RelayList)
1158
1159 return relayEvents.map((event) => {
1160 if (event) {
1161 // Cache the relay list for future use
1162 relayListCacheService.setRelayList(event)
1163 return getRelayListFromEvent(event, storage.getFilterOutOnionRelays())
1164 }
1165 // No relay list found - use discovered/bootstrap relays as fallback
1166 const fallback = this.getFallbackRelays()
1167 return {
1168 write: fallback,
1169 read: fallback,
1170 originalRelays: []
1171 }
1172 })
1173 }
1174
1175 async forceUpdateRelayListEvent(pubkey: string) {
1176 await this.replaceableEventFromBigRelaysBatchLoadFn([{ pubkey, kind: kinds.RelayList }])
1177 }
1178
1179 async updateRelayListCache(event: NEvent) {
1180 return await this.updateReplaceableEventFromBigRelaysCache(event)
1181 }
1182
1183 /** =========== Replaceable event from big relays dataloader =========== */
1184
1185 private replaceableEventFromBigRelaysDataloader = new DataLoader<
1186 { pubkey: string; kind: number },
1187 NEvent | null,
1188 string
1189 >(this.replaceableEventFromBigRelaysBatchLoadFn.bind(this), {
1190 batchScheduleFn: (callback) => setTimeout(callback, 50),
1191 maxBatchSize: 500,
1192 cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}`
1193 })
1194
1195 private async replaceableEventFromBigRelaysBatchLoadFn(
1196 params: readonly { pubkey: string; kind: number }[]
1197 ) {
1198 const groups = new Map<number, string[]>()
1199 params.forEach(({ pubkey, kind }) => {
1200 if (!groups.has(kind)) {
1201 groups.set(kind, [])
1202 }
1203 groups.get(kind)!.push(pubkey)
1204 })
1205
1206 const eventsMap = new Map<string, NEvent>()
1207 // Use current relays, falling back to discovered/bootstrap relays
1208 const relays = this.currentRelays.length > 0 ? this.currentRelays : this.getFallbackRelays()
1209
1210 await Promise.allSettled(
1211 Array.from(groups.entries()).map(async ([kind, pubkeys]) => {
1212 const events = await this.query(relays, {
1213 authors: pubkeys,
1214 kinds: [kind]
1215 })
1216
1217 for (const event of events) {
1218 const key = `${event.pubkey}:${event.kind}`
1219 const existing = eventsMap.get(key)
1220 if (!existing || existing.created_at < event.created_at) {
1221 eventsMap.set(key, event)
1222 }
1223 }
1224 })
1225 )
1226
1227 return params.map(({ pubkey, kind }) => {
1228 const key = `${pubkey}:${kind}`
1229 const event = eventsMap.get(key)
1230 if (event) {
1231 indexedDb.putReplaceableEvent(event)
1232 return event
1233 } else {
1234 indexedDb.putNullReplaceableEvent(pubkey, kind)
1235 return null
1236 }
1237 })
1238 }
1239
1240 private async fetchReplaceableEventsFromBigRelays(pubkeys: string[], kind: number) {
1241 const events = await indexedDb.getManyReplaceableEvents(pubkeys, kind)
1242 const nonExistingPubkeyIndexMap = new Map<string, number>()
1243 const existingPubkeys: string[] = []
1244 pubkeys.forEach((pubkey, i) => {
1245 if (events[i] === undefined) {
1246 nonExistingPubkeyIndexMap.set(pubkey, i)
1247 } else {
1248 existingPubkeys.push(pubkey)
1249 }
1250 })
1251 const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany(
1252 Array.from(nonExistingPubkeyIndexMap.keys()).map((pubkey) => ({ pubkey, kind }))
1253 )
1254 newEvents.forEach((event) => {
1255 if (event && !(event instanceof Error)) {
1256 const index = nonExistingPubkeyIndexMap.get(event.pubkey)
1257 if (index !== undefined) {
1258 events[index] = event
1259 }
1260 }
1261 })
1262
1263 this.replaceableEventFromBigRelaysDataloader.loadMany(
1264 existingPubkeys.map((pubkey) => ({ pubkey, kind }))
1265 ) // update cache in background
1266
1267 return events
1268 }
1269
1270 private async updateReplaceableEventFromBigRelaysCache(event: NEvent) {
1271 const newEvent = await indexedDb.putReplaceableEvent(event)
1272 if (newEvent.id !== event.id) {
1273 return newEvent
1274 }
1275
1276 this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: event.pubkey, kind: event.kind })
1277 this.replaceableEventFromBigRelaysDataloader.prime(
1278 { pubkey: event.pubkey, kind: event.kind },
1279 Promise.resolve(event)
1280 )
1281 return newEvent
1282 }
1283
1284 /** =========== Replaceable event dataloader =========== */
1285
1286 private replaceableEventDataLoader = new DataLoader<
1287 { pubkey: string; kind: number; d?: string },
1288 NEvent | null,
1289 string
1290 >(this.replaceableEventBatchLoadFn.bind(this), {
1291 cacheKeyFn: ({ pubkey, kind, d }) => `${kind}:${pubkey}:${d ?? ''}`
1292 })
1293
1294 private async replaceableEventBatchLoadFn(
1295 params: readonly { pubkey: string; kind: number; d?: string }[]
1296 ) {
1297 const groups = new Map<string, { kind: number; d?: string }[]>()
1298 params.forEach(({ pubkey, kind, d }) => {
1299 if (!groups.has(pubkey)) {
1300 groups.set(pubkey, [])
1301 }
1302 groups.get(pubkey)!.push({ kind: kind, d })
1303 })
1304
1305 const eventMap = new Map<string, NEvent | null>()
1306 await Promise.allSettled(
1307 Array.from(groups.entries()).map(async ([pubkey, _params]) => {
1308 const groupByKind = new Map<number, string[]>()
1309 _params.forEach(({ kind, d }) => {
1310 if (!groupByKind.has(kind)) {
1311 groupByKind.set(kind, [])
1312 }
1313 if (d) {
1314 groupByKind.get(kind)!.push(d)
1315 }
1316 })
1317 const filters = Array.from(groupByKind.entries()).map(
1318 ([kind, dList]) =>
1319 (dList.length > 0
1320 ? {
1321 authors: [pubkey],
1322 kinds: [kind],
1323 '#d': dList
1324 }
1325 : { authors: [pubkey], kinds: [kind] }) as Filter
1326 )
1327 const relayList = await this.fetchRelayList(pubkey)
1328 // Use author's relays, falling back to current relays, then discovered/bootstrap relays
1329 let authorRelays = relayList.write.length > 0 ? relayList.write : this.currentRelays
1330 if (authorRelays.length === 0) {
1331 authorRelays = this.getFallbackRelays()
1332 }
1333 const relays = authorRelays.slice(0, 5)
1334 const events = await this.query(relays, filters)
1335
1336 for (const event of events) {
1337 const key = getReplaceableCoordinateFromEvent(event)
1338 const existing = eventMap.get(key)
1339 if (!existing || existing.created_at < event.created_at) {
1340 eventMap.set(key, event)
1341 }
1342 }
1343 })
1344 )
1345
1346 return params.map(({ pubkey, kind, d }) => {
1347 const key = `${kind}:${pubkey}:${d ?? ''}`
1348 const event = eventMap.get(key)
1349 if (kind === kinds.Pinlist) return event ?? null
1350
1351 if (event) {
1352 indexedDb.putReplaceableEvent(event)
1353 return event
1354 } else {
1355 indexedDb.putNullReplaceableEvent(pubkey, kind, d)
1356 return null
1357 }
1358 })
1359 }
1360
1361 private async fetchReplaceableEvent(
1362 pubkey: string,
1363 kind: number,
1364 d?: string,
1365 updateCache = true
1366 ) {
1367 const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind, d)
1368 if (storedEvent !== undefined) {
1369 if (updateCache) {
1370 this.replaceableEventDataLoader.load({ pubkey, kind, d }) // update cache in background
1371 }
1372 return storedEvent
1373 }
1374
1375 return await this.replaceableEventDataLoader.load({ pubkey, kind, d })
1376 }
1377
1378 private async updateReplaceableEventCache(event: NEvent) {
1379 const newEvent = await indexedDb.putReplaceableEvent(event)
1380 if (newEvent.id !== event.id) {
1381 return
1382 }
1383
1384 this.replaceableEventDataLoader.clear({ pubkey: event.pubkey, kind: event.kind })
1385 this.replaceableEventDataLoader.prime(
1386 { pubkey: event.pubkey, kind: event.kind },
1387 Promise.resolve(event)
1388 )
1389 }
1390
1391 /** =========== Replaceable event =========== */
1392
1393 async fetchFollowListEvent(pubkey: string, updateCache = true) {
1394 return await this.fetchReplaceableEvent(pubkey, kinds.Contacts, undefined, updateCache)
1395 }
1396
1397 async fetchFollowings(pubkey: string, updateCache = true) {
1398 const followListEvent = await this.fetchFollowListEvent(pubkey, updateCache)
1399 return followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
1400 }
1401
1402 async updateFollowListCache(evt: NEvent) {
1403 await this.updateReplaceableEventCache(evt)
1404 }
1405
1406 async fetchMuteListEvent(pubkey: string) {
1407 return await this.fetchReplaceableEvent(pubkey, kinds.Mutelist)
1408 }
1409
1410 async fetchBookmarkListEvent(pubkey: string) {
1411 return this.fetchReplaceableEvent(pubkey, kinds.BookmarkList)
1412 }
1413
1414 async fetchBlossomServerListEvent(pubkey: string) {
1415 return await this.fetchReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST)
1416 }
1417
1418 async fetchBlossomServerList(pubkey: string) {
1419 const evt = await this.fetchBlossomServerListEvent(pubkey)
1420 return evt ? getServersFromServerTags(evt.tags) : []
1421 }
1422
1423 async fetchPinListEvent(pubkey: string) {
1424 return this.fetchReplaceableEvent(pubkey, kinds.Pinlist)
1425 }
1426
1427 async fetchRelayListEvent(pubkey: string) {
1428 return this.fetchReplaceableEvent(pubkey, kinds.RelayList)
1429 }
1430
1431 async fetchFavoriteRelaysEvent(pubkey: string) {
1432 return this.fetchReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS)
1433 }
1434
1435 async fetchUserEmojiListEvent(pubkey: string) {
1436 return this.fetchReplaceableEvent(pubkey, kinds.UserEmojiList)
1437 }
1438
1439 async fetchPinnedUsersList(pubkey: string) {
1440 return this.fetchReplaceableEvent(pubkey, ExtendedKind.PINNED_USERS)
1441 }
1442
1443 async updateBlossomServerListEventCache(evt: NEvent) {
1444 await this.updateReplaceableEventCache(evt)
1445 }
1446
1447 async fetchEmojiSetEvents(pointers: string[], updateCacheInBackground = true) {
1448 const params = pointers
1449 .map((pointer) => {
1450 const [kindStr, pubkey, d = ''] = pointer.split(':')
1451 if (!pubkey || !kindStr) return null
1452
1453 const kind = parseInt(kindStr, 10)
1454 if (kind !== kinds.Emojisets) return null
1455
1456 return { pubkey, kind, d }
1457 })
1458 .filter(Boolean) as { pubkey: string; kind: number; d: string }[]
1459 return await Promise.all(
1460 params.map(({ pubkey, kind, d }) =>
1461 this.fetchReplaceableEvent(pubkey, kind, d, updateCacheInBackground)
1462 )
1463 )
1464 }
1465
1466 // ================= Utils =================
1467
1468 async generateSubRequestsForPubkeys(pubkeys: string[], myPubkey?: string | null) {
1469 // If many websocket connections are initiated simultaneously, it will be
1470 // very slow on Safari (for unknown reason)
1471 if (isSafari()) {
1472 let urls = this.currentRelays
1473 if (myPubkey) {
1474 const relayList = await this.fetchRelayList(myPubkey)
1475 urls = relayList.read.length > 0 ? relayList.read.slice(0, 5) : this.currentRelays
1476 }
1477 return [{ urls, filter: { authors: pubkeys } }]
1478 }
1479
1480 const relayLists = await this.fetchRelayLists(pubkeys)
1481 const group: Record<string, Set<string>> = {}
1482 relayLists.forEach((relayList, index) => {
1483 relayList.write.slice(0, 4).forEach((url) => {
1484 if (!group[url]) {
1485 group[url] = new Set()
1486 }
1487 group[url].add(pubkeys[index])
1488 })
1489 })
1490
1491 const relayCount = Object.keys(group).length
1492 const coveredCount = new Map<string, number>()
1493 Object.entries(group)
1494 .sort(([, a], [, b]) => b.size - a.size)
1495 .forEach(([url, pubkeys]) => {
1496 if (
1497 relayCount > 10 &&
1498 pubkeys.size < 10 &&
1499 Array.from(pubkeys).every((pubkey) => (coveredCount.get(pubkey) ?? 0) >= 2)
1500 ) {
1501 delete group[url]
1502 } else {
1503 pubkeys.forEach((pubkey) => {
1504 coveredCount.set(pubkey, (coveredCount.get(pubkey) ?? 0) + 1)
1505 })
1506 }
1507 })
1508
1509 return Object.entries(group).map(([url, authors]) => ({
1510 urls: [url],
1511 filter: { authors: Array.from(authors) }
1512 }))
1513 }
1514 }
1515
1516 const instance = ClientService.getInstance()
1517 export default instance
1518