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 @@ @@ -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<string>()
for (const h of hits) {
@ -62,17 +36,13 @@ function extractMergedHitAuthorPubkeys(hits: MergedHit[]): string[] { @@ -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({ @@ -80,8 +50,8 @@ function SearchMergedProfileProvider({
pending: new Set<string>(),
version: 0
}))
const mergedHitsRef = useRef(mergedHits)
mergedHitsRef.current = mergedHits
const hitsRef = useRef(hits)
hitsRef.current = hits
const fetchAttemptedRef = useRef(new Set<string>())
useEffect(() => {
@ -91,11 +61,11 @@ function SearchMergedProfileProvider({ @@ -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({ @@ -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({ @@ -183,9 +153,6 @@ function SearchMergedProfileProvider({
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(
events: Event[],
runGeneration: { current: number },
@ -203,429 +170,69 @@ async function addSearchEventsToSessionCacheBatched( @@ -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, unknown>) => 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<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({
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<LocalSearchRow | null>(null)
const [relayRows, setRelayRows] = useState<RelayFetchRow[]>([])
const [mergedHits, setMergedHits] = useState<MergedHit[]>([])
const [relayInfoByKey, setRelayInfoByKey] = useState<Record<string, TRelayInfo | undefined>>({})
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
const [localRow, setLocalRow] = useState<LocalSearchRow | null>(null)
const [hits, setHits] = useState<LocalHit[]>([])
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<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])
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<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 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<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 })
}
}
})
}
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({ @@ -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 (
<div className="min-w-0 space-y-3" aria-busy={anyLoading}>
<p className="text-sm text-muted-foreground leading-snug">
{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
})}
</p>
<SearchSourceProgressList
localRow={localSearchRow}
relayRows={relayRows}
relayInfoByKey={relayInfoByKey}
anyLoading={anyLoading}
/>
<SearchMergedProfileProvider resetKey={searchProfileResetKey} mergedHits={mergedHits}>
<div className="min-w-0 space-y-3" aria-busy={loading}>
<p className="text-sm text-muted-foreground leading-snug">{t('Notes search local intro')}</p>
{localRow ? (
<section
className="rounded-lg border border-border/60 bg-muted/20 text-xs"
aria-label={t('Full-text search sources progress')}
aria-busy={loading}
>
<ul className="divide-y divide-border/50">
<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'
)}
>
{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">
{anyLoading && mergedHits.length === 0 && (
<div className="space-y-2" aria-label={t('Full-text search relay querying')}>
{loading && hits.length === 0 && (
<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-14 w-full rounded-md" />
</div>
)}
{mergedHits.map((hit) => (
{hits.map((hit) => (
<article
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"
>
{(hit.relayUrls.length > 0 || hit.fromLocalArchive) && (
<div
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
aria-label={
hit.relayUrls.length > 0
? t('Full-text search seen on relays')
: t('Full-text search local archive description')
}
<div
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')}
>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0">
{t('Full-text search seen on label')}
</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 seen on label')}
</span>
<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>
)}
{t('Full-text search local archive badge')}
</span>
</div>
<NoteCard
event={hit.event}
className="w-full border-0 bg-transparent shadow-none"
@ -855,18 +365,12 @@ export default function FullTextSearchByRelay({ @@ -855,18 +365,12 @@ export default function FullTextSearchByRelay({
</div>
</SearchMergedProfileProvider>
{allTerminal && mergedHits.length === 0 && (
{done && hits.length === 0 && (
<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}
</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>
)
}

26
src/components/SearchResult/index.tsx

@ -1,4 +1,4 @@ @@ -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' @@ -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 @@ -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 @@ -109,8 +92,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
return (
<FullTextSearchByRelay
searchQuery={searchParams.search}
relayUrls={searchableUrls}
kinds={NIP_SEARCH_PAGE_KINDS}
kinds={GENERAL_SEARCH_PAGE_KINDS}
alexandriaEmptyHref={alexandriaEmptyHref}
/>
)

14
src/constants.ts

@ -547,6 +547,12 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -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[] = [ @@ -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<number>([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

6
src/i18n/locales/en.ts

@ -2411,12 +2411,16 @@ export default { @@ -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.',

7
src/lib/dtag-search.ts

@ -55,8 +55,8 @@ function dTagMatchRank(needle: string, dVal: string | undefined): number { @@ -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( @@ -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()

65
src/lib/general-search-text-match.test.ts

@ -0,0 +1,65 @@ @@ -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 @@ @@ -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 @@ @@ -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 = { @@ -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( @@ -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)

6
src/services/client-events.service.ts

@ -51,7 +51,7 @@ import { calendarRsvpMatchesCalendarEvent } from '@/lib/calendar-rsvp-match' @@ -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 { @@ -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 { @@ -854,7 +854,7 @@ export class EventService {
continue
}
if (eventMatchesNip50LocalFullTextQuery(event, queryTrim)) {
if (eventMatchesGeneralSearchQuery(event, queryTrim)) {
buf.push(event)
}
}

17
src/services/client-query.service.ts

@ -59,6 +59,11 @@ function filtersHaveNip50Search(filters: readonly Filter[]): boolean { @@ -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 { @@ -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 { @@ -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

10
src/services/client.service.ts

@ -3738,7 +3738,11 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 } : {})
}

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

@ -27,7 +27,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' @@ -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 { @@ -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 { @@ -1488,7 +1488,7 @@ class IndexedDbService {
const item = cursor.value as TValue<Event> | 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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)

Loading…
Cancel
Save