diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index b3800d0..5523f5f 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -52,11 +52,23 @@ cargo run -p chat-cli -- --name alice --transport file cargo run -p chat-cli -- --name bob --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. Alice types `/account` and sends Bob her address. +2. Bob types `/dm `. +3. The chat opens on both sides; either can message. + +**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. ### Optional: KeyPackage registry @@ -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 [name] [address...]` | Create a group chat (optionally naming it and 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 5791522..a313ae7 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; @@ -17,10 +20,36 @@ pub struct DisplayMessage { pub timestamp: u64, } +/// Which kind of MLS conversation this is. `Dm` is a DirectV1 1:1 — no members +/// can be added; `Group` is an addable GroupV2 conversation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChatKind { + Dm, + Group, +} + +impl Default for ChatKind { + fn default() -> Self { + // Chats persisted before this field existed were all DirectV1 DMs. + ChatKind::Dm + } +} + +impl ChatKind { + pub fn badge(self) -> &'static str { + match self { + ChatKind::Dm => "DM", + ChatKind::Group => "group", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatSession { pub chat_id: String, pub nickname: Option, + #[serde(default)] + pub kind: ChatKind, pub messages: Vec, } @@ -132,6 +161,20 @@ where self.command_output.clear(); } + /// Insert a freshly created conversation and make it active. + fn start_session(&mut self, chat_id: String, kind: ChatKind, 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. @@ -165,22 +208,18 @@ 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 kind = match class { + ConversationClass::Private => ChatKind::Dm, + ConversationClass::Group => ChatKind::Group, + }; + let label = chat_id[..8.min(chat_id.len())].to_string(); + self.status = format!("New {} ({label})! Use /nickname to name it.", kind.badge()); + self.start_session(chat_id, kind, None); } Event::MessageReceived { convo_id, content, .. @@ -207,7 +246,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."))?; self.client .send_message(&chat_id, content.as_bytes()) @@ -242,7 +281,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 [name] [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"); @@ -264,35 +304,48 @@ 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:?}"))?; - 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(), - }; - session.messages.push(DisplayMessage { - from_self: true, - content: initial, - timestamp: now(), - }); - self.state.chats.insert(chat_id.clone(), session); - self.set_active_chat(Some(chat_id)); + self.start_session(chat_id, ChatKind::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 (optional) group name; any remaining tokens + // are addresses to invite at creation. `/new` alone makes an empty + // group. + let mut tokens = args.split_whitespace(); + let name = tokens.next().unwrap_or("").to_string(); + let nickname = (!name.is_empty()).then(|| name.clone()); + // 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, "")) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + let label = chat_id[..8.min(chat_id.len())].to_string(); + self.start_session(chat_id, ChatKind::Group, nickname); + 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() { @@ -316,7 +369,7 @@ 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 { @@ -326,7 +379,8 @@ where "" }; let label = format!( - " • {} ({}){marker}", + " • [{}] {} ({}){marker}", + s.kind.badge(), 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 595c48e..2c5387c 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -267,7 +267,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) => {