feat: replace chat-cli /connect with /dm and /new

This commit is contained in:
Mojtaba Chenani
2026-08-12 23:10:59 +02:00
parent 130a2d080c
commit cc3988af74
3 changed files with 113 additions and 46 deletions
+18 -5
View File
@@ -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 <paste address here>`.
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 <paste alice's address>`.
3. The chat opens on both sides; either can message.
**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.
### 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 <address>` | Connect to a user using their address |
| `/dm <address>` | 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 <user>` | Switch active chat |
| `/delete <user>` | Delete a chat session |
+94 -40
View File
@@ -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<String>,
#[serde(default)]
pub kind: ChatKind,
pub messages: Vec<DisplayMessage>,
}
@@ -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<String>) {
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 <address> - Connect using an 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("/nickname <name> - Name the active chat");
self.add_system_message("/chats - List all chats");
self.add_system_message("/switch <name|id> - 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 <address>".to_string()));
"/dm" => {
let address = args.trim();
if address.is_empty() {
return Ok(Some("Usage: /dm <address>".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())]
);
+1 -1
View File
@@ -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) => {