20 changed files with 481 additions and 264 deletions
@ -1,58 +1,76 @@ |
|||||||
import { PROFILE_RELAY_URLS } from '@/constants' |
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' |
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
import client from '@/services/client.service' |
import client from '@/services/client.service' |
||||||
import { TProfile } from '@/types' |
import { TProfile } from '@/types' |
||||||
import { useEffect, useState } from 'react' |
import { useEffect, useRef, useState } from 'react' |
||||||
|
|
||||||
const PROFILE_SEARCH_RELAY_URLS = Array.from( |
export function useSearchProfiles( |
||||||
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)) |
search: string, |
||||||
) |
limit: number, |
||||||
|
debounceMs: number = SEARCH_QUERY_DEBOUNCE_MS |
||||||
export function useSearchProfiles(search: string, limit: number) { |
) { |
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState(() => search.trim()) |
||||||
const [isFetching, setIsFetching] = useState(false) |
const [isFetching, setIsFetching] = useState(false) |
||||||
const [error, setError] = useState<Error | null>(null) |
const [error, setError] = useState<Error | null>(null) |
||||||
const [profiles, setProfiles] = useState<TProfile[]>([]) |
const [profiles, setProfiles] = useState<TProfile[]>([]) |
||||||
|
const abortRef = useRef<AbortController | null>(null) |
||||||
|
const partialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
||||||
|
|
||||||
useEffect(() => { |
useEffect(() => { |
||||||
const fetchProfiles = async () => { |
const trimmed = search.trim() |
||||||
if (!search.trim()) { |
if (!trimmed) { |
||||||
|
setDebouncedSearch('') |
||||||
|
return |
||||||
|
} |
||||||
|
const timer = setTimeout(() => setDebouncedSearch(trimmed), debounceMs) |
||||||
|
return () => clearTimeout(timer) |
||||||
|
}, [search, debounceMs]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
abortRef.current?.abort() |
||||||
|
if (partialTimerRef.current) clearTimeout(partialTimerRef.current) |
||||||
|
const ac = new AbortController() |
||||||
|
abortRef.current = ac |
||||||
|
let cancelled = false |
||||||
|
|
||||||
|
const run = async () => { |
||||||
|
if (!debouncedSearch) { |
||||||
setProfiles([]) |
setProfiles([]) |
||||||
setIsFetching(false) |
setIsFetching(false) |
||||||
|
setError(null) |
||||||
return |
return |
||||||
} |
} |
||||||
|
|
||||||
setIsFetching(true) |
setIsFetching(true) |
||||||
setProfiles([]) |
setProfiles([]) |
||||||
|
setError(null) |
||||||
try { |
try { |
||||||
const profiles = await client.searchProfilesFromLocal(search, limit) |
const result = await client.searchProfilesStaged( |
||||||
setProfiles(profiles) |
debouncedSearch, |
||||||
if (profiles.length >= limit) { |
limit, |
||||||
return |
(partial) => { |
||||||
} |
if (cancelled || ac.signal.aborted) return |
||||||
const existingPubkeys = new Set(profiles.map((profile) => profile.pubkey)) |
if (partialTimerRef.current) clearTimeout(partialTimerRef.current) |
||||||
const fetchedProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { |
partialTimerRef.current = setTimeout(() => { |
||||||
search, |
if (!cancelled && !ac.signal.aborted) setProfiles([...partial]) |
||||||
limit |
}, 80) |
||||||
}) |
}, |
||||||
if (fetchedProfiles.length) { |
ac.signal |
||||||
fetchedProfiles.forEach((profile) => { |
) |
||||||
if (existingPubkeys.has(profile.pubkey)) { |
if (!cancelled && !ac.signal.aborted) setProfiles(result) |
||||||
return |
|
||||||
} |
|
||||||
existingPubkeys.add(profile.pubkey) |
|
||||||
profiles.push(profile) |
|
||||||
}) |
|
||||||
setProfiles([...profiles]) |
|
||||||
} |
|
||||||
} catch (err) { |
} catch (err) { |
||||||
setError(err as Error) |
if (!cancelled && !ac.signal.aborted) setError(err as Error) |
||||||
} finally { |
} finally { |
||||||
setIsFetching(false) |
if (!cancelled && !ac.signal.aborted) setIsFetching(false) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
fetchProfiles() |
void run() |
||||||
}, [search, limit]) |
return () => { |
||||||
|
cancelled = true |
||||||
|
if (partialTimerRef.current) clearTimeout(partialTimerRef.current) |
||||||
|
ac.abort() |
||||||
|
} |
||||||
|
}, [debouncedSearch, limit]) |
||||||
|
|
||||||
return { isFetching, error, profiles } |
return { isFetching, error, profiles, debouncedSearch } |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,18 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
import { mergedSearchNoteHasPreviewBody } from './merged-search-note-preview' |
||||||
|
|
||||||
|
describe('mergedSearchNoteHasPreviewBody', () => { |
||||||
|
it('treats hashtag-only kind 1 notes as visible', () => { |
||||||
|
const ev = { |
||||||
|
id: 'a'.repeat(64), |
||||||
|
pubkey: 'b'.repeat(64), |
||||||
|
created_at: 1_700_000_000, |
||||||
|
kind: kinds.ShortTextNote, |
||||||
|
tags: [['t', 'Palantir']], |
||||||
|
content: '', |
||||||
|
sig: 'sig' |
||||||
|
} |
||||||
|
expect(mergedSearchNoteHasPreviewBody(ev)).toBe(true) |
||||||
|
}) |
||||||
|
}) |
||||||
Loading…
Reference in new issue