12 changed files with 641 additions and 139 deletions
@ -0,0 +1,353 @@ |
|||||||
|
import NoteCard from '@/components/NoteCard' |
||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import { compareEventsForDTagQuery } from '@/lib/dtag-search' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' |
||||||
|
import type { Event, Filter } from 'nostr-tools' |
||||||
|
import { Loader2 } from 'lucide-react' |
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
/** One-shot NIP-50 REQ per relay; bounded wait so the page always reaches a terminal state. */ |
||||||
|
const FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS = 10_000 |
||||||
|
/** Avoid opening every index relay at once (pool + main thread); still completes all relays. */ |
||||||
|
const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3 |
||||||
|
const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 |
||||||
|
/** Cap rows per card so a hot relay cannot mount hundreds of {@link NoteCard}s at once. */ |
||||||
|
const FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY = 40 |
||||||
|
|
||||||
|
type RelayCardPhase = 'loading' | 'done' | 'error' |
||||||
|
|
||||||
|
type RelayCardModel = { |
||||||
|
relayUrl: string |
||||||
|
host: string |
||||||
|
phase: RelayCardPhase |
||||||
|
events: Event[] |
||||||
|
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))) |
||||||
|
} |
||||||
|
|
||||||
|
/** Console hint: what this one-shot outcome suggests about NIP-50 (never proof without NIP-11). */ |
||||||
|
function nip50OutcomeHint(args: { |
||||||
|
phase: 'done' | 'error' |
||||||
|
rawCount: number |
||||||
|
connectionError?: string |
||||||
|
}): string { |
||||||
|
if (args.phase === 'error') { |
||||||
|
return 'no_transport_or_relay_closed_request — cannot tell NIP-50 from this run' |
||||||
|
} |
||||||
|
if (args.rawCount > 0) { |
||||||
|
return 'returned_events_for_REQ_with_search_field — relay likely honors NIP-50 for this query (verify with NIP-11 supported_nips)' |
||||||
|
} |
||||||
|
if (args.connectionError) { |
||||||
|
return 'zero_events_but_connection_error_message — partial failure or restrictive CLOSE; NIP-50 unclear' |
||||||
|
} |
||||||
|
return 'zero_events_clean_close — no_hits_or_search_ignored_or_empty_index — cannot distinguish without NIP-11 or a known match' |
||||||
|
} |
||||||
|
|
||||||
|
export default function FullTextSearchByRelay({ |
||||||
|
searchQuery, |
||||||
|
relayUrls, |
||||||
|
kinds |
||||||
|
}: { |
||||||
|
searchQuery: string |
||||||
|
relayUrls: readonly string[] |
||||||
|
kinds: readonly number[] |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const runGeneration = useRef(0) |
||||||
|
const [cards, setCards] = useState<RelayCardModel[]>([]) |
||||||
|
|
||||||
|
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) |
||||||
|
|
||||||
|
const q = searchQuery.trim() |
||||||
|
const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const myRun = ++runGeneration.current |
||||||
|
if (!q || normalizedRelays.length === 0) { |
||||||
|
setCards([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
/** React 18 Strict Mode (dev) mounts twice; bump invalidates the previous run’s workers and ignores stale fetches. */ |
||||||
|
const cleanupInvalidatePreviousRun = () => { |
||||||
|
runGeneration.current += 1 |
||||||
|
} |
||||||
|
|
||||||
|
const filter: Filter = { |
||||||
|
search: q, |
||||||
|
kinds: [...kinds], |
||||||
|
limit: FULL_TEXT_SEARCH_PER_RELAY_LIMIT |
||||||
|
} |
||||||
|
|
||||||
|
const poolSize = Math.min(FULL_TEXT_SEARCH_RELAY_CONCURRENCY, normalizedRelays.length) |
||||||
|
|
||||||
|
setCards( |
||||||
|
normalizedRelays.map((relayUrl) => ({ |
||||||
|
relayUrl, |
||||||
|
host: relayHostForSubscribeLog(relayUrl), |
||||||
|
phase: 'loading', |
||||||
|
events: [] |
||||||
|
})) |
||||||
|
) |
||||||
|
|
||||||
|
let relayCursor = 0 |
||||||
|
const nextRelayUrl = (): string | undefined => { |
||||||
|
if (relayCursor >= normalizedRelays.length) return undefined |
||||||
|
return normalizedRelays[relayCursor++]! |
||||||
|
} |
||||||
|
|
||||||
|
const runOneRelay = async (relayUrl: string) => { |
||||||
|
const host = relayHostForSubscribeLog(relayUrl) |
||||||
|
logger.debug('[NIP-50 full-text] card_begin', { |
||||||
|
runId: myRun, |
||||||
|
relayUrl, |
||||||
|
host, |
||||||
|
timeoutMs: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS, |
||||||
|
filter: { search: filter.search, kinds: filter.kinds, limit: filter.limit } |
||||||
|
}) |
||||||
|
|
||||||
|
const t0 = performance.now() |
||||||
|
try { |
||||||
|
const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay( |
||||||
|
relayUrl, |
||||||
|
filter, |
||||||
|
{ globalTimeout: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS } |
||||||
|
) |
||||||
|
if (myRun !== runGeneration.current) return |
||||||
|
|
||||||
|
const sorted = [...raw].sort((a, b) => compareEventsForDTagQuery(q, a, b)).slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) |
||||||
|
for (const e of sorted) { |
||||||
|
client.addEventToCache(e, { explicitNoteLookupHexId: e.id }) |
||||||
|
} |
||||||
|
|
||||||
|
const ms = Math.round(performance.now() - t0) |
||||||
|
if (sorted.length === 0 && connectionError) { |
||||||
|
logger.debug('[NIP-50 full-text] card_end', { |
||||||
|
runId: myRun, |
||||||
|
relayUrl, |
||||||
|
host, |
||||||
|
phase: 'error' as const, |
||||||
|
ms, |
||||||
|
eventCountRaw: raw.length, |
||||||
|
eventCountShown: 0, |
||||||
|
connectionError, |
||||||
|
cardErrorMessage: connectionError, |
||||||
|
nip50Hint: nip50OutcomeHint({ phase: 'error', rawCount: 0, connectionError }) |
||||||
|
}) |
||||||
|
setCards((prev) => |
||||||
|
prev.map((c) => |
||||||
|
c.relayUrl === relayUrl |
||||||
|
? { |
||||||
|
...c, |
||||||
|
phase: 'error', |
||||||
|
events: [], |
||||||
|
ms, |
||||||
|
errorMessage: connectionError |
||||||
|
} |
||||||
|
: c |
||||||
|
) |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
logger.debug('[NIP-50 full-text] card_end', { |
||||||
|
runId: myRun, |
||||||
|
relayUrl, |
||||||
|
host, |
||||||
|
phase: 'done' as const, |
||||||
|
ms, |
||||||
|
eventCountRaw: raw.length, |
||||||
|
eventCountShown: sorted.length, |
||||||
|
connectionError: sorted.length > 0 ? undefined : connectionError, |
||||||
|
cardNote: |
||||||
|
sorted.length === 0 && connectionError |
||||||
|
? 'UI shows soft warning (empty with message)' |
||||||
|
: sorted.length === 0 |
||||||
|
? 'UI empty state' |
||||||
|
: 'UI lists notes', |
||||||
|
nip50Hint: nip50OutcomeHint({ |
||||||
|
phase: 'done', |
||||||
|
rawCount: raw.length, |
||||||
|
connectionError: sorted.length > 0 ? undefined : connectionError |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
setCards((prev) => |
||||||
|
prev.map((c) => |
||||||
|
c.relayUrl === relayUrl |
||||||
|
? { |
||||||
|
...c, |
||||||
|
phase: 'done', |
||||||
|
events: sorted, |
||||||
|
ms, |
||||||
|
errorMessage: sorted.length > 0 ? undefined : connectionError |
||||||
|
} |
||||||
|
: c |
||||||
|
) |
||||||
|
) |
||||||
|
} catch (err) { |
||||||
|
if (myRun !== runGeneration.current) return |
||||||
|
const msg = err instanceof Error ? err.message : String(err) |
||||||
|
const ms = Math.round(performance.now() - t0) |
||||||
|
logger.debug('[NIP-50 full-text] card_end', { |
||||||
|
runId: myRun, |
||||||
|
relayUrl, |
||||||
|
host, |
||||||
|
phase: 'error' as const, |
||||||
|
ms, |
||||||
|
eventCountRaw: 0, |
||||||
|
eventCountShown: 0, |
||||||
|
connectionError: undefined, |
||||||
|
cardErrorMessage: msg, |
||||||
|
nip50Hint: nip50OutcomeHint({ phase: 'error', rawCount: 0 }) |
||||||
|
}) |
||||||
|
setCards((prev) => |
||||||
|
prev.map((c) => |
||||||
|
c.relayUrl === relayUrl |
||||||
|
? { |
||||||
|
...c, |
||||||
|
phase: 'error', |
||||||
|
events: [], |
||||||
|
ms, |
||||||
|
errorMessage: msg |
||||||
|
} |
||||||
|
: c |
||||||
|
) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const worker = async () => { |
||||||
|
while (myRun === runGeneration.current) { |
||||||
|
const relayUrl = nextRelayUrl() |
||||||
|
if (!relayUrl) break |
||||||
|
await runOneRelay(relayUrl) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void (async () => { |
||||||
|
logger.debug('[NIP-50 full-text] wave_begin', { |
||||||
|
runId: myRun, |
||||||
|
query: q, |
||||||
|
relayCount: normalizedRelays.length, |
||||||
|
concurrency: poolSize, |
||||||
|
filter: { search: filter.search, kinds: filter.kinds, limit: filter.limit }, |
||||||
|
relays: normalizedRelays.map((u) => ({ url: u, host: relayHostForSubscribeLog(u) })) |
||||||
|
}) |
||||||
|
try { |
||||||
|
await Promise.all(Array.from({ length: poolSize }, () => worker())) |
||||||
|
} catch { |
||||||
|
/* runOneRelay already updates card errors */ |
||||||
|
} |
||||||
|
if (myRun !== runGeneration.current) return |
||||||
|
logger.debug('[NIP-50 full-text] wave_end', { |
||||||
|
runId: myRun, |
||||||
|
relayCount: normalizedRelays.length, |
||||||
|
note: 'matches UI "all relays finished" when every card is done or error' |
||||||
|
}) |
||||||
|
})() |
||||||
|
|
||||||
|
return cleanupInvalidatePreviousRun |
||||||
|
}, [q, normalizedRelays, kinds]) |
||||||
|
|
||||||
|
const allTerminal = |
||||||
|
cards.length > 0 && cards.every((c) => c.phase === 'done' || c.phase === 'error') |
||||||
|
const anyLoading = cards.some((c) => c.phase === 'loading') |
||||||
|
|
||||||
|
if (!q) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="min-w-0 space-y-4" aria-busy={anyLoading}> |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{t('Full-text search per relay intro', { |
||||||
|
relayCount: normalizedRelays.length, |
||||||
|
seconds: timeoutSec, |
||||||
|
concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY |
||||||
|
})} |
||||||
|
</p> |
||||||
|
|
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'grid gap-4 min-w-0', |
||||||
|
'grid-cols-1 md:grid-cols-2 xl:grid-cols-3' |
||||||
|
)} |
||||||
|
> |
||||||
|
{cards.map((c) => ( |
||||||
|
<Card key={c.relayUrl} className="min-w-0 flex flex-col overflow-hidden"> |
||||||
|
<CardHeader className="pb-2 space-y-1"> |
||||||
|
<div className="flex items-start justify-between gap-2"> |
||||||
|
<CardTitle className="text-base font-medium break-all">{c.host}</CardTitle> |
||||||
|
{c.phase === 'loading' ? ( |
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" aria-hidden /> |
||||||
|
) : ( |
||||||
|
<Badge variant="secondary" className="shrink-0"> |
||||||
|
{c.events.length} |
||||||
|
</Badge> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<CardDescription className="break-all text-xs font-mono opacity-80">{c.relayUrl}</CardDescription> |
||||||
|
{c.phase === 'done' && c.ms != null && ( |
||||||
|
<p className="text-xs text-muted-foreground"> |
||||||
|
{t('Full-text search relay timing', { ms: c.ms })} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</CardHeader> |
||||||
|
<CardContent className="flex-1 min-h-0 pt-0 flex flex-col gap-2"> |
||||||
|
{c.phase === 'loading' && ( |
||||||
|
<div className="space-y-2" aria-label={t('Full-text search relay querying')}> |
||||||
|
<Skeleton className="h-16 w-full" /> |
||||||
|
<Skeleton className="h-16 w-full" /> |
||||||
|
<Skeleton className="h-12 w-full" /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{c.phase === 'error' && ( |
||||||
|
<p className="text-sm text-destructive"> |
||||||
|
{t('Full-text search relay error')}: {c.errorMessage ?? t('Full-text search relay unknown error')} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
{c.phase === 'done' && c.events.length === 0 && !c.errorMessage && ( |
||||||
|
<p className="text-sm text-muted-foreground">{t('Full-text search relay no hits')}</p> |
||||||
|
)} |
||||||
|
{c.phase === 'done' && c.events.length === 0 && c.errorMessage && ( |
||||||
|
<p className="text-sm text-muted-foreground">{c.errorMessage}</p> |
||||||
|
)} |
||||||
|
{c.events.length > 0 && ( |
||||||
|
<ul |
||||||
|
className="max-h-[min(28rem,55vh)] overflow-y-auto space-y-3 pr-1 -mr-1 min-w-0" |
||||||
|
role="list" |
||||||
|
> |
||||||
|
{c.events.map((ev) => ( |
||||||
|
<li key={ev.id} className="min-w-0"> |
||||||
|
<NoteCard event={ev} className="w-full" filterMutedNotes /> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
)} |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
{allTerminal && ( |
||||||
|
<p className="text-sm text-muted-foreground border-t pt-3" role="status"> |
||||||
|
{t('Full-text search all relays finished')} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue