Browse Source

fix search

imwald
Silberengel 2 weeks ago
parent
commit
d66ceb5035
  1. 722
      src/components/SearchResult/FullTextSearchByRelay.tsx
  2. 26
      src/components/SearchResult/index.tsx
  3. 14
      src/constants.ts
  4. 6
      src/i18n/locales/en.ts
  5. 7
      src/lib/dtag-search.ts
  6. 65
      src/lib/general-search-text-match.test.ts
  7. 105
      src/lib/general-search-text-match.ts
  8. 8
      src/lib/local-nip50-search-merge.ts
  9. 6
      src/services/client-events.service.ts
  10. 17
      src/services/client-query.service.ts
  11. 10
      src/services/client.service.ts
  12. 14
      src/services/indexed-db.service.ts

722
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -1,56 +1,30 @@
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import RelayIcon from '@/components/RelayIcon'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { toRelay } from '@/lib/link' import { compareMergedGeneralSearchHits } from '@/lib/dtag-search'
import { compareMergedNip50SearchHits } from '@/lib/dtag-search'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import client from '@/services/client.service' import client from '@/services/client.service'
import { NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS } from '@/services/client-query.service' import type { TProfile } from '@/types'
import relayInfoService from '@/services/relay-info.service' import type { Event } from 'nostr-tools'
import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service'
import type { TProfile, TRelayInfo } from '@/types'
import type { Event, Filter } from 'nostr-tools'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url' import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url'
import { HardDrive, Loader2 } from 'lucide-react' import { HardDrive, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSmartRelayNavigationOptional } from '@/PageManager'
type MergedHit = { type LocalHit = {
event: Event event: Event
relayUrls: string[]
/** Matched publication cache / event archive on this device (not relay NIP-50). */
fromLocalArchive?: boolean
} }
/** const LOCAL_SEARCH_MAX_EVENTS = 150
* 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 SEARCH_MERGED_PROFILE_CHUNK = 80 const SEARCH_MERGED_PROFILE_CHUNK = 80
/** Coalesce rapid merge updates before hitting the network. */
const SEARCH_MERGED_PROFILE_DEBOUNCE_MS = 240 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 out: string[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const h of hits) { for (const h of hits) {
@ -62,17 +36,13 @@ function extractMergedHitAuthorPubkeys(hits: MergedHit[]): string[] {
return out 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({ function SearchMergedProfileProvider({
resetKey, resetKey,
mergedHits, hits,
children children
}: { }: {
resetKey: string resetKey: string
mergedHits: MergedHit[] hits: LocalHit[]
children: ReactNode children: ReactNode
}) { }) {
const [batch, setBatch] = useState(() => ({ const [batch, setBatch] = useState(() => ({
@ -80,8 +50,8 @@ function SearchMergedProfileProvider({
pending: new Set<string>(), pending: new Set<string>(),
version: 0 version: 0
})) }))
const mergedHitsRef = useRef(mergedHits) const hitsRef = useRef(hits)
mergedHitsRef.current = mergedHits hitsRef.current = hits
const fetchAttemptedRef = useRef(new Set<string>()) const fetchAttemptedRef = useRef(new Set<string>())
useEffect(() => { useEffect(() => {
@ -91,11 +61,11 @@ function SearchMergedProfileProvider({
const hitsIdentity = useMemo( const hitsIdentity = useMemo(
() => () =>
[...mergedHits] [...hits]
.map((h) => h.event.id) .map((h) => h.event.id)
.sort() .sort()
.join('\x1e'), .join('\x1e'),
[mergedHits] [hits]
) )
useEffect(() => { useEffect(() => {
@ -103,8 +73,8 @@ function SearchMergedProfileProvider({
let cancelled = false let cancelled = false
const t = window.setTimeout(() => { const t = window.setTimeout(() => {
if (cancelled) return if (cancelled) return
const hits = mergedHitsRef.current const currentHits = hitsRef.current
const pubkeys = extractMergedHitAuthorPubkeys(hits) const pubkeys = extractHitAuthorPubkeys(currentHits)
if (pubkeys.length === 0) return if (pubkeys.length === 0) return
const need = pubkeys.filter((pk) => !fetchAttemptedRef.current.has(pk)) const need = pubkeys.filter((pk) => !fetchAttemptedRef.current.has(pk))
@ -183,9 +153,6 @@ function SearchMergedProfileProvider({
return <NoteFeedProfileContext.Provider value={ctxVal}>{children}</NoteFeedProfileContext.Provider> return <NoteFeedProfileContext.Provider value={ctxVal}>{children}</NoteFeedProfileContext.Provider>
} }
/** 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( async function addSearchEventsToSessionCacheBatched(
events: Event[], events: Event[],
runGeneration: { current: number }, runGeneration: { current: number },
@ -203,429 +170,69 @@ async function addSearchEventsToSessionCacheBatched(
} }
} }
type SearchSourcePhase = 'loading' | 'done' | 'error' type LocalSearchPhase = 'loading' | 'done' | 'error'
type LocalSearchRow = { type LocalSearchRow = {
phase: SearchSourcePhase phase: LocalSearchPhase
/** Notes that pass the preview filter (shown in results). */
hitCount: number hitCount: number
/** Raw matches before preview filter. */
rawCount: number rawCount: number
ms?: number ms?: number
errorMessage?: string errorMessage?: string
} }
type RelayFetchRow = { function formatLocalStatusLabel(
relayUrl: string row: LocalSearchRow,
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
},
t: (key: string, opts?: Record<string, unknown>) => string t: (key: string, opts?: Record<string, unknown>) => string
): string { ): string {
if (row.phase === 'loading') { if (row.phase === 'loading') return t('Full-text search source loading')
return t('Full-text search source loading')
}
if (row.phase === 'error') { if (row.phase === 'error') {
const msg = row.errorMessage?.trim() const msg = row.errorMessage?.trim()
return msg && msg.length <= 120 ? msg : t('Full-text search relay error') return msg && msg.length <= 120 ? msg : t('Full-text search relay error')
} }
const shown = row.hitCount const shown = row.hitCount
const raw = row.rawCount ?? shown const raw = row.rawCount
if (shown === 0 && raw === 0) { if (shown === 0 && raw === 0) return t('Full-text search source zero hits')
if (row.errorMessage?.trim()) { if (raw > shown) return t('Full-text search source hits with raw', { shown, raw })
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 })
}
return t('Full-text search source hits', { count: shown }) return t('Full-text search source hits', { count: shown })
} }
function SearchSourceProgressList({
localRow,
relayRows,
relayInfoByKey,
anyLoading
}: {
localRow: LocalSearchRow | null
relayRows: RelayFetchRow[]
relayInfoByKey: Record<string, TRelayInfo | undefined>
anyLoading: boolean
}) {
const { t } = useTranslation()
if (!localRow && relayRows.length === 0) return null
return (
<section
className="rounded-lg border border-border/60 bg-muted/20 text-xs"
aria-label={t('Full-text search sources progress')}
aria-busy={anyLoading}
>
<ul className="divide-y divide-border/50">
{localRow ? (
<li className="flex min-w-0 items-center gap-2 px-2.5 py-2">
<HardDrive className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 shrink-0 font-medium text-foreground">
{t('Full-text search source local')}
</span>
<span
className={cn(
'ml-auto min-w-0 text-right',
localRow.phase === 'error'
? 'text-destructive'
: localRow.phase === 'loading'
? 'text-muted-foreground'
: localRow.hitCount > 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' ? (
<span className="text-muted-foreground"> · {localRow.ms} ms</span>
) : null}
</span>
{localRow.phase === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
) : null}
{relayRows.map((row) => (
<li key={row.relayUrl} className="flex min-w-0 items-center gap-2 px-2.5 py-2">
<RelayIcon
url={row.relayUrl}
className="h-4 w-4 shrink-0 rounded-sm"
iconSize={10}
relayInfo={relayInfoByKey[relayKey(row.relayUrl)]}
skipRelayInfoFetch
/>
<span className="min-w-0 truncate font-mono text-foreground" title={row.relayUrl}>
{row.host}
</span>
<span
className={cn(
'ml-auto min-w-0 max-w-[55%] text-right leading-snug',
row.phase === 'error'
? 'text-destructive'
: row.phase === 'loading'
? 'text-muted-foreground'
: (row.eventCount ?? 0) > 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' ? (
<span className="text-muted-foreground"> · {row.ms} ms</span>
) : null}
</span>
{row.phase === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
))}
</ul>
</section>
)
}
/** Dedupe while preserving {@link SEARCHABLE_RELAY_URLS} priority (fast index relays first). */
function normalizeRelayList(urls: readonly string[]): string[] {
const seen = new Set<string>()
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({ export default function FullTextSearchByRelay({
searchQuery, searchQuery,
relayUrls,
kinds, kinds,
alexandriaEmptyHref: alexandriaEmptyHrefProp = null alexandriaEmptyHref: alexandriaEmptyHrefProp = null
}: { }: {
searchQuery: string searchQuery: string
relayUrls: readonly string[]
kinds: readonly number[] kinds: readonly number[]
alexandriaEmptyHref?: string | null alexandriaEmptyHref?: string | null
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigationOptional() ?? {
navigateToRelay: (url: string) => {
window.location.href = url
}
}
const runGeneration = useRef(0) const runGeneration = useRef(0)
const [localSearchRow, setLocalSearchRow] = useState<LocalSearchRow | null>(null) const [localRow, setLocalRow] = useState<LocalSearchRow | null>(null)
const [relayRows, setRelayRows] = useState<RelayFetchRow[]>([]) const [hits, setHits] = useState<LocalHit[]>([])
const [mergedHits, setMergedHits] = useState<MergedHit[]>([])
const [relayInfoByKey, setRelayInfoByKey] = useState<Record<string, TRelayInfo | undefined>>({})
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
const q = searchQuery.trim() const q = searchQuery.trim()
const alexandriaEmptyHref = useMemo(() => { const alexandriaEmptyHref = useMemo(() => {
if (alexandriaEmptyHrefProp) return alexandriaEmptyHrefProp if (alexandriaEmptyHrefProp) return alexandriaEmptyHrefProp
return q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null return q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null
}, [alexandriaEmptyHrefProp, q]) }, [alexandriaEmptyHrefProp, q])
const searchProfileResetKey = useMemo(
() => `${q}\n${normalizedRelays.join('\n')}`,
[q, normalizedRelays]
)
const relayUrlsForIconPrefetch = useMemo(() => { const searchProfileResetKey = q
const seen = new Set<string>()
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])
useEffect(() => { useEffect(() => {
if (relayUrlsForIconPrefetch.length === 0) {
setRelayInfoByKey({})
return
}
let cancelled = false
void relayInfoService.getRelayInfos(relayUrlsForIconPrefetch).then((infos) => {
if (cancelled) return
const next: Record<string, TRelayInfo | undefined> = {}
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<typeof setTimeout> | null = null
let stopSchedulingTimer: ReturnType<typeof setTimeout> | null = null
const myRun = ++runGeneration.current 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) { if (!q) {
setLocalSearchRow(null) setLocalRow(null)
setRelayRows([]) setHits([])
setMergedHits([]) return
return dispose
} }
const kindsArr = [...kinds] const kindsArr = [...kinds]
const filter: Filter = { setLocalRow({ phase: 'loading', hitCount: 0, rawCount: 0 })
search: q, setHits([])
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<string, { event: Event; relays: Set<string>; local: boolean }>) => void
) => {
setMergedHits((prev) => {
const urlByKey = new Map<string, string>()
for (const u of normalizedRelays) {
urlByKey.set(relayKey(u), normalizeUrl(u) || u)
}
const map = new Map<string, { event: Event; relays: Set<string>; 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 })
}
}
})
}
void (async () => { void (async () => {
const localT0 = performance.now() const t0 = performance.now()
try { try {
const mergedLocal = await collectLocalEventsForTextSearch({ const mergedLocal = await collectLocalEventsForTextSearch({
query: q, query: q,
@ -636,212 +243,115 @@ export default function FullTextSearchByRelay({
includeOtherStoresFullText: true, includeOtherStoresFullText: true,
fullTextStoreHitCap: 260 fullTextStoreHitCap: 260
}) })
if (myRun !== runGeneration.current || runAbort.signal.aborted) return if (myRun !== runGeneration.current) return
const mergedLocalMatching = mergedLocal.filter((e) => mergedSearchNoteHasPreviewBody(e))
const localMs = Math.round(performance.now() - localT0) const visible = mergedLocal
setLocalSearchRow({ .filter((e) => mergedSearchNoteHasPreviewBody(e))
.sort((a, b) => compareMergedGeneralSearchHits(q, { event: a }, { event: b }))
.slice(0, LOCAL_SEARCH_MAX_EVENTS)
setLocalRow({
phase: 'done', phase: 'done',
hitCount: mergedLocalMatching.length, hitCount: visible.length,
rawCount: mergedLocal.length, rawCount: mergedLocal.length,
ms: localMs ms: Math.round(performance.now() - t0)
}) })
if (mergedLocalMatching.length > 0) { setHits(visible.map((event) => ({ event })))
applyMergedUpdate((map) => { if (visible.length > 0) {
for (const ev of mergedLocalMatching) { void addSearchEventsToSessionCacheBatched(visible, runGeneration, myRun)
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)
} }
} catch (err) { } catch (err) {
if (myRun !== runGeneration.current) return if (myRun !== runGeneration.current) return
setLocalSearchRow({ setLocalRow({
phase: 'error', phase: 'error',
hitCount: 0, hitCount: 0,
rawCount: 0, rawCount: 0,
ms: Math.round(performance.now() - localT0), ms: Math.round(performance.now() - t0),
errorMessage: err instanceof Error ? err.message : String(err) errorMessage: err instanceof Error ? err.message : String(err)
}) })
} }
})() })()
const runOneRelay = async (relayUrl: string) => { return () => {
if (myRun !== runGeneration.current || runAbort.signal.aborted) return runGeneration.current += 1
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)
}
} }
}, [q, kinds])
void (async () => { if (!q) return null
try {
await Promise.all(Array.from({ length: poolSize }, () => worker()))
} catch {
/* runOneRelay already updates relay rows */
}
})()
return dispose
}, [q, normalizedRelays, kinds])
if (!q) { const loading = localRow?.phase === 'loading'
return null const done = localRow != null && localRow.phase !== 'loading'
}
return ( return (
<div className="min-w-0 space-y-3" aria-busy={anyLoading}> <div className="min-w-0 space-y-3" aria-busy={loading}>
<p className="text-sm text-muted-foreground leading-snug"> <p className="text-sm text-muted-foreground leading-snug">{t('Notes search local intro')}</p>
{t('Full-text search merged intro', {
relayCount: normalizedRelays.length, {localRow ? (
totalSeconds: Math.round(SEARCH_TOTAL_WALL_MS / 1000), <section
afterFirstSeconds: Math.round(SEARCH_AFTER_FIRST_RELAY_MS / 1000), className="rounded-lg border border-border/60 bg-muted/20 text-xs"
concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY aria-label={t('Full-text search sources progress')}
})} aria-busy={loading}
</p> >
<ul className="divide-y divide-border/50">
<SearchSourceProgressList <li className="flex min-w-0 items-center gap-2 px-2.5 py-2">
localRow={localSearchRow} <HardDrive className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
relayRows={relayRows} <span className="min-w-0 shrink-0 font-medium text-foreground">
relayInfoByKey={relayInfoByKey} {t('Full-text search source local')}
anyLoading={anyLoading} </span>
/> <span
className={cn(
<SearchMergedProfileProvider resetKey={searchProfileResetKey} mergedHits={mergedHits}> 'ml-auto min-w-0 text-right',
localRow.phase === 'error'
? 'text-destructive'
: localRow.phase === 'loading'
? 'text-muted-foreground'
: localRow.hitCount > 0
? 'text-foreground'
: 'text-muted-foreground'
)}
>
{formatLocalStatusLabel(localRow, t)}
{localRow.ms != null && localRow.phase !== 'loading' ? (
<span className="text-muted-foreground"> · {localRow.ms} ms</span>
) : null}
</span>
{localRow.phase === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
</ul>
</section>
) : null}
<SearchMergedProfileProvider resetKey={searchProfileResetKey} hits={hits}>
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
{anyLoading && mergedHits.length === 0 && ( {loading && hits.length === 0 && (
<div className="space-y-2" aria-label={t('Full-text search relay querying')}> <div className="space-y-2" aria-label={t('Full-text search source loading')}>
<Skeleton className="h-16 w-full rounded-md" /> <Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-16 w-full rounded-md" /> <Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" /> <Skeleton className="h-14 w-full rounded-md" />
</div> </div>
)} )}
{mergedHits.map((hit) => ( {hits.map((hit) => (
<article <article
key={hit.event.id} 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" 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"
> >
{(hit.relayUrls.length > 0 || hit.fromLocalArchive) && ( <div
<div className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1" aria-label={t('Full-text search local archive description')}
aria-label={ >
hit.relayUrls.length > 0 <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0">
? t('Full-text search seen on relays') {t('Full-text search seen on label')}
: t('Full-text search local archive description') </span>
} <span
className="inline-flex shrink-0 rounded-sm border border-border/50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90"
title={t('Full-text search local archive description')}
> >
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0"> {t('Full-text search local archive badge')}
{t('Full-text search seen on label')} </span>
</span> </div>
<div className="flex flex-wrap items-center gap-0.5">
{hit.relayUrls.map((url) => (
<button
key={`${hit.event.id}-${relayKey(url)}`}
type="button"
title={relayHostForSubscribeLog(url)}
className="inline-flex shrink-0 rounded-sm opacity-90 ring-offset-background hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(url))
}}
>
<RelayIcon
url={url}
className="h-5 w-5 rounded-sm"
iconSize={12}
relayInfo={relayInfoByKey[relayKey(url)]}
skipRelayInfoFetch
/>
</button>
))}
{hit.fromLocalArchive && (
<span
className="ml-0.5 inline-flex shrink-0 rounded-sm border border-border/50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90"
title={t('Full-text search local archive description')}
>
{t('Full-text search local archive badge')}
</span>
)}
</div>
</div>
)}
<NoteCard <NoteCard
event={hit.event} event={hit.event}
className="w-full border-0 bg-transparent shadow-none" className="w-full border-0 bg-transparent shadow-none"
@ -855,18 +365,12 @@ export default function FullTextSearchByRelay({
</div> </div>
</SearchMergedProfileProvider> </SearchMergedProfileProvider>
{allTerminal && mergedHits.length === 0 && ( {done && hits.length === 0 && (
<div className="flex flex-col items-start gap-0" role="status"> <div className="flex flex-col items-start gap-0" role="status">
<p className="text-sm text-muted-foreground">{t('Full-text search empty merged')}</p> <p className="text-sm text-muted-foreground">{t('Full-text search empty local')}</p>
{alexandriaEmptyHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyHref} /> : null} {alexandriaEmptyHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyHref} /> : null}
</div> </div>
)} )}
{allTerminal && mergedHits.length > 0 && (
<p className="text-sm text-muted-foreground border-t pt-3" role="status">
{t('Full-text search all relays finished')}
</p>
)}
</div> </div>
) )
} }

26
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 { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed' import NormalFeed from '../NormalFeed'
import FullTextSearchByRelay from './FullTextSearchByRelay' import FullTextSearchByRelay from './FullTextSearchByRelay'
@ -7,11 +7,10 @@ import { ProfileListBySearch } from '../ProfileListBySearch'
import Relay from '../Relay' import Relay from '../Relay'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url' import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { useLayoutEffect, useMemo } from 'react' import { useMemo } from 'react'
function relayDedupeKey(url: string): string { function relayDedupeKey(url: string): string {
return (normalizeUrl(url) || url.trim()).toLowerCase() return (normalizeUrl(url) || url.trim()).toLowerCase()
@ -21,23 +20,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
const { relayList, cacheRelayListEvent } = useNostr() const { relayList, cacheRelayListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
/** /** Index relays for hashtag search and relay dedupe (notes search is local cache only). */
* 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. */
const searchableUrls = useMemo( const searchableUrls = useMemo(
() => () =>
Array.from( Array.from(
@ -109,8 +92,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
return ( return (
<FullTextSearchByRelay <FullTextSearchByRelay
searchQuery={searchParams.search} searchQuery={searchParams.search}
relayUrls={searchableUrls} kinds={GENERAL_SEARCH_PAGE_KINDS}
kinds={NIP_SEARCH_PAGE_KINDS}
alexandriaEmptyHref={alexandriaEmptyHref} alexandriaEmptyHref={alexandriaEmptyHref}
/> />
) )

14
src/constants.ts

@ -547,6 +547,12 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr.wine', 'wss://nostr.wine',
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
'wss://nostr-pub.wellorder.net', '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. * Primary Search page note kinds: profiles, short notes, and document kinds.
* Search used only {@link NIP_SEARCH_DOCUMENT_KINDS} before, so handles and npub-related * {@link GENERAL_SEARCH_PAGE_KINDS} is an alias for the same set.
* metadata (kind 0) and normal notes (kind 1) never matched.
*/ */
export const NIP_SEARCH_PAGE_KINDS: readonly number[] = Array.from( export const NIP_SEARCH_PAGE_KINDS: readonly number[] = Array.from(
new Set<number>([kinds.Metadata, kinds.ShortTextNote, ...NIP_SEARCH_DOCUMENT_KINDS]) new Set<number>([kinds.Metadata, kinds.ShortTextNote, ...NIP_SEARCH_DOCUMENT_KINDS])
).sort((a, b) => a - b) ).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 { export function relayFilterIncludesDocumentRelayKind(filter: Filter): boolean {
const k = filter.kinds const k = filter.kinds
if (k === undefined) return false if (k === undefined) return false

6
src/i18n/locales/en.ts

@ -2411,12 +2411,16 @@ export default {
'Searching…': 'Searching…', 'Searching…': 'Searching…',
'Full-text search merged intro': '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 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 progress relays': '{{done}} / {{total}} index relays',
'Full-text search seen on label': 'Seen on', 'Full-text search seen on label': 'Seen on',
'Full-text search seen on relays': 'Relays that returned this note', 'Full-text search seen on relays': 'Relays that returned this note',
'Full-text search local archive badge': 'This device', 'Full-text search local archive badge': 'This device',
'Full-text search local archive description': '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': '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).', '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.', 'Full-text search relay errors summary': '{{count}} relay(s) could not be queried.',

7
src/lib/dtag-search.ts

@ -55,8 +55,8 @@ function dTagMatchRank(needle: string, dVal: string | undefined): number {
return 3 return 3
} }
/** Merged NIP-50 search: device cache/archive hits before relay-only hits; then {@link compareEventsForDTagQuery}. */ /** Merged general search: device cache/archive hits before relay-only hits; then {@link compareEventsForDTagQuery}. */
export function compareMergedNip50SearchHits( export function compareMergedGeneralSearchHits(
needle: string, needle: string,
a: { event: Event; fromLocalArchive?: boolean }, a: { event: Event; fromLocalArchive?: boolean },
b: { event: Event; fromLocalArchive?: boolean } b: { event: Event; fromLocalArchive?: boolean }
@ -67,6 +67,9 @@ export function compareMergedNip50SearchHits(
return compareEventsForDTagQuery(needle, a.event, b.event) 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. */ /** 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 { export function compareEventsForDTagQuery(needle: string, a: Event, b: Event): number {
const nl = needle.trim().toLowerCase() const nl = needle.trim().toLowerCase()

65
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<Event> & Pick<Event, 'kind' | 'content'>): 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)
})
})

105
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
}

8
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 indexedDb from '@/services/indexed-db.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
export type CollectLocalTextSearchParams = { export type CollectLocalTextSearchParams = {
query: string query: string
/** Kind filter (same semantics as NIP-50 `kinds` on relays). */ /** Kind filter (same semantics as search page `kinds`). */
allowedKinds: readonly number[] allowedKinds: readonly number[]
/** /**
* Session LRU scan cap for {@link EventService.getSessionEventsMatchingSearch}. * 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 * 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 * 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( export async function collectLocalEventsForTextSearch(
params: CollectLocalTextSearchParams params: CollectLocalTextSearchParams
@ -44,7 +44,7 @@ export async function collectLocalEventsForTextSearch(
const push = (ev: Event) => { const push = (ev: Event) => {
if (!kindSet.has(ev.kind)) return if (!kindSet.has(ev.kind)) return
if (!eventMatchesNip50LocalFullTextQuery(ev, q)) return if (!eventMatchesGeneralSearchQuery(ev, q)) return
if (seen.has(ev.id)) return if (seen.has(ev.id)) return
seen.add(ev.id) seen.add(ev.id)
out.push(ev) out.push(ev)

6
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 { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter' 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 { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' 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). * 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). * matches the trimmed query are returned (not recent rows without a text hit).
*/ */
getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] { getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] {
@ -854,7 +854,7 @@ export class EventService {
continue continue
} }
if (eventMatchesNip50LocalFullTextQuery(event, queryTrim)) { if (eventMatchesGeneralSearchQuery(event, queryTrim)) {
buf.push(event) buf.push(event)
} }
} }

17
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) 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()`. */ /** 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 const NIP50_RELAY_SUBSCRIPTION_EOSE_TIMEOUT_MS = 38_000
/** /**
@ -478,8 +483,8 @@ export class QueryService {
const effectiveFilter: Filter | Filter[] = const effectiveFilter: Filter | Filter[] =
sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters
const hasNip50Search = filtersHaveNip50Search(sanitizedFilters) const hasNip50Search = filtersHaveNip50Search(sanitizedFilters)
const useNip50FetchPath = const useIndexSearchFetchPath =
hasNip50Search && options?.relayOpSource === 'fetchEventsFromSingleRelay' hasNip50Search && relayOpUsesIndexSearchFetchPath(options?.relayOpSource)
const globalTimeoutRaw = options?.globalTimeout ?? 10000 const globalTimeoutRaw = options?.globalTimeout ?? 10000
/** /**
* Callers that pass a budget **below** {@link NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS} (e.g. merged search UI) * 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). * before index relays finish (default {@link ClientService.fetchEventsFromSingleRelay} uses 25s).
*/ */
const globalTimeout = const globalTimeout =
useNip50FetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS useIndexSearchFetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS
? globalTimeoutRaw ? globalTimeoutRaw
: useNip50FetchPath : useIndexSearchFetchPath
? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS) ? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS)
: globalTimeoutRaw : globalTimeoutRaw
/** After all relays EOSE, brief settle; shorter when the caller uses a short NIP-50 global budget. */ /** After all relays EOSE, brief settle; shorter when the caller uses a short NIP-50 global budget. */
const eoseTimeout = 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)) ? Math.max(options?.eoseTimeout ?? 500, Math.min(2_000, globalTimeout))
: useNip50FetchPath : useIndexSearchFetchPath
? Math.max(options?.eoseTimeout ?? 500, 3_000) ? Math.max(options?.eoseTimeout ?? 500, 3_000)
: options?.eoseTimeout ?? 500 : options?.eoseTimeout ?? 500
const replaceableRace = options?.replaceableRace ?? false const replaceableRace = options?.replaceableRace ?? false

10
src/services/client.service.ts

@ -3738,7 +3738,11 @@ class ClientService extends EventTarget {
async fetchEventsFromSingleRelay( async fetchEventsFromSingleRelay(
url: string, url: string,
filter: Filter | Filter[], filter: Filter | Filter[],
options?: { globalTimeout?: number; signal?: AbortSignal } options?: {
globalTimeout?: number
signal?: AbortSignal
relayOpSource?: string
}
): Promise<{ events: NEvent[]; connectionError?: string }> { ): Promise<{ events: NEvent[]; connectionError?: string }> {
const normalized = normalizeAnyRelayUrl(url) || url const normalized = normalizeAnyRelayUrl(url) || url
if (!normalized) { if (!normalized) {
@ -3746,9 +3750,9 @@ class ClientService extends EventTarget {
} }
const queryOpts = { const queryOpts = {
globalTimeout: options?.globalTimeout ?? 25_000, globalTimeout: options?.globalTimeout ?? 25_000,
relayOpSource: 'fetchEventsFromSingleRelay' as const, relayOpSource: options?.relayOpSource ?? ('fetchEventsFromSingleRelay' as const),
foreground: true 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, firstRelayResultGraceMs: false as const,
...(options?.signal ? { signal: options.signal } : {}) ...(options?.signal ? { signal: options.signal } : {})
} }

14
src/services/indexed-db.service.ts

@ -27,7 +27,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' 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 { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import { import {
paymentAttestationIdbRowFromEvent, paymentAttestationIdbRowFromEvent,
@ -1446,7 +1446,7 @@ class IndexedDbService {
/** /**
* Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and that match the query via * 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}. * to `scanBudget` rows and keeps up to `collectCap` matches, then returns the newest `limit` by {@link Event.created_at}.
*/ */
async getCachedEventsForSearch( async getCachedEventsForSearch(
@ -1488,7 +1488,7 @@ class IndexedDbService {
const item = cursor.value as TValue<Event> | undefined const item = cursor.value as TValue<Event> | undefined
if (item?.value) { if (item?.value) {
const event = item.value as Event 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) 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 * 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( async getCachedAndArchivedEventsMatchingLocalSearch(
query: string, query: string,
@ -1664,7 +1664,7 @@ class IndexedDbService {
} }
const row = cursor.value as TArchivedEventRow const row = cursor.value as TArchivedEventRow
const ev = row?.value 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) seen.add(ev.id)
rest.push(ev) rest.push(ev)
} }
@ -2061,7 +2061,7 @@ class IndexedDbService {
const row = raw as TArchivedEventRow const row = raw as TArchivedEventRow
if (row?.value && isLikelyCachedNostrEvent(row.value)) { if (row?.value && isLikelyCachedNostrEvent(row.value)) {
const ev = row.value const ev = row.value
if (eventMatchesNip50LocalFullTextQuery(ev, query)) { if (eventMatchesGeneralSearchQuery(ev, query)) {
const dedupeKey = `${storeName}:${row.key}` const dedupeKey = `${storeName}:${row.key}`
if (!seen.has(dedupeKey)) { if (!seen.has(dedupeKey)) {
seen.add(dedupeKey) seen.add(dedupeKey)
@ -2082,7 +2082,7 @@ class IndexedDbService {
isLikelyCachedNostrEvent(item.value) isLikelyCachedNostrEvent(item.value)
) { ) {
const ev = item.value const ev = item.value
if (eventMatchesNip50LocalFullTextQuery(ev, query)) { if (eventMatchesGeneralSearchQuery(ev, query)) {
const dedupeKey = `${storeName}:${item.key}` const dedupeKey = `${storeName}:${item.key}`
if (!seen.has(dedupeKey)) { if (!seen.has(dedupeKey)) {
seen.add(dedupeKey) seen.add(dedupeKey)

Loading…
Cancel
Save