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.
636 lines
23 KiB
636 lines
23 KiB
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<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 |
|
} |
|
|
|
/** |
|
* 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<string, TProfile>(), |
|
pending: new Set<string>(), |
|
version: 0 |
|
})) |
|
const mergedHitsRef = useRef(mergedHits) |
|
mergedHitsRef.current = mergedHits |
|
const fetchAttemptedRef = useRef(new Set<string>()) |
|
|
|
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<NoteFeedProfileContextValue>( |
|
() => ({ |
|
profiles: batch.profiles, |
|
pendingPubkeys: batch.pending, |
|
version: batch.version |
|
}), |
|
[batch.profiles, batch.pending, batch.version] |
|
) |
|
|
|
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 }, |
|
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 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<RelayFetchRow[]>([]) |
|
const [mergedHits, setMergedHits] = useState<MergedHit[]>([]) |
|
|
|
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<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) { |
|
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<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) => 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 ( |
|
<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> |
|
|
|
{relayRows.length > 0 && ( |
|
<p className="text-xs text-muted-foreground flex items-center gap-2" role="status"> |
|
{anyLoading && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />} |
|
{t('Full-text search progress relays', { done: doneRelayCount, total: relayRows.length })} |
|
</p> |
|
)} |
|
|
|
{errorRelayCount > 0 && allTerminal && ( |
|
<p className="text-xs text-amber-600 dark:text-amber-500" role="status"> |
|
{t('Full-text search relay errors summary', { count: errorRelayCount })} |
|
</p> |
|
)} |
|
|
|
<SearchMergedProfileProvider resetKey={searchProfileResetKey} mergedHits={mergedHits}> |
|
<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')}> |
|
<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) => ( |
|
<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') |
|
} |
|
> |
|
<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} /> |
|
</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> |
|
)} |
|
<NoteCard |
|
event={hit.event} |
|
className="w-full border-0 bg-transparent shadow-none" |
|
filterMutedNotes |
|
fetchNoteStatsIfMissing={false} |
|
deferAuthorAvatar |
|
searchListPreview |
|
/> |
|
</article> |
|
))} |
|
</div> |
|
</SearchMergedProfileProvider> |
|
|
|
{allTerminal && mergedHits.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> |
|
{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> |
|
) |
|
}
|
|
|