refactor: client service

This commit is contained in:
codytseng
2025-08-04 22:53:36 +08:00
parent 5afbdd0aa4
commit 21f09426cf
8 changed files with 459 additions and 491 deletions

View File

@@ -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: () => {

View File

@@ -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

View File

@@ -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: [] }
}

View File

@@ -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]
}

View File

@@ -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[][]) => {

View File

@@ -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])

View File

@@ -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<string, NEvent | undefined>(
this.profileEventBatchLoadFn.bind(this),
{
batchScheduleFn: (callback) => setTimeout(callback, 50),
maxBatchSize: 500
}
)
private fetchFollowListEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
this.followListEventBatchLoadFn.bind(this),
{
batchScheduleFn: (callback) => setTimeout(callback, 50),
maxBatchSize: 500
}
)
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
this.relayListEventBatchLoadFn.bind(this),
{
batchScheduleFn: (callback) => setTimeout(callback, 50),
maxBatchSize: 500
}
)
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 2000,
fetchMethod: this._fetchFollowListEvent.bind(this)
})
private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
max: 10,
fetchMethod: this._fetchFollowingFavoriteRelays.bind(this)
})
private blossomServerListEventCache = new LRUCache<string, Promise<NEvent | null>>({
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<NEvent[]>((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<Filter, 'since' | 'until'> & { 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<NEvent[]>((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<NEvent | undefined> {
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<TProfile | undefined> {
const profileEvent = await this.fetchProfileEvent(id, skipCache)
if (profileEvent) {
return getProfileFromEvent(profileEvent)
}
try {
const pubkey = userIdToPubkey(id)
return { pubkey, npub: pubkeyToNpub(pubkey) ?? '', username: formatPubkey(pubkey) }
} catch {
return undefined
}
}
async searchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
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<TRelayList> {
const [relayList] = await this.fetchRelayLists([pubkey])
return relayList
}
async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> {
const relayEvents = await indexedDb.getManyReplaceableEvents(pubkeys, kinds.RelayList)
const nonExistingPubkeyIndexMap = new Map<string, number>()
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<NEvent | undefined> {
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<NEvent | undefined> {
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<string>()
const alreadyExistsRelaySetsPubkeySet = new Set<string>()
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<string, Set<string>>()
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<NEvent | undefined> {
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<string, NEvent>()
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<string, Promise<[string, string[]][]>>({
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<string, NEvent>()
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<string>()
const alreadyExistsRelaySetsPubkeySet = new Set<string>()
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<string, Set<string>>()
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<string, NEvent>()
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<TProfile[]> {
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<NEvent | undefined> {
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<TProfile | undefined> {
const profileEvent = await this.fetchProfileEvent(id, skipCache)
if (profileEvent) {
return getProfileFromEvent(profileEvent)
}
try {
const pubkey = userIdToPubkey(id)
return { pubkey, npub: pubkeyToNpub(pubkey) ?? '', username: formatPubkey(pubkey) }
} catch {
return undefined
}
}
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<TRelayList> {
const [relayList] = await this.fetchRelayLists([pubkey])
return relayList
}
async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> {
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<number, string[]>()
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<string, NEvent>()
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<string, number>()
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)
}
}

View File

@@ -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(