diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 3c19ca58..2d82b15f 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -2,6 +2,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { useFetchRelayInfo } from '@/hooks' import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { cn } from '@/lib/utils' +import type { TRelayInfo } from '@/types' import { Server } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' @@ -33,15 +34,21 @@ export default function RelayIcon({ url, className, iconSize = 14, + /** When set, used instead of fetching NIP-11 (e.g. parent batched {@link relayInfoService.getRelayInfos}). */ + relayInfo: relayInfoProp, /** When true, do not hit NIP-11 (parent already fetches relay info, or icon-only row). */ skipRelayInfoFetch = false }: { url?: string className?: string iconSize?: number + relayInfo?: TRelayInfo skipRelayInfoFetch?: boolean }) { - const { relayInfo } = useFetchRelayInfo(skipRelayInfoFetch ? undefined : url) + const { relayInfo: fetchedRelayInfo } = useFetchRelayInfo( + relayInfoProp !== undefined || skipRelayInfoFetch ? undefined : url + ) + const relayInfo = relayInfoProp !== undefined ? relayInfoProp : fetchedRelayInfo const [iconLoadFailed, setIconLoadFailed] = useState(false) useEffect(() => { setIconLoadFailed(false) diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index b5705f37..554b859b 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -2,7 +2,7 @@ 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 { compareMergedNip50SearchHits } 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' @@ -10,12 +10,14 @@ 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 relayInfoService from '@/services/relay-info.service' import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' -import type { TProfile } from '@/types' +import type { TProfile, TRelayInfo } 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 { HardDrive, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { useSmartRelayNavigationOptional } from '@/PageManager' @@ -201,21 +203,178 @@ async function addSearchEventsToSessionCacheBatched( } } -type RelayFetchPhase = 'loading' | 'done' | 'error' +type SearchSourcePhase = 'loading' | 'done' | 'error' + +type LocalSearchRow = { + phase: SearchSourcePhase + /** Notes that pass the preview filter (shown in results). */ + hitCount: number + /** Raw matches before preview filter. */ + rawCount: number + ms?: number + errorMessage?: string +} type RelayFetchRow = { relayUrl: string host: string - phase: RelayFetchPhase + phase: SearchSourcePhase + /** Preview-visible hits merged into results. */ eventCount?: number + /** Events returned by the relay before preview filter. */ + rawEventCount?: number ms?: number errorMessage?: string } +function formatSourceStatusLabel( + row: { + phase: SearchSourcePhase + hitCount: number + rawCount?: number + errorMessage?: string + }, + 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 ?? shown + if (shown === 0 && raw === 0) { + if (row.errorMessage?.trim()) { + return t('Full-text search source zero with note', { note: row.errorMessage.trim() }) + } + 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 }) +} + +function SearchSourceProgressList({ + localRow, + relayRows, + relayInfoByKey, + anyLoading +}: { + localRow: LocalSearchRow | null + relayRows: RelayFetchRow[] + relayInfoByKey: Record + anyLoading: boolean +}) { + const { t } = useTranslation() + + if (!localRow && relayRows.length === 0) return null + + return ( +
+
    + {localRow ? ( +
  • + + + {t('Full-text search source local')} + + 0 + ? 'text-foreground' + : 'text-muted-foreground' + )} + > + {formatSourceStatusLabel( + { + phase: localRow.phase, + hitCount: localRow.hitCount, + rawCount: localRow.rawCount, + errorMessage: localRow.errorMessage + }, + t + )} + {localRow.ms != null && localRow.phase !== 'loading' ? ( + · {localRow.ms} ms + ) : null} + + {localRow.phase === 'loading' ? ( + + ) : null} +
  • + ) : null} + {relayRows.map((row) => ( +
  • + + + {row.host} + + 0 + ? 'text-foreground' + : 'text-muted-foreground' + )} + > + {formatSourceStatusLabel( + { + phase: row.phase, + hitCount: row.eventCount ?? 0, + rawCount: row.rawEventCount, + errorMessage: row.errorMessage + }, + t + )} + {row.ms != null && row.phase !== 'loading' ? ( + · {row.ms} ms + ) : null} + + {row.phase === 'loading' ? ( + + ) : null} +
  • + ))} +
