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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue