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).
This commit is contained in:
SkateSpots 2026-10-13 00:06:49 +07:00
parent 6c10d880c6
commit 27084a4e7b
5 changed files with 34 additions and 10 deletions

View file

@ -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

View file

@ -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 }: {

View file

@ -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: {

View file

@ -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: {

View file

@ -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<string, string> = {};
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 };
}