perf: cache following favorite relays

This commit is contained in:
codytseng
2025-07-09 22:32:41 +08:00
parent 3517476984
commit 2a842ff246
4 changed files with 127 additions and 71 deletions

View File

@@ -1,9 +1,8 @@
import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service'
import { TNip66RelayInfo } from '@/types'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
@@ -12,10 +11,9 @@ const SHOW_COUNT = 10
export default function FollowingFavoriteRelayList() { export default function FollowingFavoriteRelayList() {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [relays, setRelays] = useState<(TNip66RelayInfo & { users: string[] })[]>([]) const [relays, setRelays] = useState<[string, string[]][]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
@@ -25,21 +23,8 @@ export default function FollowingFavoriteRelayList() {
const init = async () => { const init = async () => {
if (!pubkey) return if (!pubkey) return
const relayMap = const relays = (await client.fetchFollowingFavoriteRelays(pubkey)) ?? []
(await client.fetchFollowingFavoriteRelays(pubkey)) ?? new Map<string, Set<string>>() setRelays(relays)
const relayUrls = Array.from(relayMap.keys())
const relayInfos = await relayInfoService.getRelayInfos(relayUrls ?? [])
setRelays(
(relayInfos.filter(Boolean) as TNip66RelayInfo[])
.map((relayInfo) => {
const users = Array.from(relayMap.get(relayInfo.url) ?? [])
return {
...relayInfo,
users
}
})
.sort((a, b) => b.users.length - a.users.length)
)
} }
init().finally(() => { init().finally(() => {
setLoading(false) setLoading(false)
@@ -73,16 +58,8 @@ export default function FollowingFavoriteRelayList() {
return ( return (
<div> <div>
{relays.slice(0, showCount).map((relay) => ( {relays.slice(0, showCount).map(([url, users]) => (
<RelaySimpleInfo <RelayItem key={url} url={url} users={users} />
key={relay.url}
relayInfo={relay}
className="clickable p-4 border-b"
onClick={(e) => {
e.stopPropagation()
push(toRelay(relay.url))
}}
/>
))} ))}
{showCount < relays.length && <div ref={bottomRef} />} {showCount < relays.length && <div ref={bottomRef} />}
{loading && <RelaySimpleInfoSkeleton />} {loading && <RelaySimpleInfoSkeleton />}
@@ -94,3 +71,21 @@ export default function FollowingFavoriteRelayList() {
</div> </div>
) )
} }
function RelayItem({ url, users }: { url: string; users: string[] }) {
const { push } = useSecondaryPage()
const { relayInfo } = useFetchRelayInfo(url)
return (
<RelaySimpleInfo
key={url}
relayInfo={relayInfo}
users={users}
className="clickable p-4 border-b"
onClick={(e) => {
e.stopPropagation()
push(toRelay(url))
}}
/>
)
}

View File

@@ -10,11 +10,13 @@ import { SimpleUserAvatar } from '../UserAvatar'
export default function RelaySimpleInfo({ export default function RelaySimpleInfo({
relayInfo, relayInfo,
users,
hideBadge = false, hideBadge = false,
className, className,
...props ...props
}: HTMLProps<HTMLDivElement> & { }: HTMLProps<HTMLDivElement> & {
relayInfo?: TNip66RelayInfo & { users?: string[] } relayInfo?: TNip66RelayInfo
users?: string[]
hideBadge?: boolean hideBadge?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -35,16 +37,16 @@ export default function RelaySimpleInfo({
</div> </div>
{!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />} {!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />}
{!!relayInfo?.description && <div className="line-clamp-4">{relayInfo.description}</div>} {!!relayInfo?.description && <div className="line-clamp-4">{relayInfo.description}</div>}
{!!relayInfo?.users?.length && ( {!!users?.length && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-muted-foreground">{t('Favorited by')} </div> <div className="text-muted-foreground">{t('Favorited by')} </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{relayInfo.users.slice(0, 10).map((user) => ( {users.slice(0, 10).map((user) => (
<SimpleUserAvatar key={user} userId={user} size="xSmall" /> <SimpleUserAvatar key={user} userId={user} size="xSmall" />
))} ))}
{relayInfo.users.length > 10 && ( {users.length > 10 && (
<div className="text-muted-foreground text-xs rounded-full bg-muted w-5 h-5 flex items-center justify-center"> <div className="text-muted-foreground text-xs rounded-full bg-muted w-5 h-5 flex items-center justify-center">
+{relayInfo.users.length - 10} +{users.length - 10}
</div> </div>
)} )}
</div> </div>

View File

@@ -74,10 +74,7 @@ class ClientService extends EventTarget {
max: 2000, max: 2000,
fetchMethod: this._fetchFollowListEvent.bind(this) fetchMethod: this._fetchFollowListEvent.bind(this)
}) })
private fetchFollowingFavoriteRelaysCache = new LRUCache< private fetchFollowingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
string,
Promise<Map<string, Set<string>>>
>({
max: 10, max: 10,
fetchMethod: this._fetchFollowingFavoriteRelays.bind(this) fetchMethod: this._fetchFollowingFavoriteRelays.bind(this)
}) })
@@ -823,40 +820,54 @@ class ClientService extends EventTarget {
} }
private async _fetchFollowingFavoriteRelays(pubkey: string) { private async _fetchFollowingFavoriteRelays(pubkey: string) {
const followings = await this.fetchFollowings(pubkey) const fetchNewData = async () => {
const events = await this.fetchEvents(BIG_RELAY_URLS, { const followings = await this.fetchFollowings(pubkey)
authors: followings, const events = await this.fetchEvents(BIG_RELAY_URLS, {
kinds: [ExtendedKind.FAVORITE_RELAYS, kinds.Relaysets], authors: followings,
limit: 1000 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 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>>() const relayMap = new Map<string, Set<string>>()
uniqueEvents.forEach((event) => { uniqueEvents.forEach((event) => {
event.tags.forEach(([tagName, tagValue]) => { event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) { if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) {
const url = normalizeUrl(tagValue) const url = normalizeUrl(tagValue)
relayMap.set(url, (relayMap.get(url) || new Set()).add(event.pubkey)) relayMap.set(url, (relayMap.get(url) || new Set()).add(event.pubkey))
} }
})
}) })
}) const relayMapEntries = Array.from(relayMap.entries())
return relayMap .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()
} }
updateFollowListCache(event: NEvent) { updateFollowListCache(event: NEvent) {

View File

@@ -17,7 +17,8 @@ const StoreNames = {
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
RELAY_INFO_EVENTS: 'relayInfoEvents', RELAY_INFO_EVENTS: 'relayInfoEvents',
FAVORITE_RELAYS: 'favoriteRelays', FAVORITE_RELAYS: 'favoriteRelays',
RELAY_SETS: 'relaySets' RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays'
} }
class IndexedDbService { class IndexedDbService {
@@ -36,7 +37,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 4) const request = window.indexedDB.open('jumble', 5)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@@ -76,6 +77,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) { if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) {
db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' }) db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' })
}
this.db = db this.db = db
} }
}) })
@@ -363,6 +367,50 @@ class IndexedDbService {
}) })
} }
async putFollowingFavoriteRelays(pubkey: string, relays: [string, string[]][]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readwrite')
const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
const putRequest = store.put(this.formatValue(pubkey, relays))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][] | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readonly')
const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
const request = store.get(pubkey)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<[string, string[]][]>)?.value)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private getReplaceableEventKey(event: Event): string { private getReplaceableEventKey(event: Event): string {
if ( if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) || [kinds.Metadata, kinds.Contacts].includes(event.kind) ||