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