From 92fe1c262db926e9e000911df32732a064389f94 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 19:39:46 +0200 Subject: [PATCH] bug-fixes --- .../SearchResult/FullTextSearchByRelay.tsx | 246 ++++++++++-------- src/constants.ts | 4 - src/i18n/locales/en.ts | 11 +- src/services/client-query.service.ts | 31 ++- src/services/client.service.ts | 9 +- 5 files changed, 179 insertions(+), 122 deletions(-) diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 6ae96c13..7264091a 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -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' 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({ }) { const { t } = useTranslation() const runGeneration = useRef(0) - const [cards, setCards] = useState([]) + const [relayRows, setRelayRows] = useState([]) + const [mergedHits, setMergedHits] = useState([]) 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({ 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({ return normalizedRelays[relayCursor++]! } + const mergeIntoHits = (relayUrl: string, events: Event[]) => { + const rk = relayKey(relayUrl) + setMergedHits((prev) => { + const map = new Map }>() + 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() + 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({ ) 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({ 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({ }) }) - 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({ 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({ 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({ return (

- {t('Full-text search per relay intro', { + {t('Full-text search merged intro', { relayCount: normalizedRelays.length, seconds: timeoutSec, concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY })}

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

+ )} + +
+ {anyLoading && mergedHits.length === 0 && ( +
+ + + +
)} - > - {cards.map((c) => ( - - -
- {c.host} - {c.phase === 'loading' ? ( - - ) : ( - - {c.events.length} - - )} + + {mergedHits.map((hit) => ( + + +
+ + {t('Full-text search seen on label')} + + {hit.relayUrls.map((url) => ( + + + + ))}
- {c.relayUrl} - {c.phase === 'done' && c.ms != null && ( -

- {t('Full-text search relay timing', { ms: c.ms })} -

- )}
- - {c.phase === 'loading' && ( -
- - - -
- )} - {c.phase === 'error' && ( -

- {t('Full-text search relay error')}: {c.errorMessage ?? t('Full-text search relay unknown error')} -

- )} - {c.phase === 'done' && c.events.length === 0 && !c.errorMessage && ( -

{t('Full-text search relay no hits')}

- )} - {c.phase === 'done' && c.events.length === 0 && c.errorMessage && ( -

{c.errorMessage}

- )} - {c.events.length > 0 && ( -
    - {c.events.map((ev) => ( -
  • - -
  • - ))} -
- )} + +
))}
- {allTerminal && ( + {allTerminal && mergedHits.length === 0 && ( +

+ {t('Full-text search empty merged')} +

+ )} + + {allTerminal && mergedHits.length > 0 && (

{t('Full-text search all relays finished')}

diff --git a/src/constants.ts b/src/constants.ts index e142d3f7..1cfde56c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ec3eec6e..ce822c95 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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.", diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index a44fa254..da086afb 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -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 { 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 { 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 other’s REQ before EOSE → empty results until globalTimeout. @@ -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 { handleClose(i, reason) }, alreadyHaveEvent: localAlreadyHaveEvent, - eoseTimeout: 10_000 + eoseTimeout: relaySubscriptionEoseTimeoutMs }) subs.push({ relayKey, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 31500d38..8f237673 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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 {