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 }; }