feat: replace user search service

This commit is contained in:
codytseng 2026-01-15 22:58:55 +08:00
parent 400da44543
commit b6cb701ff1
5 changed files with 86 additions and 84 deletions

View file

@ -1,76 +1,48 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants' import { useInfiniteScroll } from '@/hooks'
import client from '@/services/client.service' import fayan from '@/services/fayan.service'
import dayjs from 'dayjs' import { useCallback, useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import UserItem, { UserItemSkeleton } from '../UserItem' import UserItem, { UserItemSkeleton } from '../UserItem'
const LIMIT = 50 const LIMIT = 50
const SHOW_COUNT = 20
export function ProfileListBySearch({ search }: { search: string }) { export function ProfileListBySearch({ search }: { search: string }) {
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [pubkeys, setPubkeys] = useState<string[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
setUntil(dayjs().unix()) setPubkeys([])
setHasMore(true)
setPubkeySet(new Set<string>())
loadMore()
}, [search]) }, [search])
useEffect(() => { const handleLoadMore = useCallback(async () => {
if (!hasMore) return const profiles = await fayan.searchUsers(search, LIMIT, pubkeys.length)
const options = { if (profiles.length === 0) {
root: null, return false
rootMargin: '10px',
threshold: 1
} }
const pubkeySet = new Set(pubkeys)
const observerInstance = new IntersectionObserver((entries) => { const newPubkeys = [...pubkeys]
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [hasMore, search, until])
const loadMore = async () => {
const profiles = await client.searchProfiles(SEARCHABLE_RELAY_URLS, {
search,
until,
limit: LIMIT
})
const newPubkeySet = new Set<string>()
profiles.forEach((profile) => { profiles.forEach((profile) => {
if (!pubkeySet.has(profile.pubkey)) { if (!pubkeySet.has(profile.pubkey)) {
newPubkeySet.add(profile.pubkey) pubkeySet.add(profile.pubkey)
newPubkeys.push(profile.pubkey)
} }
}) })
setPubkeySet((prev) => new Set([...prev, ...newPubkeySet])) setPubkeys(newPubkeys)
setHasMore(profiles.length >= LIMIT) return profiles.length >= LIMIT
const lastProfileCreatedAt = profiles[profiles.length - 1].created_at }, [search, pubkeys])
setUntil(lastProfileCreatedAt ? lastProfileCreatedAt - 1 : 0)
} const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
items: pubkeys,
showCount: SHOW_COUNT,
onLoadMore: handleLoadMore
})
return ( return (
<div className="px-4"> <div className="px-4">
{Array.from(pubkeySet).map((pubkey, index) => ( {visibleItems.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} userId={pubkey} /> <UserItem key={`${index}-${pubkey}`} userId={pubkey} />
))} ))}
{hasMore && <UserItemSkeleton />} <div ref={bottomRef} />
{hasMore && <div ref={bottomRef} />} {shouldShowLoadingIndicator && <UserItemSkeleton />}
</div> </div>
) )
} }

View file

