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:
parent
4474cc26e9
commit
1ef35c14b8
4 changed files with 198 additions and 103 deletions
22
AGENTS.md
22
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<RelayMetadata>;
|
||||
|
||||
// Zod schema for AppConfig validation
|
||||
|
|
|
|||
|
|
@ -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<string>(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<string, string[]>();
|
||||
|
||||
// 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<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
|
||||
|
|
@ -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<Conversation[]> => {
|
||||
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<string, DirectMessage[]>();
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue