From 1ef35c14b8c834a7f01dabc99c5b2c923ff838a9 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 19:58:54 +0700 Subject: [PATCH] fix(dms): DM inbox relays were wiped on every load, so DMs went nowhere Root cause: AppProvider validates the persisted config with AppConfigSchema.partial().parse(), and RelayMetadataSchema only declared 'relays' + 'updatedAt'. Zod drops unknown keys by default, so dmRelays (and localRelays + blockedRelays) were SILENTLY STRIPPED on every page load. The DM inbox relays (kind 10050) are usually not in a user's NIP-65 list, so DM reads and writes fell back to NIP-65 relays - where the other side isn't listening - and messages looked like they never went through. - Declare localRelays/blockedRelays/dmRelays in RelayMetadataSchema. - Send: publish gift wraps in PARALLEL with per-relay timeouts and resolve on the first relay that accepts (a dead relay or auth.nostr1.com no longer stalls the send); deliver to the recipient's NIP-65 WRITE relays when they have no discoverable 10050 (the old fallback was dead code because our own DM relays are always non-empty); discovery of 10050/10002 now runs in parallel. - Read: query all DM relays in parallel with per-relay timeouts. Previously one slow/dead relay burned the shared 15s budget and then the fired abort made every remaining relay fail instantly. Verified with an nsec login + a hostile relay list (dead + auth-required): before, the wrap reached only write relays and the recipient found 0; after, it lands on the configured inbox relays and both the recipient and our own copy are found. Send worst case 20s -> 4.5s. --- AGENTS.md | 22 +++ release-notes.txt | 8 + src/components/AppProvider.tsx | 9 ++ src/hooks/useDirectMessages.ts | 262 ++++++++++++++++++++------------- 4 files changed, 198 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c6c42f7..7e97ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,28 @@ session with **no other memory of this project**. So: - DM **inbox** relays (kind 10050) are also **block-filtered** now (reads and delivery), so the blocklist applies to DMs too. - **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`). + - ⚠️ **Zod strips undeclared keys — it cost us the DM relay list (fixed + 2026-09-27).** `AppProvider` validates the persisted config with + `AppConfigSchema.partial().parse(...)`, and `RelayMetadataSchema` only declared + `relays` + `updatedAt`. Zod's default behaviour is to **drop unknown keys**, so + `dmRelays`, `localRelays` and `blockedRelays` were **wiped on every load**. + The DM inbox relays (kind 10050) are usually NOT in a user's NIP-65 list, so + with `dmRelays` gone the app read/wrote DMs only over NIP-65 relays — where + the other side isn't listening — and inbox messages appeared to never arrive. + **Rule: any new `RelayMetadata`/`AppConfig` field MUST be added to the Zod + schema** or it silently won't persist. Verified end-to-end: before the fix the + gift wrap reached only write relays (recipient found 0); after, it lands on + the configured inbox relays (recipient and self-fetch both find it). + - **DM send/read robustness (same date):** gift-wrap publishing and gift-wrap + reading were both **sequential with one shared timeout**, so a single dead + relay (e.g. `wotr.relatr.xyz`) or an auth-required one (`auth.nostr1.com`) + stalled the send or aborted the remaining reads — hiding every message. Both + are now **parallel with per-relay timeouts**; the send **resolves on the first + relay that accepts** and continues the rest in the background, and a recipient + with no discoverable 10050 falls back to their **NIP-65 write relays** + (previously the NIP-65 fallback was effectively dead code because our own DM + relays are always non-empty). `NRelay1.event()` DOES throw on `OK:false`, so + per-relay failures are real; the composer only clears on success. - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and does **not** use the ContextVM server. AI goes through our own **server-side diff --git a/release-notes.txt b/release-notes.txt index 0d50ba4..b3aa481 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,5 +1,13 @@ v1.5.3 +✉️ Direct messages fixed +- Your DM inbox relays were being forgotten every time the app loaded, so + messages were sent to your normal relays instead of the ones the other person + actually reads — which is why DMs looked like they never arrived. Your DM + relays, local relays and relay blocklist now persist properly. +- Sending and loading DMs no longer stall or break because of one dead or + login-only relay in your list; they now run in parallel. + 🧭 Real navigation, not just a route preview - A much bigger map (with a metric scale bar), and the plan now reads at a glance: distance, time and arrival time as clear stats, plus step count and diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 24e5919..dd12e0d 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -20,6 +20,15 @@ const RelayMetadataSchema = z.object({ write: z.boolean(), })), updatedAt: z.number(), + // These MUST be declared. Zod strips keys it doesn't know, so omitting them + // silently wiped the user's app-local settings on every load: their NIP-17 DM + // inbox relays (kind 10050), local relays and the relay blocklist. With + // `dmRelays` gone, DM reads/writes fell back to the NIP-65 list — which does + // NOT include the DM relays — so inbox messages looked like they "never + // arrived". + localRelays: z.array(z.string()).optional(), + blockedRelays: z.array(z.string()).optional(), + dmRelays: z.array(z.string()).optional(), }) satisfies z.ZodType; // Zod schema for AppConfig validation diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 2d2ad30..1a8951f 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -191,41 +191,96 @@ export function useSendDirectMessage() { const allTargets = [...new Set([...recipients, user.pubkey])]; const giftWraps = await Promise.all(allTargets.map(createSealAndWrap)); - // 4. Deliver to NIP-17 DM relays (kind 10050): the recipients' inboxes plus - // our own, so both sides can read from their DM relays. Fall back to the - // NIP-65 write relays when nobody has published a DM relay list. - const writeRelays = write; - + // 4. Work out where to deliver. NIP-17: publish the wrap to each + // RECIPIENT's inbox relays (their kind 10050), plus our own inbox so we + // can read our sent copy. const dmTargets = [...new Set([...recipients, user.pubkey])]; const deliverRelays = new Set(config.relayMetadata.dmRelays ?? []); - try { - const dmEvents = await nostr.query( - [{ kinds: [10050], authors: dmTargets, limit: dmTargets.length * 2 }], - { signal: AbortSignal.timeout(6000) }, - ); - for (const ev of dmEvents) { - for (const [name, url] of ev.tags) { - if (name === 'relay' && url) deliverRelays.add(url); - } + const inboxes = new Map(); + + // Discover both lists IN PARALLEL: the recipients' inbox relays (10050) and + // their relay lists (10002, used to derive write relays when they have no + // inbox). Sequentially these two lookups doubled the send's worst case. + const [dmEvents, relayListEvents] = await Promise.all([ + nostr + .query([{ kinds: [10050], authors: dmTargets, limit: dmTargets.length * 2 }], { + signal: AbortSignal.timeout(4000), + }) + .catch((error) => { + console.warn('[DM] Failed to fetch DM relay lists:', error); + return [] as NostrEvent[]; + }), + nostr + .query([{ kinds: [10002], authors: recipients, limit: recipients.length }], { + signal: AbortSignal.timeout(4000), + }) + .catch(() => [] as NostrEvent[]), + ]); + + for (const ev of dmEvents) { + const urls = ev.tags + .filter(([name]) => name === 'relay') + .map(([, url]) => url) + .filter(Boolean); + if (urls.length) inboxes.set(ev.pubkey, urls); + for (const url of urls) deliverRelays.add(url); + } + + // A recipient with NO discoverable inbox list falls back to their NIP-65 + // WRITE relays (their "inbox" in the base spec). Previously we skipped this + // entirely — and because our own DM relays are always non-empty, the NIP-65 + // write relays were never used at all, so a recipient without a 10050 was + // simply unreachable. + for (const ev of relayListEvents) { + if (inboxes.get(ev.pubkey)?.length) continue; + for (const [name, url, marker] of ev.tags) { + if (name === 'r' && url && (!marker || marker === 'write')) deliverRelays.add(url); } - } catch (error) { - console.warn('[DM] Failed to fetch DM relays, using write relays:', error); } const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase())); - const deliverList = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase())); - const publishRelays = deliverList.length > 0 ? deliverList : writeRelays; + let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase())); + if (publishRelays.length === 0) publishRelays = write; - // Publish all gift wraps - for (const relayUrl of publishRelays) { - try { - const relay = nostr.relay(relayUrl); - for (const wrap of giftWraps) { - await relay.event(wrap); - } - } catch (error) { - console.warn(`[DM] Failed to publish to ${relayUrl}:`, error); - } + // 5. Publish in PARALLEL, each relay with its own timeout. People's lists + // commonly contain a dead relay or one that demands NIP-42 auth (e.g. + // auth.nostr1.com); publishing sequentially with no timeout meant ONE bad + // relay could stall the whole send. A failure is now per-relay, and we + // only fail the send if NO relay accepted the message. + const attempts = publishRelays.map(async (relayUrl) => { + const relay = nostr.relay(relayUrl); + await Promise.all( + giftWraps.map((wrap) => relay.event(wrap, { signal: AbortSignal.timeout(6000) })), + ); + }); + + if (attempts.length > 0) { + // Resolve as soon as ONE relay accepts, so a dead or auth-required relay + // never delays the send; the remaining attempts finish in the background. + await new Promise((resolve, reject) => { + let remaining = attempts.length; + let accepted = 0; + attempts.forEach((attempt) => { + attempt + .then(() => { + accepted += 1; + resolve(); + }) + .catch((error) => { + console.warn('[DM] A relay rejected the message:', error); + }) + .finally(() => { + remaining -= 1; + if (remaining === 0 && accepted === 0) { + reject( + new Error( + 'Could not send the message — no relay accepted it. Check your connection and relays.', + ), + ); + } + }); + }); + }); } // Invalidate queries to refresh message list @@ -301,29 +356,31 @@ export function useConversation(otherPubkey: string) { queryFn: async (c) => { if (!user?.signer?.nip44 || !otherPubkey) return []; - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = dmReadRelays(config.relayMetadata); - // Query for gift wraps addressed to us - const allEvents: NostrEvent[] = []; - - for (const relayUrl of readRelays) { - try { - const relay = nostr.relay(relayUrl); - const events = await relay.query( - [{ - kinds: [GIFT_WRAP_KIND], - '#p': [user.pubkey], - limit: 100, - }], - { signal } - ); - allEvents.push(...events); - } catch (error) { - console.warn(`[DM] Failed to query ${relayUrl}:`, error); - } - } + // Query every relay IN PARALLEL with its OWN timeout. Sequentially, one + // dead / slow / auth-required relay burned the shared 15s budget and then + // the already-aborted signal made every remaining relay fail instantly — + // so a single bad relay in the list could hide ALL your messages. + const batches = await Promise.all( + readRelays.map(async (relayUrl) => { + try { + const relay = nostr.relay(relayUrl); + return await relay.query( + [{ + kinds: [GIFT_WRAP_KIND], + '#p': [user.pubkey], + limit: 100, + }], + { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, + ); + } catch (error) { + console.warn(`[DM] Failed to query ${relayUrl}:`, error); + return []; + } + }), + ); + const allEvents: NostrEvent[] = batches.flat(); // Deduplicate by ID const uniqueEvents = Array.from( @@ -414,29 +471,28 @@ export function useConversations() { queryFn: async (c) => { if (!user?.signer?.nip44) return []; - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = dmReadRelays(config.relayMetadata); // Query for gift wraps addressed to us - const allEvents: NostrEvent[] = []; - - for (const relayUrl of readRelays) { - try { - const relay = nostr.relay(relayUrl); - const events = await relay.query( - [{ - kinds: [GIFT_WRAP_KIND], - '#p': [user.pubkey], - limit: 200, - }], - { signal } - ); - allEvents.push(...events); - } catch (error) { - console.warn(`[DM] Failed to query ${relayUrl}:`, error); - } - } + const batches = await Promise.all( + readRelays.map(async (relayUrl) => { + try { + const relay = nostr.relay(relayUrl); + return await relay.query( + [{ + kinds: [GIFT_WRAP_KIND], + '#p': [user.pubkey], + limit: 200, + }], + { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, + ); + } catch (error) { + console.warn(`[DM] Failed to query ${relayUrl}:`, error); + return []; + } + }), + ); + const allEvents: NostrEvent[] = batches.flat(); // Deduplicate const uniqueEvents = Array.from( @@ -552,22 +608,23 @@ export function useGroupConversations() { queryFn: async (c): Promise => { if (!user?.signer?.nip44) return []; - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); const readRelays = dmReadRelays(config.relayMetadata); - const allEvents: NostrEvent[] = []; - for (const relayUrl of readRelays) { - try { - const relay = nostr.relay(relayUrl); - const events = await relay.query( - [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200 }], - { signal }, - ); - allEvents.push(...events); - } catch (error) { - console.warn(`[DM] Failed to query ${relayUrl}:`, error); - } - } + const batches = await Promise.all( + readRelays.map(async (relayUrl) => { + try { + const relay = nostr.relay(relayUrl); + return await relay.query( + [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200 }], + { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, + ); + } catch (error) { + console.warn(`[DM] Failed to query ${relayUrl}:`, error); + return []; + } + }), + ); + const allEvents: NostrEvent[] = batches.flat(); const uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values()); const byGroup = new Map(); @@ -667,29 +724,28 @@ export function useGroupConversation(participants: string[]) { queryFn: async (c) => { if (!user?.signer?.nip44 || participants.length === 0) return []; - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]); - const readRelays = dmReadRelays(config.relayMetadata); // Query for gift wraps addressed to us - const allEvents: NostrEvent[] = []; - - for (const relayUrl of readRelays) { - try { - const relay = nostr.relay(relayUrl); - const events = await relay.query( - [{ - kinds: [GIFT_WRAP_KIND], - '#p': [user.pubkey], - limit: 200, - }], - { signal } - ); - allEvents.push(...events); - } catch (error) { - console.warn(`[DM] Failed to query ${relayUrl}:`, error); - } - } + const batches = await Promise.all( + readRelays.map(async (relayUrl) => { + try { + const relay = nostr.relay(relayUrl); + return await relay.query( + [{ + kinds: [GIFT_WRAP_KIND], + '#p': [user.pubkey], + limit: 200, + }], + { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) }, + ); + } catch (error) { + console.warn(`[DM] Failed to query ${relayUrl}:`, error); + return []; + } + }), + ); + const allEvents: NostrEvent[] = batches.flat(); // Deduplicate const uniqueEvents = Array.from(