From d66ceb50358b13e194f2309473cb78e57a455907 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 2 Jun 2026 22:52:54 +0200 Subject: [PATCH] fix search --- .../SearchResult/FullTextSearchByRelay.tsx | 722 +++--------------- src/components/SearchResult/index.tsx | 26 +- src/constants.ts | 14 +- src/i18n/locales/en.ts | 6 +- src/lib/dtag-search.ts | 7 +- src/lib/general-search-text-match.test.ts | 65 ++ src/lib/general-search-text-match.ts | 105 +++ src/lib/local-nip50-search-merge.ts | 8 +- src/services/client-events.service.ts | 6 +- src/services/client-query.service.ts | 17 +- src/services/client.service.ts | 10 +- src/services/indexed-db.service.ts | 14 +- 12 files changed, 340 insertions(+), 660 deletions(-) create mode 100644 src/lib/general-search-text-match.test.ts create mode 100644 src/lib/general-search-text-match.ts diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 554b859b..3a04ec39 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -1,56 +1,30 @@ import NoteCard from '@/components/NoteCard' -import RelayIcon from '@/components/RelayIcon' import { Skeleton } from '@/components/ui/skeleton' -import { toRelay } from '@/lib/link' -import { compareMergedNip50SearchHits } from '@/lib/dtag-search' +import { compareMergedGeneralSearchHits } from '@/lib/dtag-search' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' 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 relayInfoService from '@/services/relay-info.service' -import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' -import type { TProfile, TRelayInfo } from '@/types' -import type { Event, Filter } from 'nostr-tools' +import type { TProfile } from '@/types' +import type { Event } from 'nostr-tools' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url' import { HardDrive, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { useSmartRelayNavigationOptional } from '@/PageManager' -type MergedHit = { +type LocalHit = { event: Event - relayUrls: string[] - /** Matched publication cache / event archive on this device (not relay NIP-50). */ - fromLocalArchive?: boolean } -/** - * Hard cap for the merged search wave (abort signal), from the first relay query start. - * Must exceed {@link NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS} so at least one slow index relay can EOSE. - */ -const SEARCH_TOTAL_WALL_MS = NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 -/** After the first results arrive from any relay, end the wave this many ms later (capped by {@link SEARCH_TOTAL_WALL_MS}). */ -const SEARCH_AFTER_FIRST_RELAY_MS = 6_000 -/** Per-relay {@link QueryService.query} budget (capped by remaining wave wall). Align with NIP-50 index latency. */ -const SEARCH_PER_RELAY_QUERY_MS = NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS -/** Avoid opening every index relay at once (pool + main thread). */ -const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3 -const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 -/** Per-relay cap before merge (limits duplicate work). */ -const FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY = 40 -/** Max merged unique notes shown after deduping across relays. */ -const FULL_TEXT_SEARCH_MAX_MERGED_EVENTS = 150 -/** Batched kind-0 fetch chunk size (aligned with feed profile batching). */ +const LOCAL_SEARCH_MAX_EVENTS = 150 const SEARCH_MERGED_PROFILE_CHUNK = 80 -/** Coalesce rapid merge updates before hitting the network. */ const SEARCH_MERGED_PROFILE_DEBOUNCE_MS = 240 +const ADD_TO_CACHE_PER_FRAME = 8 -function extractMergedHitAuthorPubkeys(hits: MergedHit[]): string[] { +function extractHitAuthorPubkeys(hits: LocalHit[]): string[] { const out: string[] = [] const seen = new Set() for (const h of hits) { @@ -62,17 +36,13 @@ function extractMergedHitAuthorPubkeys(hits: MergedHit[]): string[] { return out } -/** - * Feed-style batched profile hydration so merged NIP-50 cards do not each run a separate - * {@link useFetchProfile} network path (main-thread + pool pressure). - */ function SearchMergedProfileProvider({ resetKey, - mergedHits, + hits, children }: { resetKey: string - mergedHits: MergedHit[] + hits: LocalHit[] children: ReactNode }) { const [batch, setBatch] = useState(() => ({ @@ -80,8 +50,8 @@ function SearchMergedProfileProvider({ pending: new Set(), version: 0 })) - const mergedHitsRef = useRef(mergedHits) - mergedHitsRef.current = mergedHits + const hitsRef = useRef(hits) + hitsRef.current = hits const fetchAttemptedRef = useRef(new Set()) useEffect(() => { @@ -91,11 +61,11 @@ function SearchMergedProfileProvider({ const hitsIdentity = useMemo( () => - [...mergedHits] + [...hits] .map((h) => h.event.id) .sort() .join('\x1e'), - [mergedHits] + [hits] ) useEffect(() => { @@ -103,8 +73,8 @@ function SearchMergedProfileProvider({ let cancelled = false const t = window.setTimeout(() => { if (cancelled) return - const hits = mergedHitsRef.current - const pubkeys = extractMergedHitAuthorPubkeys(hits) + const currentHits = hitsRef.current + const pubkeys = extractHitAuthorPubkeys(currentHits) if (pubkeys.length === 0) return const need = pubkeys.filter((pk) => !fetchAttemptedRef.current.has(pk)) @@ -183,9 +153,6 @@ function SearchMergedProfileProvider({ return {children} } -/** Max events to push into session cache per animation frame (keeps the tab responsive during merges). */ -const ADD_TO_CACHE_PER_FRAME = 8 - async function addSearchEventsToSessionCacheBatched( events: Event[], runGeneration: { current: number }, @@ -203,429 +170,69 @@ async function addSearchEventsToSessionCacheBatched( } } -type SearchSourcePhase = 'loading' | 'done' | 'error' +type LocalSearchPhase = 'loading' | 'done' | 'error' type LocalSearchRow = { - phase: SearchSourcePhase - /** Notes that pass the preview filter (shown in results). */ + phase: LocalSearchPhase hitCount: number - /** Raw matches before preview filter. */ rawCount: number ms?: number errorMessage?: string } -type RelayFetchRow = { - relayUrl: string - host: string - phase: SearchSourcePhase - /** Preview-visible hits merged into results. */ - eventCount?: number - /** Events returned by the relay before preview filter. */ - rawEventCount?: number - ms?: number - errorMessage?: string -} - -function formatSourceStatusLabel( - row: { - phase: SearchSourcePhase - hitCount: number - rawCount?: number - errorMessage?: string - }, +function formatLocalStatusLabel( + row: LocalSearchRow, t: (key: string, opts?: Record) => string ): string { - if (row.phase === 'loading') { - return t('Full-text search source loading') - } + if (row.phase === 'loading') return t('Full-text search source loading') if (row.phase === 'error') { const msg = row.errorMessage?.trim() return msg && msg.length <= 120 ? msg : t('Full-text search relay error') } const shown = row.hitCount - const raw = row.rawCount ?? shown - if (shown === 0 && raw === 0) { - if (row.errorMessage?.trim()) { - return t('Full-text search source zero with note', { note: row.errorMessage.trim() }) - } - return t('Full-text search source zero hits') - } - if (raw > shown) { - return t('Full-text search source hits with raw', { shown, raw }) - } + const raw = row.rawCount + if (shown === 0 && raw === 0) return t('Full-text search source zero hits') + if (raw > shown) return t('Full-text search source hits with raw', { shown, raw }) return t('Full-text search source hits', { count: shown }) } -function SearchSourceProgressList({ - localRow, - relayRows, - relayInfoByKey, - anyLoading -}: { - localRow: LocalSearchRow | null - relayRows: RelayFetchRow[] - relayInfoByKey: Record - anyLoading: boolean -}) { - const { t } = useTranslation() - - if (!localRow && relayRows.length === 0) return null - - return ( -
-
    - {localRow ? ( -
  • - - - {t('Full-text search source local')} - - 0 - ? 'text-foreground' - : 'text-muted-foreground' - )} - > - {formatSourceStatusLabel( - { - phase: localRow.phase, - hitCount: localRow.hitCount, - rawCount: localRow.rawCount, - errorMessage: localRow.errorMessage - }, - t - )} - {localRow.ms != null && localRow.phase !== 'loading' ? ( - · {localRow.ms} ms - ) : null} - - {localRow.phase === 'loading' ? ( - - ) : null} -
  • - ) : null} - {relayRows.map((row) => ( -
  • - - - {row.host} - - 0 - ? 'text-foreground' - : 'text-muted-foreground' - )} - > - {formatSourceStatusLabel( - { - phase: row.phase, - hitCount: row.eventCount ?? 0, - rawCount: row.rawEventCount, - errorMessage: row.errorMessage - }, - t - )} - {row.ms != null && row.phase !== 'loading' ? ( - · {row.ms} ms - ) : null} - - {row.phase === 'loading' ? ( - - ) : null} -
  • - ))} -
