From 27084a4e7b483a756afba9a24a8504167c0f1d8a Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 13 Oct 2026 00:06:49 +0700 Subject: [PATCH] fix(amber): send crew messages one signer intent at a time Sending to a crew fired every recipient's gift wrap in parallel (Promise.all), and the sealed-relay crew chat encrypted to the whole roster in parallel. Amber is a single foreground activity: concurrent nostrsigner: intents mean only one is answered and the rest sit until the 60s native timeout rejects with 'Amber didn't respond'. Both paths now go sequentially for an interactive signer (parallel kept for silent signers). Also corrected the approval hint (2 per member: encrypt + sign, not 1 per recipient). --- AGENTS.md | 13 ++++++++++++- src/hooks/useDirectMessages.ts | 16 ++++++++++++++-- src/i18n/en.ts | 2 +- src/i18n/es.ts | 2 +- src/lib/cordn/crypto.ts | 11 ++++++----- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30f0850..d1125fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -512,7 +512,18 @@ session with **no other memory of this project**. So: **legacy/unsigned** posts in a 30 s loop with **no interactive gate** — it now skips unsigned posts for interactive signers (pre-signed ones still publish). ⚠️ If you add a new read hook that decrypts, gate it the same way or Amber - will storm on every navigation. (Not yet done: a persisted decrypt cache, so + will storm on every navigation. ⚠️ **Never fire concurrent signer intents with + an interactive signer (fixed 2026-10-13).** `useSendDirectMessage` created the + per-recipient gift wraps with `Promise.all`, and `sealGroupMessage` + (`lib/cordn/crypto.ts`) encrypted to the whole roster with `Promise.all`. Amber + is a single foreground activity: several `nostrsigner:` intents at once means + only one is answered, and the others sit until the **60 s** `TIMEOUT_MS` in + `android-templates/AmberIntentPlugin.java` rejects with the misleading *"Amber + didn't respond. Make sure Amber is installed"*. Both now send **sequentially + for an interactive signer** (parallel kept for silent ones); the NIP-17 crew + Chat therefore costs **2 approvals per member** (encrypt + sign). Steering + Amber users to the **MLS Private chat** (≈1 approval per action) is the real + cure. (Not yet done: a persisted decrypt cache, so the first load per app start still decrypts everything.) - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index b2f9c65..bf2d46b 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -225,6 +225,7 @@ function createRumor( export function useSendDirectMessage() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); const { config } = useAppContext(); const { write } = useRelays(); const queryClient = useQueryClient(); @@ -292,7 +293,18 @@ export function useSendDirectMessage() { // 3. Create gift wraps for all recipients AND sender (so sender sees own messages) const allTargets = [...new Set([...recipients, user.pubkey])]; - const giftWraps = await Promise.all(allTargets.map(createSealAndWrap)); + // Each wrap costs TWO interactive signer ops (encrypt the rumor, sign the + // seal). An interactive signer (Amber) opens a foreground activity per op — + // firing them in parallel launches several intents at once, only one gets + // answered, and the rest time out ("Amber didn't respond"). So for Amber we + // MUST do them one at a time; silent signers keep the fast parallel path. + const interactive = isInteractiveSigner(user, logins); + const giftWraps: NostrEvent[] = []; + if (interactive) { + for (const target of allTargets) giftWraps.push(await createSealAndWrap(target)); + } else { + giftWraps.push(...(await Promise.all(allTargets.map(createSealAndWrap)))); + } // 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 @@ -401,7 +413,7 @@ export function useSendDirectMessage() { queryClient.invalidateQueries({ queryKey: ['group-conversations'] }); } - }, [user, nostr, config, write, queryClient]); + }, [user, logins, nostr, config, write, queryClient]); return useMutation({ mutationFn: async ({ recipients, content, replyTo, subject }: { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a6968ac..a98dccd 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -4523,7 +4523,7 @@ settingsUi: { defaultAction: 'send', multiBefore: 'Your signer will ask you to approve', multiTimes: '{{approvals}} times', - multiAfter: 'to {{action}} — that\'s normal for encrypted messages (one approval per recipient). Approve each.', + multiAfter: 'to {{action}} — that\'s normal for encrypted messages (an encrypt + a signature per member). Approve each.', single: 'Your signer will ask you to approve to {{action}}.', }, amberCallback: { diff --git a/src/i18n/es.ts b/src/i18n/es.ts index bcb4080..58acfc7 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -4523,7 +4523,7 @@ settingsUi: { defaultAction: 'enviar', multiBefore: 'Tu firmador te pedirá que apruebes', multiTimes: '{{approvals}} veces', - multiAfter: 'para {{action}} — es normal en los mensajes cifrados (una aprobación por destinatario). Aprueba cada una.', + multiAfter: 'para {{action}} — es normal en los mensajes cifrados (un cifrado y una firma por miembro). Aprueba cada una.', single: 'Tu firmador te pedirá que apruebes para {{action}}.', }, amberCallback: { diff --git a/src/lib/cordn/crypto.ts b/src/lib/cordn/crypto.ts index 801a0fc..d646618 100644 --- a/src/lib/cordn/crypto.ts +++ b/src/lib/cordn/crypto.ts @@ -109,13 +109,14 @@ export async function sealGroupMessage( const json = JSON.stringify(plaintext); // Encrypt once per unique recipient (roster ∪ {sender}). + // ⚠️ Serial, not Promise.all: with an interactive signer (Amber) each encrypt + // opens a foreground activity, and concurrent intents collide/time out + // ("Amber didn't respond"). For a silent signer the cost is negligible. const targets = Array.from(new Set([senderPk.toLowerCase(), ...roster.map((r) => r.toLowerCase())])); const recipients: Record = {}; - await Promise.all( - targets.map(async (pk) => { - recipients[pk] = await signer.nip44.encrypt(pk, json); - }), - ); + for (const pk of targets) { + recipients[pk] = await signer.nip44.encrypt(pk, json); + } return { v: 2, alg: 'nip44-group-v2', recipients }; }