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.
This commit is contained in:
SkateSpots 2026-09-27 19:58:54 +07:00
parent 4474cc26e9
commit 1ef35c14b8
4 changed files with 198 additions and 103 deletions

View file

@ -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 - DM **inbox** relays (kind 10050) are also **block-filtered** now (reads and
delivery), so the blocklist applies to DMs too. delivery), so the blocklist applies to DMs too.
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`). - **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 - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat
queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and
does **not** use the ContextVM server. AI goes through our own **server-side does **not** use the ContextVM server. AI goes through our own **server-side

View file

@ -1,5 +1,13 @@
v1.5.3 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 🧭 Real navigation, not just a route preview
- A much bigger map (with a metric scale bar), and the plan now reads at a - 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 glance: distance, time and arrival time as clear stats, plus step count and

View file

@ -20,6 +20,15 @@ const RelayMetadataSchema = z.object({
write: z.boolean(), write: z.boolean(),
})), })),
updatedAt: z.number(), 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<RelayMetadata>; }) satisfies z.ZodType<RelayMetadata>;
// Zod schema for AppConfig validation // Zod schema for AppConfig validation

View file

@ -191,41 +191,96 @@ export function useSendDirectMessage() {
const allTargets = [...new Set([...recipients, user.pubkey])]; const allTargets = [...new Set([...recipients, user.pubkey])];
const giftWraps = await Promise.all(allTargets.map(createSealAndWrap)); const giftWraps = await Promise.all(allTargets.map(createSealAndWrap));
// 4. Deliver to NIP-17 DM relays (kind 10050): the recipients' inboxes plus // 4. Work out where to deliver. NIP-17: publish the wrap to each
// our own, so both sides can read from their DM relays. Fall back to the // RECIPIENT's inbox relays (their kind 10050), plus our own inbox so we
// NIP-65 write relays when nobody has published a DM relay list. // can read our sent copy.
const writeRelays = write;
const dmTargets = [...new Set([...recipients, user.pubkey])]; const dmTargets = [...new Set([...recipients, user.pubkey])];
const deliverRelays = new Set<string>(config.relayMetadata.dmRelays ?? []); const deliverRelays = new Set<string>(config.relayMetadata.dmRelays ?? []);
try { const inboxes = new Map<string, string[]>();
const dmEvents = await nostr.query(
[{ kinds: [10050], authors: dmTargets, limit: dmTargets.length * 2 }], // Discover both lists IN PARALLEL: the recipients' inbox relays (10050) and
{ signal: AbortSignal.timeout(6000) }, // their relay lists (10002, used to derive write relays when they have no
); // inbox). Sequentially these two lookups doubled the send's worst case.
for (const ev of dmEvents) { const [dmEvents, relayListEvents] = await Promise.all([
for (const [name, url] of ev.tags) { nostr
if (name === 'relay' && url) deliverRelays.add(url); .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 blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
const deliverList = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase())); let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase()));
const publishRelays = deliverList.length > 0 ? deliverList : writeRelays; if (publishRelays.length === 0) publishRelays = write;
// Publish all gift wraps // 5. Publish in PARALLEL, each relay with its own timeout. People's lists
for (const relayUrl of publishRelays) { // commonly contain a dead relay or one that demands NIP-42 auth (e.g.
try { // auth.nostr1.com); publishing sequentially with no timeout meant ONE bad
const relay = nostr.relay(relayUrl); // relay could stall the whole send. A failure is now per-relay, and we
for (const wrap of giftWraps) { // only fail the send if NO relay accepted the message.
await relay.event(wrap); const attempts = publishRelays.map(async (relayUrl) => {
} const relay = nostr.relay(relayUrl);
} catch (error) { await Promise.all(
console.warn(`[DM] Failed to publish to ${relayUrl}:`, error); 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<void>((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 // Invalidate queries to refresh message list
@ -301,29 +356,31 @@ export function useConversation(otherPubkey: string) {
queryFn: async (c) => { queryFn: async (c) => {
if (!user?.signer?.nip44 || !otherPubkey) return []; if (!user?.signer?.nip44 || !otherPubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
const readRelays = dmReadRelays(config.relayMetadata); const readRelays = dmReadRelays(config.relayMetadata);
// Query for gift wraps addressed to us // Query every relay IN PARALLEL with its OWN timeout. Sequentially, one
const allEvents: NostrEvent[] = []; // dead / slow / auth-required relay burned the shared 15s budget and then
// the already-aborted signal made every remaining relay fail instantly —
for (const relayUrl of readRelays) { // so a single bad relay in the list could hide ALL your messages.
try { const batches = await Promise.all(
const relay = nostr.relay(relayUrl); readRelays.map(async (relayUrl) => {
const events = await relay.query( try {
[{ const relay = nostr.relay(relayUrl);
kinds: [GIFT_WRAP_KIND], return await relay.query(
'#p': [user.pubkey], [{
limit: 100, kinds: [GIFT_WRAP_KIND],
}], '#p': [user.pubkey],
{ signal } limit: 100,
); }],
allEvents.push(...events); { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) },
} catch (error) { );
console.warn(`[DM] Failed to query ${relayUrl}:`, error); } catch (error) {
} console.warn(`[DM] Failed to query ${relayUrl}:`, error);
} return [];
}
}),
);
const allEvents: NostrEvent[] = batches.flat();
// Deduplicate by ID // Deduplicate by ID
const uniqueEvents = Array.from( const uniqueEvents = Array.from(
@ -414,29 +471,28 @@ export function useConversations() {
queryFn: async (c) => { queryFn: async (c) => {
if (!user?.signer?.nip44) return []; if (!user?.signer?.nip44) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
const readRelays = dmReadRelays(config.relayMetadata); const readRelays = dmReadRelays(config.relayMetadata);
// Query for gift wraps addressed to us // Query for gift wraps addressed to us
const allEvents: NostrEvent[] = []; const batches = await Promise.all(
readRelays.map(async (relayUrl) => {
for (const relayUrl of readRelays) { try {
try { const relay = nostr.relay(relayUrl);
const relay = nostr.relay(relayUrl); return await relay.query(
const events = await relay.query( [{
[{ kinds: [GIFT_WRAP_KIND],
kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey],
'#p': [user.pubkey], limit: 200,
limit: 200, }],
}], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) },
{ signal } );
); } catch (error) {
allEvents.push(...events); console.warn(`[DM] Failed to query ${relayUrl}:`, error);
} catch (error) { return [];
console.warn(`[DM] Failed to query ${relayUrl}:`, error); }
} }),
} );
const allEvents: NostrEvent[] = batches.flat();
// Deduplicate // Deduplicate
const uniqueEvents = Array.from( const uniqueEvents = Array.from(
@ -552,22 +608,23 @@ export function useGroupConversations() {
queryFn: async (c): Promise<Conversation[]> => { queryFn: async (c): Promise<Conversation[]> => {
if (!user?.signer?.nip44) return []; if (!user?.signer?.nip44) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
const readRelays = dmReadRelays(config.relayMetadata); const readRelays = dmReadRelays(config.relayMetadata);
const allEvents: NostrEvent[] = []; const batches = await Promise.all(
for (const relayUrl of readRelays) { readRelays.map(async (relayUrl) => {
try { try {
const relay = nostr.relay(relayUrl); const relay = nostr.relay(relayUrl);
const events = await relay.query( return await relay.query(
[{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200 }], [{ kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal }, { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) },
); );
allEvents.push(...events); } catch (error) {
} catch (error) { console.warn(`[DM] Failed to query ${relayUrl}:`, 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 uniqueEvents = Array.from(new Map(allEvents.map(e => [e.id, e])).values());
const byGroup = new Map<string, DirectMessage[]>(); const byGroup = new Map<string, DirectMessage[]>();
@ -667,29 +724,28 @@ export function useGroupConversation(participants: string[]) {
queryFn: async (c) => { queryFn: async (c) => {
if (!user?.signer?.nip44 || participants.length === 0) return []; if (!user?.signer?.nip44 || participants.length === 0) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
const readRelays = dmReadRelays(config.relayMetadata); const readRelays = dmReadRelays(config.relayMetadata);
// Query for gift wraps addressed to us // Query for gift wraps addressed to us
const allEvents: NostrEvent[] = []; const batches = await Promise.all(
readRelays.map(async (relayUrl) => {
for (const relayUrl of readRelays) { try {
try { const relay = nostr.relay(relayUrl);
const relay = nostr.relay(relayUrl); return await relay.query(
const events = await relay.query( [{
[{ kinds: [GIFT_WRAP_KIND],
kinds: [GIFT_WRAP_KIND], '#p': [user.pubkey],
'#p': [user.pubkey], limit: 200,
limit: 200, }],
}], { signal: AbortSignal.any([c.signal, AbortSignal.timeout(10000)]) },
{ signal } );
); } catch (error) {
allEvents.push(...events); console.warn(`[DM] Failed to query ${relayUrl}:`, error);
} catch (error) { return [];
console.warn(`[DM] Failed to query ${relayUrl}:`, error); }
} }),
} );
const allEvents: NostrEvent[] = batches.flat();
// Deduplicate // Deduplicate
const uniqueEvents = Array.from( const uniqueEvents = Array.from(