diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index bad11d88..00f6a3ab 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -45,6 +45,7 @@ const SearchBar = forwardRef< const [displayList, setDisplayList] = useState(false) const [selectableOptions, setSelectableOptions] = useState([]) const [selectedIndex, setSelectedIndex] = useState(-1) + const prevSelectableCountRef = useRef(0) const searchInputRef = useRef(null) const barContainerRef = useRef(null) const [suggestPanelTop, setSuggestPanelTop] = useState(0) @@ -271,9 +272,32 @@ const SearchBar = forwardRef< }, [selectableOptions, selectedIndex, isFetchingProfiles, profiles]) useEffect(() => { - setDisplayList(searching && !!input) + setDisplayList(searching && !!input.trim()) }, [searching, input]) + /** + * Prefilled / parent-controlled `input` (e.g. URL sync) can have suggestions while the field never received + * focus, so `searching` stays false and the dropdown never mounts. When options first appear, focus the input + * once so `onFocus` runs and the list opens (mousedown on suggestions still prevents premature blur). + */ + useEffect(() => { + const trimmed = input.trim() + const len = selectableOptions.length + if (!trimmed) { + prevSelectableCountRef.current = 0 + return + } + if (len > 0 && prevSelectableCountRef.current === 0) { + const el = searchInputRef.current + if (el && document.activeElement !== el) { + queueMicrotask(() => { + el.focus({ preventScroll: true }) + }) + } + } + prevSelectableCountRef.current = len + }, [input, selectableOptions]) + useEffect(() => { if (displayList && list) { modalManager.register(id, () => { @@ -389,12 +413,18 @@ const SearchBar = forwardRef< ref={searchInputRef} className={cn( 'bg-surface-background shadow-inner h-full border-none', - searching && isSmallScreen && 'relative z-[120]', - searching && !isSmallScreen && 'z-50' + displayList && isSmallScreen && 'relative z-[120]', + displayList && !isSmallScreen && 'z-50' )} placeholder={t('People, keywords, or relays')} value={input} - onChange={(e) => setInput(e.target.value)} + onChange={(e) => { + setSearching(true) + setInput(e.target.value) + }} + onPaste={() => { + setSearching(true) + }} onKeyDown={handleKeyDown} onFocus={() => setSearching(true)} onBlur={() => setSearching(false)} diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 5c852899..8d2cf8eb 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -4,11 +4,13 @@ import { Skeleton } from '@/components/ui/skeleton' import { toRelay } from '@/lib/link' import { compareEventsForDTagQuery } from '@/lib/dtag-search' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' +import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import client from '@/services/client.service' import { NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS } from '@/services/client-query.service' +import indexedDb from '@/services/indexed-db.service' import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' import type { TProfile } from '@/types' import type { Event, Filter } from 'nostr-tools' @@ -20,6 +22,8 @@ import { useSmartRelayNavigationOptional } from '@/PageManager' type MergedHit = { event: Event relayUrls: string[] + /** Matched publication cache / event archive on this device (not relay NIP-50). */ + fromLocalArchive?: boolean } /** @@ -278,9 +282,10 @@ export default function FullTextSearchByRelay({ return dispose } + const kindsArr = [...kinds] const filter: Filter = { search: q, - kinds: [...kinds], + kinds: kindsArr, limit: FULL_TEXT_SEARCH_PER_RELAY_LIMIT } @@ -348,36 +353,96 @@ export default function FullTextSearchByRelay({ return normalizedRelays[relayCursor++]! } - const mergeIntoHits = (relayUrl: string, events: Event[]) => { - const rk = relayKey(relayUrl) + const applyMergedUpdate = ( + mutate: (map: Map; local: boolean }>) => void + ) => { setMergedHits((prev) => { - const map = new Map }>() + const urlByKey = new Map() + for (const u of normalizedRelays) { + urlByKey.set(relayKey(u), normalizeUrl(u) || u) + } + const map = new Map; local: boolean }>() for (const hit of prev) { - map.set(hit.event.id, { event: hit.event, relays: new Set(hit.relayUrls.map((u) => relayKey(u))) }) + map.set(hit.event.id, { + event: hit.event, + relays: new Set(hit.relayUrls.map((u) => relayKey(u))), + local: hit.fromLocalArchive ?? false + }) } + mutate(map) + return [...map.values()] + .map(({ event, relays, local }) => { + const relayUrls = sortRelaysByHost( + [...relays].map((k) => urlByKey.get(k) || k).filter((u) => /^wss?:\/\//i.test(u)) + ) + const row: MergedHit = { event, relayUrls } + if (local) row.fromLocalArchive = true + return row + }) + .filter((h) => h.relayUrls.length > 0 || h.fromLocalArchive) + .sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event)) + .slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS) + }) + } + + const mergeIntoHits = (relayUrl: string, events: Event[]) => { + const rk = relayKey(relayUrl) + applyMergedUpdate((map) => { for (const ev of events) { if (!mergedSearchNoteHasPreviewBody(ev)) continue const cur = map.get(ev.id) if (cur) { cur.relays.add(rk) } else { - map.set(ev.id, { event: ev, relays: new Set([rk]) }) + map.set(ev.id, { event: ev, relays: new Set([rk]), local: false }) } } - const urlByKey = new Map() - for (const u of normalizedRelays) { - urlByKey.set(relayKey(u), normalizeUrl(u) || u) - } - return [...map.values()] - .map(({ event, relays }) => ({ - event, - relayUrls: sortRelaysByHost([...relays].map((k) => urlByKey.get(k) || k)) - })) - .sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event)) - .slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS) }) } + void (async () => { + const fromSession = client.getSessionEventsMatchingSearch(q, 220, kindsArr) + let fromIdb: Event[] = [] + try { + fromIdb = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, 120, kindsArr, { + archiveScanMaxMs: 15_000 + }) + } catch { + fromIdb = [] + } + if (myRun !== runGeneration.current || abort.signal.aborted) return + const seen = new Set() + const mergedLocal: Event[] = [] + for (const e of fromSession) { + if (seen.has(e.id)) continue + seen.add(e.id) + mergedLocal.push(e) + } + for (const e of fromIdb) { + if (seen.has(e.id)) continue + seen.add(e.id) + mergedLocal.push(e) + } + const mergedLocalMatching = mergedLocal.filter( + (e) => + kindsArr.includes(e.kind) && + eventMatchesNip50LocalFullTextQuery(e, q) && + mergedSearchNoteHasPreviewBody(e) + ) + if (mergedLocalMatching.length === 0) return + applyMergedUpdate((map) => { + for (const ev of mergedLocalMatching) { + const cur = map.get(ev.id) + if (cur) { + cur.local = true + } else { + map.set(ev.id, { event: ev, relays: new Set(), local: true }) + } + } + }) + void addSearchEventsToSessionCacheBatched(mergedLocalMatching, runGeneration, myRun) + })() + const runOneRelay = async (relayUrl: string) => { if (myRun !== runGeneration.current || abort.signal.aborted) return beginWaveIfNeeded() @@ -502,30 +567,44 @@ export default function FullTextSearchByRelay({ key={hit.event.id} className="min-w-0 overflow-hidden rounded-lg border border-border/60 bg-card/30 shadow-none transition-[border-color,box-shadow,background-color] duration-150 hover:border-border hover:bg-muted/15 hover:shadow-sm" > -
- - {t('Full-text search seen on label')} - -
- {hit.relayUrls.map((url) => ( - - ))} + {(hit.relayUrls.length > 0 || hit.fromLocalArchive) && ( +
0 + ? t('Full-text search seen on relays') + : t('Full-text search local archive description') + } + > + + {t('Full-text search seen on label')} + +
+ {hit.relayUrls.map((url) => ( + + ))} + {hit.fromLocalArchive && ( + + {t('Full-text search local archive badge')} + + )} +
-
+ )} (typeof v === 'string' ? v.toLowerCase() : '') + const nip05 = pick(o.nip05) + const blob = [pick(o.name), pick(o.display_name), pick(o.about), nip05] + .filter(Boolean) + .join(' ') + if (blob.includes(q)) return true + const qNeedle = q.startsWith('@') ? q.slice(1) : q + if (q.startsWith('@') && qNeedle.length > 0 && blob.includes(qNeedle)) return true + } catch { + /* ignore invalid profile JSON */ + } + } + + return false +} diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index 003bcdd9..ae2d10a2 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -6,7 +6,7 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { TPageRef, TSearchParams } from '@/types' -import { BookOpen, Search, X } from 'lucide-react' +import { BookOpen, Search } from 'lucide-react' import { Button } from '@/components/ui/button' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -52,14 +52,6 @@ const SearchPage = forwardRef((_props, ref) => { layoutRef.current?.scrollToTop('instant') } - const clearSearch = useCallback(() => { - setInput('') - setSearchParams(null) - setResultRefreshKey((k) => k + 1) - searchBarRef.current?.blur() - void Promise.resolve().then(() => searchBarRef.current?.focus()) - }, []) - return ( ((_props, ref) => { >
-
-
- -
- +
+
-
-
- -
- +
+