+
+ ) +} + +/** Dedupe while preserving {@link SEARCHABLE_RELAY_URLS} priority (fast index relays first). */ 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))) + const seen = new Set() + const out: string[] = [] + for (const raw of urls) { + const n = normalizeUrl(raw) || raw.trim() + if (!n) continue + const k = relayKey(n) + if (seen.has(k)) continue + seen.add(k) + out.push(n) + } + return out } function relayKey(url: string): string { @@ -246,8 +405,10 @@ export default function FullTextSearchByRelay({ } } const runGeneration = useRef(0) + const [localSearchRow, setLocalSearchRow] = useState(null) const [relayRows, setRelayRows] = useState([]) const [mergedHits, setMergedHits] = useState([]) + const [relayInfoByKey, setRelayInfoByKey] = useState>({}) const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) @@ -261,11 +422,49 @@ export default function FullTextSearchByRelay({ [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 relayUrlsForIconPrefetch = useMemo(() => { + const seen = new Set() + const out: string[] = [] + const push = (url: string) => { + const k = relayKey(url) + if (!k || seen.has(k)) return + seen.add(k) + out.push(url) + } + for (const u of normalizedRelays) push(u) + for (const hit of mergedHits) { + for (const u of hit.relayUrls) push(u) + } + return out + }, [normalizedRelays, mergedHits]) + + useEffect(() => { + if (relayUrlsForIconPrefetch.length === 0) { + setRelayInfoByKey({}) + return + } + let cancelled = false + void relayInfoService.getRelayInfos(relayUrlsForIconPrefetch).then((infos) => { + if (cancelled) return + const next: Record = {} + relayUrlsForIconPrefetch.forEach((url, i) => { + const info = infos[i] + if (info) next[relayKey(url)] = info + }) + setRelayInfoByKey((prev) => ({ ...prev, ...next })) + }) + return () => { + cancelled = true + } + }, [relayUrlsForIconPrefetch]) + + const anyLoading = + localSearchRow?.phase === 'loading' || relayRows.some((r) => r.phase === 'loading') const allTerminal = - relayRows.length > 0 && relayRows.every((r) => r.phase === 'done' || r.phase === 'error') + localSearchRow != null && + localSearchRow.phase !== 'loading' && + 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. */ @@ -290,6 +489,7 @@ export default function FullTextSearchByRelay({ } if (!q || normalizedRelays.length === 0) { + setLocalSearchRow(null) setRelayRows([]) setMergedHits([]) return dispose @@ -304,6 +504,7 @@ export default function FullTextSearchByRelay({ const poolSize = Math.min(FULL_TEXT_SEARCH_RELAY_CONCURRENCY, normalizedRelays.length) + setLocalSearchRow({ phase: 'loading', hitCount: 0, rawCount: 0 }) setRelayRows( normalizedRelays.map((relayUrl) => ({ relayUrl, @@ -312,12 +513,16 @@ export default function FullTextSearchByRelay({ })) ) setMergedHits([]) + setRelayInfoByKey({}) /** 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. */ + /** + * After first preview-visible relay hits: may stop dequeuing *extra* relays after a short delay, + * but every index relay in the list still gets one REQ (see worker loop). + */ let stopSchedulingNewRelays = false - /** Only after ≥1 preview-visible event from a relay: stop starting new relays after …ms (empty EOSE must not shorten). */ + /** Only after ≥1 preview-visible event from a relay: arm the early scheduling cutoff (empty EOSE must not shorten). */ let appliedRelativeSchedulingCutoff = false const scheduleMasterWallAbort = () => { @@ -399,7 +604,7 @@ export default function FullTextSearchByRelay({ return row }) .filter((h) => h.relayUrls.length > 0 || h.fromLocalArchive) - .sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event)) + .sort((a, b) => compareMergedNip50SearchHits(q, a, b)) .slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS) }) } @@ -420,29 +625,49 @@ export default function FullTextSearchByRelay({ } 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 }) - } + const localT0 = performance.now() + try { + 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)) + const localMs = Math.round(performance.now() - localT0) + setLocalSearchRow({ + phase: 'done', + hitCount: mergedLocalMatching.length, + rawCount: mergedLocal.length, + ms: localMs + }) + if (mergedLocalMatching.length > 0) { + 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) } - }) - void addSearchEventsToSessionCacheBatched(mergedLocalMatching, runGeneration, myRun) + } catch (err) { + if (myRun !== runGeneration.current) return + setLocalSearchRow({ + phase: 'error', + hitCount: 0, + rawCount: 0, + ms: Math.round(performance.now() - localT0), + errorMessage: err instanceof Error ? err.message : String(err) + }) + } })() const runOneRelay = async (relayUrl: string) => { @@ -458,7 +683,7 @@ export default function FullTextSearchByRelay({ if (myRun !== runGeneration.current) return const sorted = [...raw] - .sort((a, b) => compareEventsForDTagQuery(q, a, b)) + .sort((a, b) => compareMergedNip50SearchHits(q, { event: a }, { event: b })) .slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) const previewVisible = sorted.filter((e) => mergedSearchNoteHasPreviewBody(e)) @@ -467,7 +692,14 @@ export default function FullTextSearchByRelay({ setRelayRows((prev) => prev.map((r) => r.relayUrl === relayUrl - ? { ...r, phase: 'error', eventCount: 0, ms, errorMessage: connectionError } + ? { + ...r, + phase: 'error', + eventCount: 0, + rawEventCount: sorted.length, + ms, + errorMessage: connectionError + } : r ) ) @@ -487,6 +719,7 @@ export default function FullTextSearchByRelay({ ...r, phase: 'done', eventCount: previewVisible.length, + rawEventCount: sorted.length, ms, errorMessage: previewVisible.length > 0 ? undefined : connectionError } @@ -500,14 +733,17 @@ export default function FullTextSearchByRelay({ const ms = Math.round(performance.now() - t0) setRelayRows((prev) => prev.map((r) => - r.relayUrl === relayUrl ? { ...r, phase: 'error', eventCount: 0, ms, errorMessage: msg } : r + r.relayUrl === relayUrl + ? { ...r, phase: 'error', eventCount: 0, rawEventCount: 0, ms, errorMessage: msg } + : r ) ) } } const worker = async () => { - while (myRun === runGeneration.current && !runAbort.signal.aborted && !stopSchedulingNewRelays) { + while (myRun === runGeneration.current && !runAbort.signal.aborted) { + if (stopSchedulingNewRelays && relayCursor >= normalizedRelays.length) break const relayUrl = nextRelayUrl() if (!relayUrl) break await runOneRelay(relayUrl) @@ -540,18 +776,12 @@ export default function FullTextSearchByRelay({ })}

- {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 })} -

- )} +
@@ -592,7 +822,13 @@ export default function FullTextSearchByRelay({ navigateToRelay(toRelay(url)) }} > - + ))} {hit.fromLocalArchive && ( diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index bbe397f3..dda218c4 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1535,6 +1535,13 @@ export default { "Treffer im Veröffentlichungs-Cache oder Event-Archiv auf diesem Gerät. Index-Relays haben die Notiz ggf. noch nicht.", "Full-text search empty merged": "Keine Notizen zu dieser Suche in deinem Archiv oder auf den konfigurierten Index-Relays (langsam oder offline).", + "Full-text search sources progress": "Suchquellen", + "Full-text search source local": "Dieses Gerät", + "Full-text search source loading": "Suche läuft…", + "Full-text search source zero hits": "0 Treffer", + "Full-text search source zero with note": "0 Treffer · {{note}}", + "Full-text search source hits": "{{count}} Treffer", + "Full-text search source hits with raw": "{{shown}} angezeigt ({{raw}} von der Quelle)", Geohash: "Geohash", "Geohash (optional)": "Geohash (optional)", "Global quiet mode": "Global quiet mode", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 86fb0b8a..1e4290fc 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1936,6 +1936,13 @@ export default { "Full-text search relay error": "Query failed", "Full-text search relay unknown error": "Unknown error", "Full-text search all relays finished": "All relay queries have finished.", + "Full-text search sources progress": "Search sources", + "Full-text search source local": "This device", + "Full-text search source loading": "Searching…", + "Full-text search source zero hits": "0 hits", + "Full-text search source zero with note": "0 hits · {{note}}", + "Full-text search source hits": "{{count}} hit(s)", + "Full-text search source hits with raw": "{{shown}} shown ({{raw}} from source)", "See reference": "See reference", "Select Group": "Select Group", "Select Media Type": "Select Media Type", diff --git a/src/lib/dtag-search.test.ts b/src/lib/dtag-search.test.ts new file mode 100644 index 00000000..cdfc8c7b --- /dev/null +++ b/src/lib/dtag-search.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { compareMergedNip50SearchHits } from '@/lib/dtag-search' +import type { Event } from 'nostr-tools' + +function ev(id: string, created_at: number, d?: string): Event { + return { + id, + kind: 30023, + pubkey: 'a'.repeat(64), + created_at, + tags: d ? [['d', d]] : [], + content: '', + sig: 'b'.repeat(128) + } +} + +describe('compareMergedNip50SearchHits', () => { + it('ranks local archive hits before relay-only hits', () => { + const local = { event: ev('1'.repeat(64), 100), fromLocalArchive: true } + const relay = { event: ev('2'.repeat(64), 200) } + expect(compareMergedNip50SearchHits('foo', local, relay)).toBeLessThan(0) + expect(compareMergedNip50SearchHits('foo', relay, local)).toBeGreaterThan(0) + }) +}) diff --git a/src/lib/dtag-search.ts b/src/lib/dtag-search.ts index 0334787a..5dc8804e 100644 --- a/src/lib/dtag-search.ts +++ b/src/lib/dtag-search.ts @@ -55,6 +55,18 @@ function dTagMatchRank(needle: string, dVal: string | undefined): number { return 3 } +/** Merged NIP-50 search: device cache/archive hits before relay-only hits; then {@link compareEventsForDTagQuery}. */ +export function compareMergedNip50SearchHits( + needle: string, + a: { event: Event; fromLocalArchive?: boolean }, + b: { event: Event; fromLocalArchive?: boolean } +): number { + const aTier = a.fromLocalArchive ? 0 : 1 + const bTier = b.fromLocalArchive ? 0 : 1 + if (aTier !== bTier) return aTier - bTier + return compareEventsForDTagQuery(needle, a.event, b.event) +} + /** For merged lists: better d-tag match first; tie-break newest first. Kind 30041 sinks unless `d` equals the needle. */ export function compareEventsForDTagQuery(needle: string, a: Event, b: Event): number { const nl = needle.trim().toLowerCase()