Browse Source

fix search

imwald
Silberengel 4 weeks ago
parent
commit
4fcdffe9c8
  1. 9
      src/components/RelayIcon/index.tsx
  2. 302
      src/components/SearchResult/FullTextSearchByRelay.tsx
  3. 7
      src/i18n/locales/de.ts
  4. 7
      src/i18n/locales/en.ts
  5. 24
      src/lib/dtag-search.test.ts
  6. 12
      src/lib/dtag-search.ts

9
src/components/RelayIcon/index.tsx

@ -2,6 +2,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' @@ -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({ @@ -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)

302
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -2,7 +2,7 @@ import NoteCard from '@/components/NoteCard' @@ -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' @@ -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( @@ -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, 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 ?? 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<string, TRelayInfo | undefined>
anyLoading: boolean
}) {
const { t } = useTranslation()
if (!localRow && relayRows.length === 0) return null
return (
<section
className="rounded-lg border border-border/60 bg-muted/20 text-xs"
aria-label={t('Full-text search sources progress')}
aria-busy={anyLoading}
>
<ul className="divide-y divide-border/50">
{localRow ? (
<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'
)}
>
{formatSourceStatusLabel(
{
phase: localRow.phase,
hitCount: localRow.hitCount,
rawCount: localRow.rawCount,
errorMessage: localRow.errorMessage
},
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>
) : null}
{relayRows.map((row) => (
<li key={row.relayUrl} className="flex min-w-0 items-center gap-2 px-2.5 py-2">
<RelayIcon
url={row.relayUrl}
className="h-4 w-4 shrink-0 rounded-sm"
iconSize={10}
relayInfo={relayInfoByKey[relayKey(row.relayUrl)]}
skipRelayInfoFetch
/>
<span className="min-w-0 truncate font-mono text-foreground" title={row.relayUrl}>
{row.host}
</span>
<span
className={cn(
'ml-auto min-w-0 max-w-[55%] text-right leading-snug',
row.phase === 'error'
? 'text-destructive'
: row.phase === 'loading'
? 'text-muted-foreground'
: (row.eventCount ?? 0) > 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' ? (
<span className="text-muted-foreground"> · {row.ms} ms</span>
) : null}
</span>
{row.phase === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
))}
</ul>
</section>
)
}
/** 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<string>()
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({ @@ -246,8 +405,10 @@ export default function FullTextSearchByRelay({
}
}
const runGeneration = useRef(0)
const [localSearchRow, setLocalSearchRow] = useState<LocalSearchRow | null>(null)
const [relayRows, setRelayRows] = useState<RelayFetchRow[]>([])
const [mergedHits, setMergedHits] = useState<MergedHit[]>([])
const [relayInfoByKey, setRelayInfoByKey] = useState<Record<string, TRelayInfo | undefined>>({})
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
@ -261,11 +422,49 @@ export default function FullTextSearchByRelay({ @@ -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<string>()
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<string, TRelayInfo | undefined> = {}
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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,6 +625,8 @@ export default function FullTextSearchByRelay({ @@ -420,6 +625,8 @@ export default function FullTextSearchByRelay({
}
void (async () => {
const localT0 = performance.now()
try {
const mergedLocal = await collectLocalEventsForTextSearch({
query: q,
allowedKinds: kindsArr,
@ -431,7 +638,14 @@ export default function FullTextSearchByRelay({ @@ -431,7 +638,14 @@ export default function FullTextSearchByRelay({
})
if (myRun !== runGeneration.current || runAbort.signal.aborted) return
const mergedLocalMatching = mergedLocal.filter((e) => mergedSearchNoteHasPreviewBody(e))
if (mergedLocalMatching.length === 0) return
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)
@ -443,6 +657,17 @@ export default function FullTextSearchByRelay({ @@ -443,6 +657,17 @@ export default function FullTextSearchByRelay({
}
})
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -540,18 +776,12 @@ export default function FullTextSearchByRelay({
})}
</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>
)}
<SearchSourceProgressList
localRow={localSearchRow}
relayRows={relayRows}
relayInfoByKey={relayInfoByKey}
anyLoading={anyLoading}
/>
<SearchMergedProfileProvider resetKey={searchProfileResetKey} mergedHits={mergedHits}>
<div className="min-w-0 space-y-2">
@ -592,7 +822,13 @@ export default function FullTextSearchByRelay({ @@ -592,7 +822,13 @@ export default function FullTextSearchByRelay({
navigateToRelay(toRelay(url))
}}
>
<RelayIcon url={url} className="h-5 w-5 rounded-sm" iconSize={12} />
<RelayIcon
url={url}
className="h-5 w-5 rounded-sm"
iconSize={12}
relayInfo={relayInfoByKey[relayKey(url)]}
skipRelayInfoFetch
/>
</button>
))}
{hit.fromLocalArchive && (

7
src/i18n/locales/de.ts

@ -1535,6 +1535,13 @@ export default { @@ -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",

7
src/i18n/locales/en.ts

@ -1936,6 +1936,13 @@ export default { @@ -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",

24
src/lib/dtag-search.test.ts

@ -0,0 +1,24 @@ @@ -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)
})
})

12
src/lib/dtag-search.ts

@ -55,6 +55,18 @@ function dTagMatchRank(needle: string, dVal: string | undefined): number { @@ -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()

Loading…
Cancel
Save