Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
92fe1c262d
  1. 246
      src/components/SearchResult/FullTextSearchByRelay.tsx
  2. 4
      src/constants.ts
  3. 11
      src/i18n/locales/en.ts
  4. 31
      src/services/client-query.service.ts
  5. 9
      src/services/client.service.ts

246
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -1,10 +1,9 @@ @@ -1,10 +1,9 @@
import NoteCard from '@/components/NoteCard'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import RelayIcon from '@/components/RelayIcon'
import { Card, CardContent, CardHeader } 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'
@ -13,31 +12,48 @@ import { Loader2 } from 'lucide-react' @@ -13,31 +12,48 @@ 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
/** One-shot NIP-50 REQ per relay; bounded wait so the page always reaches a terminal state (see QueryService NIP-50 global floor). */
const FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS = 45_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. */
/** 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
type RelayCardPhase = 'loading' | 'done' | 'error'
type RelayFetchPhase = 'loading' | 'done' | 'error'
type RelayCardModel = {
type RelayFetchRow = {
relayUrl: string
host: string
phase: RelayCardPhase
events: Event[]
phase: RelayFetchPhase
eventCount?: number
ms?: number
errorMessage?: string
}
type MergedHit = {
event: Event
relayUrls: 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))
)
}
/** Console hint: what this one-shot outcome suggests about NIP-50 (never proof without NIP-11). */
function nip50OutcomeHint(args: {
phase: 'done' | 'error'
@ -67,21 +83,28 @@ export default function FullTextSearchByRelay({ @@ -67,21 +83,28 @@ export default function FullTextSearchByRelay({
}) {
const { t } = useTranslation()
const runGeneration = useRef(0)
const [cards, setCards] = useState<RelayCardModel[]>([])
const [relayRows, setRelayRows] = useState<RelayFetchRow[]>([])
const [mergedHits, setMergedHits] = useState<MergedHit[]>([])
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
const q = searchQuery.trim()
const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000)
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(() => {
const myRun = ++runGeneration.current
if (!q || normalizedRelays.length === 0) {
setCards([])
setRelayRows([])
setMergedHits([])
return
}
/** React 18 Strict Mode (dev) mounts twice; bump invalidates the previous run’s workers and ignores stale fetches. */
const cleanupInvalidatePreviousRun = () => {
runGeneration.current += 1
}
@ -94,14 +117,14 @@ export default function FullTextSearchByRelay({ @@ -94,14 +117,14 @@ export default function FullTextSearchByRelay({
const poolSize = Math.min(FULL_TEXT_SEARCH_RELAY_CONCURRENCY, normalizedRelays.length)
setCards(
setRelayRows(
normalizedRelays.map((relayUrl) => ({
relayUrl,
host: relayHostForSubscribeLog(relayUrl),
phase: 'loading',
events: []
phase: 'loading'
}))
)
setMergedHits([])
let relayCursor = 0
const nextRelayUrl = (): string | undefined => {
@ -109,6 +132,35 @@ export default function FullTextSearchByRelay({ @@ -109,6 +132,35 @@ export default function FullTextSearchByRelay({
return normalizedRelays[relayCursor++]!
}
const mergeIntoHits = (relayUrl: string, events: Event[]) => {
const rk = relayKey(relayUrl)
setMergedHits((prev) => {
const map = new Map<string, { event: Event; relays: Set<string> }>()
for (const hit of prev) {
map.set(hit.event.id, { event: hit.event, relays: new Set(hit.relayUrls.map((u) => relayKey(u))) })
}
for (const ev of events) {
const cur = map.get(ev.id)
if (cur) {
cur.relays.add(rk)
} else {
map.set(ev.id, { event: ev, relays: new Set([rk]) })
}
}
const urlByKey = new Map<string, string>()
for (const u of normalizedRelays) {
urlByKey.set(relayKey(u), normalizeUrl(u) || u)
}
return [...map.values()]
.map(({ event, relays }) => ({
event,
relayUrls: sortRelaysByHost([...relays].map((k) => urlByKey.get(k) || k))
}))
.sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event))
.slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS)
})
}
const runOneRelay = async (relayUrl: string) => {
const host = relayHostForSubscribeLog(relayUrl)
logger.debug('[NIP-50 full-text] card_begin', {
@ -128,7 +180,9 @@ export default function FullTextSearchByRelay({ @@ -128,7 +180,9 @@ export default function FullTextSearchByRelay({
)
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 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 })
}
@ -147,22 +201,18 @@ export default function FullTextSearchByRelay({ @@ -147,22 +201,18 @@ export default function FullTextSearchByRelay({
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
setRelayRows((prev) =>
prev.map((r) =>
r.relayUrl === relayUrl
? { ...r, phase: 'error', eventCount: 0, ms, errorMessage: connectionError }
: r
)
)
return
}
mergeIntoHits(relayUrl, sorted)
logger.debug('[NIP-50 full-text] card_end', {
runId: myRun,
relayUrl,
@ -185,17 +235,17 @@ export default function FullTextSearchByRelay({ @@ -185,17 +235,17 @@ export default function FullTextSearchByRelay({
})
})
setCards((prev) =>
prev.map((c) =>
c.relayUrl === relayUrl
setRelayRows((prev) =>
prev.map((r) =>
r.relayUrl === relayUrl
? {
...c,
...r,
phase: 'done',
events: sorted,
eventCount: sorted.length,
ms,
errorMessage: sorted.length > 0 ? undefined : connectionError
}
: c
: r
)
)
} catch (err) {
@ -214,17 +264,9 @@ export default function FullTextSearchByRelay({ @@ -214,17 +264,9 @@ export default function FullTextSearchByRelay({
cardErrorMessage: msg,
nip50Hint: nip50OutcomeHint({ phase: 'error', rawCount: 0 })
})
setCards((prev) =>
prev.map((c) =>
c.relayUrl === relayUrl
? {
...c,
phase: 'error',
events: [],
ms,
errorMessage: msg
}
: c
setRelayRows((prev) =>
prev.map((r) =>
r.relayUrl === relayUrl ? { ...r, phase: 'error', eventCount: 0, ms, errorMessage: msg } : r
)
)
}
@ -250,23 +292,19 @@ export default function FullTextSearchByRelay({ @@ -250,23 +292,19 @@ export default function FullTextSearchByRelay({
try {
await Promise.all(Array.from({ length: poolSize }, () => worker()))
} catch {
/* runOneRelay already updates card errors */
/* runOneRelay already updates relay rows */
}
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'
note: 'matches UI "all relays finished" when every relay row 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
}
@ -274,76 +312,70 @@ export default function FullTextSearchByRelay({ @@ -274,76 +312,70 @@ export default function FullTextSearchByRelay({
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', {
{t('Full-text search merged 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'
{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>
)}
<div className="min-w-0 space-y-4">
{anyLoading && mergedHits.length === 0 && (
<div className="space-y-3" aria-label={t('Full-text search relay querying')}>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-20 w-full" />
</div>
)}
>
{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>
)}
{mergedHits.map((hit) => (
<Card key={hit.event.id} className="min-w-0 overflow-hidden">
<CardHeader className="pb-2 space-y-2 border-b bg-muted/30">
<div
className="flex flex-wrap items-center gap-1.5"
aria-label={t('Full-text search seen on relays')}
>
<span className="text-xs text-muted-foreground shrink-0 mr-1">
{t('Full-text search seen on label')}
</span>
{hit.relayUrls.map((url) => (
<span
key={`${hit.event.id}-${relayKey(url)}`}
title={relayHostForSubscribeLog(url)}
className="inline-flex shrink-0"
>
<RelayIcon url={url} skipRelayInfoFetch className="h-7 w-7 rounded-sm" iconSize={14} />
</span>
))}
</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 className="pt-4">
<NoteCard event={hit.event} className="w-full border-0 shadow-none p-0" filterMutedNotes />
</CardContent>
</Card>
))}
</div>
{allTerminal && (
{allTerminal && mergedHits.length === 0 && (
<p className="text-sm text-muted-foreground" role="status">
{t('Full-text search empty merged')}
</p>
)}
{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>

4
src/constants.ts

@ -435,10 +435,6 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -435,10 +435,6 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://search.nos.today',
'wss://nostr.wine',
'wss://orly-relay.imwald.eu',
'wss://aggr.nostr.land',
'wss://thecitadel.nostr1.com',
'wss://nos.lol',
'wss://nostr.mom',
'wss://relay.noswhere.com',
'wss://relay.wikifreedia.xyz',
'wss://nostr.einundzwanzig.space',

11
src/i18n/locales/en.ts

@ -1851,11 +1851,14 @@ export default { @@ -1851,11 +1851,14 @@ export default {
"Search threads by title, content, tags, npub, author...": "Search threads by title, content, tags, npub, author...",
"Searching all available relays...": "Searching all available relays...",
"Searching…": "Searching…",
"Full-text search per relay intro":
"Each card runs one bounded NIP-50 query on that index relay ({{relayCount}} relays, {{seconds}}s timeout each, up to {{concurrency}} in parallel so the tab stays responsive). This is not a live feed — results do not auto-update.",
"Full-text search merged intro":
"Results are merged by note: each card shows one event and which index relays returned it ({{relayCount}} relays, up to {{seconds}}s per relay, up to {{concurrency}} in parallel). This is not a live feed — results do not auto-update.",
"Full-text search progress relays": "{{done}} / {{total}} index relays",
"Full-text search seen on label": "Seen on",
"Full-text search seen on relays": "Relays that returned this note",
"Full-text search empty merged": "No notes matched this search on any index relay.",
"Full-text search relay errors summary": "{{count}} relay(s) could not be queried.",
"Full-text search relay querying": "Querying relay…",
"Full-text search relay timing": "Finished in {{ms}} ms",
"Full-text search relay no hits": "No hits on this relay.",
"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.",

31
src/services/client-query.service.ts

@ -51,6 +51,18 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { @@ -51,6 +51,18 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
return rest as Filter
}
function filtersHaveNip50Search(filters: readonly Filter[]): boolean {
return filters.some((f) => typeof f.search === 'string' && f.search.trim().length > 0)
}
/** NIP-50 index relays answer after connect + slot wait; nostr-tools synthetic EOSE runs this long after REQ `fire()`. */
const NIP50_RELAY_SUBSCRIPTION_EOSE_TIMEOUT_MS = 38_000
/**
* {@link QueryService.query} `globalTimeout` is armed at call start; REQ may start seconds later. Used only for
* {@link ClientService.fetchEventsFromSingleRelay} so mention/picker queries keep their own shorter caps.
*/
const NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS = 42_000
const HEX_EVENT_ID_RE = /^[0-9a-f]{64}$/i
let queryReqSeq = 0
@ -384,7 +396,13 @@ export class QueryService { @@ -384,7 +396,13 @@ export class QueryService {
const effectiveFilter: Filter | Filter[] =
sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters
const eoseTimeout = options?.eoseTimeout ?? 500
const globalTimeout = options?.globalTimeout ?? 10000
const hasNip50Search = filtersHaveNip50Search(sanitizedFilters)
const globalTimeoutRaw = options?.globalTimeout ?? 10000
const useNip50QueryTimeoutFloor =
hasNip50Search && options?.relayOpSource === 'fetchEventsFromSingleRelay'
const globalTimeout = useNip50QueryTimeoutFloor
? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS)
: globalTimeoutRaw
const replaceableRace = options?.replaceableRace ?? false
const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? FIRST_RELAY_RESULT_GRACE_MS
const immediateReturn = options?.immediateReturn ?? false
@ -746,9 +764,10 @@ export class QueryService { @@ -746,9 +764,10 @@ export class QueryService {
return { url, filters: filtersForRelay }
})
const hasNip50Search = filters.some(
(f) => typeof f.search === 'string' && f.search.trim().length > 0
)
const hasNip50Search = filtersHaveNip50Search(filters)
const relaySubscriptionEoseTimeoutMs = hasNip50Search
? NIP50_RELAY_SUBSCRIPTION_EOSE_TIMEOUT_MS
: 10_000
/**
* Single-relay `pool.close` before subscribe resets the socket. Overlapping NIP-50 one-shots (e.g. Strict Mode
* double effect) then tear down each others REQ before EOSE empty results until globalTimeout.
@ -896,7 +915,7 @@ export class QueryService { @@ -896,7 +915,7 @@ export class QueryService {
handleClose(i, reason2)
},
alreadyHaveEvent: localAlreadyHaveEvent,
eoseTimeout: 10_000
eoseTimeout: relaySubscriptionEoseTimeoutMs
})
subs.push({
relayKey,
@ -928,7 +947,7 @@ export class QueryService { @@ -928,7 +947,7 @@ export class QueryService {
handleClose(i, reason)
},
alreadyHaveEvent: localAlreadyHaveEvent,
eoseTimeout: 10_000
eoseTimeout: relaySubscriptionEoseTimeoutMs
})
subs.push({
relayKey,

9
src/services/client.service.ts

@ -2393,7 +2393,14 @@ class ClientService extends EventTarget { @@ -2393,7 +2393,14 @@ class ClientService extends EventTarget {
return { url, filters: filtersForRelay }
})
if (groupedRequests.length === 1) {
const hasNip50Search = filters.some(
(f) => typeof f.search === 'string' && f.search.trim().length > 0
)
/**
* Same rule as {@link QueryService.subscribe}: never `pool.close` a lone relay when the REQ carries NIP-50
* `search` overlapping one-shots (e.g. Strict Mode) otherwise reset the socket before EOSE.
*/
if (groupedRequests.length === 1 && !hasNip50Search) {
try {
this.pool.close([groupedRequests[0]!.url])
} catch {

Loading…
Cancel
Save