From ee2572a1419af5a957e7325753e04e7bf0438f9e Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Wed, 19 Aug 2026 15:37:46 +0200 Subject: [PATCH] feat: add chat-cli /add (#208) --- bin/chat-cli/README.md | 9 +++++--- bin/chat-cli/src/app.rs | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index 3908879..7c8827d 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -66,9 +66,11 @@ and copies it to the clipboard. **Group:** -1. Saro types `/new weekend ` to create a group named "weekend" - and invite Raya. A name is required; more addresses (e.g. Pax's) can follow. -2. Once the invite commits, everyone can chat. +1. Saro types `/new weekend` to create a group named "weekend". A name is + required; addresses can follow (e.g. Pax's) to invite people at creation. +2. Saro types `/add ` to invite Raya; the invite stays pending + until the group commits it. +3. Once the invite commits, everyone can chat. ### Optional: KeyPackage registry @@ -112,6 +114,7 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a | `/account` | Show your account address (copies to clipboard) | | `/dm
` | Start a direct (1:1) chat | | `/new [address...]` | Create a named group chat (optionally inviting members) | +| `/add
` | Add someone to the active group | | `/chats` | List all established chats | | `/switch ` | Switch active chat | | `/delete ` | Delete a chat session | diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 7fadba3..60adb1e 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -272,6 +272,12 @@ where session.display_name() ); } + Event::ConversationMembersChanged { convo_id } => { + let chat_id = convo_id.to_string(); + if let Some(session) = self.state.chats.get(&chat_id) { + self.status = format!("Membership changed in {}.", session.display_name()); + } + } Event::InboundError { message } => { self.status = format!("Could not process incoming message: {message}"); } @@ -318,6 +324,7 @@ where self.add_system_message("/account - Show your account address"); self.add_system_message("/dm
- Start a direct (1:1) chat"); self.add_system_message("/new [address...] - Create a group chat"); + self.add_system_message("/add
- Add someone to the active group"); self.add_system_message("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); self.add_system_message("/switch - Switch active chat"); @@ -385,6 +392,50 @@ where self.status = msg.clone(); Ok(Some(msg)) } + "/add" => { + let address = args.trim(); + if address.is_empty() { + return Ok(Some("Usage: /add
".to_string())); + } + let chat_id = self.state.active_chat.as_deref().ok_or_else(|| { + anyhow::anyhow!("No active conversation. Use /new to create a group.") + })?; + // DMs are 1:1 and reject adds at the protocol level; refuse early + // with a friendly hint rather than surfacing UnsupportedFunction. + if self.state.chats.get(chat_id).map(|s| s.kind) == Some(ConversationClass::Dm) { + return Ok(Some( + "DMs are 1:1 — start a group with /new to add people.".to_string(), + )); + } + // Adding a signature key already in the group (yourself, or a + // member/pending invite) makes MLS reject the commit with + // DuplicateSignatureKey. Catch it here as a friendly no-op. + if address == self.client.addr() { + return Ok(Some( + "That's your own address — you're already in the group.".to_string(), + )); + } + let already_present = self + .client + .group_members(chat_id) + .map(|members| { + members + .iter() + .any(|m| m.account.as_ref().map(|a| a.as_str()) == Some(address)) + }) + .unwrap_or(false); + if already_present { + return Ok(Some( + "That account is already in the group (or its invite is pending)." + .to_string(), + )); + } + self.client + .add_group_members(chat_id, &[address]) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + self.status = "Invite pending — the group will commit it shortly.".to_string(); + Ok(Some("Invite pending".to_string())) + } "/nickname" => { if args.is_empty() { return Ok(Some("Usage: /nickname ".to_string()));