mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-25 15:11:15 +00:00
feat: add chat-cli /add with fast group-commit timing
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
Reference in New Issue
Block a user