-
- ) -} - -/** Dedupe while preserving {@link SEARCHABLE_RELAY_URLS} priority (fast index relays first). */ -function normalizeRelayList(urls: readonly string[]): string[] { - const seen = new Set() - const out: string[] = [] - for (const raw of urls) { - const n = normalizeUrl(raw) || raw.trim() - if (!n) continue - const k = relayKey(n) - if (seen.has(k)) continue - seen.add(k) - out.push(n) - } - return out -} - -function relayKey(url: string): string { - return (normalizeUrl(url) || url.trim()).toLowerCase() -} - -function sortRelaysByHost(urls: readonly string[]): string[] { - return Array.from(new Set(urls.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean) as string[])).sort((a, b) => - relayHostForSubscribeLog(a).localeCompare(relayHostForSubscribeLog(b)) - ) -} - export default function FullTextSearchByRelay({ searchQuery, - relayUrls, kinds, alexandriaEmptyHref: alexandriaEmptyHrefProp = null }: { searchQuery: string - relayUrls: readonly string[] kinds: readonly number[] alexandriaEmptyHref?: string | null }) { const { t } = useTranslation() - const { navigateToRelay } = useSmartRelayNavigationOptional() ?? { - navigateToRelay: (url: string) => { - window.location.href = url - } - } const runGeneration = useRef(0) - const [localSearchRow, setLocalSearchRow] = useState(null) - const [relayRows, setRelayRows] = useState([]) - const [mergedHits, setMergedHits] = useState([]) - const [relayInfoByKey, setRelayInfoByKey] = useState>({}) - - const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) + const [localRow, setLocalRow] = useState(null) + const [hits, setHits] = useState([]) const q = searchQuery.trim() const alexandriaEmptyHref = useMemo(() => { if (alexandriaEmptyHrefProp) return alexandriaEmptyHrefProp return q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null }, [alexandriaEmptyHrefProp, q]) - const searchProfileResetKey = useMemo( - () => `${q}\n${normalizedRelays.join('\n')}`, - [q, normalizedRelays] - ) - const relayUrlsForIconPrefetch = useMemo(() => { - const seen = new Set() - const out: string[] = [] - const push = (url: string) => { - const k = relayKey(url) - if (!k || seen.has(k)) return - seen.add(k) - out.push(url) - } - for (const u of normalizedRelays) push(u) - for (const hit of mergedHits) { - for (const u of hit.relayUrls) push(u) - } - return out - }, [normalizedRelays, mergedHits]) + const searchProfileResetKey = q useEffect(() => { - if (relayUrlsForIconPrefetch.length === 0) { - setRelayInfoByKey({}) - return - } - let cancelled = false - void relayInfoService.getRelayInfos(relayUrlsForIconPrefetch).then((infos) => { - if (cancelled) return - const next: Record = {} - relayUrlsForIconPrefetch.forEach((url, i) => { - const info = infos[i] - if (info) next[relayKey(url)] = info - }) - setRelayInfoByKey((prev) => ({ ...prev, ...next })) - }) - return () => { - cancelled = true - } - }, [relayUrlsForIconPrefetch]) - - const anyLoading = - localSearchRow?.phase === 'loading' || relayRows.some((r) => r.phase === 'loading') - const allTerminal = - localSearchRow != null && - localSearchRow.phase !== 'loading' && - relayRows.length > 0 && - relayRows.every((r) => r.phase === 'done' || r.phase === 'error') - - useEffect(() => { - /** Unmount / total wall only — must not abort in-flight NIP-50 when the “first hits + …ms” scheduling cutoff runs. */ - const runAbort = new AbortController() - let masterTimer: ReturnType | null = null - let stopSchedulingTimer: ReturnType | null = null const myRun = ++runGeneration.current - const cleanupInvalidatePreviousRun = () => { - runGeneration.current += 1 - } - const dispose = () => { - if (masterTimer != null) { - clearTimeout(masterTimer) - masterTimer = null - } - if (stopSchedulingTimer != null) { - clearTimeout(stopSchedulingTimer) - stopSchedulingTimer = null - } - runAbort.abort() - cleanupInvalidatePreviousRun() - } - if (!q || normalizedRelays.length === 0) { - setLocalSearchRow(null) - setRelayRows([]) - setMergedHits([]) - return dispose + if (!q) { + setLocalRow(null) + setHits([]) + return } const kindsArr = [...kinds] - const filter: Filter = { - search: q, - kinds: kindsArr, - limit: FULL_TEXT_SEARCH_PER_RELAY_LIMIT - } - - const poolSize = Math.min(FULL_TEXT_SEARCH_RELAY_CONCURRENCY, normalizedRelays.length) - - setLocalSearchRow({ phase: 'loading', hitCount: 0, rawCount: 0 }) - setRelayRows( - normalizedRelays.map((relayUrl) => ({ - relayUrl, - host: relayHostForSubscribeLog(relayUrl), - phase: 'loading' - })) - ) - setMergedHits([]) - setRelayInfoByKey({}) - - /** Set when the first {@link runOneRelay} begins (first real NIP-50 query); master wall clock starts then. */ - let waveT0: number | null = null - /** - * After first preview-visible relay hits: may stop dequeuing *extra* relays after a short delay, - * but every index relay in the list still gets one REQ (see worker loop). - */ - let stopSchedulingNewRelays = false - /** Only after ≥1 preview-visible event from a relay: arm the early scheduling cutoff (empty EOSE must not shorten). */ - let appliedRelativeSchedulingCutoff = false - - const scheduleMasterWallAbort = () => { - if (masterTimer != null) { - clearTimeout(masterTimer) - masterTimer = null - } - if (waveT0 === null) return - const ms = Math.max(0, waveT0 + SEARCH_TOTAL_WALL_MS - Date.now()) - masterTimer = setTimeout(() => { - masterTimer = null - stopSchedulingNewRelays = true - runAbort.abort() - }, ms) - } - - const beginWaveIfNeeded = () => { - if (waveT0 !== null) return - waveT0 = Date.now() - scheduleMasterWallAbort() - } - - const onFirstPreviewVisibleRelayHits = () => { - if (appliedRelativeSchedulingCutoff || waveT0 === null) return - appliedRelativeSchedulingCutoff = true - if (stopSchedulingTimer != null) { - clearTimeout(stopSchedulingTimer) - } - stopSchedulingTimer = setTimeout(() => { - stopSchedulingTimer = null - stopSchedulingNewRelays = true - }, SEARCH_AFTER_FIRST_RELAY_MS) - } - - runAbort.signal.addEventListener( - 'abort', - () => { - setRelayRows((prev) => - prev.map((r) => - r.phase === 'loading' - ? { ...r, phase: 'done' as const, eventCount: 0, ms: undefined, errorMessage: undefined } - : r - ) - ) - }, - { once: true } - ) - - let relayCursor = 0 - const nextRelayUrl = (): string | undefined => { - if (relayCursor >= normalizedRelays.length) return undefined - return normalizedRelays[relayCursor++]! - } - - const applyMergedUpdate = ( - mutate: (map: Map; local: boolean }>) => void - ) => { - setMergedHits((prev) => { - 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))), - 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) => compareMergedNip50SearchHits(q, a, b)) - .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]), local: false }) - } - } - }) - } + setLocalRow({ phase: 'loading', hitCount: 0, rawCount: 0 }) + setHits([]) void (async () => { - const localT0 = performance.now() + const t0 = performance.now() try { const mergedLocal = await collectLocalEventsForTextSearch({ query: q, @@ -636,212 +243,115 @@ export default function FullTextSearchByRelay({ includeOtherStoresFullText: true, fullTextStoreHitCap: 260 }) - if (myRun !== runGeneration.current || runAbort.signal.aborted) return - const mergedLocalMatching = mergedLocal.filter((e) => mergedSearchNoteHasPreviewBody(e)) - const localMs = Math.round(performance.now() - localT0) - setLocalSearchRow({ + if (myRun !== runGeneration.current) return + + const visible = mergedLocal + .filter((e) => mergedSearchNoteHasPreviewBody(e)) + .sort((a, b) => compareMergedGeneralSearchHits(q, { event: a }, { event: b })) + .slice(0, LOCAL_SEARCH_MAX_EVENTS) + + setLocalRow({ phase: 'done', - hitCount: mergedLocalMatching.length, + hitCount: visible.length, rawCount: mergedLocal.length, - ms: localMs + ms: Math.round(performance.now() - t0) }) - if (mergedLocalMatching.length > 0) { - 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) + setHits(visible.map((event) => ({ event }))) + if (visible.length > 0) { + void addSearchEventsToSessionCacheBatched(visible, runGeneration, myRun) } } catch (err) { if (myRun !== runGeneration.current) return - setLocalSearchRow({ + setLocalRow({ phase: 'error', hitCount: 0, rawCount: 0, - ms: Math.round(performance.now() - localT0), + ms: Math.round(performance.now() - t0), errorMessage: err instanceof Error ? err.message : String(err) }) } })() - const runOneRelay = async (relayUrl: string) => { - if (myRun !== runGeneration.current || runAbort.signal.aborted) return - beginWaveIfNeeded() - const t0 = performance.now() - try { - const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay( - relayUrl, - filter, - { globalTimeout: SEARCH_PER_RELAY_QUERY_MS, signal: runAbort.signal } - ) - if (myRun !== runGeneration.current) return - - const sorted = [...raw] - .sort((a, b) => compareMergedNip50SearchHits(q, { event: a }, { event: b })) - .slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) - const previewVisible = sorted.filter((e) => mergedSearchNoteHasPreviewBody(e)) - - const ms = Math.round(performance.now() - t0) - if (previewVisible.length === 0 && connectionError) { - setRelayRows((prev) => - prev.map((r) => - r.relayUrl === relayUrl - ? { - ...r, - phase: 'error', - eventCount: 0, - rawEventCount: sorted.length, - ms, - errorMessage: connectionError - } - : r - ) - ) - return - } - - mergeIntoHits(relayUrl, sorted) - void addSearchEventsToSessionCacheBatched(previewVisible, runGeneration, myRun) - - if (previewVisible.length > 0) { - onFirstPreviewVisibleRelayHits() - } - setRelayRows((prev) => - prev.map((r) => - r.relayUrl === relayUrl - ? { - ...r, - phase: 'done', - eventCount: previewVisible.length, - rawEventCount: sorted.length, - ms, - errorMessage: previewVisible.length > 0 ? undefined : connectionError - } - : r - ) - ) - } catch (err) { - if (myRun !== runGeneration.current) return - if (runAbort.signal.aborted) return - const msg = err instanceof Error ? err.message : String(err) - const ms = Math.round(performance.now() - t0) - setRelayRows((prev) => - prev.map((r) => - r.relayUrl === relayUrl - ? { ...r, phase: 'error', eventCount: 0, rawEventCount: 0, ms, errorMessage: msg } - : r - ) - ) - } - } - - const worker = async () => { - while (myRun === runGeneration.current && !runAbort.signal.aborted) { - if (stopSchedulingNewRelays && relayCursor >= normalizedRelays.length) break - const relayUrl = nextRelayUrl() - if (!relayUrl) break - await runOneRelay(relayUrl) - } + return () => { + runGeneration.current += 1 } + }, [q, kinds]) - void (async () => { - try { - await Promise.all(Array.from({ length: poolSize }, () => worker())) - } catch { - /* runOneRelay already updates relay rows */ - } - })() - - return dispose - }, [q, normalizedRelays, kinds]) + if (!q) return null - if (!q) { - return null - } + const loading = localRow?.phase === 'loading' + const done = localRow != null && localRow.phase !== 'loading' return ( -
-

- {t('Full-text search merged intro', { - relayCount: normalizedRelays.length, - totalSeconds: Math.round(SEARCH_TOTAL_WALL_MS / 1000), - afterFirstSeconds: Math.round(SEARCH_AFTER_FIRST_RELAY_MS / 1000), - concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY - })} -

- - - - +
+

{t('Notes search local intro')}

+ + {localRow ? ( +
+
    +
  • + + + {t('Full-text search source local')} + + 0 + ? 'text-foreground' + : 'text-muted-foreground' + )} + > + {formatLocalStatusLabel(localRow, t)} + {localRow.ms != null && localRow.phase !== 'loading' ? ( + · {localRow.ms} ms + ) : null} + + {localRow.phase === 'loading' ? ( + + ) : null} +
  • +
+
+ ) : null} + +
- {anyLoading && mergedHits.length === 0 && ( -
+ {loading && hits.length === 0 && ( +
)} - {mergedHits.map((hit) => ( + {hits.map((hit) => (
- {(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')} + + - - {t('Full-text search seen on label')} - -
- {hit.relayUrls.map((url) => ( - - ))} - {hit.fromLocalArchive && ( - - {t('Full-text search local archive badge')} - - )} -
-
- )} + {t('Full-text search local archive badge')} + +
- {allTerminal && mergedHits.length === 0 && ( + {done && hits.length === 0 && (
-

{t('Full-text search empty merged')}

+

{t('Full-text search empty local')}

{alexandriaEmptyHref ? : null}
)} - - {allTerminal && mergedHits.length > 0 && ( -

- {t('Full-text search all relays finished')} -

- )}
) } diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index 79fc4d7c..597d67d4 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -1,4 +1,4 @@ -import { FAST_READ_RELAY_URLS, NIP_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { FAST_READ_RELAY_URLS, GENERAL_SEARCH_PAGE_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' import { TSearchParams } from '@/types' import NormalFeed from '../NormalFeed' import FullTextSearchByRelay from './FullTextSearchByRelay' @@ -7,11 +7,10 @@ import { ProfileListBySearch } from '../ProfileListBySearch' import Relay from '../Relay' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import client from '@/services/client.service' import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { normalizeUrl } from '@/lib/url' import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url' -import { useLayoutEffect, useMemo } from 'react' +import { useMemo } from 'react' function relayDedupeKey(url: string): string { return (normalizeUrl(url) || url.trim()).toLowerCase() @@ -21,23 +20,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - /** - * Before NIP-50 / hashtag REQs, yield the pool — but do not abort profile lookups (npub / profile search). - */ - useLayoutEffect(() => { - if (!searchParams) return - if ( - searchParams.type === 'relay' || - searchParams.type === 'profile' || - searchParams.type === 'profiles' - ) { - return - } - /** Yield pool capacity to search REQs without closing in-flight NIP-50 sockets (that zeroed results). */ - client.interruptBackgroundQueries() - }, [searchParams?.type, searchParams?.search, searchParams?.input]) - - /** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */ + /** Index relays for hashtag search and relay dedupe (notes search is local cache only). */ const searchableUrls = useMemo( () => Array.from( @@ -109,8 +92,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa return ( ) diff --git a/src/constants.ts b/src/constants.ts index 85d2184f..46114638 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -547,6 +547,12 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://nostr.wine', 'wss://relay.noswhere.com', 'wss://nostr-pub.wellorder.net', + 'wss://relay.damus.io', + 'wss://theforest.nostr1.com', + 'wss://nostr.land', + 'wss://relay.primal.net', + 'wss://nos.lol', + 'wss://thecitadel.nostr1.com' ] /** @@ -888,14 +894,16 @@ export const NIP_SEARCH_DOCUMENT_KINDS: readonly number[] = [ ] /** - * Primary Search page NIP-50 `kinds`: profiles, short notes, and document kinds. - * Search used only {@link NIP_SEARCH_DOCUMENT_KINDS} before, so handles and npub-related - * metadata (kind 0) and normal notes (kind 1) never matched. + * Primary Search page note kinds: profiles, short notes, and document kinds. + * {@link GENERAL_SEARCH_PAGE_KINDS} is an alias for the same set. */ export const NIP_SEARCH_PAGE_KINDS: readonly number[] = Array.from( new Set([kinds.Metadata, kinds.ShortTextNote, ...NIP_SEARCH_DOCUMENT_KINDS]) ).sort((a, b) => a - b) +/** Alias for {@link NIP_SEARCH_PAGE_KINDS} (general search UI). */ +export const GENERAL_SEARCH_PAGE_KINDS = NIP_SEARCH_PAGE_KINDS + export function relayFilterIncludesDocumentRelayKind(filter: Filter): boolean { const k = filter.kinds if (k === undefined) return false diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8a7792f4..df7bff2b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -2411,12 +2411,16 @@ export default { 'Searching…': 'Searching…', 'Full-text search merged intro': 'Notes appear as each index relay responds (merged by card; each card shows which relays returned it). The search wave stops at the sooner of {{totalSeconds}}s from start or {{afterFirstSeconds}}s after the first results arrive from any relay (up to {{concurrency}} relays in parallel, {{relayCount}} total). This is not a live feed — results do not auto-update.', + 'Notes search local intro': + 'Searches your local cache and archive only — session memory, publication cache, and event archive on this device. Matches title, summary, description, context, content, and similar readable fields.', + 'Full-text search empty local': + 'No notes matched this search in your local cache or archive.', 'Full-text search progress relays': '{{done}} / {{total}} index relays', 'Full-text search seen on label': 'Seen on', 'Full-text search seen on relays': 'Relays that returned this note', 'Full-text search local archive badge': 'This device', 'Full-text search local archive description': - 'Matched in your publication cache or event archive on this device. Index relays may not have ingested the note yet.', + 'Matched in your publication cache or event archive on this device.', 'Full-text search empty merged': 'No notes matched this search in your archive or on the configured index relays (they can be slow or offline).', 'Full-text search relay errors summary': '{{count}} relay(s) could not be queried.', diff --git a/src/lib/dtag-search.ts b/src/lib/dtag-search.ts index 5dc8804e..87b0e559 100644 --- a/src/lib/dtag-search.ts +++ b/src/lib/dtag-search.ts @@ -55,8 +55,8 @@ function dTagMatchRank(needle: string, dVal: string | undefined): number { return 3 } -/** Merged NIP-50 search: device cache/archive hits before relay-only hits; then {@link compareEventsForDTagQuery}. */ -export function compareMergedNip50SearchHits( +/** Merged general search: device cache/archive hits before relay-only hits; then {@link compareEventsForDTagQuery}. */ +export function compareMergedGeneralSearchHits( needle: string, a: { event: Event; fromLocalArchive?: boolean }, b: { event: Event; fromLocalArchive?: boolean } @@ -67,6 +67,9 @@ export function compareMergedNip50SearchHits( return compareEventsForDTagQuery(needle, a.event, b.event) } +/** @deprecated Use {@link compareMergedGeneralSearchHits}. */ +export const compareMergedNip50SearchHits = compareMergedGeneralSearchHits + /** For merged lists: better d-tag match first; tie-break newest first. Kind 30041 sinks unless `d` equals the needle. */ export function compareEventsForDTagQuery(needle: string, a: Event, b: Event): number { const nl = needle.trim().toLowerCase() diff --git a/src/lib/general-search-text-match.test.ts b/src/lib/general-search-text-match.test.ts new file mode 100644 index 00000000..ee83f54e --- /dev/null +++ b/src/lib/general-search-text-match.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + eventMatchesGeneralSearchQuery, + generalSearchHaystack, + generalSearchQueryTerms, + normalizeGeneralSearchQuery +} from '@/lib/general-search-text-match' +import type { Event } from 'nostr-tools' + +function ev(partial: Partial & Pick): Event { + return { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1_700_000_000, + sig: 'sig', + tags: [], + ...partial + } +} + +describe('normalizeGeneralSearchQuery', () => { + it('strips outer double quotes and collapses whitespace', () => { + expect(normalizeGeneralSearchQuery('"Nostr is a thankless protocol."')).toBe( + 'Nostr is a thankless protocol.' + ) + expect(normalizeGeneralSearchQuery(' foo bar ')).toBe('foo bar') + }) + + it('extracts significant terms without punctuation', () => { + expect(generalSearchQueryTerms('"thankless protocol"')).toEqual(['thankless', 'protocol']) + }) +}) + +describe('eventMatchesGeneralSearchQuery', () => { + it('matches content and ignores pubkey/id substrings', () => { + const note = ev({ kind: 1, content: 'Hello bitcoin world' }) + expect(eventMatchesGeneralSearchQuery(note, 'bitcoin')).toBe(true) + expect(eventMatchesGeneralSearchQuery(note, note.pubkey.slice(0, 8))).toBe(false) + }) + + it('matches title and summary tags', () => { + const article = ev({ + kind: 30023, + content: 'body', + tags: [ + ['title', 'My Article Title'], + ['summary', 'A short summary here'] + ] + }) + expect(eventMatchesGeneralSearchQuery(article, 'article title')).toBe(true) + expect(eventMatchesGeneralSearchQuery(article, 'short summary')).toBe(true) + expect(generalSearchHaystack(article)).toContain('my article title') + }) + + it('matches multi-word queries when all words appear in haystack', () => { + const note = ev({ kind: 1, content: 'foo bar baz' }) + expect(eventMatchesGeneralSearchQuery(note, 'foo baz')).toBe(true) + expect(eventMatchesGeneralSearchQuery(note, 'foo missing')).toBe(false) + }) + + it('matches quoted phrase against note content', () => { + const note = ev({ kind: 1, content: 'Nostr is a thankless protocol.' }) + expect(eventMatchesGeneralSearchQuery(note, '"Nostr is a thankless protocol."')).toBe(true) + }) +}) diff --git a/src/lib/general-search-text-match.ts b/src/lib/general-search-text-match.ts new file mode 100644 index 00000000..b2e54b94 --- /dev/null +++ b/src/lib/general-search-text-match.ts @@ -0,0 +1,105 @@ +import { tryParseCitationEventIdFromQuery } from '@/lib/citation-picker-search' +import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' +import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +/** Strip outer quotes and collapse whitespace for matching and relay index hints. */ +export function normalizeGeneralSearchQuery(raw: string): string { + let s = raw.trim() + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) || + (s.startsWith('“') && s.endsWith('”')) || + (s.startsWith('‘') && s.endsWith('’')) + ) { + s = s.slice(1, -1).trim() + } + return s.replace(/\s+/g, ' ') +} + +/** Significant terms for multi-word matching and optional relay `#t` / `search` hints. */ +export function generalSearchQueryTerms(raw: string): string[] { + const norm = normalizeGeneralSearchQuery(raw).toLowerCase() + return norm + .split(/\s+/) + .map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')) + .filter((w) => w.length > 1) +} + +/** Nostr tag names whose values are human-readable text for general search. */ +const GENERAL_SEARCH_TEXT_TAG_NAMES = new Set([ + 'title', + 'summary', + 'description', + 'context', + 'subject', + 'name', + 'alt', + 'caption', + 'd', + 't', + 'author', + 'chapter_title', + 'published_in', + 'published_by', + 'location', + 'editor', + 'version', + 'llm' +]) + +function tagLine(ev: Event, name: string): string { + const parts: string[] = [] + for (const row of ev.tags ?? []) { + if (!Array.isArray(row) || row[0] !== name) continue + const rest = row.slice(1).filter(Boolean) + if (rest.length) parts.push(rest.join(' ')) + } + return parts.join(' ') +} + +/** + * Lowercased haystack from human-readable fields only: `content`, common metadata tags + * (title, summary, description, context, …), and kind-0 profile JSON fields. + */ +export function generalSearchHaystack(ev: Event): string { + const chunks: string[] = [ev.content ?? ''] + for (const name of GENERAL_SEARCH_TEXT_TAG_NAMES) { + const line = tagLine(ev, name) + if (line) chunks.push(line) + } + return chunks.join('\n').toLowerCase() +} + +/** + * Client-side “general search”: substring match over readable text fields (not raw id/pubkey/kind). + * Still resolves npub/nprofile/hex author, note/nevent id, and kind-0 profile queries. + */ +export function eventMatchesGeneralSearchQuery(ev: Event, query: string): boolean { + const raw = query.trim() + if (!raw) return false + + const decodedAuthor = decodeProfileSearchQueryToPubkeyHex(raw) + if (decodedAuthor && ev.pubkey.toLowerCase() === decodedAuthor) return true + + const eventId = tryParseCitationEventIdFromQuery(raw) + if (eventId && ev.id.toLowerCase() === eventId) return true + + if (ev.kind === kinds.Metadata && profileKind0MatchesSearchQuery(ev, raw)) return true + + const normalized = normalizeGeneralSearchQuery(raw) + const q = normalized.toLowerCase() + const qSpace = q.replace(/-/g, ' ') + const needles = qSpace !== q ? [q, qSpace] : [q] + + const haystack = generalSearchHaystack(ev) + for (const needle of needles) { + if (needle && haystack.includes(needle)) return true + } + + const words = generalSearchQueryTerms(raw) + if (words.length >= 2 && words.every((w) => haystack.includes(w))) return true + + return false +} diff --git a/src/lib/local-nip50-search-merge.ts b/src/lib/local-nip50-search-merge.ts index 2a72dc0c..b2e713ca 100644 --- a/src/lib/local-nip50-search-merge.ts +++ b/src/lib/local-nip50-search-merge.ts @@ -1,11 +1,11 @@ -import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' +import { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match' import indexedDb from '@/services/indexed-db.service' import { eventService } from '@/services/client.service' import type { Event } from 'nostr-tools' export type CollectLocalTextSearchParams = { query: string - /** Kind filter (same semantics as NIP-50 `kinds` on relays). */ + /** Kind filter (same semantics as search page `kinds`). */ allowedKinds: readonly number[] /** * Session LRU scan cap for {@link EventService.getSessionEventsMatchingSearch}. @@ -27,7 +27,7 @@ export type CollectLocalTextSearchParams = { /** * Merges local session + publication + event-archive (and optionally other IndexedDB stores) for the same * text query and kind filter, deduped by id, sorted newest-first. Every row must satisfy - * {@link eventMatchesNip50LocalFullTextQuery} (defense in depth on top of store-specific scans). + * {@link eventMatchesGeneralSearchQuery} (defense in depth on top of store-specific scans). */ export async function collectLocalEventsForTextSearch( params: CollectLocalTextSearchParams @@ -44,7 +44,7 @@ export async function collectLocalEventsForTextSearch( const push = (ev: Event) => { if (!kindSet.has(ev.kind)) return - if (!eventMatchesNip50LocalFullTextQuery(ev, q)) return + if (!eventMatchesGeneralSearchQuery(ev, q)) return if (seen.has(ev.id)) return seen.add(ev.id) out.push(ev) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 41a4cd18..ec069468 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -51,7 +51,7 @@ import { calendarRsvpMatchesCalendarEvent } from '@/lib/calendar-rsvp-match' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' -import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' +import { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -834,7 +834,7 @@ export class EventService { /** * Get events from session cache matching search (newest {@link Event.created_at} first). - * Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries; only rows where {@link eventMatchesNip50LocalFullTextQuery} + * Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries; only rows where {@link eventMatchesGeneralSearchQuery} * matches the trimmed query are returned (not “recent rows” without a text hit). */ getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] { @@ -854,7 +854,7 @@ export class EventService { continue } - if (eventMatchesNip50LocalFullTextQuery(event, queryTrim)) { + if (eventMatchesGeneralSearchQuery(event, queryTrim)) { buf.push(event) } } diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 4ff601d9..0b161e5c 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -59,6 +59,11 @@ function filtersHaveNip50Search(filters: readonly Filter[]): boolean { return filters.some((f) => typeof f.search === 'string' && f.search.trim().length > 0) } +/** Single-relay index `search` queries (NIP-50 wire format) that need long EOSE / global budgets. */ +function relayOpUsesIndexSearchFetchPath(source: string | undefined): boolean { + return source === 'fetchEventsFromSingleRelay' +} + /** NIP-50 index relays answer after connect + slot wait; nostr-tools synthetic EOSE runs this long after REQ `fire()`. */ const NIP50_RELAY_SUBSCRIPTION_EOSE_TIMEOUT_MS = 38_000 /** @@ -478,8 +483,8 @@ export class QueryService { const effectiveFilter: Filter | Filter[] = sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters const hasNip50Search = filtersHaveNip50Search(sanitizedFilters) - const useNip50FetchPath = - hasNip50Search && options?.relayOpSource === 'fetchEventsFromSingleRelay' + const useIndexSearchFetchPath = + hasNip50Search && relayOpUsesIndexSearchFetchPath(options?.relayOpSource) const globalTimeoutRaw = options?.globalTimeout ?? 10000 /** * Callers that pass a budget **below** {@link NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS} (e.g. merged search UI) @@ -487,16 +492,16 @@ export class QueryService { * before index relays finish (default {@link ClientService.fetchEventsFromSingleRelay} uses 25s). */ const globalTimeout = - useNip50FetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + useIndexSearchFetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS ? globalTimeoutRaw - : useNip50FetchPath + : useIndexSearchFetchPath ? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS) : globalTimeoutRaw /** After all relays EOSE, brief settle; shorter when the caller uses a short NIP-50 global budget. */ const eoseTimeout = - useNip50FetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + useIndexSearchFetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS ? Math.max(options?.eoseTimeout ?? 500, Math.min(2_000, globalTimeout)) - : useNip50FetchPath + : useIndexSearchFetchPath ? Math.max(options?.eoseTimeout ?? 500, 3_000) : options?.eoseTimeout ?? 500 const replaceableRace = options?.replaceableRace ?? false diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 897d7a7d..55c8421a 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3738,7 +3738,11 @@ class ClientService extends EventTarget { async fetchEventsFromSingleRelay( url: string, filter: Filter | Filter[], - options?: { globalTimeout?: number; signal?: AbortSignal } + options?: { + globalTimeout?: number + signal?: AbortSignal + relayOpSource?: string + } ): Promise<{ events: NEvent[]; connectionError?: string }> { const normalized = normalizeAnyRelayUrl(url) || url if (!normalized) { @@ -3746,9 +3750,9 @@ class ClientService extends EventTarget { } const queryOpts = { globalTimeout: options?.globalTimeout ?? 25_000, - relayOpSource: 'fetchEventsFromSingleRelay' as const, + relayOpSource: options?.relayOpSource ?? ('fetchEventsFromSingleRelay' as const), foreground: true as const, - /** NIP-50 must run to EOSE; implicit feed grace would close the REQ after the first hit. */ + /** General / picker single-relay queries use explicit grace from caller globalTimeout. */ firstRelayResultGraceMs: false as const, ...(options?.signal ? { signal: options.signal } : {}) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index bfb354fd..37eaebd4 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -27,7 +27,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' -import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' +import { eventMatchesGeneralSearchQuery } from '@/lib/general-search-text-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { paymentAttestationIdbRowFromEvent, @@ -1446,7 +1446,7 @@ class IndexedDbService { /** * Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and that match the query via - * {@link eventMatchesNip50LocalFullTextQuery} (id, pubkey, kind, content, tags, kind-0 profile fields). Scans up + * {@link eventMatchesGeneralSearchQuery} (id, pubkey, kind, content, tags, kind-0 profile fields). Scans up * to `scanBudget` rows and keeps up to `collectCap` matches, then returns the newest `limit` by {@link Event.created_at}. */ async getCachedEventsForSearch( @@ -1488,7 +1488,7 @@ class IndexedDbService { const item = cursor.value as TValue | undefined if (item?.value) { const event = item.value as Event - if (kindSet.has(event.kind) && eventMatchesNip50LocalFullTextQuery(event, query)) { + if (kindSet.has(event.kind) && eventMatchesGeneralSearchQuery(event, query)) { results.push(event) } } @@ -1611,7 +1611,7 @@ class IndexedDbService { /** * Publication store + hot {@link StoreNames.EVENT_ARCHIVE}: events whose kind is allowed and that match the - * query via {@link eventMatchesNip50LocalFullTextQuery}. Used for local NIP-50-style hits alongside relay search. + * query via {@link eventMatchesGeneralSearchQuery}. Used for local NIP-50-style hits alongside relay search. */ async getCachedAndArchivedEventsMatchingLocalSearch( query: string, @@ -1664,7 +1664,7 @@ class IndexedDbService { } const row = cursor.value as TArchivedEventRow const ev = row?.value - if (ev && kindSet.has(ev.kind) && !seen.has(ev.id) && eventMatchesNip50LocalFullTextQuery(ev, query)) { + if (ev && kindSet.has(ev.kind) && !seen.has(ev.id) && eventMatchesGeneralSearchQuery(ev, query)) { seen.add(ev.id) rest.push(ev) } @@ -2061,7 +2061,7 @@ class IndexedDbService { const row = raw as TArchivedEventRow if (row?.value && isLikelyCachedNostrEvent(row.value)) { const ev = row.value - if (eventMatchesNip50LocalFullTextQuery(ev, query)) { + if (eventMatchesGeneralSearchQuery(ev, query)) { const dedupeKey = `${storeName}:${row.key}` if (!seen.has(dedupeKey)) { seen.add(dedupeKey) @@ -2082,7 +2082,7 @@ class IndexedDbService { isLikelyCachedNostrEvent(item.value) ) { const ev = item.value - if (eventMatchesNip50LocalFullTextQuery(ev, query)) { + if (eventMatchesGeneralSearchQuery(ev, query)) { const dedupeKey = `${storeName}:${item.key}` if (!seen.has(dedupeKey)) { seen.add(dedupeKey)