From 21f09426cf83fad5d9f4a1df22b82135af0b5a25 Mon Sep 17 00:00:00 2001 From: codytseng Date: Mon, 4 Aug 2025 22:53:36 +0800 Subject: [PATCH] refactor: client service --- .../PostEditor/PostTextarea/suggestion.ts | 2 +- src/hooks/useSearchProfiles.tsx | 2 +- src/lib/event-metadata.ts | 2 +- src/lib/event.ts | 2 +- src/providers/NostrProvider/index.tsx | 2 +- src/providers/UserTrustProvider.tsx | 22 +- src/services/client.service.ts | 916 +++++++++--------- src/services/indexed-db.service.ts | 2 +- 8 files changed, 459 insertions(+), 491 deletions(-) diff --git a/src/components/PostEditor/PostTextarea/suggestion.ts b/src/components/PostEditor/PostTextarea/suggestion.ts index 7f1336a..beb42aa 100644 --- a/src/components/PostEditor/PostTextarea/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/suggestion.ts @@ -8,7 +8,7 @@ import MentionList, { MentionListHandle, MentionListProps } from './MentionList' const suggestion = { items: async ({ query }: { query: string }) => { - return await client.searchNpubsFromCache(query, 20) + return await client.searchNpubsFromLocal(query, 20) }, render: () => { diff --git a/src/hooks/useSearchProfiles.tsx b/src/hooks/useSearchProfiles.tsx index f930fff..512e4c6 100644 --- a/src/hooks/useSearchProfiles.tsx +++ b/src/hooks/useSearchProfiles.tsx @@ -22,7 +22,7 @@ export function useSearchProfiles(search: string, limit: number) { setIsFetching(true) setProfiles([]) try { - const profiles = await client.searchProfilesFromCache(search, limit) + const profiles = await client.searchProfilesFromLocal(search, limit) setProfiles(profiles) if (profiles.length >= limit) { return diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 9c9f0e9..5457cfd 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -8,7 +8,7 @@ import { generateBech32IdFromETag, tagNameEquals } from './tag' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isTorBrowser } from './utils' -export function getRelayListFromEvent(event?: Event) { +export function getRelayListFromEvent(event?: Event | null) { if (!event) { return { write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] } } diff --git a/src/lib/event.ts b/src/lib/event.ts index 32e8006..7d7dcd3 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -222,7 +222,7 @@ export function getEmbeddedPubkeys(event: Event) { return embeddedPubkeys } -export function getLatestEvent(events: Event[]) { +export function getLatestEvent(events: Event[]): Event | undefined { return events.sort((a, b) => b.created_at - a.created_at)[0] } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 1bea41b..f24015c 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -650,7 +650,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (newFollowListEvent.id !== followListEvent.id) return setFollowListEvent(newFollowListEvent) - client.updateFollowListCache(newFollowListEvent) + await client.updateFollowListCache(newFollowListEvent) } const updateMuteListEvent = async (muteListEvent: Event, privateTags: string[][]) => { diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index 9260884..e01d212 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -42,13 +42,21 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) { const initWoT = async () => { const followings = await client.fetchFollowings(currentPubkey) - await Promise.allSettled( - followings.map(async (pubkey) => { - wotSet.add(pubkey) - const _followings = await client.fetchFollowings(pubkey) - _followings.forEach((following) => wotSet.add(following)) - }) - ) + followings.forEach((pubkey) => wotSet.add(pubkey)) + + const batchSize = 20 + for (let i = 0; i < followings.length; i += batchSize) { + const batch = followings.slice(i, i + batchSize) + await Promise.allSettled( + batch.map(async (pubkey) => { + const _followings = await client.fetchFollowings(pubkey) + _followings.forEach((following) => { + wotSet.add(following) + }) + }) + ) + await new Promise((resolve) => setTimeout(resolve, 200)) + } } initWoT() }, [currentPubkey]) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index da245dd..56570e5 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,5 @@ import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { getLatestEvent } from '@/lib/event' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' @@ -50,39 +51,6 @@ class ClientService extends EventTarget { this.fetchEventsFromBigRelays.bind(this), { cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) } ) - private fetchProfileEventFromBigRelaysDataloader = new DataLoader( - this.profileEventBatchLoadFn.bind(this), - { - batchScheduleFn: (callback) => setTimeout(callback, 50), - maxBatchSize: 500 - } - ) - private fetchFollowListEventFromBigRelaysDataloader = new DataLoader( - this.followListEventBatchLoadFn.bind(this), - { - batchScheduleFn: (callback) => setTimeout(callback, 50), - maxBatchSize: 500 - } - ) - private relayListEventDataLoader = new DataLoader( - this.relayListEventBatchLoadFn.bind(this), - { - batchScheduleFn: (callback) => setTimeout(callback, 50), - maxBatchSize: 500 - } - ) - private followListCache = new LRUCache>({ - max: 2000, - fetchMethod: this._fetchFollowListEvent.bind(this) - }) - private followingFavoriteRelaysCache = new LRUCache>({ - max: 10, - fetchMethod: this._fetchFollowingFavoriteRelays.bind(this) - }) - private blossomServerListEventCache = new LRUCache>({ - max: 1000, - fetchMethod: this._fetchBlossomServerListEvent.bind(this) - }) private userIndex = new FlexSearch.Index({ tokenize: 'forward' @@ -169,6 +137,8 @@ class ClientService extends EventTarget { return 'Nostr ' + btoa(JSON.stringify(event)) } + /** =========== Timeline =========== */ + private generateTimelineKey(urls: string[], filter: Filter) { const stableFilter: any = {} Object.entries(filter) @@ -427,27 +397,6 @@ class ClientService extends EventTarget { } } - private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { - return await new Promise((resolve) => { - const events: NEvent[] = [] - const sub = this.subscribe(urls, filter, { - onevent(evt) { - onevent?.(evt) - events.push(evt) - }, - oneose: (eosed) => { - if (eosed) { - sub.close() - resolve(events) - } - }, - onclose: () => { - resolve(events) - } - }) - }) - } - private async _subscribeTimeline( urls: string[], filter: Omit & { limit: number }, // filter with limit, @@ -610,6 +559,54 @@ class ClientService extends EventTarget { return [...cachedEvents, ...events] } + /** =========== Event =========== */ + + getSeenEventRelays(eventId: string) { + return Array.from(this.pool.seenOn.get(eventId)?.values() || []) + } + + getSeenEventRelayUrls(eventId: string) { + return this.getSeenEventRelays(eventId).map((relay) => relay.url) + } + + getEventHints(eventId: string) { + return this.getSeenEventRelayUrls(eventId).filter((url) => !isLocalNetworkUrl(url)) + } + + getEventHint(eventId: string) { + return this.getSeenEventRelayUrls(eventId).find((url) => !isLocalNetworkUrl(url)) ?? '' + } + + trackEventSeenOn(eventId: string, relay: AbstractRelay) { + let set = this.pool.seenOn.get(eventId) + if (!set) { + set = new Set() + this.pool.seenOn.set(eventId, set) + } + set.add(relay) + } + + private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { + return await new Promise((resolve) => { + const events: NEvent[] = [] + const sub = this.subscribe(urls, filter, { + onevent(evt) { + onevent?.(evt) + events.push(evt) + }, + oneose: (eosed) => { + if (eosed) { + sub.close() + resolve(events) + } + }, + onclose: () => { + resolve(events) + } + }) + }) + } + async fetchEvents( urls: string[], filter: Filter | Filter[], @@ -661,332 +658,6 @@ class ClientService extends EventTarget { this.eventDataLoader.prime(event.id, Promise.resolve(event)) } - async fetchProfileEvent(id: string, skipCache: boolean = false): Promise { - let pubkey: string | undefined - let relays: string[] = [] - if (/^[0-9a-f]{64}$/.test(id)) { - pubkey = id - } else { - const { data, type } = nip19.decode(id) - switch (type) { - case 'npub': - pubkey = data - break - case 'nprofile': - pubkey = data.pubkey - if (data.relays) relays = data.relays - break - } - } - - if (!pubkey) { - throw new Error('Invalid id') - } - if (!skipCache) { - const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) - if (localProfile) { - return localProfile - } - } - const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey) - if (profileFromBigRelays) { - this.addUsernameToIndex(profileFromBigRelays) - return profileFromBigRelays - } - - if (!relays.length) { - return undefined - } - - const profileEvent = await this.tryHarderToFetchEvent( - relays, - { - authors: [pubkey], - kinds: [kinds.Metadata], - limit: 1 - }, - true - ) - - if (profileEvent) { - this.addUsernameToIndex(profileEvent) - indexedDb.putReplaceableEvent(profileEvent) - } - - return profileEvent - } - - async fetchProfile(id: string, skipCache: boolean = false): Promise { - const profileEvent = await this.fetchProfileEvent(id, skipCache) - if (profileEvent) { - return getProfileFromEvent(profileEvent) - } - - try { - const pubkey = userIdToPubkey(id) - return { pubkey, npub: pubkeyToNpub(pubkey) ?? '', username: formatPubkey(pubkey) } - } catch { - return undefined - } - } - - async searchProfiles(relayUrls: string[], filter: Filter): Promise { - const events = await this.query(relayUrls, { - ...filter, - kinds: [kinds.Metadata] - }) - - const profileEvents = events.sort((a, b) => b.created_at - a.created_at) - await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile))) - profileEvents.forEach((profile) => this.updateProfileEventCache(profile)) - return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent)) - } - - async fetchRelayListEvent(pubkey: string) { - return this.relayListEventDataLoader.load(pubkey) - } - - async fetchRelayList(pubkey: string): Promise { - const [relayList] = await this.fetchRelayLists([pubkey]) - return relayList - } - - async fetchRelayLists(pubkeys: string[]): Promise { - const relayEvents = await indexedDb.getManyReplaceableEvents(pubkeys, kinds.RelayList) - const nonExistingPubkeyIndexMap = new Map() - pubkeys.forEach((pubkey, i) => { - if (relayEvents[i] === undefined) { - nonExistingPubkeyIndexMap.set(pubkey, i) - } - }) - const newEvents = await this.relayListEventDataLoader.loadMany( - Array.from(nonExistingPubkeyIndexMap.keys()) - ) - newEvents.forEach((event) => { - if (event && !(event instanceof Error)) { - const index = nonExistingPubkeyIndexMap.get(event.pubkey) - if (index !== undefined) { - relayEvents[index] = event - } - } - }) - - return relayEvents.map((event) => { - if (event) { - return getRelayListFromEvent(event) - } - return { - write: BIG_RELAY_URLS, - read: BIG_RELAY_URLS, - originalRelays: [] - } - }) - } - - async forceUpdateRelayListEvent(pubkey: string) { - await this.relayListEventBatchLoadFn([pubkey]) - } - - async fetchFollowListEvent(pubkey: string) { - return await this.followListCache.fetch(pubkey) - } - - async fetchMuteListEvent(pubkey: string): Promise { - const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Mutelist) - if (storedEvent) { - return storedEvent - } - const relayList = await this.fetchRelayList(pubkey) - const events = await this.query(relayList.write.concat(BIG_RELAY_URLS), { - authors: [pubkey], - kinds: [kinds.Mutelist] - }) - const muteList = events.sort((a, b) => b.created_at - a.created_at)[0] - if (muteList) { - await indexedDb.putReplaceableEvent(muteList) - } - return muteList - } - - async fetchBookmarkListEvent(pubkey: string): Promise { - const storedBookmarkListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.BookmarkList) - if (storedBookmarkListEvent) { - return storedBookmarkListEvent - } - - const relayList = await this.fetchRelayList(pubkey) - const events = await this.query(relayList.write.concat(BIG_RELAY_URLS), { - authors: [pubkey], - kinds: [kinds.BookmarkList] - }) - - return events.sort((a, b) => b.created_at - a.created_at)[0] - } - - async fetchFollowings(pubkey: string) { - const followListEvent = await this.fetchFollowListEvent(pubkey) - return followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] - } - - async fetchFollowingFavoriteRelays(pubkey: string) { - return this.followingFavoriteRelaysCache.fetch(pubkey) - } - - private async _fetchFollowingFavoriteRelays(pubkey: string) { - const fetchNewData = async () => { - const followings = await this.fetchFollowings(pubkey) - const events = await this.fetchEvents(BIG_RELAY_URLS, { - authors: followings, - kinds: [ExtendedKind.FAVORITE_RELAYS, kinds.Relaysets], - limit: 1000 - }) - const alreadyExistsFavoriteRelaysPubkeySet = new Set() - const alreadyExistsRelaySetsPubkeySet = new Set() - const uniqueEvents: NEvent[] = [] - events - .sort((a, b) => b.created_at - a.created_at) - .forEach((event) => { - if (event.kind === ExtendedKind.FAVORITE_RELAYS) { - if (alreadyExistsFavoriteRelaysPubkeySet.has(event.pubkey)) return - alreadyExistsFavoriteRelaysPubkeySet.add(event.pubkey) - } else if (event.kind === kinds.Relaysets) { - if (alreadyExistsRelaySetsPubkeySet.has(event.pubkey)) return - alreadyExistsRelaySetsPubkeySet.add(event.pubkey) - } else { - return - } - uniqueEvents.push(event) - }) - - const relayMap = new Map>() - uniqueEvents.forEach((event) => { - event.tags.forEach(([tagName, tagValue]) => { - if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) { - const url = normalizeUrl(tagValue) - relayMap.set(url, (relayMap.get(url) || new Set()).add(event.pubkey)) - } - }) - }) - const relayMapEntries = Array.from(relayMap.entries()) - .sort((a, b) => b[1].size - a[1].size) - .map(([url, pubkeys]) => [url, Array.from(pubkeys)]) as [string, string[]][] - - indexedDb.putFollowingFavoriteRelays(pubkey, relayMapEntries) - return relayMapEntries - } - - const cached = await indexedDb.getFollowingFavoriteRelays(pubkey) - if (cached) { - fetchNewData() - return cached - } - return fetchNewData() - } - - async fetchBlossomServerList(pubkey: string) { - const evt = await this.blossomServerListEventCache.fetch(pubkey) - return evt ? getServersFromServerTags(evt.tags) : [] - } - - async fetchBlossomServerListEvent(pubkey: string) { - return (await this.blossomServerListEventCache.fetch(pubkey)) ?? null - } - - async updateBlossomServerListEventCache(evt: NEvent) { - this.blossomServerListEventCache.set(evt.pubkey, Promise.resolve(evt)) - await indexedDb.putReplaceableEvent(evt) - } - - private async _fetchBlossomServerListEvent(pubkey: string) { - const fetchNew = async () => { - const relayList = await this.fetchRelayList(pubkey) - const events = await this.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), { - authors: [pubkey], - kinds: [ExtendedKind.BLOSSOM_SERVER_LIST] - }) - const blossomServerListEvent = events.sort((a, b) => b.created_at - a.created_at)[0] - if (!blossomServerListEvent) { - indexedDb.putNullReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST) - return null - } - indexedDb.putReplaceableEvent(blossomServerListEvent) - return blossomServerListEvent - } - - const storedBlossomServerListEvent = await indexedDb.getReplaceableEvent( - pubkey, - ExtendedKind.BLOSSOM_SERVER_LIST - ) - if (storedBlossomServerListEvent) { - fetchNew() - return storedBlossomServerListEvent - } - return fetchNew() - } - - updateFollowListCache(event: NEvent) { - this.followListCache.set(event.pubkey, Promise.resolve(event)) - indexedDb.putReplaceableEvent(event) - } - - updateRelayListCache(event: NEvent) { - this.relayListEventDataLoader.clear(event.pubkey) - this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event)) - indexedDb.putReplaceableEvent(event) - } - - updateProfileEventCache(event: NEvent) { - this.fetchProfileEventFromBigRelaysDataloader.clear(event.pubkey) - this.fetchProfileEventFromBigRelaysDataloader.prime(event.pubkey, Promise.resolve(event)) - } - - async searchNpubsFromCache(query: string, limit: number = 100) { - const result = await this.userIndex.searchAsync(query, { limit }) - return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[] - } - - async searchProfilesFromCache(query: string, limit: number = 100) { - const npubs = await this.searchNpubsFromCache(query, limit) - const profiles = await Promise.all(npubs.map((npub) => this.fetchProfile(npub))) - return profiles.filter((profile) => !!profile) as TProfile[] - } - - async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { - const followings = await this.fetchFollowings(pubkey) - for (let i = 0; i * 20 < followings.length; i++) { - if (signal.aborted) return - await Promise.all( - followings.slice(i * 20, (i + 1) * 20).map((pubkey) => this.fetchProfileEvent(pubkey)) - ) - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - } - - getSeenEventRelays(eventId: string) { - return Array.from(this.pool.seenOn.get(eventId)?.values() || []) - } - - getSeenEventRelayUrls(eventId: string) { - return this.getSeenEventRelays(eventId).map((relay) => relay.url) - } - - getEventHints(eventId: string) { - return this.getSeenEventRelayUrls(eventId).filter((url) => !isLocalNetworkUrl(url)) - } - - getEventHint(eventId: string) { - return this.getSeenEventRelayUrls(eventId).find((url) => !isLocalNetworkUrl(url)) ?? '' - } - - trackEventSeenOn(eventId: string, relay: AbstractRelay) { - let set = this.pool.seenOn.get(eventId) - if (!set) { - set = new Set() - this.pool.seenOn.set(eventId, set) - } - set.add(relay) - } - private async fetchEventById(relayUrls: string[], id: string): Promise { const event = await this.fetchEventFromBigRelaysDataloader.load(id) if (event) { @@ -1048,25 +719,6 @@ class ClientService extends EventTarget { return event } - private async addUsernameToIndex(profileEvent: NEvent) { - try { - const profileObj = JSON.parse(profileEvent.content) - const text = [ - profileObj.display_name?.trim() ?? '', - profileObj.name?.trim() ?? '', - profileObj.nip05 - ?.split('@') - .map((s: string) => s.trim()) - .join(' ') ?? '' - ].join(' ') - if (!text) return - - await this.userIndex.addAsync(profileEvent.pubkey, text) - } catch { - return - } - } - private async tryHarderToFetchEvent( relayUrls: string[], filter: Filter, @@ -1099,103 +751,411 @@ class ClientService extends EventTarget { return ids.map((id) => eventsMap.get(id)) } - private async profileEventBatchLoadFn(pubkeys: readonly string[]) { - const events = await this.query(BIG_RELAY_URLS, { - authors: Array.from(new Set(pubkeys)), - kinds: [kinds.Metadata], - limit: pubkeys.length - }) - const eventsMap = new Map() - for (const event of events) { - const pubkey = event.pubkey - const existing = eventsMap.get(pubkey) - if (!existing || existing.created_at < event.created_at) { - eventsMap.set(pubkey, event) - } - } - const profileEvents = pubkeys.map((pubkey) => { - return eventsMap.get(pubkey) - }) + /** =========== Following favorite relays =========== */ - profileEvents.forEach( - (profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent) - ) - return profileEvents + private followingFavoriteRelaysCache = new LRUCache>({ + max: 10, + fetchMethod: this._fetchFollowingFavoriteRelays.bind(this) + }) + + async fetchFollowingFavoriteRelays(pubkey: string) { + return this.followingFavoriteRelaysCache.fetch(pubkey) } - private async followListEventBatchLoadFn(pubkeys: readonly string[]) { - const events = await this.query(BIG_RELAY_URLS, { - authors: Array.from(new Set(pubkeys)), - kinds: [kinds.Contacts], - limit: pubkeys.length - }) - const eventsMap = new Map() - for (const event of events) { - const pubkey = event.pubkey - const existing = eventsMap.get(pubkey) - if (!existing || existing.created_at < event.created_at) { - eventsMap.set(pubkey, event) - } - } - const followListEvents = pubkeys.map((pubkey) => { - return eventsMap.get(pubkey) - }) + private async _fetchFollowingFavoriteRelays(pubkey: string) { + const fetchNewData = async () => { + const followings = await this.fetchFollowings(pubkey) + const events = await this.fetchEvents(BIG_RELAY_URLS, { + authors: followings, + kinds: [ExtendedKind.FAVORITE_RELAYS, kinds.Relaysets], + limit: 1000 + }) + const alreadyExistsFavoriteRelaysPubkeySet = new Set() + const alreadyExistsRelaySetsPubkeySet = new Set() + const uniqueEvents: NEvent[] = [] + events + .sort((a, b) => b.created_at - a.created_at) + .forEach((event) => { + if (event.kind === ExtendedKind.FAVORITE_RELAYS) { + if (alreadyExistsFavoriteRelaysPubkeySet.has(event.pubkey)) return + alreadyExistsFavoriteRelaysPubkeySet.add(event.pubkey) + } else if (event.kind === kinds.Relaysets) { + if (alreadyExistsRelaySetsPubkeySet.has(event.pubkey)) return + alreadyExistsRelaySetsPubkeySet.add(event.pubkey) + } else { + return + } + uniqueEvents.push(event) + }) - followListEvents.forEach( - (followListEvent) => followListEvent && indexedDb.putReplaceableEvent(followListEvent) - ) - return followListEvents + const relayMap = new Map>() + uniqueEvents.forEach((event) => { + event.tags.forEach(([tagName, tagValue]) => { + if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) { + const url = normalizeUrl(tagValue) + relayMap.set(url, (relayMap.get(url) || new Set()).add(event.pubkey)) + } + }) + }) + const relayMapEntries = Array.from(relayMap.entries()) + .sort((a, b) => b[1].size - a[1].size) + .map(([url, pubkeys]) => [url, Array.from(pubkeys)]) as [string, string[]][] + + indexedDb.putFollowingFavoriteRelays(pubkey, relayMapEntries) + return relayMapEntries + } + + const cached = await indexedDb.getFollowingFavoriteRelays(pubkey) + if (cached) { + fetchNewData() + return cached + } + return fetchNewData() } - private async relayListEventBatchLoadFn(pubkeys: readonly string[]) { - const events = await this.query(BIG_RELAY_URLS, { - authors: pubkeys as string[], - kinds: [kinds.RelayList], - limit: pubkeys.length - }) - const eventsMap = new Map() - for (const event of events) { - const pubkey = event.pubkey - const existing = eventsMap.get(pubkey) - if (!existing || existing.created_at < event.created_at) { - eventsMap.set(pubkey, event) - } - } - pubkeys.forEach((pubkey) => { - const event = eventsMap.get(pubkey) - if (event) { - indexedDb.putReplaceableEvent(event) - } else { - indexedDb.putNullReplaceableEvent(pubkey, kinds.RelayList) - } - }) + /** =========== Followings =========== */ - return pubkeys.map((pubkey) => eventsMap.get(pubkey)) + async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { + const followings = await this.fetchFollowings(pubkey) + for (let i = 0; i * 20 < followings.length; i++) { + if (signal.aborted) return + await Promise.all( + followings.slice(i * 20, (i + 1) * 20).map((pubkey) => this.fetchProfileEvent(pubkey)) + ) + await new Promise((resolve) => setTimeout(resolve, 1000)) + } } - private async _fetchFollowListEvent(pubkey: string) { - const storedFollowListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts) - if (storedFollowListEvent) { - return storedFollowListEvent + /** =========== Profile =========== */ + + async searchProfiles(relayUrls: string[], filter: Filter): Promise { + const events = await this.query(relayUrls, { + ...filter, + kinds: [kinds.Metadata] + }) + + const profileEvents = events.sort((a, b) => b.created_at - a.created_at) + await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile))) + profileEvents.forEach((profile) => this.updateProfileEventCache(profile)) + return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent)) + } + + async searchNpubsFromLocal(query: string, limit: number = 100) { + const result = await this.userIndex.searchAsync(query, { limit }) + return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[] + } + + async searchProfilesFromLocal(query: string, limit: number = 100) { + const npubs = await this.searchNpubsFromLocal(query, limit) + const profiles = await Promise.all(npubs.map((npub) => this.fetchProfile(npub))) + return profiles.filter((profile) => !!profile) as TProfile[] + } + + private async addUsernameToIndex(profileEvent: NEvent) { + try { + const profileObj = JSON.parse(profileEvent.content) + const text = [ + profileObj.display_name?.trim() ?? '', + profileObj.name?.trim() ?? '', + profileObj.nip05 + ?.split('@') + .map((s: string) => s.trim()) + .join(' ') ?? '' + ].join(' ') + if (!text) return + + await this.userIndex.addAsync(profileEvent.pubkey, text) + } catch { + return } - const followListEventFromBigRelays = - await this.fetchFollowListEventFromBigRelaysDataloader.load(pubkey) - if (followListEventFromBigRelays) { - return followListEventFromBigRelays + } + + async fetchProfileEvent(id: string, skipCache: boolean = false): Promise { + let pubkey: string | undefined + let relays: string[] = [] + if (/^[0-9a-f]{64}$/.test(id)) { + pubkey = id + } else { + const { data, type } = nip19.decode(id) + switch (type) { + case 'npub': + pubkey = data + break + case 'nprofile': + pubkey = data.pubkey + if (data.relays) relays = data.relays + break + } + } + + if (!pubkey) { + throw new Error('Invalid id') + } + if (!skipCache) { + const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) + if (localProfile) { + return localProfile + } + } + const profileFromBigRelays = await this.replaceableEventFromBigRelaysDataloader.load({ + pubkey, + kind: kinds.Metadata + }) + if (profileFromBigRelays) { + this.addUsernameToIndex(profileFromBigRelays) + return profileFromBigRelays } - const relayList = await this.fetchRelayList(pubkey) - const relays = relayList.write.filter((url) => !BIG_RELAY_URLS.includes(url)) if (!relays.length) { return undefined } - const followListEvents = await this.query(relays, { - authors: [pubkey], - kinds: [kinds.Contacts] + const profileEvent = await this.tryHarderToFetchEvent( + relays, + { + authors: [pubkey], + kinds: [kinds.Metadata], + limit: 1 + }, + true + ) + + if (profileEvent) { + this.addUsernameToIndex(profileEvent) + indexedDb.putReplaceableEvent(profileEvent) + } + + return profileEvent + } + + async fetchProfile(id: string, skipCache: boolean = false): Promise { + const profileEvent = await this.fetchProfileEvent(id, skipCache) + if (profileEvent) { + return getProfileFromEvent(profileEvent) + } + + try { + const pubkey = userIdToPubkey(id) + return { pubkey, npub: pubkeyToNpub(pubkey) ?? '', username: formatPubkey(pubkey) } + } catch { + return undefined + } + } + + async updateProfileEventCache(event: NEvent) { + await this.updateReplaceableEventFromBigRelaysCache(event) + } + + /** =========== Relay list =========== */ + + async fetchRelayListEvent(pubkey: string) { + const [relayEvent] = await this.fetchReplaceableEventsFromBigRelays([pubkey], kinds.RelayList) + return relayEvent ?? null + } + + async fetchRelayList(pubkey: string): Promise { + const [relayList] = await this.fetchRelayLists([pubkey]) + return relayList + } + + async fetchRelayLists(pubkeys: string[]): Promise { + const relayEvents = await this.fetchReplaceableEventsFromBigRelays(pubkeys, kinds.RelayList) + + return relayEvents.map((event) => { + if (event) { + return getRelayListFromEvent(event) + } + return { + write: BIG_RELAY_URLS, + read: BIG_RELAY_URLS, + originalRelays: [] + } + }) + } + + async forceUpdateRelayListEvent(pubkey: string) { + await this.replaceableEventBatchLoadFn([{ pubkey, kind: kinds.RelayList }]) + } + + async updateRelayListCache(event: NEvent) { + await this.updateReplaceableEventFromBigRelaysCache(event) + } + + /** =========== Replaceable event from big relays dataloader =========== */ + + private replaceableEventFromBigRelaysDataloader = new DataLoader< + { pubkey: string; kind: number }, + NEvent | null, + string + >(this.replaceableEventFromBigRelaysBatchLoadFn.bind(this), { + batchScheduleFn: (callback) => setTimeout(callback, 50), + maxBatchSize: 500, + cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}` + }) + + private async replaceableEventFromBigRelaysBatchLoadFn( + params: readonly { pubkey: string; kind: number }[] + ) { + const groups = new Map() + params.forEach(({ pubkey, kind }) => { + if (!groups.has(kind)) { + groups.set(kind, []) + } + groups.get(kind)!.push(pubkey) }) - return followListEvents.sort((a, b) => b.created_at - a.created_at)[0] + const eventsMap = new Map() + await Promise.allSettled( + Array.from(groups.entries()).map(async ([kind, pubkeys]) => { + const events = await this.query(BIG_RELAY_URLS, { + authors: pubkeys, + kinds: [kind] + }) + + for (const event of events) { + const key = `${event.pubkey}:${event.kind}` + const existing = eventsMap.get(key) + if (!existing || existing.created_at < event.created_at) { + eventsMap.set(key, event) + } + } + }) + ) + + return params.map(({ pubkey, kind }) => { + const key = `${pubkey}:${kind}` + const event = eventsMap.get(key) + if (event) { + indexedDb.putReplaceableEvent(event) + return event + } else { + indexedDb.putNullReplaceableEvent(pubkey, kind) + return null + } + }) + } + + private async fetchReplaceableEventsFromBigRelays(pubkeys: string[], kind: number) { + const events = await indexedDb.getManyReplaceableEvents(pubkeys, kind) + const nonExistingPubkeyIndexMap = new Map() + pubkeys.forEach((pubkey, i) => { + if (events[i] === undefined) { + nonExistingPubkeyIndexMap.set(pubkey, i) + } + }) + const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany( + Array.from(nonExistingPubkeyIndexMap.keys()).map((pubkey) => ({ pubkey, kind })) + ) + newEvents.forEach((event) => { + if (event && !(event instanceof Error)) { + const index = nonExistingPubkeyIndexMap.get(event.pubkey) + if (index !== undefined) { + events[index] = event + } + } + }) + + return events + } + + private async updateReplaceableEventFromBigRelaysCache(event: NEvent) { + this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: event.pubkey, kind: event.kind }) + this.replaceableEventFromBigRelaysDataloader.prime( + { pubkey: event.pubkey, kind: event.kind }, + Promise.resolve(event) + ) + await indexedDb.putReplaceableEvent(event) + } + + /** =========== Replaceable event dataloader =========== */ + + private replaceableEventDataLoader = new DataLoader< + { pubkey: string; kind: number }, + NEvent | null, + string + >(this.replaceableEventBatchLoadFn.bind(this), { + cacheKeyFn: ({ pubkey, kind }) => `${pubkey}:${kind}` + }) + + private async replaceableEventBatchLoadFn(params: readonly { pubkey: string; kind: number }[]) { + const results = await Promise.allSettled( + params.map(async ({ pubkey, kind }) => { + const relayList = await this.fetchRelayList(pubkey) + const events = await this.query(relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), { + authors: [pubkey], + kinds: [kind] + }) + const event = getLatestEvent(events) ?? null + if (event) { + indexedDb.putReplaceableEvent(event) + } else { + indexedDb.putNullReplaceableEvent(pubkey, kind) + } + return event + }) + ) + return results.map((result) => { + if (result.status === 'fulfilled') { + return result.value + } else { + console.error('Failed to load replaceable event:', result.reason) + return null + } + }) + } + + private async fetchReplaceableEvent(pubkey: string, kind: number) { + const storedEvent = await indexedDb.getReplaceableEvent(pubkey, kind) + if (storedEvent !== undefined) { + return storedEvent + } + + return await this.replaceableEventDataLoader.load({ pubkey, kind }) + } + + private async updateReplaceableEventCache(event: NEvent) { + this.replaceableEventDataLoader.clear({ pubkey: event.pubkey, kind: event.kind }) + this.replaceableEventDataLoader.prime( + { pubkey: event.pubkey, kind: event.kind }, + Promise.resolve(event) + ) + await indexedDb.putReplaceableEvent(event) + } + + /** =========== Replaceable event =========== */ + + async fetchFollowListEvent(pubkey: string) { + return await this.fetchReplaceableEvent(pubkey, kinds.Contacts) + } + + async fetchFollowings(pubkey: string) { + const followListEvent = await this.fetchFollowListEvent(pubkey) + return followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + } + + async updateFollowListCache(evt: NEvent) { + await this.updateReplaceableEventCache(evt) + } + + async fetchMuteListEvent(pubkey: string) { + return await this.fetchReplaceableEvent(pubkey, kinds.Mutelist) + } + + async fetchBookmarkListEvent(pubkey: string) { + return this.fetchReplaceableEvent(pubkey, kinds.BookmarkList) + } + + async fetchBlossomServerListEvent(pubkey: string) { + return await this.fetchReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST) + } + + async fetchBlossomServerList(pubkey: string) { + const evt = await this.fetchBlossomServerListEvent(pubkey) + return evt ? getServersFromServerTags(evt.tags) : [] + } + + async updateBlossomServerListEventCache(evt: NEvent) { + await this.updateReplaceableEventCache(evt) } } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index ae17058..c72b573 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -473,7 +473,7 @@ class IndexedDbService { }, { name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS, - expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 7 // 7 days + expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days } ] const transaction = this.db!.transaction(