You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

526 lines
18 KiB

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<string>()
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<string, TProfile>(),
pending: new Set<string>(),
version: 0
}))
const hitsRef = useRef(hits)
hitsRef.current = hits
const fetchAttemptedRef = useRef(new Set<string>())
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<NoteFeedProfileContextValue>(
() => ({
profiles: batch.profiles,
pendingPubkeys: batch.pending,
version: batch.version
}),
[batch.profiles, batch.pending, batch.version]
)
return <NoteFeedProfileContext.Provider value={ctxVal}>{children}</NoteFeedProfileContext.Provider>
}
async function addSearchEventsToSessionCacheBatched(
events: Event[],
runGeneration: { current: number },
myRun: number
): Promise<void> {
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<void>((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, unknown>) => 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<LocalSearchRow | null>(null)
const [archivesRow, setArchivesRow] = 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 = 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<string, LocalHit>()
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 (
<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>
{archivesRow ? (
<li className="flex min-w-0 items-center gap-2 px-2.5 py-2">
<Archive 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 archives')}
</span>
<span
className={cn(
'ml-auto min-w-0 text-right',
archivesRow.phase === 'error'
? 'text-destructive'
: archivesRow.phase === 'loading'
? 'text-muted-foreground'
: archivesRow.hitCount > 0
? 'text-foreground'
: 'text-muted-foreground'
)}
>
{formatLocalStatusLabel(archivesRow, t)}
{archivesRow.ms != null && archivesRow.phase !== 'loading' ? (
<span className="text-muted-foreground"> · {archivesRow.ms} ms</span>
) : null}
</span>
{archivesRow.phase === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
) : null}
</ul>
</section>
) : null}
<SearchMergedProfileProvider resetKey={searchProfileResetKey} hits={hits}>
<div className="min-w-0 space-y-2">
{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>
)}
{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"
>
<div
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
aria-label={
hit.source === 'archives'
? t('Full-text search archives description')
: 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={
hit.source === 'archives'
? t('Full-text search archives description')
: t('Full-text search local archive description')
}
>
{hit.source === 'archives'
? t('Full-text search archives badge')
: t('Full-text search local archive badge')}
</span>
</div>
<NoteCard
event={hit.event}
className="w-full border-0 bg-transparent shadow-none"
filterMutedNotes
fetchNoteStatsIfMissing={false}
deferAuthorAvatar
searchListPreview
/>
</article>
))}
</div>
</SearchMergedProfileProvider>
{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 local')}</p>
{alexandriaEmptyHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyHref} /> : null}
</div>
)}
</div>
)
}