diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 3d49a3d1..fd55fbf2 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -342,7 +342,7 @@ export function useMenuActions({ label:
{t('All available relays')} ({allAvailableRelayUrls.length})
, onClick: async () => { closeDrawer() - const promise = client.publishEvent(allAvailableRelayUrls, event).then((result) => { + const promise = client.publishEvent(allAvailableRelayUrls, event, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('No relay accepted the event')) } @@ -380,7 +380,7 @@ export function useMenuActions({ if (!relays?.length) { throw new Error(t('No relays available')) } - const result = await client.publishEvent(relays, event) + const result = await client.publishEvent(relays, event, { skipOutboxRetry: true }) const minRequired = usedMonitoringList ? 5 : 1 if (result.successCount < minRequired) { throw new Error( @@ -434,7 +434,7 @@ export function useMenuActions({ label:
{set.name}
, onClick: async () => { closeDrawer() - const promise = client.publishEvent(set.relayUrls, event).then((result) => { + const promise = client.publishEvent(set.relayUrls, event, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('No relay accepted the event')) } @@ -466,7 +466,7 @@ export function useMenuActions({ ), onClick: async () => { closeDrawer() - const promise = client.publishEvent([relay], event).then((result) => { + const promise = client.publishEvent([relay], event, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('Relay did not accept the event')) } @@ -612,7 +612,7 @@ export function useMenuActions({ const batch = uniqueEvents.slice(i, i + BATCH_SIZE) const batchResults = await Promise.allSettled( batch.map(async (ev) => { - const result = await client.publishEvent(selectedRelayUrls, ev) + const result = await client.publishEvent(selectedRelayUrls, ev, { skipOutboxRetry: true }) if (result.successCount > 0) { acceptedEvents++ acceptedRelayAcks += result.successCount diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 65659a81..12a59ce2 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -332,7 +332,7 @@ export default function Profile({ const handleRepublishToAllAvailable = async () => { if (!profileEvent) return - const promise = client.publishEvent(allAvailableRelayUrls, profileEvent).then((result) => { + const promise = client.publishEvent(allAvailableRelayUrls, profileEvent, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('No relay accepted the event')) } @@ -356,7 +356,7 @@ export default function Profile({ if (!relays?.length) { throw new Error(t('No relays available')) } - const result = await client.publishEvent(relays, profileEvent) + const result = await client.publishEvent(relays, profileEvent, { skipOutboxRetry: true }) const minRequired = usedMonitoringList ? 5 : 1 if (result.successCount < minRequired) { throw new Error( diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index f93ade95..158286e1 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -121,7 +121,7 @@ export default function ProfileOptions({ toast.error(t('Profile event not available')) return } - const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay).then((result) => { + const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay, { skipOutboxRetry: true }).then((result) => { if (result.successCount < 1) { throw new Error(t('No relay accepted the event')) } @@ -148,7 +148,7 @@ export default function ProfileOptions({ if (!relays?.length) { throw new Error(t('No relays available')) } - const result = await client.publishEvent(relays, kind0ForRelay) + const result = await client.publishEvent(relays, kind0ForRelay, { skipOutboxRetry: true }) const minRequired = usedMonitoringList ? 5 : 1 if (result.successCount < minRequired) { throw new Error( diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 99e58b91..ebd6516c 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -18,9 +18,11 @@ type MergedHit = { relayUrls: string[] } -/** 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. */ +/** Hard cap for the whole merged search wave (from effect start). */ +const SEARCH_TOTAL_WALL_MS = 10_000 +/** After the first relay reaches a terminal state, end the wave this many ms later (capped by {@link SEARCH_TOTAL_WALL_MS}). */ +const SEARCH_AFTER_FIRST_RELAY_MS = 2_000 +/** Avoid opening every index relay at once (pool + main thread). */ const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3 const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 /** Per-relay cap before merge (limits duplicate work). */ @@ -229,7 +231,6 @@ export default function FullTextSearchByRelay({ const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) const q = searchQuery.trim() - const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000) const searchProfileResetKey = useMemo( () => `${q}\n${normalizedRelays.join('\n')}`, [q, normalizedRelays] @@ -243,11 +244,16 @@ export default function FullTextSearchByRelay({ useEffect(() => { const abort = new AbortController() + let masterTimer: ReturnType | null = null const myRun = ++runGeneration.current const cleanupInvalidatePreviousRun = () => { runGeneration.current += 1 } const dispose = () => { + if (masterTimer != null) { + clearTimeout(masterTimer) + masterTimer = null + } abort.abort() cleanupInvalidatePreviousRun() } @@ -275,6 +281,47 @@ export default function FullTextSearchByRelay({ ) setMergedHits([]) + const waveT0 = Date.now() + let waveEndAt = waveT0 + SEARCH_TOTAL_WALL_MS + /** Only after ≥1 event from a relay: apply "first results + 2s" (empty EOSE must not shorten the wave). */ + let appliedRelativeWaveCutoff = false + + const scheduleMasterAbort = () => { + if (masterTimer != null) { + clearTimeout(masterTimer) + masterTimer = null + } + const ms = Math.max(0, waveEndAt - Date.now()) + masterTimer = setTimeout(() => { + masterTimer = null + abort.abort() + }, ms) + } + + const onFirstSearchHits = () => { + if (appliedRelativeWaveCutoff) return + appliedRelativeWaveCutoff = true + const now = Date.now() + waveEndAt = Math.min(waveT0 + SEARCH_TOTAL_WALL_MS, now + SEARCH_AFTER_FIRST_RELAY_MS) + scheduleMasterAbort() + } + + abort.signal.addEventListener( + 'abort', + () => { + setRelayRows((prev) => + prev.map((r) => + r.phase === 'loading' + ? { ...r, phase: 'done' as const, eventCount: 0, ms: undefined, errorMessage: undefined } + : r + ) + ) + }, + { once: true } + ) + + scheduleMasterAbort() + let relayCursor = 0 const nextRelayUrl = (): string | undefined => { if (relayCursor >= normalizedRelays.length) return undefined @@ -311,20 +358,21 @@ export default function FullTextSearchByRelay({ } const runOneRelay = async (relayUrl: string) => { + if (myRun !== runGeneration.current || abort.signal.aborted) return const t0 = performance.now() + const perRelayBudget = Math.max(1000, waveEndAt - Date.now()) try { const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay( relayUrl, filter, - { globalTimeout: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS, signal: abort.signal } + { globalTimeout: perRelayBudget, signal: abort.signal } ) if (myRun !== runGeneration.current) return const sorted = [...raw] .sort((a, b) => compareEventsForDTagQuery(q, a, b)) .slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) - await addSearchEventsToSessionCacheBatched(sorted, runGeneration, myRun) - if (myRun !== runGeneration.current) return + const ms = Math.round(performance.now() - t0) if (sorted.length === 0 && connectionError) { setRelayRows((prev) => @@ -338,7 +386,11 @@ export default function FullTextSearchByRelay({ } mergeIntoHits(relayUrl, sorted) + void addSearchEventsToSessionCacheBatched(sorted, runGeneration, myRun) + if (sorted.length > 0) { + onFirstSearchHits() + } setRelayRows((prev) => prev.map((r) => r.relayUrl === relayUrl @@ -354,6 +406,7 @@ export default function FullTextSearchByRelay({ ) } catch (err) { if (myRun !== runGeneration.current) return + if (abort.signal.aborted) return const msg = err instanceof Error ? err.message : String(err) const ms = Math.round(performance.now() - t0) setRelayRows((prev) => @@ -365,7 +418,7 @@ export default function FullTextSearchByRelay({ } const worker = async () => { - while (myRun === runGeneration.current) { + while (myRun === runGeneration.current && !abort.signal.aborted) { const relayUrl = nextRelayUrl() if (!relayUrl) break await runOneRelay(relayUrl) @@ -392,7 +445,8 @@ export default function FullTextSearchByRelay({

{t('Full-text search merged intro', { relayCount: normalizedRelays.length, - seconds: timeoutSec, + totalSeconds: Math.round(SEARCH_TOTAL_WALL_MS / 1000), + afterFirstSeconds: Math.round(SEARCH_AFTER_FIRST_RELAY_MS / 1000), concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY })}

diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ce822c95..e835742b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1852,7 +1852,7 @@ export default { "Searching all available relays...": "Searching all available relays...", "Searching…": "Searching…", "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.", + "Notes appear as each index relay responds (merged by card; each card shows which relays returned it). The search wave stops at the sooner of {{totalSeconds}}s from start or {{afterFirstSeconds}}s after the first results arrive from any relay (up to {{concurrency}} relays in parallel, {{relayCount}} total). 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", diff --git a/src/lib/pre-publish-relay-cap.ts b/src/lib/pre-publish-relay-cap.ts index 9d44ceef..3eb02b51 100644 --- a/src/lib/pre-publish-relay-cap.ts +++ b/src/lib/pre-publish-relay-cap.ts @@ -18,8 +18,8 @@ export type TPrePublishRelayCapPreview = { } /** - * Pre-publish preview: mirrors merge + cap order in {@link ClientService.publishEvent}: NIP-65 write list first, then - * relays checked in the post relay picker, capped at {@link MAX_PUBLISH_RELAYS}. + * Pre-publish preview: mirrors {@link ClientService.publishEvent} when the user checked relays in the picker — + * only those URLs (deduped, capped), not a second merge of the full NIP-65 outbox on top. */ export function computePrePublishRelayCapPreview({ relayListWrite, @@ -60,10 +60,14 @@ export function computePrePublishRelayCapPreview({ }) ) - const merged = dedupeNormalizeRelayUrlsOrdered([...outbox, ...selectedRelayUrls]) + const merged = + selectedRelayUrls.length > 0 + ? dedupeNormalizeRelayUrlsOrdered(selectedRelayUrls) + : dedupeNormalizeRelayUrlsOrdered([...outbox]) const capped = merged.slice(0, MAX_PUBLISH_RELAYS) const outboxNormSet = new Set(outbox) - const outboxSlotsInPublish = capped.filter((u) => outboxNormSet.has(u)).length + const outboxSlotsInPublish = + selectedRelayUrls.length > 0 ? 0 : capped.filter((u) => outboxNormSet.has(u)).length const selectedNorm = selectedRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u) const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 594e4299..5ac7d72a 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1550,7 +1550,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) }) logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) }) - const publishResult = await client.publishEvent(relays, event, { favoriteRelayUrls }) + const publishResult = await client.publishEvent(relays, event, { + favoriteRelayUrls, + /** Picker / `specifiedRelayUrls` is the authoritative target list — do not prepend full NIP-65 outbox again. */ + skipOutboxRetry: (options.specifiedRelayUrls?.length ?? 0) > 0 + }) logger.debug('[Publish] publishEvent completed', { success: publishResult.success, successCount: publishResult.successCount, diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 14deb2aa..9d9c2d25 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -406,16 +406,27 @@ export class QueryService { const effectiveFilter: Filter | Filter[] = sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters const hasNip50Search = filtersHaveNip50Search(sanitizedFilters) - const useNip50QueryTimeoutFloor = + const useNip50FetchPath = hasNip50Search && options?.relayOpSource === 'fetchEventsFromSingleRelay' - /** After all relays EOSE, wait this long before closing so slow `EVENT` tails are not cut off (NIP-50 is heavy). */ - const eoseTimeout = useNip50QueryTimeoutFloor - ? Math.max(options?.eoseTimeout ?? 500, 3_000) - : options?.eoseTimeout ?? 500 const globalTimeoutRaw = options?.globalTimeout ?? 10000 - const globalTimeout = useNip50QueryTimeoutFloor - ? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS) - : globalTimeoutRaw + /** + * Callers that pass a budget **below** {@link NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS} (e.g. merged search UI) + * intend a short cap — honor it. Larger budgets still get the floor so one-shot fetches are not cut off + * before index relays finish (default {@link ClientService.fetchEventsFromSingleRelay} uses 25s). + */ + const globalTimeout = + useNip50FetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + ? globalTimeoutRaw + : useNip50FetchPath + ? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS) + : globalTimeoutRaw + /** After all relays EOSE, brief settle; shorter when the caller uses a short NIP-50 global budget. */ + const eoseTimeout = + useNip50FetchPath && globalTimeoutRaw < NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + ? Math.max(options?.eoseTimeout ?? 500, Math.min(2_000, globalTimeout)) + : useNip50FetchPath + ? Math.max(options?.eoseTimeout ?? 500, 3_000) + : options?.eoseTimeout ?? 500 const replaceableRace = options?.replaceableRace ?? false const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? FIRST_RELAY_RESULT_GRACE_MS const immediateReturn = options?.immediateReturn ?? false diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 5ed2ad32..2bb10224 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -201,7 +201,7 @@ export type TPublishOptions = { /** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */ export type TPublishEventExtras = { favoriteRelayUrls?: string[] - /** When true (internal): only publish to the given URLs; do not merge outboxes or schedule outbox retry. */ + /** When true: publish only to the given URLs (no NIP-65 outbox prepend, no outbox retry wave). Use when the list is already authoritative (relay picker, relay set, monitoring list, …). */ skipOutboxRetry?: boolean /** Shown in relay batch logs and an info line (e.g. "NIP-65 outbox retry — 2nd attempt"). */ publishBatchLabel?: string