@ -76,7 +76,6 @@ export const BIG_RELAY_URLS = [
export const SEARCHABLE_RELAY_URLS = [ export const SEARCHABLE_RELAY_URLS = [
'wss://search.nos.today/', 'wss://search.nos.today/',
'wss://relay.ditto.pub/', 'wss://relay.ditto.pub/',
'wss://relay.nostrcheck.me/',
'wss://relay.nostr.band/' 'wss://relay.nostr.band/'
] ]

View file

@ -1,6 +1,5 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import client from '@/services/client.service' import fayan from '@/services/fayan.service'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useFetchRelayInfos } from './useFetchRelayInfos' import { useFetchRelayInfos } from './useFetchRelayInfos'
@ -22,19 +21,9 @@ export function useSearchProfiles(search: string, limit: number) {
setIsFetching(true) setIsFetching(true)
setProfiles([]) setProfiles([])
try { try {
const profiles = await client.searchProfilesFromLocal(search, limit) const existingPubkeys = new Set<string>()
setProfiles(profiles) const profiles: TProfile[] = []
if (profiles.length >= limit) { const fetchedProfiles = await fayan.searchUsers(search, limit)
return
}
const existingPubkeys = new Set(profiles.map((profile) => profile.pubkey))
const fetchedProfiles = await client.searchProfiles(
searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4),
{
search,
limit
}
)
if (fetchedProfiles.length) { if (fetchedProfiles.length) {
fetchedProfiles.forEach((profile) => { fetchedProfiles.forEach((profile) => {
if (existingPubkeys.has(profile.pubkey)) { if (existingPubkeys.has(profile.pubkey)) {

View file

@ -1031,18 +1031,6 @@ class ClientService extends EventTarget {
/** =========== Profile =========== */ /** =========== 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) { async searchNpubsFromLocal(query: string, limit: number = 100) {
const result = await this.userIndex.searchAsync(query, { limit }) const result = await this.userIndex.searchAsync(query, { limit })
return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[] return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[]
@ -1170,7 +1158,10 @@ class ClientService extends EventTarget {
} }
async updateProfileEventCache(event: NEvent) { async updateProfileEventCache(event: NEvent) {
await this.updateReplaceableEventFromBigRelaysCache(event) await Promise.allSettled([
this.updateReplaceableEventFromBigRelaysCache(event),
this.addUsernameToIndex(event)
])
} }
/** =========== Relay list =========== */ /** =========== Relay list =========== */

View file

@ -1,5 +1,11 @@
import { getProfileFromEvent } from '@/lib/event-metadata'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { TProfile } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import { NostrEvent } from 'nostr-tools'
import client from './client.service'
const SERVICE_URL = 'https://fayan.jumble.social'
class FayanService { class FayanService {
static instance: FayanService static instance: FayanService
@ -7,7 +13,7 @@ class FayanService {
private userPercentileDataLoader = new DataLoader<string, number | null>( private userPercentileDataLoader = new DataLoader<string, number | null>(
async (pubkeys) => { async (pubkeys) => {
try { try {
const res = await fetch(`https://fayan.jumble.social/users`, { const res = await fetch(`${SERVICE_URL}/users`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -30,6 +36,7 @@ class FayanService {
} }
} }
) )
private searchResultCache: Map<string, TProfile[]> = new Map()
constructor() { constructor() {
if (!FayanService.instance) { if (!FayanService.instance) {
@ -42,6 +49,50 @@ class FayanService {
async fetchUserPercentile(userId: string): Promise<number | null> { async fetchUserPercentile(userId: string): Promise<number | null> {
return await this.userPercentileDataLoader.load(userId) return await this.userPercentileDataLoader.load(userId)
} }
async searchUsers(query: string, limit = 20, offset = 0) {
const cache = this.searchResultCache.get(query)
if (cache) {
if (offset + limit <= cache.length) {
console.log('FayanService searchUsers returning from cache')
return cache.slice(offset, offset + limit)
}
}
try {
const url = new URL('/search', SERVICE_URL)
url.searchParams.append('q', query)
url.searchParams.append('limit', limit.toString())
if (offset > 0) {
url.searchParams.append('offset', offset.toString())
}
const res = await fetch(url.toString())
if (!res.ok) {
return []
}
const data = (await res.json()) as { event: NostrEvent; percentile: number }[]
const profiles: TProfile[] = []
data.forEach(({ event, percentile }) => {
const profile = getProfileFromEvent(event)
profiles.push(profile)
this.userPercentileDataLoader.prime(profile.pubkey, percentile)
client.updateProfileEventCache(event)
})
// Cache the results
const existingCache = this.searchResultCache.get(query) || []
if (offset === 0) {
this.searchResultCache.set(query, profiles)
} else if (offset <= existingCache.length) {
const newCache = existingCache.slice(0, offset).concat(profiles)
this.searchResultCache.set(query, newCache)
}
return profiles
} catch {
return []
}
}
} }
const instance = new FayanService() const instance = new FayanService()