feat: add chat-cli /add with fast group-commit timing

This commit is contained in:
Mojtaba Chenani
2026-08-12 23:16:33 +02:00
parent cc3988af74
commit a5ccfa5713
3 changed files with 118 additions and 5 deletions
+7 -3
View File
@@ -66,9 +66,11 @@ and copies it to the clipboard.
**Group:**
1. Bob types `/new weekend <alice's address>` 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 <alice's address>` to invite someone at creation).
2. Bob types `/add <alice's address>` 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 <kind>` | `logos-delivery` | Transport to use (`logos-delivery` or `file`) |
| `--group-commit <mode>` | `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 <dir>` | `tmp/chat-cli-data` | Data directory (UI state and default SQLite path) |
| `--db <path>` | `<data>/<name>.db` | SQLite file for persistent identity |
| `--preset <name>` | `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 <address>` | Start a direct (1:1) chat |
| `/new [name] [address...]` | Create a group chat (optionally naming it and inviting members) |
| `/add <address>` | Add someone to the active group |
| `/chats` | List all established chats |
| `/switch <user>` | Switch active chat |
| `/delete <user>` | Delete a chat session |
+51
View File
@@ -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 <address> - Start a direct (1:1) chat");
self.add_system_message("/new [name] [address...] - Create a group chat");
self.add_system_message("/add <address> - Add someone to the active group");
self.add_system_message("/nickname <name> - Name the active chat");
self.add_system_message("/chats - List all chats");
self.add_system_message("/switch <name|id> - 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 <address>".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 <name>".to_string()));
+60 -2
View File
@@ -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<GroupV2Config> {
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")?;