import NoteCard from '@/components/NoteCard' import { Skeleton } from '@/components/ui/skeleton' import { compareMergedGeneralSearchHits } from '@/lib/dtag-search' import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { searchArchivesNotesForGeneralSearch } from '@/lib/nostr-archives-search' import { useNostrArchivesAvailable } from '@/hooks/useNostrArchivesAvailable' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch' import client from '@/services/client.service' 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 { Archive, HardDrive, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' type SearchHitSource = 'local' | 'archives' type LocalHit = { event: Event source: SearchHitSource } const LOCAL_SEARCH_MAX_EVENTS = 150 const SEARCH_MERGED_PROFILE_CHUNK = 80 const SEARCH_MERGED_PROFILE_DEBOUNCE_MS = 240 const ADD_TO_CACHE_PER_FRAME = 8 function extractHitAuthorPubkeys(hits: LocalHit[]): 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 } function SearchMergedProfileProvider({ resetKey, hits, children }: { resetKey: string hits: LocalHit[] children: ReactNode }) { const [batch, setBatch] = useState(() => ({ profiles: new Map(), pending: new Set(), version: 0 })) const hitsRef = useRef(hits) hitsRef.current = hits const fetchAttemptedRef = useRef(new Set()) useEffect(() => { fetchAttemptedRef.current = new Set() setBatch({ profiles: new Map(), pending: new Set(), version: 0 }) }, [resetKey]) const hitsIdentity = useMemo( () => [...hits] .map((h) => h.event.id) .sort() .join('\x1e'), [hits] ) useEffect(() => { if (!hitsIdentity) return let cancelled = false const t = window.setTimeout(() => { if (cancelled) return const currentHits = hitsRef.current const pubkeys = extractHitAuthorPubkeys(currentHits) 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 fetchProfilesMetadataBatch(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} } 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 LocalSearchPhase = 'loading' | 'done' | 'error' type LocalSearchRow = { phase: LocalSearchPhase hitCount: number rawCount: number ms?: number errorMessage?: string } function formatLocalStatusLabel( row: LocalSearchRow, t: (key: string, opts?: Record) => string ): string { if (row.phase === 'loading') return t('Full-text search source loading') if (row.phase === '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 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 }) } export default function FullTextSearchByRelay({ searchQuery, kinds, alexandriaEmptyHref: alexandriaEmptyHrefProp = null }: { searchQuery: string kinds: readonly number[] alexandriaEmptyHref?: string | null }) { const { t } = useTranslation() const archivesAvailable = useNostrArchivesAvailable() const runGeneration = useRef(0) const [localRow, setLocalRow] = useState(null) const [archivesRow, setArchivesRow] = useState(null) const [hits, setHits] = useState([]) const q = searchQuery.trim() const alexandriaEmptyHref = useMemo(() => { if (alexandriaEmptyHrefProp) return alexandriaEmptyHrefProp return q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null }, [alexandriaEmptyHrefProp, q]) const searchProfileResetKey = q useEffect(() => { const myRun = ++runGeneration.current if (!q) { setLocalRow(null) setArchivesRow(null) setHits([]) return } const kindsArr = [...kinds] setLocalRow({ phase: 'loading', hitCount: 0, rawCount: 0 }) setArchivesRow(archivesAvailable ? { phase: 'loading', hitCount: 0, rawCount: 0 } : null) setHits([]) void (async () => { const t0Local = performance.now() const t0Archives = performance.now() const localPromise = collectLocalEventsForTextSearch({ query: q, allowedKinds: kindsArr, sessionCap: 220, idbMergedLimit: 120, archiveScanMaxMs: 15_000, includeOtherStoresFullText: true, fullTextStoreHitCap: 260 }) const archivesPromise = archivesAvailable ? searchArchivesNotesForGeneralSearch({ query: q, kinds: kindsArr, limit: 100 }) : Promise.resolve({ ok: false, events: [], total: 0 }) let mergedLocal: Event[] = [] let localError: string | undefined try { mergedLocal = await localPromise if (myRun !== runGeneration.current) return const localVisible = mergedLocal .filter((e) => mergedSearchNoteHasPreviewBody(e)) .sort((a, b) => compareMergedGeneralSearchHits(q, { event: a, fromLocalArchive: true }, { event: b, fromLocalArchive: true })) setLocalRow({ phase: 'done', hitCount: localVisible.length, rawCount: mergedLocal.length, ms: Math.round(performance.now() - t0Local) }) let archivesEvents: Event[] = [] if (archivesAvailable) { const archivesRes = await archivesPromise if (myRun !== runGeneration.current) return if (archivesRes.ok) { archivesEvents = archivesRes.events setArchivesRow({ phase: 'done', hitCount: archivesEvents.length, rawCount: archivesRes.total, ms: Math.round(performance.now() - t0Archives) }) } else { setArchivesRow({ phase: 'error', hitCount: 0, rawCount: 0, ms: Math.round(performance.now() - t0Archives), errorMessage: t('Full-text search archives unavailable') }) } } const byId = new Map() for (const event of localVisible) { byId.set(event.id, { event, source: 'local' }) } for (const event of archivesEvents) { if (!byId.has(event.id)) { byId.set(event.id, { event, source: 'archives' }) } } const mergedHits = [...byId.values()] .sort((a, b) => compareMergedGeneralSearchHits(q, { event: a.event, fromLocalArchive: a.source === 'local' }, { event: b.event, fromLocalArchive: b.source === 'local' }) ) .slice(0, LOCAL_SEARCH_MAX_EVENTS) setHits(mergedHits) if (mergedHits.length > 0) { void addSearchEventsToSessionCacheBatched( mergedHits.map((h) => h.event), runGeneration, myRun ) } } catch (err) { if (myRun !== runGeneration.current) return localError = err instanceof Error ? err.message : String(err) setLocalRow({ phase: 'error', hitCount: 0, rawCount: 0, ms: Math.round(performance.now() - t0Local), errorMessage: localError }) if (archivesAvailable) { try { const archivesRes = await archivesPromise if (myRun !== runGeneration.current) return if (archivesRes.ok && archivesRes.events.length > 0) { const mergedHits = archivesRes.events .slice(0, LOCAL_SEARCH_MAX_EVENTS) .map((event) => ({ event, source: 'archives' as const })) setArchivesRow({ phase: 'done', hitCount: mergedHits.length, rawCount: archivesRes.total, ms: Math.round(performance.now() - t0Archives) }) setHits(mergedHits) void addSearchEventsToSessionCacheBatched( mergedHits.map((h) => h.event), runGeneration, myRun ) } else { setArchivesRow({ phase: 'error', hitCount: 0, rawCount: 0, ms: Math.round(performance.now() - t0Archives), errorMessage: t('Full-text search archives unavailable') }) } } catch { setArchivesRow({ phase: 'error', hitCount: 0, rawCount: 0, ms: Math.round(performance.now() - t0Archives), errorMessage: t('Full-text search archives unavailable') }) } } } })() return () => { runGeneration.current += 1 } }, [q, kinds, archivesAvailable]) if (!q) return null const loading = localRow?.phase === 'loading' || (archivesAvailable && archivesRow?.phase === 'loading') const done = localRow != null && localRow.phase !== 'loading' && (!archivesAvailable || (archivesRow != null && archivesRow.phase !== 'loading')) return (

{t('Notes search local intro')}

{localRow ? (
  • {t('Full-text search source local')} 0 ? 'text-foreground' : 'text-muted-foreground' )} > {formatLocalStatusLabel(localRow, t)} {localRow.ms != null && localRow.phase !== 'loading' ? ( · {localRow.ms} ms ) : null} {localRow.phase === 'loading' ? ( ) : null}
  • {archivesRow ? (
  • {t('Full-text search source archives')} 0 ? 'text-foreground' : 'text-muted-foreground' )} > {formatLocalStatusLabel(archivesRow, t)} {archivesRow.ms != null && archivesRow.phase !== 'loading' ? ( · {archivesRow.ms} ms ) : null} {archivesRow.phase === 'loading' ? ( ) : null}
  • ) : null}
) : null}
{loading && hits.length === 0 && (
)} {hits.map((hit) => (
{t('Full-text search seen on label')} {hit.source === 'archives' ? t('Full-text search archives badge') : t('Full-text search local archive badge')}
))}
{done && hits.length === 0 && (

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

{alexandriaEmptyHref ? : null}
)}
) }