From cfad8f60096a61069c603c1743a8fa2bdff0d144 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 16 Mar 2026 10:55:25 +0100 Subject: [PATCH] bug-fix change --- src/components/ClientSelect/index.tsx | 21 ++- src/services/client.service.ts | 200 ++++++++++++++++++++++---- 2 files changed, 187 insertions(+), 34 deletions(-) diff --git a/src/components/ClientSelect/index.tsx b/src/components/ClientSelect/index.tsx index 86015985..317bf7fe 100644 --- a/src/components/ClientSelect/index.tsx +++ b/src/components/ClientSelect/index.tsx @@ -72,6 +72,11 @@ const clients: Record string }> } } +/** Raw hex event IDs (64 hex chars) are not NIP-19; nip19.decode() only accepts note1/nevent1/naddr1 etc. */ +function isRawHexEventId(id: string): boolean { + return /^[0-9a-f]{64}$/i.test(id) +} + export default function ClientSelect({ event, originalNoteId, @@ -88,7 +93,7 @@ export default function ClientSelect({ let kind: number | undefined if (event) { kind = event.kind - } else if (originalNoteId) { + } else if (originalNoteId && !isRawHexEventId(originalNoteId)) { try { const pointer = nip19.decode(originalNoteId) if (pointer.type === 'naddr') { @@ -117,7 +122,7 @@ export default function ClientSelect({ default: return ['njump'] } - }, [event]) + }, [event, originalNoteId]) if (!originalNoteId && !event) { return null @@ -215,10 +220,14 @@ function RelayBasedGroupChatSelector({ }) { const { relay, id } = useMemo(() => { let relay: string | undefined - if (originalNoteId) { - const pointer = nip19.decode(originalNoteId) - if (pointer.type === 'naddr' && pointer.data.relays?.length) { - relay = pointer.data.relays[0] + if (originalNoteId && !isRawHexEventId(originalNoteId)) { + try { + const pointer = nip19.decode(originalNoteId) + if (pointer.type === 'naddr' && pointer.data.relays?.length) { + relay = pointer.data.relays[0] + } + } catch { + // not NIP-19 or invalid; fall back to event hint } } if (!relay) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 104ce6f9..ba49ab7d 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,11 @@ import { BIG_RELAY_URLS, BOOKSTR_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' + +/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ +function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { + if (relaySupportsSearch) return f + const { search: _search, ...rest } = f + return rest as Filter +} import { compareEvents, getReplaceableCoordinate, @@ -65,6 +72,11 @@ class ClientService extends EventTarget { tokenize: 'forward' }) + /** Max concurrent REQ subscriptions per relay (many relays enforce ~10; we stay under to avoid NOTICE/rejection) */ + private static readonly MAX_CONCURRENT_SUBS_PER_RELAY = 8 + private activeSubCountByRelay = new Map() + private subSlotWaitQueueByRelay = new Map void>>() + constructor() { super() this.pool = new SimplePool() @@ -83,6 +95,43 @@ class ClientService extends EventTarget { await indexedDb.iterateProfileEvents((profileEvent) => this.addUsernameToIndex(profileEvent)) } + /** + * Acquire a slot to open a new subscription to the given relay. Resolves when we're under the per-relay limit. + * Call releaseSubSlot(relayKey) when the subscription closes (user close() or relay onclose). + */ + private acquireSubSlot(relayKey: string): Promise { + const count = this.activeSubCountByRelay.get(relayKey) ?? 0 + if (count < ClientService.MAX_CONCURRENT_SUBS_PER_RELAY) { + this.activeSubCountByRelay.set(relayKey, count + 1) + return Promise.resolve() + } + return new Promise((resolve) => { + let queue = this.subSlotWaitQueueByRelay.get(relayKey) + if (!queue) { + queue = [] + this.subSlotWaitQueueByRelay.set(relayKey, queue) + } + queue.push(() => { + const n = this.activeSubCountByRelay.get(relayKey) ?? 0 + this.activeSubCountByRelay.set(relayKey, n + 1) + resolve() + }) + }) + } + + /** + * Release a subscription slot for the relay. Wakes the next waiter if any. + */ + private releaseSubSlot(relayKey: string): void { + const count = (this.activeSubCountByRelay.get(relayKey) ?? 1) - 1 + this.activeSubCountByRelay.set(relayKey, Math.max(0, count)) + const queue = this.subSlotWaitQueueByRelay.get(relayKey) + if (queue?.length) { + const next = queue.shift()! + next() + } + } + /** * Determine which relays to publish an event to. * Fallbacks (used when user relay list is empty or fetch fails): @@ -647,35 +696,128 @@ class ClientService extends EventTarget { const that = this const _knownIds = new Set() - // One request per (relay, filter) so pool groups by relay and sends one REQ per relay with all filters - const requests = relays.flatMap((url) => - filters.map((f) => ({ url: normalizeUrl(url) || url, filter: f })) - ) + // Group by relay (same as pool.subscribeMap) so one REQ per relay with all filters + const grouped = new Map() + for (const url of relays) { + const key = normalizeUrl(url) || url + if (!grouped.has(key)) grouped.set(key, []) + grouped.get(key)!.push(...filters) + } + const searchableSet = new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u)) + const groupedRequests = Array.from(grouped.entries()).map(([url, f]) => { + const relaySupportsSearch = searchableSet.has(url) + const filtersForRelay = f.map((one) => filterForRelay(one, relaySupportsSearch)) + return { url, filters: filtersForRelay } + }) - const poolCloser = this.pool.subscribeMap(requests, { - onevent: (evt: NEvent) => onevent?.(evt), - oneose: () => oneose?.(true), - onclose: (reasons: string[]) => { - relays.forEach((url, i) => onclose?.(url, reasons[i] ?? '')) - onAllClose?.(reasons) - }, - onauth: async (authEvt: EventTemplate) => { - if (that.signer) { - const evt = await that.signer.signEvent(authEvt) - if (!evt) throw new Error('sign event failed') - return evt as VerifiedEvent + const eosesReceived: boolean[] = [] + const closesReceived: (string | undefined)[] = [] + const handleEose = (i: number) => { + if (eosesReceived[i]) return + eosesReceived[i] = true + if (eosesReceived.filter(Boolean).length === groupedRequests.length) { + oneose?.(true) + } + } + const handleClose = (i: number, reason: string) => { + if (closesReceived[i] !== undefined) return + handleEose(i) + closesReceived[i] = reason + const { url } = groupedRequests[i]! + onclose?.(url, reason) + if (closesReceived.every((r) => r !== undefined)) { + onAllClose?.(closesReceived as string[]) + } + } + + const localAlreadyHaveEvent = (id: string) => { + const have = _knownIds.has(id) + if (have) return true + _knownIds.add(id) + return false + } + + const subs: { relayKey: string; close: () => void }[] = [] + const allOpened = Promise.all( + groupedRequests.map(async ({ url, filters: relayFilters }, i) => { + const relayKey = normalizeUrl(url) || url + await that.acquireSubSlot(relayKey) + let relay: AbstractRelay + try { + relay = await that.pool.ensureRelay(url, { connectionTimeout: 5000 }) + } catch (err) { + that.releaseSubSlot(relayKey) + handleClose(i, (err as Error)?.message ?? String(err)) + return + } + + let slotReleased = false + const releaseOnce = () => { + if (!slotReleased) { + slotReleased = true + that.releaseSubSlot(relayKey) + } } - startLogin?.() - throw new Error('Login required') - }, - alreadyHaveEvent: (id: string) => { - const have = _knownIds.has(id) - if (have) return true - _knownIds.add(id) - return false - }, - eoseTimeout: 10_000 - }) + + const sub = relay.subscribe(relayFilters, { + receivedEvent: (_relay, id) => that.trackEventSeenOn(id, _relay), + onevent: (evt: NEvent) => onevent?.(evt), + oneose: () => handleEose(i), + onclose: (reason: string) => { + releaseOnce() + if (reason.startsWith('auth-required: ') && that.signer) { + relay.auth(async (authEvt: EventTemplate) => { + const evt = await that.signer!.signEvent(authEvt) + if (!evt) throw new Error('sign event failed') + return evt as VerifiedEvent + }).then(() => that.acquireSubSlot(relayKey)).then(() => { + let slotReleased2 = false + const releaseSlot2 = () => { + if (!slotReleased2) { + slotReleased2 = true + that.releaseSubSlot(relayKey) + } + } + const sub2 = relay.subscribe(relayFilters, { + receivedEvent: (_relay, id) => that.trackEventSeenOn(id, _relay), + onevent: (evt: NEvent) => onevent?.(evt), + oneose: () => handleEose(i), + onclose: (reason2: string) => { + releaseSlot2() + handleClose(i, reason2) + }, + alreadyHaveEvent: localAlreadyHaveEvent, + eoseTimeout: 10_000 + }) + subs.push({ + relayKey, + close: () => { + releaseSlot2() + sub2.close() + } + }) + }).catch((err) => { + handleClose(i, `auth failed: ${(err as Error)?.message ?? err}`) + }) + return + } + if (reason.startsWith('auth-required: ')) { + startLogin?.() + } + handleClose(i, reason) + }, + alreadyHaveEvent: localAlreadyHaveEvent, + eoseTimeout: 10_000 + }) + subs.push({ + relayKey, + close: () => { + releaseOnce() + sub.close() + } + }) + }) + ) const handleNewEventFromInternal = (data: Event) => { const customEvent = data as CustomEvent @@ -695,7 +837,9 @@ class ClientService extends EventTarget { return { close: () => { this.removeEventListener('newEvent', handleNewEventFromInternal) - poolCloser.close() + allOpened.then(() => { + subs.forEach(({ close: subClose }) => subClose()) + }) } } }