20 changed files with 481 additions and 264 deletions
@ -1,58 +1,76 @@
@@ -1,58 +1,76 @@
|
||||
import { PROFILE_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' |
||||
import client from '@/services/client.service' |
||||
import { TProfile } from '@/types' |
||||
import { useEffect, useState } from 'react' |
||||
import { useEffect, useRef, useState } from 'react' |
||||
|
||||
const PROFILE_SEARCH_RELAY_URLS = Array.from( |
||||
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)) |
||||
) |
||||
|
||||
export function useSearchProfiles(search: string, limit: number) { |
||||
export function useSearchProfiles( |
||||
search: string, |
||||
limit: number, |
||||
debounceMs: number = SEARCH_QUERY_DEBOUNCE_MS |
||||
) { |
||||
const [debouncedSearch, setDebouncedSearch] = useState(() => search.trim()) |
||||
const [isFetching, setIsFetching] = useState(false) |
||||
const [error, setError] = useState<Error | null>(null) |
||||
const [profiles, setProfiles] = useState<TProfile[]>([]) |
||||
const abortRef = useRef<AbortController | null>(null) |
||||
const partialTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
||||
|
||||
useEffect(() => { |
||||
const trimmed = search.trim() |
||||
if (!trimmed) { |
||||
setDebouncedSearch('') |
||||
return |
||||
} |
||||
const timer = setTimeout(() => setDebouncedSearch(trimmed), debounceMs) |
||||
return () => clearTimeout(timer) |
||||
}, [search, debounceMs]) |
||||
|
||||
useEffect(() => { |
||||
const fetchProfiles = async () => { |
||||
if (!search.trim()) { |
||||
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([]) |
||||
setIsFetching(false) |
||||
setError(null) |
||||
return |
||||
} |
||||
|
||||
setIsFetching(true) |
||||
setProfiles([]) |
||||
setError(null) |
||||
try { |
||||
const profiles = await client.searchProfilesFromLocal(search, limit) |
||||
setProfiles(profiles) |
||||
if (profiles.length >= limit) { |
||||
return |
||||
} |
||||
const existingPubkeys = new Set(profiles.map((profile) => profile.pubkey)) |
||||
const fetchedProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { |
||||
search, |
||||
limit |
||||
}) |
||||
if (fetchedProfiles.length) { |
||||
fetchedProfiles.forEach((profile) => { |
||||
if (existingPubkeys.has(profile.pubkey)) { |
||||
return |
||||
} |
||||
existingPubkeys.add(profile.pubkey) |
||||
profiles.push(profile) |
||||
}) |
||||
setProfiles([...profiles]) |
||||
} |
||||
const result = await client.searchProfilesStaged( |
||||
debouncedSearch, |
||||
limit, |
||||
(partial) => { |
||||
if (cancelled || ac.signal.aborted) return |
||||
if (partialTimerRef.current) clearTimeout(partialTimerRef.current) |
||||
partialTimerRef.current = setTimeout(() => { |
||||
if (!cancelled && !ac.signal.aborted) setProfiles([...partial]) |
||||
}, 80) |
||||
}, |
||||
ac.signal |
||||
) |
||||
if (!cancelled && !ac.signal.aborted) setProfiles(result) |
||||
} catch (err) { |
||||
setError(err as Error) |
||||
if (!cancelled && !ac.signal.aborted) setError(err as Error) |
||||
} finally { |
||||
setIsFetching(false) |
||||
if (!cancelled && !ac.signal.aborted) setIsFetching(false) |
||||
} |
||||
} |
||||
|
||||
fetchProfiles() |
||||
}, [search, limit]) |
||||
void run() |
||||
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 @@
@@ -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