From 409f0765f4972143b6347b6c549f5cf2ff5cd54c Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Wed, 19 Aug 2026 12:03:50 +0200 Subject: [PATCH] feat: replace chat-cli /connect with /dm and /new (#206) --- bin/chat-cli/README.md | 35 ++++++++---- bin/chat-cli/src/app.rs | 114 ++++++++++++++++++++++++++-------------- bin/chat-cli/src/ui.rs | 2 +- 3 files changed, 100 insertions(+), 51 deletions(-) diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index b3800d0..3908879 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -36,27 +36,39 @@ Run two instances in separate terminals: ```bash # Terminal 1 -cargo run -p chat-cli -- --name alice --port 60001 +cargo run -p chat-cli -- --name saro --port 60001 # Terminal 2 -cargo run -p chat-cli -- --name bob --port 60002 +cargo run -p chat-cli -- --name raya --port 60002 ``` For local-only testing without any network dependency, use the file transport: ```bash # Terminal 1 -cargo run -p chat-cli -- --name alice --transport file +cargo run -p chat-cli -- --name saro --transport file # Terminal 2 -cargo run -p chat-cli -- --name bob --transport file +cargo run -p chat-cli -- --name raya --transport file ``` -### Establishing a connection +### Starting a conversation -1. In Alice's terminal, type `/account` — her address is copied to the clipboard automatically. -2. In Bob's terminal, type `/connect `. -3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat. +Every conversation is an MLS group. A **DM** is a 1:1; a **group** is a named +conversation. First share your address: type `/account` — it prints your address +and copies it to the clipboard. + +**Direct message (1:1):** + +1. Raya runs `/account` and shares her address. +2. Saro types `/dm `. +3. The chat opens on both sides; either can message. + +**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. ### Optional: KeyPackage registry @@ -71,9 +83,9 @@ process. cargo run -- --bind 127.0.0.1:18080 # Terminal 2 / 3 — chat clients pointing at it -cargo run -p chat-cli -- --name alice --transport file \ +cargo run -p chat-cli -- --name saro --transport file \ --registry-url http://127.0.0.1:18080 -cargo run -p chat-cli -- --name bob --transport file \ +cargo run -p chat-cli -- --name raya --transport file \ --registry-url http://127.0.0.1:18080 ``` @@ -98,7 +110,8 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a |---------|-------------| | `/help` | Show available commands | | `/account` | Show your account address (copies to clipboard) | -| `/connect
` | Connect to a user using their address | +| `/dm
` | Start a direct (1:1) chat | +| `/new [address...]` | Create a named group chat (optionally inviting members) | | `/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 570e35b..7fadba3 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -5,7 +5,10 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use arboard::Clipboard; use crossbeam_channel::Receiver; -use logos_chat::{AccountDirectory, ChatClient, ChatStore, Event, RegistrationService, Transport}; +use logos_chat::{ + AccountDirectory, ChatClient, ChatStore, ConversationClass, Event, GroupMetadata, + RegistrationService, Transport, +}; use serde::{Deserialize, Serialize}; use crate::utils::now; @@ -36,6 +39,7 @@ impl DisplayMessage { pub struct ChatSession { pub chat_id: String, pub nickname: Option, + pub kind: ConversationClass, pub messages: Vec, } @@ -147,6 +151,25 @@ where self.command_output.clear(); } + /// Insert a freshly created conversation and make it active. + fn start_session( + &mut self, + chat_id: String, + kind: ConversationClass, + nickname: Option, + ) { + self.state.chats.insert( + chat_id.clone(), + ChatSession { + chat_id: chat_id.clone(), + nickname, + kind, + messages: Vec::new(), + }, + ); + self.set_active_chat(Some(chat_id)); + } + /// Find a chat_id by nickname (exact) or chat_id prefix. fn resolve_chat_id(&self, query: &str) -> Option<&str> { // Exact nickname match first. @@ -180,22 +203,14 @@ where fn handle_event(&mut self, event: Event) { match event { - Event::ConversationStarted { convo_id, .. } => { + Event::ConversationStarted { convo_id, class } => { let chat_id = convo_id.to_string(); if self.state.chats.contains_key(&chat_id) { return; } - self.state.chats.insert( - chat_id.clone(), - ChatSession { - chat_id: chat_id.clone(), - nickname: None, - messages: Vec::new(), - }, - ); - let label = &chat_id[..8.min(chat_id.len())]; - self.status = format!("New chat ({label})! Use /nickname to name it."); - self.set_active_chat(Some(chat_id)); + let label = chat_id[..8.min(chat_id.len())].to_string(); + self.status = format!("New {class:?} ({label})! Use /nickname to name it."); + self.start_session(chat_id, class, None); } Event::MessageReceived { convo_id, content, .. @@ -269,7 +284,7 @@ where .state .active_chat .clone() - .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; + .ok_or_else(|| anyhow::anyhow!("No active chat. Use /dm or /new first."))?; let message_id = self .client @@ -301,7 +316,8 @@ where "/help" => { self.add_system_message("── Commands ──"); self.add_system_message("/account - Show your account address"); - self.add_system_message("/connect
- Connect using an 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("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); self.add_system_message("/switch - Switch active chat"); @@ -323,34 +339,51 @@ where self.add_system_message(clipboard_msg); Ok(Some("Account address shown".to_string())) } - "/connect" => { - if args.is_empty() { - return Ok(Some("Usage: /connect
".to_string())); + "/dm" => { + let address = args.trim(); + if address.is_empty() { + return Ok(Some("Usage: /dm
".to_string())); } - let initial = format!("Hello from {}!", self.user_name); let chat_id = self .client - .create_direct_conversation(args) + .create_direct_conversation(address) .map_err(|e| anyhow::anyhow!("{e:?}"))?; - let message_id = self - .client - .send_message(&chat_id, initial.as_bytes()) - .map_err(|e| anyhow::anyhow!("{e:?}"))?; - let label = chat_id[..8.min(chat_id.len())].to_string(); - let mut session = ChatSession { - chat_id: chat_id.clone(), - nickname: None, - messages: Vec::new(), - }; - let mut message = DisplayMessage::new(true, initial); - message.message_id = Some(message_id); - session.messages.push(message); - self.state.chats.insert(chat_id.clone(), session); - self.set_active_chat(Some(chat_id)); + self.start_session(chat_id, ConversationClass::Dm, None); self.save_state()?; - self.status = format!("Connected ({label})! Use /nickname to name this chat."); - Ok(Some(format!("Connected ({label})"))) + self.status = format!("Direct chat started ({label}). Say hello!"); + Ok(Some(format!("DM started ({label})"))) + } + "/new" => { + // First token is the group name (required); any remaining tokens + // are addresses to invite at creation. + let mut tokens = args.split_whitespace(); + let Some(name) = tokens.next().map(str::to_string) else { + return Ok(Some("Usage: /new [address...]".to_string())); + }; + // The creator is already a member; drop self and any repeats so we + // don't propose a duplicate signature key (which MLS rejects). + let my_addr = self.client.addr().to_string(); + let mut members: Vec<&str> = tokens.filter(|a| *a != my_addr).collect(); + members.sort_unstable(); + members.dedup(); + let chat_id = self + .client + .create_group_conversation(&members, GroupMetadata::new(name.clone(), "")) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + let label = chat_id[..8.min(chat_id.len())].to_string(); + self.start_session(chat_id, ConversationClass::Group, Some(name)); + self.save_state()?; + let msg = if members.is_empty() { + format!("Group created ({label}).") + } else { + format!( + "Group created ({label}); {} invite(s) pending.", + members.len() + ) + }; + self.status = msg.clone(); + Ok(Some(msg)) } "/nickname" => { if args.is_empty() { @@ -374,7 +407,9 @@ where "/chats" => { let sessions: Vec<_> = self.state.chats.values().cloned().collect(); if sessions.is_empty() { - Ok(Some("No chats yet. Use /connect to start one.".to_string())) + Ok(Some( + "No chats yet. Use /dm or /new to start one.".to_string(), + )) } else { self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len())); for s in &sessions { @@ -384,7 +419,8 @@ where "" }; let label = format!( - " • {} ({}){marker}", + " • [{:?}] {} ({}){marker}", + s.kind, s.display_name(), &s.chat_id[..8.min(s.chat_id.len())] ); diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index 4914dc6..2dcd30c 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -279,7 +279,7 @@ where app.status = format!("Send error: {}", e); } } else { - app.status = "No active chat. Use /connect first.".to_string(); + app.status = "No active chat. Use /dm or /new first.".to_string(); } } KeyCode::Char(c) => {