From 6a74b6e069a4b0266f7d31efc252dbe2149bd3c2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 22 May 2026 11:41:50 +0200 Subject: [PATCH] fix the in-event search --- .../AdvancedLabCitationPickerDialog.tsx | 3 +- src/components/HelpAndAccountMenu.tsx | 24 +-- src/components/InviteePicker/index.tsx | 26 +-- .../Mention/MentionAndEventToolbarButtons.tsx | 75 +++---- .../PostTextarea/Mention/MentionList.tsx | 2 +- .../Mention/NeventNaddrPickerDialog.tsx | 33 ++- .../PostTextarea/Mention/suggestion.ts | 54 ++--- src/components/ProfileListBySearch/index.tsx | 69 ++++--- src/components/SearchBar/index.tsx | 20 +- src/components/UserItem/index.tsx | 17 +- src/components/ui/ProfileSearchBar.tsx | 3 +- src/constants.ts | 6 + src/hooks/useSearchProfiles.tsx | 88 ++++---- src/lib/local-nip50-search-merge.ts | 24 +-- src/lib/merged-search-note-preview.test.ts | 18 ++ src/lib/merged-search-note-preview.ts | 1 + src/lib/profile-relay-search-filters.ts | 9 +- src/services/client-events.service.ts | 3 +- src/services/client.service.ts | 195 ++++++++++++++---- src/services/mention-event-search.service.ts | 75 +++++-- 20 files changed, 481 insertions(+), 264 deletions(-) create mode 100644 src/lib/merged-search-note-preview.test.ts diff --git a/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx b/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx index 42d4dcbb..9aea10a2 100644 --- a/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx @@ -1,3 +1,4 @@ +import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' import { Button } from '@/components/ui/button' import { Dialog, @@ -62,7 +63,7 @@ export function AdvancedLabCitationPickerDialog({ useEffect(() => { if (!open) return - const timer = setTimeout(() => setDebouncedQuery(query.trim()), 300) + const timer = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_QUERY_DEBOUNCE_MS) return () => clearTimeout(timer) }, [open, query]) diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index d48b9f36..4b1b7bfb 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { isVideo } from '@/lib/url' import { cn } from '@/lib/utils' -import { useCacheBrowserOptional } from '../contexts/cache-browser-context' +import { useCacheBrowser } from '@/contexts/cache-browser-context' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useFetchProfile } from '@/hooks/useFetchProfile' import { useNostr } from '@/providers/NostrProvider' @@ -31,7 +31,7 @@ function AccountDropdownItems({ }: { onSwitchAccount: () => void onLogoutClick: () => void - onBrowseCache?: () => void + onBrowseCache: () => void }) { const { t } = useTranslation() const { navigate } = usePrimaryPage() @@ -46,12 +46,10 @@ function AccountDropdownItems({ {t('Settings')} - {onBrowseCache ? ( - - - {t('Browse Cache')} - - ) : null} + + + {t('Browse Cache')} + @@ -72,7 +70,7 @@ function SidebarAccountMenu({ }: { onSwitchAccount: () => void onLogoutClick: () => void - onBrowseCache?: () => void + onBrowseCache: () => void }) { const { t } = useTranslation() const { account, profile } = useNostr() @@ -136,7 +134,7 @@ function TitlebarAccountMenu({ }: { onSwitchAccount: () => void onLogoutClick: () => void - onBrowseCache?: () => void + onBrowseCache: () => void }) { const { t } = useTranslation() const { account, profile } = useNostr() @@ -193,7 +191,7 @@ function TitlebarAccountMenu({ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { const { t } = useTranslation() const { pubkey, checkLogin } = useNostr() - const onBrowseCache = useCacheBrowserOptional()?.openBrowseCache + const { openBrowseCache } = useCacheBrowser() const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) @@ -204,13 +202,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun setLoginDialogOpen(true)} onLogoutClick={() => setLogoutDialogOpen(true)} - onBrowseCache={onBrowseCache} + onBrowseCache={openBrowseCache} /> ) : ( setLoginDialogOpen(true)} onLogoutClick={() => setLogoutDialogOpen(true)} - onBrowseCache={onBrowseCache} + onBrowseCache={openBrowseCache} /> ) } else if (variant === 'sidebar') { diff --git a/src/components/InviteePicker/index.tsx b/src/components/InviteePicker/index.tsx index f8422f8f..deb089f1 100644 --- a/src/components/InviteePicker/index.tsx +++ b/src/components/InviteePicker/index.tsx @@ -4,13 +4,12 @@ import { inviteInputToHexPubkey } from '@/lib/pubkey' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { X } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUsername } from '../Username' import Nip05 from '../Nip05' -const SEARCH_DEBOUNCE_MS = 300 const SEARCH_LIMIT = 10 export function InviteePicker({ @@ -32,14 +31,7 @@ export function InviteePicker({ const { t } = useTranslation() const { pubkey: myPubkey } = useNostr() const [search, setSearch] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - - useEffect(() => { - const id = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS) - return () => clearTimeout(id) - }, [search]) - - const { profiles, isFetching } = useSearchProfiles(debouncedSearch, SEARCH_LIMIT) + const { profiles, isFetching } = useSearchProfiles(search, SEARCH_LIMIT) const selectedSet = new Set(value) const atLimit = max != null && value.length >= max const filteredProfiles = profiles.filter((p) => !selectedSet.has(p.pubkey) && p.pubkey !== myPubkey) @@ -70,7 +62,12 @@ export function InviteePicker({ key={pubkey} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm" > - + p.pubkey === pubkey)} + deferRemoteAvatar={false} + className="size-5 shrink-0" + /> - ))} + {profiles.map((profile) => ( + + ))} diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index 8e127122..b40a8106 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -125,7 +125,7 @@ const MentionList = forwardRef((props, ref) ) : ( <> - +
diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx index 4673ae73..9bc9618c 100644 --- a/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx @@ -1,5 +1,7 @@ import * as React from 'react' +import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' import { getNoteBech32Id } from '@/lib/event' +import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import client from '@/services/client.service' import { searchEventsForPicker, @@ -42,18 +44,20 @@ function NeventNaddrPickerDialog({ const [debouncedQuery, setDebouncedQuery] = useState('') const [events, setEvents] = useState([]) const [loading, setLoading] = useState(false) + const [relayPending, setRelayPending] = useState(false) useEffect(() => { if (!open) return setQuery('') setDebouncedQuery('') setEvents([]) + setRelayPending(false) if (initialMode !== undefined) setMode(initialMode) }, [open, initialMode]) useEffect(() => { if (!open) return - const t = setTimeout(() => setDebouncedQuery(query.trim()), 300) + const t = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_QUERY_DEBOUNCE_MS) return () => clearTimeout(t) }, [open, query]) @@ -61,17 +65,28 @@ function NeventNaddrPickerDialog({ if (!open || !debouncedQuery) { setEvents([]) setLoading(false) + setRelayPending(false) return } let cancelled = false setLoading(true) - searchEventsForPicker(debouncedQuery, 20, mode, undefined) + setRelayPending(true) + setEvents([]) + searchEventsForPicker(debouncedQuery, 20, mode, undefined, (partial) => { + if (cancelled) return + const visible = partial.filter(mergedSearchNoteHasPreviewBody).slice(0, 15) as NEvent[] + setEvents(visible) + if (visible.length > 0) setLoading(false) + }) .then((list) => { if (cancelled) return - setEvents(list.slice(0, 15) as NEvent[]) + setEvents(list.filter(mergedSearchNoteHasPreviewBody).slice(0, 15) as NEvent[]) }) .finally(() => { - if (!cancelled) setLoading(false) + if (!cancelled) { + setLoading(false) + setRelayPending(false) + } }) return () => { cancelled = true @@ -136,20 +151,22 @@ function NeventNaddrPickerDialog({
- {loading && ( + {loading && events.length === 0 && (
{Array.from({ length: 6 }).map((_, i) => ( ))}
)} - {!loading && debouncedQuery && events.length === 0 && ( + {relayPending && events.length > 0 && ( +

{t('Searching…')}

+ )} + {!loading && !relayPending && debouncedQuery && events.length === 0 && (

{t('No events found')}

)} - {!loading && - events.map((ev: NEvent) => ( + {events.map((ev: NEvent) => (
) diff --git a/src/components/UserItem/index.tsx b/src/components/UserItem/index.tsx index befa570d..e56c688b 100644 --- a/src/components/UserItem/index.tsx +++ b/src/components/UserItem/index.tsx @@ -9,18 +9,29 @@ import type { TProfile } from '@/types' export default function UserItem({ pubkey, hideFollowButton, + hideNip05, className, - prefetchedProfile + prefetchedProfile, + deferRemoteAvatar = true }: { pubkey: string hideFollowButton?: boolean + /** Skip nip05 verification fetches (search dropdown rows). */ + hideNip05?: boolean className?: string /** When the caller already loaded this profile (e.g. search index / DB), show it immediately. */ prefetchedProfile?: TProfile | null + /** Set false in search/mention dropdowns so profile pictures load without viewport deferral. */ + deferRemoteAvatar?: boolean }) { return (
- +
- + {!hideNip05 && }
{!hideFollowButton && }
diff --git a/src/components/ui/ProfileSearchBar.tsx b/src/components/ui/ProfileSearchBar.tsx index 04a69fda..ce05f9fb 100644 --- a/src/components/ui/ProfileSearchBar.tsx +++ b/src/components/ui/ProfileSearchBar.tsx @@ -1,3 +1,4 @@ +import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' import { Input } from '@/components/ui/input' import { Search, X } from 'lucide-react' import { cn } from '@/lib/utils' @@ -23,7 +24,7 @@ export default function ProfileSearchBar({ useEffect(() => { const timer = setTimeout(() => { onSearch(query) - }, 300) + }, SEARCH_QUERY_DEBOUNCE_MS) return () => clearTimeout(timer) }, [query, onSearch]) diff --git a/src/constants.ts b/src/constants.ts index bf32eaea..8e485a41 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -505,6 +505,12 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://nostr-pub.wellorder.net', ] +/** + * Wait after the last keystroke before profile / mention / picker search hits the network + * ({@link useSearchProfiles}, @-mention dropdown, event picker, etc.). + */ +export const SEARCH_QUERY_DEBOUNCE_MS = 550 + export const PROFILE_RELAY_URLS = [ 'wss://profiles.nostr1.com', 'wss://purplepag.es', diff --git a/src/hooks/useSearchProfiles.tsx b/src/hooks/useSearchProfiles.tsx index 12422afb..d26c3b3c 100644 --- a/src/hooks/useSearchProfiles.tsx +++ b/src/hooks/useSearchProfiles.tsx @@ -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(null) const [profiles, setProfiles] = useState([]) + const abortRef = useRef(null) + const partialTimerRef = useRef | 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 } } diff --git a/src/lib/local-nip50-search-merge.ts b/src/lib/local-nip50-search-merge.ts index 65c31b0c..2a72dc0c 100644 --- a/src/lib/local-nip50-search-merge.ts +++ b/src/lib/local-nip50-search-merge.ts @@ -56,18 +56,6 @@ export async function collectLocalEventsForTextSearch( } } - const idbOpts = - params.archiveScanMaxMs !== undefined ? { archiveScanMaxMs: params.archiveScanMaxMs } : undefined - const fromPubArchive = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch( - q, - params.idbMergedLimit, - kindsArr, - idbOpts - ) - for (const ev of fromPubArchive) { - push(ev) - } - if (params.includeOtherStoresFullText) { const cap = params.fullTextStoreHitCap ?? 260 try { @@ -80,6 +68,18 @@ export async function collectLocalEventsForTextSearch( } } + const idbOpts = + params.archiveScanMaxMs !== undefined ? { archiveScanMaxMs: params.archiveScanMaxMs } : undefined + const fromPubArchive = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch( + q, + params.idbMergedLimit, + kindsArr, + idbOpts + ) + for (const ev of fromPubArchive) { + push(ev) + } + out.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) return out } diff --git a/src/lib/merged-search-note-preview.test.ts b/src/lib/merged-search-note-preview.test.ts new file mode 100644 index 00000000..293c70e0 --- /dev/null +++ b/src/lib/merged-search-note-preview.test.ts @@ -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) + }) +}) diff --git a/src/lib/merged-search-note-preview.ts b/src/lib/merged-search-note-preview.ts index 28c65500..3e59beb9 100644 --- a/src/lib/merged-search-note-preview.ts +++ b/src/lib/merged-search-note-preview.ts @@ -20,6 +20,7 @@ export function mergedSearchNoteHasPreviewBody(ev: Event): boolean { const k = ev.kind if (k === kinds.ShortTextNote || k === ExtendedKind.COMMENT) { if (ev.tags.some((t) => t[0] === 'subject' && String(t[1] ?? '').trim().length > 0)) return true + if (ev.tags.some((t) => t[0] === 't' && String(t[1] ?? '').trim().length > 0)) return true return Boolean(ev.content?.trim().length) } if (k === kinds.Metadata) { diff --git a/src/lib/profile-relay-search-filters.ts b/src/lib/profile-relay-search-filters.ts index f8b17f89..dbe248b6 100644 --- a/src/lib/profile-relay-search-filters.ts +++ b/src/lib/profile-relay-search-filters.ts @@ -15,7 +15,13 @@ export function buildProfileKind0SearchFilters(opts: { search: string limit: number until?: number + /** + * When false, only NIP-50 `search` (and pubkey `authors`) — avoids `#name` / `#nip05` REQ + * that many profile relays reject as "unrecognised filter item". + */ + includeTagFilters?: boolean }): Filter[] { + const includeTagFilters = opts.includeTagFilters !== false const searchRaw = opts.search.trim() if (!searchRaw) return [] @@ -45,7 +51,7 @@ export function buildProfileKind0SearchFilters(opts: { add({ kinds: k, search: searchNorm, limit, ...time }) } - if (searchRaw.includes('@')) { + if (includeTagFilters && searchRaw.includes('@')) { const firstToken = (searchRaw.split(/\s+/)[0] ?? searchRaw).trim() const nipVariants = new Set() if (firstToken) { @@ -66,6 +72,7 @@ export function buildProfileKind0SearchFilters(opts: { const token = searchRaw.startsWith('@') ? searchRaw.slice(1).trim() : searchRaw.trim() if ( + includeTagFilters && token && !/\s/.test(token) && token.length <= 80 && diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index aee1a793..1fb2eb49 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -791,7 +791,8 @@ export class EventService { const buf: NEvent[] = [] let scanned = 0 - for (const [, event] of this.sessionEventCache.entries()) { + // LRU order: most recently seen / published first (Map insertion order is not recency). + for (const event of this.sessionEventCache.values()) { if (++scanned > SESSION_SEARCH_MAX_SCAN) break if (shouldDropEventOnIngest(event)) continue if (kindSet && !kindSet.has(event.kind)) continue diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 6e035b66..e4c0e58b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3661,7 +3661,18 @@ class ClientService extends EventTarget { /** =========== Profile =========== */ - async searchProfiles(relayUrls: string[], filter: Filter): Promise { + async searchProfiles( + relayUrls: string[], + filter: Filter, + options?: { + relaysOnly?: boolean + includeTagFilters?: boolean + signal?: AbortSignal + /** Override query timeouts (profile-relay step uses shorter budgets). */ + eoseTimeout?: number + globalTimeout?: number + } + ): Promise { void this.ensureProfileSearchIndexFromIdb() const searchStr = typeof filter.search === 'string' ? filter.search.trim() : '' const normalizedAll = dedupeNormalizeRelayUrlsOrdered( @@ -3676,7 +3687,7 @@ class ClientService extends EventTarget { ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ]) let urls = normalizedAll - if (searchStr.length > 0) { + if (searchStr.length > 0 && !options?.relaysOnly) { const searchCapable = normalizedAll.filter( (u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u) ) @@ -3693,7 +3704,8 @@ class ClientService extends EventTarget { const built = buildProfileKind0SearchFilters({ search: searchStr, limit: limitCap, - until: filter.until + until: filter.until, + includeTagFilters: options?.includeTagFilters }) return built.length > 0 ? built : [{ ...filter, kinds: [...METADATA_CO_FETCH_KINDS] }] })() @@ -3705,14 +3717,18 @@ class ClientService extends EventTarget { (f) => typeof f.search === 'string' && f.search.trim().length > 0 ) const usesAuthorsLookup = filtersArr.some((f) => (f.authors?.length ?? 0) > 0) + if (options?.signal?.aborted) return [] + const events = await this.queryService.query(urls, queryFilter, undefined, { replaceableRace: false, - eoseTimeout: usesNip50TextSearch ? 10_000 : 4500, - globalTimeout: usesNip50TextSearch - ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 - : 9000, + eoseTimeout: + options?.eoseTimeout ?? (usesNip50TextSearch ? 10_000 : 4500), + globalTimeout: + options?.globalTimeout ?? + (usesNip50TextSearch ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000), relayOpSource: 'ClientService.searchProfiles', - foreground: usesNip50TextSearch || usesAuthorsLookup + foreground: usesNip50TextSearch || usesAuthorsLookup, + signal: options?.signal }) const byPk = new Map() @@ -3733,6 +3749,106 @@ class ClientService extends EventTarget { return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent)) } + private profileRelaySearchUrls(): string[] { + return dedupeNormalizeRelayUrlsOrdered( + PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) + ) + } + + private nip50ProfileIndexRelayUrls(): string[] { + return dedupeNormalizeRelayUrlsOrdered([ + ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) + ]) + } + + private profileSearchStagedGeneration = 0 + private profileSearchStagedAbort: AbortController | null = null + + /** + * Staged profile discovery: local cache/DB → profile relays only → NIP-50 index relays + * only when the first two stages returned nothing. Calls `onUpdate` after each stage. + * Aborts the previous in-flight staged search when a new query starts. + */ + async searchProfilesStaged( + query: string, + limit: number = 50, + onUpdate?: (profiles: TProfile[]) => void, + externalSignal?: AbortSignal + ): Promise { + const q = query.trim() + if (!q || limit <= 0) return [] + + this.profileSearchStagedAbort?.abort() + const runAbort = new AbortController() + this.profileSearchStagedAbort = runAbort + const generation = ++this.profileSearchStagedGeneration + + const isStale = () => + generation !== this.profileSearchStagedGeneration || + runAbort.signal.aborted || + externalSignal?.aborted === true + + const seen = new Set() + const out: TProfile[] = [] + + const merge = (batch: TProfile[]) => { + for (const p of batch) { + const pk = p.pubkey.toLowerCase() + if (seen.has(pk)) continue + seen.add(pk) + out.push(p) + if (out.length >= limit) break + } + } + + const emit = () => { + if (!isStale() && onUpdate) onUpdate(out.slice(0, limit)) + } + + const relaySignal = externalSignal + ? AbortSignal.any([runAbort.signal, externalSignal]) + : runAbort.signal + + merge(await this.searchProfilesFromLocal(q, limit)) + if (isStale()) return out.slice(0, limit) + emit() + if (out.length >= limit) return out.slice(0, limit) + + const needAfterLocal = limit - out.length + merge( + await this.searchProfiles( + this.profileRelaySearchUrls(), + { search: q, limit: needAfterLocal }, + { + relaysOnly: true, + includeTagFilters: false, + signal: relaySignal, + eoseTimeout: 6_000, + globalTimeout: 9_000 + } + ) + ) + if (isStale()) return out.slice(0, limit) + emit() + if (out.length >= limit) return out.slice(0, limit) + if (out.length > 0) return out.slice(0, limit) + + const indexUrls = this.nip50ProfileIndexRelayUrls() + if (indexUrls.length > 0) { + merge( + await this.searchProfiles( + indexUrls, + { search: q, limit }, + { signal: relaySignal, includeTagFilters: true } + ) + ) + if (!isStale()) emit() + } + + return out.slice(0, limit) + } + async searchNpubsFromLocal(query: string, limit: number = 100) { await this.ensureProfileSearchIndexFromIdb() const seen = new Set() @@ -3891,18 +4007,6 @@ class ClientService extends EventTarget { if (np) addNpub(np) } - // Relay query starts immediately so it can run in parallel with local + follow work (slow relays). - const profileSearchRelayUrls = dedupeNormalizeRelayUrlsOrdered( - PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) - ) - const relayTask = - q.length >= 1 - ? this.searchProfiles(profileSearchRelayUrls, { - search: q, - limit - }).catch(() => [] as TProfile[]) - : Promise.resolve([] as TProfile[]) - // 1. Local index first (FlexSearch + session) — fills the @-mention list immediately. // Cap how many local hits we take so we never fill `limit` here alone; otherwise we returned // early and skipped relay search entirely (bad for handle search beyond the local index). @@ -3989,30 +4093,41 @@ class ClientService extends EventTarget { return out } - // 3. Relay search — merge after local + follow so ordering stays local → follows → wider index. - // relayTask was started at the beginning; do not await before return (first paint stays fast). - if (q.length >= 1) { - relayTask - .then((relayProfiles) => { - for (const p of relayProfiles) { + // 3. Profile relays only (purplepag.es, profiles.nostr1.com, …) — not NIP-50 index relays. + if (q.length >= 1 && out.length < limit) { + try { + const relayProfiles = await this.searchProfiles( + this.profileRelaySearchUrls(), + { search: q, limit: limit - out.length }, + { relaysOnly: true, includeTagFilters: false, eoseTimeout: 6_000, globalTimeout: 9_000 } + ) + for (const p of relayProfiles) { + const npub = pubkeyToNpub(p.pubkey) + if (!npub) continue + if (addNpub(npub)) updateIfNeeded() + if (out.length >= limit) break + } + } catch { + /* best-effort */ + } + } + + // 4. NIP-50 index relays only when local + profile relays found nothing. + if (q.length >= 1 && out.length === 0) { + const indexUrls = this.nip50ProfileIndexRelayUrls() + if (indexUrls.length > 0) { + try { + const indexProfiles = await this.searchProfiles(indexUrls, { search: q, limit }) + for (const p of indexProfiles) { const npub = pubkeyToNpub(p.pubkey) if (!npub) continue - if (addNpub(npub)) { - updateIfNeeded() - } + if (addNpub(npub)) updateIfNeeded() if (out.length >= limit) break } - - relayProfiles.forEach((p) => { - const npub = pubkeyToNpub(p.pubkey) - if (npub) { - this.replaceableEventService.fetchProfileEvent(npub).catch(() => {}) - } - }) - }) - .catch(() => { - // relay search is best-effort - }) + } catch { + /* best-effort */ + } + } } // Prime profile cache for cached results diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts index 04b6debb..570f0d72 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -8,11 +8,12 @@ import { citationPickerMatchesQuery, tryParseCitationEventIdFromQuery } from '@/lib/citation-picker-search' -import { ExtendedKind, NIP71_VIDEO_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { ExtendedKind, NIP71_VIDEO_KINDS, NIP_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { kinds, type Event as NEvent } from 'nostr-tools' import client, { eventService, queryService } from './client.service' import indexedDb from './indexed-db.service' +import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' const DEFAULT_NOTES_LIMIT = 20 @@ -37,6 +38,9 @@ export const NEVENT_KINDS = [ ExtendedKind.CITATION_PROMPT ] as const +/** Same NIP-50 kinds as Search → Notes (without kind-0 metadata rows in the picker). */ +export const PICKER_NEVENT_KINDS = NIP_SEARCH_PAGE_KINDS.filter((k) => k !== kinds.Metadata) + /** NIP-32 citation events only (Advanced lab citation picker, etc.). */ export const CITATION_PICKER_KINDS = [ ExtendedKind.CITATION_INTERNAL, @@ -142,6 +146,8 @@ async function searchCitationEventsForPickerInternal( /** Local DB + session budget for picker search (before relay NIP-50). */ const PICKER_LOCAL_DB_MERGE_CAP = 880 const PICKER_FULLTEXT_DB_CAP = 260 +/** IndexedDB archive scan for picker — keep short so the dialog is not stuck on skeletons. */ +const PICKER_ARCHIVE_SCAN_MAX_MS = 4_000 /** * Search for events: session cache → IndexedDB (publication + archive + cross-store full text) → relays. @@ -153,13 +159,18 @@ export async function searchEventsForPicker( query: string, limit: number = DEFAULT_NOTES_LIMIT, mode: PickerSearchMode = 'nevent', - kindFilter?: readonly number[] + kindFilter?: readonly number[], + onUpdate?: (events: NEvent[]) => void ): Promise { const q = query.trim() if (!q) return [] const kindsList = - kindFilter && kindFilter.length > 0 ? [...kindFilter] : mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS] + kindFilter && kindFilter.length > 0 + ? [...kindFilter] + : mode === 'nevent' + ? [...PICKER_NEVENT_KINDS] + : [...NADDR_KINDS] if (isCitationOnlyKindFilter(kindFilter)) { return searchCitationEventsForPickerInternal(q, limit, kindsList) @@ -174,45 +185,65 @@ export async function searchEventsForPicker( out.push(evt) } + const emit = () => { + if (onUpdate) onUpdate(out.slice(0, limit)) + } + const sessionCap = Math.min(1500, Math.max(limit * 8, 200)) const localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240)) - const fromLocalMerged = await collectLocalEventsForTextSearch({ - query: q, - allowedKinds: kindsList, - sessionCap, - idbMergedLimit: localMergeTarget, - archiveScanMaxMs: 24_000, - includeOtherStoresFullText: true, - fullTextStoreHitCap: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200)) - }) - fromLocalMerged.forEach(addUnique) - - const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls() + for (const ev of eventService.getSessionEventsMatchingSearch(q, sessionCap, kindsList)) { + addUnique(ev) + } - if (out.length >= limit) return out.slice(0, limit) + const viewerPk = client.pubkey?.trim().toLowerCase() + if (viewerPk && /^[0-9a-f]{64}$/.test(viewerPk)) { + for (const ev of eventService.listSessionEventsAuthoredBy(viewerPk, { + kinds: kindsList, + limit: Math.min(400, sessionCap) + })) { + if (eventMatchesNip50LocalFullTextQuery(ev, q)) addUnique(ev) + } + } + emit() - const need = limit - out.length + const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls() + const relayLimit = Math.max(limit, 20) const searchableNip50Layer = Array.from( new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean)) ).slice(0, 28) - const [fromUserCentric, fromSearchableIndex] = await Promise.all([ + const [fromLocalMerged, fromUserCentric, fromSearchableIndex] = await Promise.all([ + collectLocalEventsForTextSearch({ + query: q, + allowedKinds: kindsList, + sessionCap: 0, + idbMergedLimit: localMergeTarget, + archiveScanMaxMs: PICKER_ARCHIVE_SCAN_MAX_MS, + includeOtherStoresFullText: true, + fullTextStoreHitCap: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200)) + }), queryService.fetchEvents( userCentricRelayUrls, - { kinds: kindsList, search: q, limit: need }, + { kinds: kindsList, search: q, limit: relayLimit }, { eoseTimeout: 5000, globalTimeout: 8000 } ), searchableNip50Layer.length > 0 ? queryService.fetchEvents( searchableNip50Layer, - { kinds: kindsList, search: q, limit: need }, + { kinds: kindsList, search: q, limit: relayLimit }, { eoseTimeout: 6500, globalTimeout: 12_000 } ) : Promise.resolve([] as NEvent[]) ]) - fromUserCentric.forEach(addUnique) - fromSearchableIndex.forEach(addUnique) + + for (const ev of fromLocalMerged) addUnique(ev) + emit() + + for (const ev of fromUserCentric) addUnique(ev) + for (const ev of fromSearchableIndex) addUnique(ev) + emit() + return out.slice(0, limit) }