From c6d81c485afd118a919660f9fc6b82d896367c7b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 09:54:35 +0200 Subject: [PATCH] restore relay strike system, fix search --- src/components/NoteList/index.tsx | 17 +++- src/components/SearchResult/index.tsx | 93 +++++++++++++------- src/i18n/locales/de.ts | 6 +- src/i18n/locales/en.ts | 4 +- src/lib/event.ts | 17 +++- src/lib/relay-list-builder.ts | 5 +- src/pages/secondary/NoteListPage/index.tsx | 13 +-- src/pages/secondary/NotePage/index.tsx | 5 +- src/services/client-events.service.ts | 22 +++-- src/services/client.service.ts | 5 +- src/services/indexed-db.service.ts | 44 ++++++--- src/services/mention-event-search.service.ts | 58 ++++++++++-- 12 files changed, 202 insertions(+), 87 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 65151d55..63bd515c 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -3483,8 +3483,6 @@ const NoteList = forwardRef( if (!timelinePublicReadFallback) return if (oneShotFetch || areAlgoRelays) return if (!navigator.onLine) return - const warm = progressiveWarmupQuery?.trim() - if (warm) return if (feedFullSearchEvents !== null) return if (feedSubscribeRelayOutcomes.length === 0) return if (publicReadFallbackAttemptedRef.current) return @@ -3492,11 +3490,22 @@ const NoteList = forwardRef( const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes) if (uiStatuses.some((s) => s.success)) return - publicReadFallbackAttemptedRef.current = true - const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) if (!mapped.length) return + // Skip fallback for d-tag / layered warmup feeds where the live REQ has no NIP-50 `search` + // (merging unfiltered FAST_READ would flood the list). Nostr text search passes `search` on + // the same filter as {@link progressiveWarmupQuery} — allow fallback there. + const warm = progressiveWarmupQuery?.trim() + if (warm) { + const primaryFilter = mapped[0]!.filter as Filter + const hasNip50Search = + typeof primaryFilter.search === 'string' && primaryFilter.search.trim().length > 0 + if (!hasNip50Search) return + } + + publicReadFallbackAttemptedRef.current = true + const filter: Filter = { ...(mapped[0]!.filter as Filter) } if (!filter.kinds?.length) { filter.kinds = effectiveShowKinds.length > 0 ? [...effectiveShowKinds] : [kinds.ShortTextNote] diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index 0f1fc75e..a179f40b 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -15,36 +15,61 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { normalizeUrl } from '@/lib/url' import { useMemo } from 'react' +function relayDedupeKey(url: string): string { + return (normalizeUrl(url) || url.trim()).toLowerCase() +} + export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) { const { pubkey, relayList } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() - - // Build comprehensive relay list for search (all available relays) - const searchRelays = useMemo(() => { + + /** NIP-50 / index relays — always queried first on their own shard so dead personal relays cannot zero out search. */ + const searchableUrls = useMemo( + () => + Array.from( + new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean)) + ), + [] + ) + + const searchableKeySet = useMemo( + () => new Set(searchableUrls.map(relayDedupeKey)), + [searchableUrls] + ) + + // User stack + defaults (full list for second subRequest; excludes searchable URLs to avoid duplicate sockets) + const combinedRelays = useMemo(() => { let relays: string[] = [] - - // User's relays + if (relayList) { relays.push(...(relayList.read || []), ...(relayList.write || [])) } - - // User's favorite relays + relays.push(...(favoriteRelays || [])) - - // All default relays + relays.push(...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS, ...SEARCHABLE_RELAY_URLS) - - // Normalize and deduplicate - const normalized = Array.from(new Set( - relays.map(url => normalizeUrl(url) || url).filter((url): url is string => !!url) - )) - - // Filter blocked - return normalized.filter(relay => - !blockedRelays.some(blocked => relay.includes(blocked)) + + const normalized = Array.from( + new Set(relays.map((url) => normalizeUrl(url) || url).filter((url): url is string => !!url)) ) + + const blockedSet = new Set( + (blockedRelays ?? []) + .map((b) => normalizeUrl(b) || b.trim()) + .filter((b): b is string => !!b) + ) + + return normalized.filter((relay) => { + const n = normalizeUrl(relay) || relay + return !blockedSet.has(n) + }) }, [pubkey, relayList, favoriteRelays, blockedRelays]) - + + const nonSearchableRelays = useMemo( + () => combinedRelays.filter((u) => !searchableKeySet.has(relayDedupeKey(u))), + [combinedRelays, searchableKeySet] + ) + if (!searchParams) { return null } @@ -55,20 +80,21 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa return } if (searchParams.type === 'notes') { + const notesFilter = { + search: searchParams.search, + kinds: [...NIP_SEARCH_PAGE_KINDS], + limit: 100 + } + const subRequests = [ + { urls: searchableUrls, filter: notesFilter }, + ...(nonSearchableRelays.length > 0 ? [{ urls: nonSearchableRelays, filter: notesFilter }] : []) + ] return ( compareEventsForDTagQuery(searchParams.search, a, b)} @@ -76,10 +102,13 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa ) } if (searchParams.type === 'hashtag') { + const hashtagFilter = { '#t': [searchParams.search] } + const subRequests = [ + { urls: searchableUrls, filter: hashtagFilter }, + ...(nonSearchableRelays.length > 0 ? [{ urls: nonSearchableRelays, filter: hashtagFilter }] : []) + ] return ( - + ) } return diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index dd3c43ab..b7ab8e5e 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -603,9 +603,9 @@ export default { relayType_contextual: "Antwort/PN", relayType_randomly_selected: "Zufällig (optional)", "Session relays": "Session-Relays", - "Session relays tab description": "Relay-Logik für diese Session: funktionierende und gestrichene Preset-Relays sowie bewertete Zufallsrelays. Gestrichene Relays werden für Lesen und Schreiben bis zum Neuladen der App übersprungen.", - "Session relays preset working": "Funktionierende Preset-Relays", - "Session relays preset working hint": "Preset-Relays aus den App-Standards.", + "Session relays tab description": "Relay-Logik für diese Session: eingebaute Preset-Relays sowie Zufallsrelays, die in dieser Session mindestens ein Publish angenommen haben; letztere werden beim Auswählen zufälliger Veröffentlichungsrelays bevorzugt.", + "Session relays preset working": "Eingebaute Preset-Relays", + "Session relays preset working hint": "URLs aus den fest eingebauten Relay-Listen der App (schnell lesen/schreiben, durchsuchbar, Starter-Favoriten) — keine Live-Gesundheitsprüfung und kein „Strich“-Sperren mehr.", "Session relays scored random": "Bewertete Zufallsrelays", "Session relays scored random hint": "Relays, die in dieser Session mindestens ein Publish angenommen haben; werden beim Auswählen von Zufallsrelays bevorzugt. Sortiert nach durchschnittlicher Latenz.", successes: "Erfolge", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2f12066e..f7bd58f3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -608,8 +608,8 @@ export default { relayType_randomly_selected: "Random (optional)", "Session relays": "Session relays", "Session relays tab description": "Relay logic for this session: preset relays and scored random relays used to prefer faster, proven relays when adding random relays to publish.", - "Session relays preset working": "Working preset relays", - "Session relays preset working hint": "Preset relays from app defaults.", + "Session relays preset working": "Built-in preset relays", + "Session relays preset working hint": "URLs from the app’s built-in relay lists (fast read/write, searchable, starter favorites). Shown for reference — not a live health check.", "Session relays scored random": "Scored random relays", "Session relays scored random hint": "Relays that have accepted at least one publish this session; used to prefer faster relays when picking random relays. Sorted by average latency.", successes: "successes", diff --git a/src/lib/event.ts b/src/lib/event.ts index 480f322d..099a0c27 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -2,6 +2,7 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { muteSetHas } from '@/lib/mute-set' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { cleanUrl, normalizeUrl } from '@/lib/url' +import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import client from '@/services/client.service' import { TImetaInfo } from '@/types' import { LRUCache } from 'lru-cache' @@ -622,25 +623,33 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): { */ export function relayHintWssUrlsFromEvent(event: Event | undefined): string[] { if (!event) return [] - const hints: string[] = [] + const fromTags: string[] = [] for (const tag of event.tags) { if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { const hint = tag[2] - if (hint.startsWith('wss://') || hint.startsWith('ws://')) hints.push(hint) + if (hint.startsWith('wss://') || hint.startsWith('ws://')) { + const n = normalizeUrl(hint) || hint + if (urlIsNonLocalForRemoteViewer(n)) fromTags.push(hint) + } } } const relaysTag = event.tags.find((t) => t[0] === 'relays') if (relaysTag) { for (let i = 1; i < relaysTag.length; i++) { const u = relaysTag[i] - if (typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://'))) hints.push(u) + if (typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://'))) { + const n = normalizeUrl(u) || u + if (urlIsNonLocalForRemoteViewer(n)) fromTags.push(u) + } } } + const seen: string[] = [] try { - hints.push(...client.getSeenEventRelayUrls(event.id)) + seen.push(...client.getSeenEventRelayUrls(event.id)) } catch { /* ignore */ } + const hints = [...fromTags, ...seen] const normalized = hints .map((u) => normalizeUrl(u)) .filter((u): u is string => Boolean(u)) diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 5a59f659..c6eba86b 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -12,6 +12,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { getCacheRelayUrls } from './private-relays' import client from '@/services/client.service' @@ -283,13 +284,13 @@ export async function buildExploreProfileAndUserRelayList( } } -/** NIP-10 relay hints from `e` / `E` tags (third value) on the focused event or thread. */ +/** NIP-10 relay hints from `e` / `E` tags (third value) on the focused event or thread. Omits loopback/LAN — those are only meaningful on the tag author's machine. */ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { const out = new Set() for (const tag of event.tags) { if ((tag[0] === 'e' || tag[0] === 'E') && tag[2]) { const n = normalizeUrl(tag[2]) || tag[2] - if (n) out.add(n) + if (n && urlIsNonLocalForRemoteViewer(n)) out.add(n) } } return [...out] diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index f698e6a4..5754c604 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -25,6 +25,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useInterestListOptional } from '@/providers/interest-list-context' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' +import { normalizeUrl } from '@/lib/url' import { UserRound, Plus } from 'lucide-react' import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -238,7 +239,7 @@ const NoteListPage = forwardRef(({ index, hid setSubRequests([]) } } else { - // D-tag browse: NIP-50 search + exact #d REQ (merged), substring match client-side, exact d-tag sorted first + // D-tag browse: exact `#d` REQ on index + user relays (no NIP-50 full-text — that is not the same as a d-tag pick). setTitle(`D-Tag: ${domain}`) setData({ type: 'dtag', @@ -255,17 +256,11 @@ const NoteListPage = forwardRef(({ index, hid new Set([...NIP_SEARCH_DOCUMENT_KINDS, ...(kinds.length > 0 ? kinds : [])]) ).sort((a, b) => a - b) const kindFilter = { kinds: mergedReqKinds } - // NIP-50 full-text search works better with natural-language spacing; - // convert the hyphenated slug back to a space-separated query for the search relay. - const searchQuery = domain.replace(/-/g, ' ') + const dUrls = [...new Set([...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean), ...relayUrls])] setSubRequests([ - { - filter: { search: searchQuery, ...kindFilter }, - urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])] - }, { filter: { '#d': [domain], ...kindFilter }, - urls: relayUrls + urls: dUrls } ]) } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index d73d1cb1..9877b023 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -510,8 +510,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
{rootITag && } {rootEventId && - !eventPointersReferenceSameNote(rootEventId, parentEventId) && - (isFetchingRootEvent || rootEventForStrip) && ( + !eventPointersReferenceSameNote(rootEventId, parentEventId) && ( )} - {parentEventId && (isFetchingParentEvent || parentEventForStrip) && ( + {parentEventId && ( >() @@ -650,32 +653,35 @@ export class EventService { } /** - * Get events from session cache matching search + * Get events from session cache matching search (newest {@link Event.created_at} first). + * Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries so LRU insertion order does not hide recent matches. */ getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] { - const results: NEvent[] = [] const queryTrim = query.trim() const queryLower = queryTrim.toLowerCase() + const kindSet = allowedKinds && allowedKinds.length > 0 ? new Set(allowedKinds) : null + const buf: NEvent[] = [] + let scanned = 0 for (const [, event] of this.sessionEventCache.entries()) { + if (++scanned > SESSION_SEARCH_MAX_SCAN) break if (shouldDropEventOnIngest(event)) continue - if (allowedKinds && !allowedKinds.includes(event.kind)) continue + if (kindSet && !kindSet.has(event.kind)) continue if (queryTrim === '') { - results.push(event) - if (results.length >= limit) break + buf.push(event) continue } const content = (event.content ?? '').toLowerCase() const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase() if (content.includes(queryLower) || tagsStr.includes(queryLower)) { - results.push(event) - if (results.length >= limit) break + buf.push(event) } } - return results + buf.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + return buf.slice(0, limit) } getSessionEventsMatchingFilters(filters: readonly Filter[], limit: number): NEvent[] { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9e4026c2..23205a3e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3899,7 +3899,10 @@ class ClientService extends EventTarget { }) } - return mergeKind10243(relayList) + const merged = mergeKind10243(relayList) + // Kind 10243 can still carry another user's loopback index (e.g. http://localhost:8080). NIP-65 `r` rows + // were stripped above; strip again after HTTP merge for other users' bundles only (viewer keeps 10432/LAN). + return isOwnRelayList ? merged : stripLocalNetworkRelaysFromRelayList(merged) }) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index cebff045..29da5a3d 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1412,31 +1412,45 @@ class IndexedDbService { /** * Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags - * match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first. + * match the query (case-insensitive). Scans up to `scanBudget` rows and keeps up to `collectCap` matches, + * then returns the newest `limit` by {@link Event.created_at} (cursor order alone is not recency). */ - async getCachedEventsForSearch(query: string, limit: number, allowedKinds: number[]): Promise { + async getCachedEventsForSearch( + query: string, + limit: number, + allowedKinds: number[], + options?: { scanBudget?: number; collectCap?: number } + ): Promise { await this.initPromise if (!this.db || !this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { return [] } const q = query.trim().toLowerCase() - if (!q || allowedKinds.length === 0) return [] + if (!q || allowedKinds.length === 0 || limit <= 0) return [] const kindSet = new Set(allowedKinds) + const scanBudget = Math.min(Math.max(options?.scanBudget ?? 28_000, 400), 120_000) + const collectCap = Math.min( + Math.max(options?.collectCap ?? Math.max(limit * 8, limit + 200, 200), limit), + 12_000 + ) return new Promise((resolve, reject) => { const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) const request = store.openCursor() const results: Event[] = [] + let scanned = 0 request.onsuccess = () => { const cursor = (request as IDBRequest).result - if (!cursor || results.length >= limit) { + if (!cursor || scanned >= scanBudget || results.length >= collectCap) { transaction.commit() - resolve(results) + results.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + resolve(results.slice(0, limit)) return } + scanned += 1 const item = cursor.value as TValue | undefined if (item?.value) { const event = item.value as Event @@ -1575,11 +1589,17 @@ class IndexedDbService { allowedKinds: number[], options?: { archiveScanMaxMs?: number } ): Promise { - const fromPub = await this.getCachedEventsForSearch(query, limit, allowedKinds) - if (fromPub.length >= limit) return fromPub.slice(0, limit) + const pubCap = Math.min(900, Math.max(limit * 6, limit + 280, 220)) + const fromPub = await this.getCachedEventsForSearch(query, pubCap, allowedKinds, { + scanBudget: 70_000, + collectCap: Math.min(10_000, pubCap * 12) + }) + if (fromPub.length >= pubCap) { + return fromPub.slice(0, limit) + } const q = query.trim().toLowerCase() - if (!q || allowedKinds.length === 0) return fromPub + if (!q || allowedKinds.length === 0) return fromPub.slice(0, limit) const kindSet = new Set(allowedKinds) const seen = new Set(fromPub.map((e) => e.id)) @@ -1589,7 +1609,7 @@ class IndexedDbService { await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) { - return fromPub + return fromPub.slice(0, limit) } await new Promise((resolve, reject) => { @@ -1607,7 +1627,7 @@ class IndexedDbService { return } const cursor = (request as IDBRequest).result - if (!cursor || fromPub.length + rest.length >= limit) { + if (!cursor || fromPub.length + rest.length >= pubCap) { transaction.commit() resolve() return @@ -1633,7 +1653,9 @@ class IndexedDbService { logger.warn('[indexedDb] getCachedAndArchivedEventsMatchingLocalSearch archive scan failed', { e }) }) - return [...fromPub, ...rest].slice(0, limit) + const merged = [...fromPub, ...rest] + merged.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + return merged.slice(0, limit) } /** diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts index 9e68c320..d82b814a 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -9,6 +9,7 @@ import { tryParseCitationEventIdFromQuery } from '@/lib/citation-picker-search' import { ExtendedKind, NIP71_VIDEO_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { normalizeUrl } from '@/lib/url' import { kinds, type Event as NEvent } from 'nostr-tools' import client, { eventService, queryService } from './client.service' import indexedDb from './indexed-db.service' @@ -138,8 +139,13 @@ async function searchCitationEventsForPickerInternal( return out.slice(0, limit) } +/** Local DB + session budget for picker search (before relay NIP-50). */ +const PICKER_LOCAL_DB_MERGE_CAP = 880 +const PICKER_FULLTEXT_DB_CAP = 260 + /** - * Search for events: session cache → IndexedDB → relays. Merges and dedupes by event id, up to limit. + * Search for events: session cache → IndexedDB (publication + archive + cross-store full text) → relays. + * Merges and dedupes by event id, up to limit. * @param mode - 'nevent' uses NEVENT_KINDS (incl. NIP-71 video 21/22/34235/34236), 'naddr' uses NADDR_KINDS (30023,30817,30818,30040). * @param kindFilter - When set, only these kinds are searched (overrides `mode` for the kinds list). */ @@ -168,22 +174,58 @@ export async function searchEventsForPicker( out.push(evt) } - const fromSession = eventService.getSessionEventsMatchingSearch(q, limit, kindsList) + const sessionCap = Math.min(1500, Math.max(limit * 8, 200)) + const fromSession = eventService.getSessionEventsMatchingSearch(q, sessionCap, kindsList) fromSession.forEach(addUnique) if (out.length >= limit) return out.slice(0, limit) + const localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240)) + + const [fromLocalDb, userCentricRelayUrls] = await Promise.all([ + indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, localMergeTarget, kindsList, { + archiveScanMaxMs: 24_000 + }), + buildCitationPickerSearchRelayUrls() + ]) + fromLocalDb.forEach(addUnique) + + try { + const fullTextHits = await indexedDb.searchAllCachedEventsFullText(q, { + limit: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200)) + }) + const kindSet = new Set(kindsList) + for (const hit of fullTextHits) { + const ev = hit.value + if (ev && kindSet.has(ev.kind)) addUnique(ev as NEvent) + if (out.length >= limit) break + } + } catch { + /* best-effort: other stores optional */ + } + + if (out.length >= limit) return out.slice(0, limit) + const need = limit - out.length - const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls() - const [fromIdb, fromRelays] = await Promise.all([ - indexedDb.getCachedEventsForSearch(q, need, kindsList), + const searchableNip50Layer = Array.from( + new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean)) + ).slice(0, 28) + + const [fromUserCentric, fromSearchableIndex] = await Promise.all([ queryService.fetchEvents( userCentricRelayUrls, { kinds: kindsList, search: q, limit: need }, { eoseTimeout: 5000, globalTimeout: 8000 } - ) + ), + searchableNip50Layer.length > 0 + ? queryService.fetchEvents( + searchableNip50Layer, + { kinds: kindsList, search: q, limit: need }, + { eoseTimeout: 6500, globalTimeout: 12_000 } + ) + : Promise.resolve([] as NEvent[]) ]) - fromIdb.forEach(addUnique) - fromRelays.forEach(addUnique) + fromUserCentric.forEach(addUnique) + fromSearchableIndex.forEach(addUnique) return out.slice(0, limit) }