import NoteCard from '@/components/NoteCard' import RelayIcon from '@/components/RelayIcon' import { Skeleton } from '@/components/ui/skeleton' import { toRelay } from '@/lib/link' import { compareEventsForDTagQuery } from '@/lib/dtag-search' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { 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 { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' import type { TProfile } from '@/types' import type { Event, Filter } from 'nostr-tools' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url' import { Loader2 } from 'lucide-react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { useSmartRelayNavigationOptional } from '@/PageManager' type MergedHit = { 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 SEARCH_MERGED_PROFILE_CHUNK = 80 /** Coalesce rapid merge updates before hitting the network. */ const SEARCH_MERGED_PROFILE_DEBOUNCE_MS = 240 function extractMergedHitAuthorPubkeys(hits: MergedHit[]): string[] { const out: string[] = [] const seen = new Set() for (const h of hits) { const pk = h.event.pubkey?.trim().toLowerCase() if (!pk || !/^[0-9a-f]{64}$/.test(pk) || seen.has(pk)) continue seen.add(pk) out.push(pk) } 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, children }: { resetKey: string mergedHits: MergedHit[] children: ReactNode }) { const [batch, setBatch] = useState(() => ({ profiles: new Map(), pending: new Set(), version: 0 })) const mergedHitsRef = useRef(mergedHits) mergedHitsRef.current = mergedHits const fetchAttemptedRef = useRef(new Set()) useEffect(() => { fetchAttemptedRef.current = new Set() setBatch({ profiles: new Map(), pending: new Set(), version: 0 }) }, [resetKey]) const hitsIdentity = useMemo( () => [...mergedHits] .map((h) => h.event.id) .sort() .join('\x1e'), [mergedHits] ) useEffect(() => { if (!hitsIdentity) return let cancelled = false const t = window.setTimeout(() => { if (cancelled) return const hits = mergedHitsRef.current const pubkeys = extractMergedHitAuthorPubkeys(hits) if (pubkeys.length === 0) return const need = pubkeys.filter((pk) => !fetchAttemptedRef.current.has(pk)) if (need.length === 0) return for (const pk of need) { fetchAttemptedRef.current.add(pk) } setBatch((prev) => { const pending = new Set(prev.pending) let changed = false for (const pk of need) { if (!prev.profiles.has(pk)) { pending.add(pk) changed = true } } if (!changed) return prev return { ...prev, pending } }) void (async () => { const chunks: string[][] = [] for (let i = 0; i < need.length; i += SEARCH_MERGED_PROFILE_CHUNK) { chunks.push(need.slice(i, i + SEARCH_MERGED_PROFILE_CHUNK)) } for (const chunk of chunks) { if (cancelled) return let profiles: TProfile[] = [] try { profiles = await client.fetchProfilesForPubkeys(chunk) } catch { profiles = [] } if (cancelled) return setBatch((prev) => { const next = new Map(prev.profiles) const pend = new Set(prev.pending) for (const p of profiles) { const pkNorm = p.pubkey.toLowerCase() next.set(pkNorm, { ...p, pubkey: pkNorm }) pend.delete(pkNorm) } for (const pk of chunk) { const pkNorm = pk.toLowerCase() pend.delete(pkNorm) if (!next.has(pkNorm)) { next.set(pkNorm, { pubkey: pkNorm, npub: pubkeyToNpub(pkNorm) ?? '', username: formatPubkey(pkNorm), batchPlaceholder: true }) } } return { profiles: next, pending: pend, version: prev.version + 1 } }) } })() }, SEARCH_MERGED_PROFILE_DEBOUNCE_MS) return () => { cancelled = true window.clearTimeout(t) } }, [hitsIdentity, resetKey]) const ctxVal = useMemo( () => ({ profiles: batch.profiles, pendingPubkeys: batch.pending, version: batch.version }), [batch.profiles, batch.pending, batch.version] ) return {children} } /** Max events to push into session cache per animation frame (keeps the tab responsive during merges). */ const ADD_TO_CACHE_PER_FRAME = 8 async function addSearchEventsToSessionCacheBatched( events: Event[], runGeneration: { current: number }, myRun: number ): Promise { for (let i = 0; i < events.length; i += ADD_TO_CACHE_PER_FRAME) { if (myRun !== runGeneration.current) return const slice = events.slice(i, i + ADD_TO_CACHE_PER_FRAME) for (const e of slice) { client.addEventToCache(e, { explicitNoteLookupHexId: e.id }) } await new Promise((resolve) => { requestAnimationFrame(() => resolve()) }) } } type RelayFetchPhase = 'loading' | 'done' | 'error' type RelayFetchRow = { relayUrl: string host: string phase: RelayFetchPhase eventCount?: number ms?: number errorMessage?: string } function normalizeRelayList(urls: readonly string[]): string[] { return Array.from( new Set(urls.map((u) => normalizeUrl(u) || u.trim()).filter((u): u is string => u.length > 0)) ).sort((a, b) => relayHostForSubscribeLog(a).localeCompare(relayHostForSubscribeLog(b))) } 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 [relayRows, setRelayRows] = useState([]) const [mergedHits, setMergedHits] = useState([]) const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) 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 doneRelayCount = relayRows.filter((r) => r.phase === 'done' || r.phase === 'error').length const errorRelayCount = relayRows.filter((r) => r.phase === 'error').length const anyLoading = relayRows.some((r) => r.phase === 'loading') const allTerminal = relayRows.length > 0 && relayRows.every((r) => r.phase === 'done' || r.phase === 'error') useEffect(() => { /** Unmount / total wall only — must not abort in-flight NIP-50 when the “first hits + …ms” scheduling cutoff runs. */ const runAbort = new AbortController() let masterTimer: ReturnType | null = null let stopSchedulingTimer: ReturnType | null = null const myRun = ++runGeneration.current const cleanupInvalidatePreviousRun = () => { runGeneration.current += 1 } const dispose = () => { if (masterTimer != null) { clearTimeout(masterTimer) masterTimer = null } if (stopSchedulingTimer != null) { clearTimeout(stopSchedulingTimer) stopSchedulingTimer = null } runAbort.abort() cleanupInvalidatePreviousRun() } if (!q || normalizedRelays.length === 0) { setRelayRows([]) setMergedHits([]) return dispose } 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) setRelayRows( normalizedRelays.map((relayUrl) => ({ relayUrl, host: relayHostForSubscribeLog(relayUrl), phase: 'loading' })) ) setMergedHits([]) /** 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: stop dequeuing new relays; in-flight REQs keep their per-relay budget. */ let stopSchedulingNewRelays = false /** Only after ≥1 preview-visible event from a relay: stop starting new relays after …ms (empty EOSE must not shorten). */ let appliedRelativeSchedulingCutoff = false const scheduleMasterWallAbort = () => { if (masterTimer != null) { clearTimeout(masterTimer) masterTimer = null } if (waveT0 === null) return const ms = Math.max(0, waveT0 + SEARCH_TOTAL_WALL_MS - Date.now()) masterTimer = setTimeout(() => { masterTimer = null stopSchedulingNewRelays = true runAbort.abort() }, ms) } const beginWaveIfNeeded = () => { if (waveT0 !== null) return waveT0 = Date.now() scheduleMasterWallAbort() } const onFirstPreviewVisibleRelayHits = () => { if (appliedRelativeSchedulingCutoff || waveT0 === null) return appliedRelativeSchedulingCutoff = true if (stopSchedulingTimer != null) { clearTimeout(stopSchedulingTimer) } stopSchedulingTimer = setTimeout(() => { stopSchedulingTimer = null stopSchedulingNewRelays = true }, SEARCH_AFTER_FIRST_RELAY_MS) } runAbort.signal.addEventListener( 'abort', () => { setRelayRows((prev) => prev.map((r) => r.phase === 'loading' ? { ...r, phase: 'done' as const, eventCount: 0, ms: undefined, errorMessage: undefined } : r ) ) }, { once: true } ) let relayCursor = 0 const nextRelayUrl = (): string | undefined => { if (relayCursor >= normalizedRelays.length) return undefined return normalizedRelays[relayCursor++]! } const applyMergedUpdate = ( mutate: (map: Map; local: boolean }>) => void ) => { setMergedHits((prev) => { const urlByKey = new Map() for (const u of normalizedRelays) { urlByKey.set(relayKey(u), normalizeUrl(u) || u) } const map = new Map; local: boolean }>() for (const hit of prev) { map.set(hit.event.id, { event: hit.event, relays: new Set(hit.relayUrls.map((u) => relayKey(u))), local: hit.fromLocalArchive ?? false }) } mutate(map) return [...map.values()] .map(({ event, relays, local }) => { const relayUrls = sortRelaysByHost( [...relays].map((k) => urlByKey.get(k) || k).filter((u) => /^wss?:\/\//i.test(u)) ) const row: MergedHit = { event, relayUrls } if (local) row.fromLocalArchive = true return row }) .filter((h) => h.relayUrls.length > 0 || h.fromLocalArchive) .sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event)) .slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS) }) } const mergeIntoHits = (relayUrl: string, events: Event[]) => { const rk = relayKey(relayUrl) applyMergedUpdate((map) => { for (const ev of events) { if (!mergedSearchNoteHasPreviewBody(ev)) continue const cur = map.get(ev.id) if (cur) { cur.relays.add(rk) } else { map.set(ev.id, { event: ev, relays: new Set([rk]), local: false }) } } }) } void (async () => { const mergedLocal = await collectLocalEventsForTextSearch({ query: q, allowedKinds: kindsArr, sessionCap: 220, idbMergedLimit: 120, archiveScanMaxMs: 15_000, includeOtherStoresFullText: true, fullTextStoreHitCap: 260 }) if (myRun !== runGeneration.current || runAbort.signal.aborted) return const mergedLocalMatching = mergedLocal.filter((e) => mergedSearchNoteHasPreviewBody(e)) if (mergedLocalMatching.length === 0) return applyMergedUpdate((map) => { for (const ev of mergedLocalMatching) { const cur = map.get(ev.id) if (cur) { cur.local = true } else { map.set(ev.id, { event: ev, relays: new Set(), local: true }) } } }) void addSearchEventsToSessionCacheBatched(mergedLocalMatching, runGeneration, myRun) })() const runOneRelay = async (relayUrl: string) => { if (myRun !== runGeneration.current || 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) => compareEventsForDTagQuery(q, a, 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, 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, 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, ms, errorMessage: msg } : r ) ) } } const worker = async () => { while (myRun === runGeneration.current && !runAbort.signal.aborted && !stopSchedulingNewRelays) { const relayUrl = nextRelayUrl() if (!relayUrl) break await runOneRelay(relayUrl) } } 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 } return (

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

{relayRows.length > 0 && (

{anyLoading && } {t('Full-text search progress relays', { done: doneRelayCount, total: relayRows.length })}

)} {errorRelayCount > 0 && allTerminal && (

{t('Full-text search relay errors summary', { count: errorRelayCount })}

)}
{anyLoading && mergedHits.length === 0 && (
)} {mergedHits.map((hit) => (
{(hit.relayUrls.length > 0 || hit.fromLocalArchive) && (
0 ? t('Full-text search seen on relays') : t('Full-text search local archive description') } > {t('Full-text search seen on label')}
{hit.relayUrls.map((url) => ( ))} {hit.fromLocalArchive && ( {t('Full-text search local archive badge')} )}
)}
))}
{allTerminal && mergedHits.length === 0 && (

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

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

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

)}
) }