Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
889f5d20fe
  1. 10
      src/components/NoteOptions/useMenuActions.tsx
  2. 4
      src/components/Profile/index.tsx
  3. 4
      src/components/ProfileOptions/index.tsx
  4. 72
      src/components/SearchResult/FullTextSearchByRelay.tsx
  5. 2
      src/i18n/locales/en.ts
  6. 12
      src/lib/pre-publish-relay-cap.ts
  7. 6
      src/providers/NostrProvider/index.tsx
  8. 23
      src/services/client-query.service.ts
  9. 2
      src/types/index.d.ts

10
src/components/NoteOptions/useMenuActions.tsx

@ -342,7 +342,7 @@ export function useMenuActions({
label: <div className="text-left">{t('All available relays')} ({allAvailableRelayUrls.length})</div>, label: <div className="text-left">{t('All available relays')} ({allAvailableRelayUrls.length})</div>,
onClick: async () => { onClick: async () => {
closeDrawer() closeDrawer()
const promise = client.publishEvent(allAvailableRelayUrls, event).then((result) => { const promise = client.publishEvent(allAvailableRelayUrls, event, { skipOutboxRetry: true }).then((result) => {
if (result.successCount < 1) { if (result.successCount < 1) {
throw new Error(t('No relay accepted the event')) throw new Error(t('No relay accepted the event'))
} }
@ -380,7 +380,7 @@ export function useMenuActions({
if (!relays?.length) { if (!relays?.length) {
throw new Error(t('No relays available')) 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 const minRequired = usedMonitoringList ? 5 : 1
if (result.successCount < minRequired) { if (result.successCount < minRequired) {
throw new Error( throw new Error(
@ -434,7 +434,7 @@ export function useMenuActions({
label: <div className="text-left truncate">{set.name}</div>, label: <div className="text-left truncate">{set.name}</div>,
onClick: async () => { onClick: async () => {
closeDrawer() 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) { if (result.successCount < 1) {
throw new Error(t('No relay accepted the event')) throw new Error(t('No relay accepted the event'))
} }
@ -466,7 +466,7 @@ export function useMenuActions({
), ),
onClick: async () => { onClick: async () => {
closeDrawer() closeDrawer()
const promise = client.publishEvent([relay], event).then((result) => { const promise = client.publishEvent([relay], event, { skipOutboxRetry: true }).then((result) => {
if (result.successCount < 1) { if (result.successCount < 1) {
throw new Error(t('Relay did not accept the event')) 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 batch = uniqueEvents.slice(i, i + BATCH_SIZE)
const batchResults = await Promise.allSettled( const batchResults = await Promise.allSettled(
batch.map(async (ev) => { batch.map(async (ev) => {
const result = await client.publishEvent(selectedRelayUrls, ev) const result = await client.publishEvent(selectedRelayUrls, ev, { skipOutboxRetry: true })
if (result.successCount > 0) { if (result.successCount > 0) {
acceptedEvents++ acceptedEvents++
acceptedRelayAcks += result.successCount acceptedRelayAcks += result.successCount

4
src/components/Profile/index.tsx

@ -332,7 +332,7 @@ export default function Profile({
const handleRepublishToAllAvailable = async () => { const handleRepublishToAllAvailable = async () => {
if (!profileEvent) return 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) { if (result.successCount < 1) {
throw new Error(t('No relay accepted the event')) throw new Error(t('No relay accepted the event'))
} }
@ -356,7 +356,7 @@ export default function Profile({
if (!relays?.length) { if (!relays?.length) {
throw new Error(t('No relays available')) 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 const minRequired = usedMonitoringList ? 5 : 1
if (result.successCount < minRequired) { if (result.successCount < minRequired) {
throw new Error( throw new Error(

4
src/components/ProfileOptions/index.tsx

@ -121,7 +121,7 @@ export default function ProfileOptions({
toast.error(t('Profile event not available')) toast.error(t('Profile event not available'))
return return
} }
const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay).then((result) => { const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay, { skipOutboxRetry: true }).then((result) => {
if (result.successCount < 1) { if (result.successCount < 1) {
throw new Error(t('No relay accepted the event')) throw new Error(t('No relay accepted the event'))
} }
@ -148,7 +148,7 @@ export default function ProfileOptions({
if (!relays?.length) { if (!relays?.length) {
throw new Error(t('No relays available')) 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 const minRequired = usedMonitoringList ? 5 : 1
if (result.successCount < minRequired) { if (result.successCount < minRequired) {
throw new Error( throw new Error(

72
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -18,9 +18,11 @@ type MergedHit = {
relayUrls: string[] 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). */ /** Hard cap for the whole merged search wave (from effect start). */
const FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS = 45_000 const SEARCH_TOTAL_WALL_MS = 10_000
/** Avoid opening every index relay at once (pool + main thread); still completes all relays. */ /** 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_RELAY_CONCURRENCY = 3
const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80
/** Per-relay cap before merge (limits duplicate work). */ /** Per-relay cap before merge (limits duplicate work). */
@ -229,7 +231,6 @@ export default function FullTextSearchByRelay({
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
const q = searchQuery.trim() const q = searchQuery.trim()
const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000)
const searchProfileResetKey = useMemo( const searchProfileResetKey = useMemo(
() => `${q}\n${normalizedRelays.join('\n')}`, () => `${q}\n${normalizedRelays.join('\n')}`,
[q, normalizedRelays] [q, normalizedRelays]
@ -243,11 +244,16 @@ export default function FullTextSearchByRelay({
useEffect(() => { useEffect(() => {
const abort = new AbortController() const abort = new AbortController()
let masterTimer: ReturnType<typeof setTimeout> | null = null
const myRun = ++runGeneration.current const myRun = ++runGeneration.current
const cleanupInvalidatePreviousRun = () => { const cleanupInvalidatePreviousRun = () => {
runGeneration.current += 1 runGeneration.current += 1
} }
const dispose = () => { const dispose = () => {
if (masterTimer != null) {
clearTimeout(masterTimer)
masterTimer = null
}
abort.abort() abort.abort()
cleanupInvalidatePreviousRun() cleanupInvalidatePreviousRun()
} }
@ -275,6 +281,47 @@ export default function FullTextSearchByRelay({
) )
setMergedHits([]) 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 let relayCursor = 0
const nextRelayUrl = (): string | undefined => { const nextRelayUrl = (): string | undefined => {
if (relayCursor >= normalizedRelays.length) return undefined if (relayCursor >= normalizedRelays.length) return undefined
@ -311,20 +358,21 @@ export default function FullTextSearchByRelay({
} }
const runOneRelay = async (relayUrl: string) => { const runOneRelay = async (relayUrl: string) => {
if (myRun !== runGeneration.current || abort.signal.aborted) return
const t0 = performance.now() const t0 = performance.now()
const perRelayBudget = Math.max(1000, waveEndAt - Date.now())
try { try {
const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay( const { events: raw, connectionError } = await client.fetchEventsFromSingleRelay(
relayUrl, relayUrl,
filter, filter,
{ globalTimeout: FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS, signal: abort.signal } { globalTimeout: perRelayBudget, signal: abort.signal }
) )
if (myRun !== runGeneration.current) return if (myRun !== runGeneration.current) return
const sorted = [...raw] const sorted = [...raw]
.sort((a, b) => compareEventsForDTagQuery(q, a, b)) .sort((a, b) => compareEventsForDTagQuery(q, a, b))
.slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) .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) const ms = Math.round(performance.now() - t0)
if (sorted.length === 0 && connectionError) { if (sorted.length === 0 && connectionError) {
setRelayRows((prev) => setRelayRows((prev) =>
@ -338,7 +386,11 @@ export default function FullTextSearchByRelay({
} }
mergeIntoHits(relayUrl, sorted) mergeIntoHits(relayUrl, sorted)
void addSearchEventsToSessionCacheBatched(sorted, runGeneration, myRun)
if (sorted.length > 0) {
onFirstSearchHits()
}
setRelayRows((prev) => setRelayRows((prev) =>
prev.map((r) => prev.map((r) =>
r.relayUrl === relayUrl r.relayUrl === relayUrl
@ -354,6 +406,7 @@ export default function FullTextSearchByRelay({
) )
} catch (err) { } catch (err) {
if (myRun !== runGeneration.current) return if (myRun !== runGeneration.current) return
if (abort.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
const ms = Math.round(performance.now() - t0) const ms = Math.round(performance.now() - t0)
setRelayRows((prev) => setRelayRows((prev) =>
@ -365,7 +418,7 @@ export default function FullTextSearchByRelay({
} }
const worker = async () => { const worker = async () => {
while (myRun === runGeneration.current) { while (myRun === runGeneration.current && !abort.signal.aborted) {
const relayUrl = nextRelayUrl() const relayUrl = nextRelayUrl()
if (!relayUrl) break if (!relayUrl) break
await runOneRelay(relayUrl) await runOneRelay(relayUrl)
@ -392,7 +445,8 @@ export default function FullTextSearchByRelay({
<p className="text-sm text-muted-foreground leading-snug"> <p className="text-sm text-muted-foreground leading-snug">
{t('Full-text search merged intro', { {t('Full-text search merged intro', {
relayCount: normalizedRelays.length, 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 concurrency: FULL_TEXT_SEARCH_RELAY_CONCURRENCY
})} })}
</p> </p>

2
src/i18n/locales/en.ts

@ -1852,7 +1852,7 @@ export default {
"Searching all available relays...": "Searching all available relays...", "Searching all available relays...": "Searching all available relays...",
"Searching…": "Searching…", "Searching…": "Searching…",
"Full-text search merged intro": "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 progress relays": "{{done}} / {{total}} index relays",
"Full-text search seen on label": "Seen on", "Full-text search seen on label": "Seen on",
"Full-text search seen on relays": "Relays that returned this note", "Full-text search seen on relays": "Relays that returned this note",

12
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 * Pre-publish preview: mirrors {@link ClientService.publishEvent} when the user checked relays in the picker
* relays checked in the post relay picker, capped at {@link MAX_PUBLISH_RELAYS}. * only those URLs (deduped, capped), not a second merge of the full NIP-65 outbox on top.
*/ */
export function computePrePublishRelayCapPreview({ export function computePrePublishRelayCapPreview({
relayListWrite, 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 capped = merged.slice(0, MAX_PUBLISH_RELAYS)
const outboxNormSet = new Set(outbox) 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 selectedNorm = selectedRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u)
const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length const selectedContacted = selectedNorm.filter((u) => capped.includes(u)).length

6
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] 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) }) 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', { logger.debug('[Publish] publishEvent completed', {
success: publishResult.success, success: publishResult.success,
successCount: publishResult.successCount, successCount: publishResult.successCount,

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

@ -406,16 +406,27 @@ export class QueryService {
const effectiveFilter: Filter | Filter[] = const effectiveFilter: Filter | Filter[] =
sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters
const hasNip50Search = filtersHaveNip50Search(sanitizedFilters) const hasNip50Search = filtersHaveNip50Search(sanitizedFilters)
const useNip50QueryTimeoutFloor = const useNip50FetchPath =
hasNip50Search && options?.relayOpSource === 'fetchEventsFromSingleRelay' 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 globalTimeoutRaw = options?.globalTimeout ?? 10000
const globalTimeout = useNip50QueryTimeoutFloor /**
* 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) ? Math.max(globalTimeoutRaw, NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS)
: globalTimeoutRaw : 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 replaceableRace = options?.replaceableRace ?? false
const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? FIRST_RELAY_RESULT_GRACE_MS const replaceableRaceWaitMs = options?.replaceableRaceWaitMs ?? FIRST_RELAY_RESULT_GRACE_MS
const immediateReturn = options?.immediateReturn ?? false const immediateReturn = options?.immediateReturn ?? false

2
src/types/index.d.ts vendored

@ -201,7 +201,7 @@ export type TPublishOptions = {
/** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */ /** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */
export type TPublishEventExtras = { export type TPublishEventExtras = {
favoriteRelayUrls?: string[] 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 skipOutboxRetry?: boolean
/** Shown in relay batch logs and an info line (e.g. "NIP-65 outbox retry — 2nd attempt"). */ /** Shown in relay batch logs and an info line (e.g. "NIP-65 outbox retry — 2nd attempt"). */
publishBatchLabel?: string publishBatchLabel?: string

Loading…
Cancel
Save