diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index 5523f5f..33d6980 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. Bob types `/new weekend ` to create a group named "weekend" - and invite Alice. (`/new weekend` alone creates an empty group.) -2. Once the invite commits, both can chat. +1. Bob types `/new weekend` to create a group named "weekend" (or + `/new weekend ` to invite someone at creation). +2. Bob types `/add ` to invite Alice; the invite stays pending + until the group commits it. +3. Once it commits, both can chat. ### Optional: KeyPackage registry @@ -97,6 +99,7 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a | Flag | Default | Description | |------|---------|-------------| | `--transport ` | `logos-delivery` | Transport to use (`logos-delivery` or `file`) | +| `--group-commit ` | `auto` | How fast group `/add`s commit: `fast` (~1s, for demos), `default` (production de-mls timing), or `auto` (fast on `file`, default on the network) | | `--data ` | `tmp/chat-cli-data` | Data directory (UI state and default SQLite path) | | `--db ` | `/.db` | SQLite file for persistent identity | | `--preset ` | `logos.dev` | logos-delivery network preset | @@ -112,6 +115,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 [name] [address...]` | Create a group chat (optionally naming it and 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 a313ae7..82067a5 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -234,6 +234,12 @@ where timestamp: now(), }); } + 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}"); } @@ -283,6 +289,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 [name] [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"); @@ -347,6 +354,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.clone().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(ChatKind::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())); diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index bf110a3..2eb0c8c 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -4,13 +4,14 @@ mod ui; mod utils; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; use crossbeam_channel::Receiver; use logos_chat::{ - AccountDirectory, ChatClient, ChatStore, Event, LogosConfig, P2pConfig, RegistrationService, - RegistryPublishMode, Transport, + AccountDirectory, ChatClient, ChatStore, Event, GroupV2Config, LogosConfig, P2pConfig, + RegistrationService, RegistryPublishMode, Transport, }; use app::ChatApp; @@ -22,6 +23,44 @@ enum TransportKind { LogosDelivery, } +#[derive(Copy, Clone, Debug, ValueEnum)] +#[value(rename_all = "kebab-case")] +enum GroupCommit { + /// Fast on `file`, library default on the network. + Auto, + /// Always use fast commit timers. + Fast, + /// Always use the de-mls library default timing. + Default, +} + +/// Fast GroupV2 timing so `/add` commits in ~1s instead of ~60s — for local +/// demos and tests. These are the vetted values from the library's group +/// tests; they are deliberately aggressive and not appropriate for a +/// high-latency network (hence `--group-commit auto` keeps defaults there). +fn fast_group_v2_config() -> GroupV2Config { + GroupV2Config { + voting_delay: Duration::from_millis(50), + consensus_timeout: Duration::from_millis(250), + commit_batch_window: Duration::from_millis(500), + freeze_duration: Duration::from_millis(500), + proposal_expiration: Duration::from_millis(4000), + ..GroupV2Config::default() + } +} + +/// Decide whether to override GroupV2 timing with fast commits: always for +/// `Fast`, never for `Default`, and — for `Auto` — only on the local file +/// transport. +fn group_v2_override(mode: GroupCommit, transport: TransportKind) -> Option { + let fast = match mode { + GroupCommit::Fast => true, + GroupCommit::Default => false, + GroupCommit::Auto => matches!(transport, TransportKind::File), + }; + fast.then(fast_group_v2_config) +} + #[derive(Parser, Debug)] #[command(name = "chat-cli", about = "End-to-end encrypted terminal chat")] struct Cli { @@ -33,6 +72,13 @@ struct Cli { #[arg(long, value_enum, default_value_t = TransportKind::File)] transport: TransportKind, + /// How quickly group membership changes commit. `fast` makes `/add` commit + /// in ~1s (great for local demos); `default` uses production de-mls timing. + /// `auto` (the default) picks `fast` for `--transport file` and `default` + /// otherwise, since fast timers are too aggressive for a high-latency network. + #[arg(long, value_enum, default_value_t = GroupCommit::Auto)] + group_commit: GroupCommit, + /// Data directory (used for UI state and the default SQLite path). #[arg(long, default_value = "tmp/chat-cli-data")] data: PathBuf, @@ -119,6 +165,12 @@ fn main() -> Result<()> { } config.set_registry_publish_mode(cli.registry_publish.into()); config.set_p2p_config(p2p_config); + if let Some(group_v2) = group_v2_override(cli.group_commit, cli.transport) { + // Demo/test-only fast timers; migrates once the library's + // wallclock/timer abstraction replaces this raw config. + #[allow(deprecated)] + config.set_group_v2_config(group_v2); + } let (client, events) = logos_chat::open(config) .map_err(|e| anyhow::anyhow!("{e:?}")) .context("failed to open chat client")?; @@ -139,6 +191,12 @@ fn main() -> Result<()> { config.set_registry_url(registry_url); } config.set_registry_publish_mode(cli.registry_publish.into()); + if let Some(group_v2) = group_v2_override(cli.group_commit, cli.transport) { + // Demo/test-only fast timers; migrates once the library's + // wallclock/timer abstraction replaces this raw config. + #[allow(deprecated)] + config.set_group_v2_config(group_v2); + } let (client, events) = logos_chat::open_with_transport(config, transport) .map_err(|e| anyhow::anyhow!("{e:?}")) .context("failed to open chat client")?;