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()