You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
220 lines
6.7 KiB
220 lines
6.7 KiB
import { useSecondaryPage } from '@/PageManager' |
|
import { PROFILE_RELAY_URLS } from '@/constants' |
|
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' |
|
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url' |
|
import { toProfile } from '@/lib/link' |
|
import { normalizeUrl } from '@/lib/url' |
|
import client from '@/services/client.service' |
|
import { cn } from '@/lib/utils' |
|
import dayjs from 'dayjs' |
|
import { useCallback, useEffect, useRef, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import UserItem, { UserItemSkeleton } from '../UserItem' |
|
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' |
|
|
|
const LIMIT = 50 |
|
|
|
const PROFILE_SEARCH_RELAY_URLS = Array.from( |
|
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)) |
|
) |
|
|
|
export function ProfileListBySearch({ search }: { search: string }) { |
|
const { t } = useTranslation() |
|
const { push } = useSecondaryPage() |
|
const [pubkeys, setPubkeys] = useState<string[]>([]) |
|
const [until, setUntil] = useState(() => dayjs().unix()) |
|
const [hasMore, setHasMore] = useState(true) |
|
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading') |
|
const [empty, setEmpty] = useState(false) |
|
const bottomRef = useRef<HTMLDivElement>(null) |
|
const loadMoreInFlight = useRef(false) |
|
const untilRef = useRef(until) |
|
untilRef.current = until |
|
|
|
/** Initial page: must not read `pubkeySet` from state — it is still the previous search until the next paint. */ |
|
useEffect(() => { |
|
let cancelled = false |
|
const untilStart = dayjs().unix() |
|
|
|
setPhase('loading') |
|
setEmpty(false) |
|
setPubkeys([]) |
|
setHasMore(true) |
|
setUntil(untilStart) |
|
|
|
void (async () => { |
|
try { |
|
const seen = new Set<string>() |
|
const batch: string[] = [] |
|
|
|
const cached = await client.searchProfilesFromIndexedDBCache(search, LIMIT) |
|
if (cancelled) return |
|
for (const p of cached) { |
|
const pk = p.pubkey.toLowerCase() |
|
if (seen.has(pk)) continue |
|
seen.add(pk) |
|
batch.push(p.pubkey) |
|
} |
|
|
|
const directPk = decodeProfileSearchQueryToPubkeyHex(search) |
|
if (directPk && !seen.has(directPk)) { |
|
seen.add(directPk) |
|
batch.push(directPk) |
|
void client.fetchProfileEvent(directPk).catch(() => {}) |
|
} |
|
|
|
const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { |
|
search, |
|
until: untilStart, |
|
limit: LIMIT |
|
}) |
|
if (cancelled) return |
|
|
|
for (const profile of relayProfiles) { |
|
const pk = profile.pubkey.toLowerCase() |
|
if (seen.has(pk)) continue |
|
seen.add(pk) |
|
batch.push(profile.pubkey) |
|
} |
|
|
|
let nextUntil = untilStart |
|
if (relayProfiles.length > 0) { |
|
const last = relayProfiles[relayProfiles.length - 1]! |
|
const ca = last.created_at |
|
if (typeof ca === 'number' && ca > 0) { |
|
nextUntil = ca - 1 |
|
} |
|
} |
|
|
|
setPubkeys(batch) |
|
setUntil(nextUntil) |
|
setHasMore(relayProfiles.length >= LIMIT) |
|
setEmpty(batch.length === 0) |
|
setPhase('ready') |
|
} catch { |
|
if (!cancelled) { |
|
setPhase('error') |
|
setEmpty(true) |
|
setHasMore(false) |
|
} |
|
} |
|
})() |
|
|
|
return () => { |
|
cancelled = true |
|
} |
|
}, [search]) |
|
|
|
const loadMore = useCallback(async () => { |
|
if (loadMoreInFlight.current || !hasMore) return |
|
loadMoreInFlight.current = true |
|
try { |
|
const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { |
|
search, |
|
until: untilRef.current, |
|
limit: LIMIT |
|
}) |
|
|
|
if (relayProfiles.length === 0) { |
|
setHasMore(false) |
|
return |
|
} |
|
|
|
let added = 0 |
|
setPubkeys((prev) => { |
|
const seen = new Set(prev.map((p) => p.toLowerCase())) |
|
const next = [...prev] |
|
for (const profile of relayProfiles) { |
|
const pk = profile.pubkey.toLowerCase() |
|
if (seen.has(pk)) continue |
|
seen.add(pk) |
|
next.push(profile.pubkey) |
|
} |
|
added = next.length - prev.length |
|
return next |
|
}) |
|
|
|
if (added === 0) { |
|
setHasMore(false) |
|
return |
|
} |
|
|
|
const last = relayProfiles[relayProfiles.length - 1]! |
|
const ca = last.created_at |
|
if (typeof ca === 'number' && ca > 0) { |
|
setUntil(ca - 1) |
|
} |
|
setHasMore(relayProfiles.length >= LIMIT) |
|
} catch { |
|
setHasMore(false) |
|
} finally { |
|
loadMoreInFlight.current = false |
|
} |
|
}, [search, hasMore]) |
|
|
|
useEffect(() => { |
|
if (!hasMore || phase !== 'ready') return |
|
const options = { root: null, rootMargin: '10px', threshold: 1 } |
|
const el = bottomRef.current |
|
if (!el) return |
|
|
|
const observer = new IntersectionObserver((entries) => { |
|
if (entries[0]?.isIntersecting) { |
|
void loadMore() |
|
} |
|
}, options) |
|
observer.observe(el) |
|
return () => observer.disconnect() |
|
}, [hasMore, phase, loadMore, pubkeys.length]) |
|
|
|
return ( |
|
<div className="px-4"> |
|
{phase === 'loading' && ( |
|
<div className="px-2 py-4"> |
|
<UserItemSkeleton hideFollowButton /> |
|
</div> |
|
)} |
|
{phase === 'error' && ( |
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('Profile search failed')}</p> |
|
)} |
|
{phase === 'ready' && empty && ( |
|
<div className="flex flex-col items-center py-6 text-center text-sm text-muted-foreground"> |
|
<p>{t('Profile search no results')}</p> |
|
{(() => { |
|
const trimmed = search.trim() |
|
if (!trimmed) return null |
|
const href = buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search }) |
|
return href ? <AlexandriaEventsSearchEmptyCta href={href} /> : null |
|
})()} |
|
</div> |
|
)} |
|
{pubkeys.map((pubkey, index) => ( |
|
<div |
|
key={`${index}-${pubkey}`} |
|
role="button" |
|
tabIndex={0} |
|
className={cn('rounded-lg clickable')} |
|
onClick={() => { |
|
client.fetchProfileEvent(pubkey).catch(() => {}) |
|
push(toProfile(pubkey)) |
|
}} |
|
onKeyDown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault() |
|
client.fetchProfileEvent(pubkey).catch(() => {}) |
|
push(toProfile(pubkey)) |
|
} |
|
}} |
|
> |
|
<UserItem pubkey={pubkey} /> |
|
</div> |
|
))} |
|
{phase === 'ready' && hasMore && pubkeys.length > 0 && ( |
|
<> |
|
<UserItemSkeleton hideFollowButton /> |
|
<div ref={bottomRef} /> |
|
</> |
|
)} |
|
</div> |
|
) |
|
}
|
|
|