From 537d48d5fdf3df22d5436300960aedb7ce626b39 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 6 Feb 2026 00:18:37 +0800 Subject: [PATCH] multiple chats support --- .gitignore | 1 + Cargo.lock | 267 ++---------------- chat-cli/Cargo.toml | 3 - chat-cli/README.md | 11 +- chat-cli/src/app.rs | 354 ++++++++++++++++++------ chat-cli/src/main.rs | 29 +- chat-cli/src/transport.rs | 205 +++++++------- chat-cli/src/ui.rs | 46 +-- conversations/src/inbox/introduction.rs | 4 +- 9 files changed, 434 insertions(+), 486 deletions(-) diff --git a/.gitignore b/.gitignore index d395ba5..d737efd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ target # Temporary data folder tmp +chat-cli-data diff --git a/Cargo.lock b/Cargo.lock index acb062d..84b0def 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,12 +30,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -127,13 +121,10 @@ version = "0.1.0" dependencies = [ "anyhow", "crossterm 0.29.0", - "dirs", "logos-chat", - "notify", "ratatui", "serde", "serde_json", - "tempfile", ] [[package]] @@ -199,7 +190,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", "mio", "parking_lot", @@ -215,7 +206,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", "derive_more 2.1.1", "document-features", @@ -376,27 +367,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "document-features" version = "0.2.12" @@ -541,15 +511,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -674,26 +635,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inotify" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" -dependencies = [ - "bitflags 2.10.0", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "inout" version = "0.1.4" @@ -749,42 +690,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags 2.10.0", - "libc", -] - [[package]] name = "libsqlite3-sys" version = "0.33.0" @@ -891,33 +802,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.10.0", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -952,12 +836,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1125,7 +1003,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cassowary", "compact_str", "crossterm 0.28.1", @@ -1146,18 +1024,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", + "bitflags", ] [[package]] @@ -1166,7 +1033,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1189,7 +1056,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1202,7 +1069,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.11.0", @@ -1253,15 +1120,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1642,16 +1500,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1683,15 +1531,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1710,16 +1549,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -1737,31 +1567,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1770,96 +1583,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" diff --git a/chat-cli/Cargo.toml b/chat-cli/Cargo.toml index a53396f..e7c4e8e 100644 --- a/chat-cli/Cargo.toml +++ b/chat-cli/Cargo.toml @@ -14,6 +14,3 @@ crossterm = "0.29" anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -notify = "8.0" -tempfile = "3" -dirs = "6.0" diff --git a/chat-cli/README.md b/chat-cli/README.md index 4018ad5..481def5 100644 --- a/chat-cli/README.md +++ b/chat-cli/README.md @@ -40,21 +40,22 @@ cargo run -p chat-cli -- bob | `/help` | Show available commands | | `/intro` | Generate and display your introduction bundle | | `/connect ` | Connect to a user using their introduction bundle | +| `/peers` | List available peers | | `/status` | Show connection status and your address | | `/clear` | Clear message history | -| `/quit` or `Esc` | Exit the application | +| `/quit` or `Esc` or `Ctrl+C` | Exit the application | ### Sending Messages -Simply type your message and press Enter. Messages are automatically encrypted and delivered via the file-based transport. +Simply type your message and press Enter. Messages are automatically encrypted and delivered via file-based transport. ## How It Works ### File-Based Transport -Since this is a local demo without a real network, messages are passed between users via files: +Messages are passed between users via files in a shared directory: -1. Each user has an "inbox" directory at `~/.local/share/chat-cli/transport//` +1. Each user has an "inbox" directory at `chat-cli-data/transport//` 2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox 3. Bob's client watches for new files and processes incoming messages 4. Files are deleted after processing @@ -62,7 +63,7 @@ Since this is a local demo without a real network, messages are passed between u ### Storage User data (identity keys, chat state) is stored in SQLite databases at: -- `~/.local/share/chat-cli/data/.db` +- `chat-cli-data/.db` ### Encryption diff --git a/chat-cli/src/app.rs b/chat-cli/src/app.rs index f67612b..c5b3cd1 100644 --- a/chat-cli/src/app.rs +++ b/chat-cli/src/app.rs @@ -1,21 +1,40 @@ //! Chat application logic. +use std::collections::HashMap; +use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; use logos_chat::{ChatManager, Introduction, StorageConfig}; +use serde::{Deserialize, Serialize}; use crate::transport::FileTransport; /// A chat message for display. -#[derive(Debug, Clone)] -#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DisplayMessage { pub from_self: bool, pub content: String, pub timestamp: u64, } +/// Metadata for a chat session (persisted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatSession { + pub chat_id: String, + pub remote_user: String, + pub messages: Vec, +} + +/// App state that gets persisted. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct AppState { + /// Map from remote username to chat session. + pub sessions: HashMap, + /// Currently active chat (remote username). + pub active_chat: Option, +} + /// The chat application state. pub struct ChatApp { /// The logos-chat manager. @@ -24,50 +43,103 @@ pub struct ChatApp { pub transport: FileTransport, /// Our introduction bundle (to share with others). pub intro_bundle: Option, - /// Current chat ID (if in a conversation). - pub current_chat_id: Option, - /// Remote user name (for transport). - pub remote_user: Option, - /// Messages to display. - pub messages: Vec, + /// Persisted app state. + pub state: AppState, + /// Global messages (shown when no active chat). + pub global_messages: Vec, /// Input buffer. pub input: String, /// Status message. pub status: String, /// Our user name. pub user_name: String, + /// Path to state file. + state_path: PathBuf, + /// Data directory. + data_dir: PathBuf, } impl ChatApp { /// Create a new chat application. - pub fn new(user_name: &str, data_dir: &PathBuf, transport_dir: &PathBuf) -> Result { + pub fn new(user_name: &str, data_dir: &PathBuf) -> Result { // Create database path let db_path = data_dir.join(format!("{}.db", user_name)); std::fs::create_dir_all(data_dir)?; // Open or create the chat manager with file-based storage - let manager = ChatManager::open(StorageConfig::File( - db_path.to_string_lossy().to_string(), - )) - .context("Failed to open ChatManager")?; + let manager = ChatManager::open(StorageConfig::File(db_path.to_string_lossy().to_string())) + .context("Failed to open ChatManager")?; - // Create file transport - let transport = FileTransport::new(user_name, transport_dir) - .context("Failed to create transport")?; + // Create file-based transport + let transport = + FileTransport::new(user_name, data_dir).context("Failed to create file transport")?; + + // Load persisted state + let state_path = data_dir.join(format!("{}_state.json", user_name)); + let state = Self::load_state(&state_path); + + // Count existing chats + let chat_count = state.sessions.len(); + let status = if chat_count > 0 { + format!( + "Welcome back, {}! {} chat(s) loaded. Type /help for commands.", + user_name, chat_count + ) + } else { + format!("Welcome, {}! Type /help for commands.", user_name) + }; Ok(Self { manager, transport, intro_bundle: None, - current_chat_id: None, - remote_user: None, - messages: Vec::new(), + state, + global_messages: Vec::new(), input: String::new(), - status: format!("Welcome, {}! Type /help for commands.", user_name), + status, user_name: user_name.to_string(), + state_path, + data_dir: data_dir.clone(), }) } + /// Load state from file. + fn load_state(path: &PathBuf) -> AppState { + if path.exists() { + if let Ok(contents) = fs::read_to_string(path) { + if let Ok(state) = serde_json::from_str(&contents) { + return state; + } + } + } + AppState::default() + } + + /// Save state to file. + fn save_state(&self) -> Result<()> { + let json = serde_json::to_string_pretty(&self.state)?; + fs::write(&self.state_path, json)?; + Ok(()) + } + + /// Get the current chat session (if any). + pub fn current_session(&self) -> Option<&ChatSession> { + self.state + .active_chat + .as_ref() + .and_then(|name| self.state.sessions.get(name)) + } + + /// Get the current messages to display. + pub fn messages(&self) -> Vec<&DisplayMessage> { + if let Some(session) = self.current_session() { + session.messages.iter().collect() + } else { + // Show global messages when no active chat + self.global_messages.iter().collect() + } + } + /// Create and display our introduction bundle. pub fn create_intro(&mut self) -> Result { let intro = self.manager.create_intro_bundle()?; @@ -80,96 +152,165 @@ impl ChatApp { /// Connect to another user using their introduction bundle. pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> { + // Check if we already have a chat with this user + if self.state.sessions.contains_key(remote_user) { + return Err(anyhow::anyhow!( + "Already have a chat with {}. Use /switch {} to switch to it.", + remote_user, + remote_user + )); + } + let intro = Introduction::try_from(bundle_str.as_bytes()) .map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?; let (chat_id, envelopes) = self.manager.start_private_chat(&intro, "👋 Hello!")?; - self.current_chat_id = Some(chat_id.clone()); - self.remote_user = Some(remote_user.to_string()); - // Send the envelopes via file transport for envelope in envelopes { self.transport.send(remote_user, envelope.data)?; } - self.messages.push(DisplayMessage { + // Create new session + let mut session = ChatSession { + chat_id: chat_id.clone(), + remote_user: remote_user.to_string(), + messages: Vec::new(), + }; + session.messages.push(DisplayMessage { from_self: true, content: "👋 Hello!".to_string(), timestamp: now(), }); - self.status = format!("Connected to {}! Chat ID: {}", remote_user, &chat_id[..8]); + self.state.sessions.insert(remote_user.to_string(), session); + self.state.active_chat = Some(remote_user.to_string()); + self.save_state()?; + + self.status = format!("Connected to {}!", remote_user); Ok(()) } + /// Switch to a different chat. + pub fn switch_chat(&mut self, remote_user: &str) -> Result<()> { + if self.state.sessions.contains_key(remote_user) { + self.state.active_chat = Some(remote_user.to_string()); + self.save_state()?; + self.status = format!("Switched to chat with {}", remote_user); + Ok(()) + } else { + Err(anyhow::anyhow!( + "No chat with {}. Use /chats to list available chats.", + remote_user + )) + } + } + /// Send a message in the current chat. pub fn send_message(&mut self, content: &str) -> Result<()> { - let chat_id = self.current_chat_id.as_ref() - .ok_or_else(|| anyhow::anyhow!("No active chat"))?; - let remote_user = self.remote_user.as_ref() - .ok_or_else(|| anyhow::anyhow!("No remote user"))?; + let active = self + .state + .active_chat + .clone() + .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; - let envelopes = self.manager.send_message(chat_id, content.as_bytes())?; + let session = self + .state + .sessions + .get(&active) + .ok_or_else(|| anyhow::anyhow!("Chat session not found"))?; + + let chat_id = session.chat_id.clone(); + let remote_user = session.remote_user.clone(); + + let envelopes = self.manager.send_message(&chat_id, content.as_bytes())?; for envelope in envelopes { - self.transport.send(remote_user, envelope.data)?; + self.transport.send(&remote_user, envelope.data)?; } - self.messages.push(DisplayMessage { - from_self: true, - content: content.to_string(), - timestamp: now(), - }); + // Update messages + if let Some(session) = self.state.sessions.get_mut(&active) { + session.messages.push(DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }); + } + self.save_state()?; Ok(()) } /// Process incoming messages from transport. pub fn process_incoming(&mut self) -> Result<()> { - // Check for new messages while let Some(envelope) = self.transport.try_recv() { self.handle_incoming_envelope(&envelope)?; } Ok(()) } - /// Process existing messages on startup. - pub fn process_existing(&mut self) -> Result<()> { - let messages = self.transport.process_existing_messages(); - for envelope in messages { - self.handle_incoming_envelope(&envelope)?; - } - Ok(()) - } - /// Handle an incoming envelope. - fn handle_incoming_envelope(&mut self, envelope: &crate::transport::FileEnvelope) -> Result<()> { + fn handle_incoming_envelope( + &mut self, + envelope: &crate::transport::MessageEnvelope, + ) -> Result<()> { match self.manager.handle_incoming(&envelope.data) { Ok(content) => { - // Update chat state if this is a new chat - if self.current_chat_id.is_none() { - self.current_chat_id = Some(content.conversation_id.clone()); - self.remote_user = Some(envelope.from.clone()); - self.status = format!("New chat from {}!", envelope.from); + let from_user = &envelope.from; + let chat_id = content.conversation_id.clone(); + + // Find or create session for this user + if !self.state.sessions.contains_key(from_user) { + // New chat from someone + let session = ChatSession { + chat_id: chat_id.clone(), + remote_user: from_user.clone(), + messages: Vec::new(), + }; + self.state.sessions.insert(from_user.clone(), session); + self.state.active_chat = Some(from_user.clone()); + self.status = format!("New chat from {}!", from_user); } let message = String::from_utf8_lossy(&content.data).to_string(); if !message.is_empty() { - self.messages.push(DisplayMessage { - from_self: false, - content: message, - timestamp: envelope.timestamp, - }); + if let Some(session) = self.state.sessions.get_mut(from_user) { + session.messages.push(DisplayMessage { + from_self: false, + content: message, + timestamp: envelope.timestamp, + }); + } } + + self.save_state()?; } Err(e) => { - self.status = format!("Error handling message: {}", e); + self.status = format!("Error: {}", e); } } Ok(()) } + /// Add a system message to the current chat (for display only). + fn add_system_message(&mut self, content: &str) { + let msg = DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }; + + if let Some(active) = &self.state.active_chat.clone() { + if let Some(session) = self.state.sessions.get_mut(active) { + session.messages.push(msg); + return; + } + } + // No active chat - add to global messages + self.global_messages.push(msg); + } + /// Handle a command (starts with /). pub fn handle_command(&mut self, cmd: &str) -> Result> { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); @@ -178,18 +319,22 @@ impl ChatApp { match command { "/help" => { - Ok(Some( - "Commands:\n\ - /intro - Show your introduction bundle\n\ - /connect - Connect to a user\n\ - /status - Show connection status\n\ - /clear - Clear messages\n\ - /quit - Exit".to_string() - )) + self.add_system_message("── Commands ──"); + self.add_system_message("/intro - Show your introduction bundle"); + self.add_system_message("/connect - Connect to a user"); + self.add_system_message("/chats - List all chats"); + self.add_system_message("/switch - Switch to chat with user"); + self.add_system_message("/status - Show connection status"); + self.add_system_message("/clear - Clear current chat messages"); + self.add_system_message("/quit or Esc or Ctrl+C - Exit"); + Ok(Some("Help displayed".to_string())) } "/intro" => { let bundle = self.create_intro()?; - Ok(Some(format!("Your bundle:\n{}", bundle))) + self.add_system_message("── Your Introduction Bundle ──"); + self.add_system_message(&bundle); + self.add_system_message("Share this bundle with others to connect!"); + Ok(Some("Bundle created".to_string())) } "/connect" => { let connect_parts: Vec<&str> = args.splitn(2, ' ').collect(); @@ -201,29 +346,70 @@ impl ChatApp { self.connect(remote_user, bundle)?; Ok(Some(format!("Connected to {}", remote_user))) } + "/chats" => { + let sessions: Vec<_> = self.state.sessions.keys().cloned().collect(); + if sessions.is_empty() { + Ok(Some("No chats yet. Use /connect to start one.".to_string())) + } else { + self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len())); + for name in &sessions { + let marker = if Some(name) == self.state.active_chat.as_ref() { + " (active)" + } else { + "" + }; + self.add_system_message(&format!(" • {}{}", name, marker)); + } + Ok(Some(format!("{} chat(s)", sessions.len()))) + } + } + "/switch" => { + if args.is_empty() { + return Ok(Some("Usage: /switch ".to_string())); + } + self.switch_chat(args)?; + Ok(Some(format!("Switched to {}", args))) + } + "/peers" => { + let peers = self.transport.list_peers(); + if peers.is_empty() { + Ok(Some( + "No peers found. Start another chat-cli instance.".to_string(), + )) + } else { + self.add_system_message(&format!("── Peers ({}) ──", peers.len())); + for peer in &peers { + self.add_system_message(&format!(" • {}", peer)); + } + Ok(Some(format!("{} peer(s)", peers.len()))) + } + } "/status" => { - let status = match &self.current_chat_id { - Some(id) => format!( - "Chat ID: {}\nRemote: {}\nAddress: {}", - &id[..8.min(id.len())], - self.remote_user.as_deref().unwrap_or("none"), - self.manager.local_address() - ), - None => format!( - "No active chat\nAddress: {}", - self.manager.local_address() - ), - }; + let chats = self.state.sessions.len(); + let active = self.state.active_chat.as_deref().unwrap_or("none"); + let status = format!( + "User: {}\nAddress: {}\nChats: {}\nActive: {}", + self.user_name, + self.manager.local_address(), + chats, + active + ); Ok(Some(status)) } "/clear" => { - self.messages.clear(); + if let Some(active) = &self.state.active_chat { + if let Some(session) = self.state.sessions.get_mut(active) { + session.messages.clear(); + self.save_state()?; + } + } Ok(Some("Messages cleared".to_string())) } - "/quit" => { - Ok(None) // Signal to quit - } - _ => Ok(Some(format!("Unknown command: {}", command))), + "/quit" => Ok(None), + _ => Ok(Some(format!( + "Unknown command: {}. Type /help for commands.", + command + ))), } } } diff --git a/chat-cli/src/main.rs b/chat-cli/src/main.rs index 2fa05f5..88b85b4 100644 --- a/chat-cli/src/main.rs +++ b/chat-cli/src/main.rs @@ -1,7 +1,7 @@ //! Chat CLI - A terminal chat application using logos-chat. //! //! This application demonstrates how to use the logos-chat library -//! with a file-based transport for local simulation. +//! with file-based transport for local communication. //! //! # Usage //! @@ -32,6 +32,16 @@ use std::path::PathBuf; use anyhow::{Context, Result}; +/// Get the data directory (in project folder). +fn get_data_dir() -> PathBuf { + // Use the directory where the binary is or current working directory + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + PathBuf::from(manifest_dir) + .parent() + .unwrap_or(&PathBuf::from(".")) + .join("chat-cli-data") +} + fn main() -> Result<()> { // Parse arguments let args: Vec = std::env::args().collect(); @@ -45,26 +55,15 @@ fn main() -> Result<()> { let user_name = &args[1]; - // Setup directories - let base_dir = dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("chat-cli"); - let data_dir = base_dir.join("data"); - let transport_dir = base_dir.join("transport"); - + // Setup data directory in project folder + let data_dir = get_data_dir(); std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?; - std::fs::create_dir_all(&transport_dir).context("Failed to create transport directory")?; println!("Starting chat as '{}'...", user_name); println!("Data dir: {:?}", data_dir); - println!("Transport dir: {:?}", transport_dir); // Create app - let mut app = app::ChatApp::new(user_name, &data_dir, &transport_dir) - .context("Failed to create chat app")?; - - // Process any existing messages - app.process_existing()?; + let mut app = app::ChatApp::new(user_name, &data_dir).context("Failed to create chat app")?; // Initialize terminal UI let mut terminal = ui::init().context("Failed to initialize terminal")?; diff --git a/chat-cli/src/transport.rs b/chat-cli/src/transport.rs index bd9129d..09c102b 100644 --- a/chat-cli/src/transport.rs +++ b/chat-cli/src/transport.rs @@ -1,126 +1,134 @@ -//! File-based transport for local chat simulation. +//! File-based transport for local chat communication. //! -//! Each user has an inbox directory where other users drop messages. -//! Messages are JSON files with envelope data. +//! Messages are passed between users via files in a shared directory. -use std::fs::{self, File}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::mpsc::{self, Receiver, Sender}; -use std::thread; -use std::time::Duration; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; use anyhow::{Context, Result}; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind}; use serde::{Deserialize, Serialize}; -/// A message envelope for file-based transport. +/// A message envelope for transport. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileEnvelope { +pub struct MessageEnvelope { pub from: String, pub data: Vec, pub timestamp: u64, } -/// File-based transport for simulating message passing. +/// File-based transport for local communication. pub struct FileTransport { - /// Our user name (used for inbox directory). + /// Our user name. user_name: String, - /// Base directory for all inboxes. + /// Base directory for transport files. base_dir: PathBuf, - /// Channel for receiving incoming messages. - incoming_rx: Receiver, - /// Watcher handle (kept alive). - _watcher: RecommendedWatcher, + /// Our inbox directory. + inbox_dir: PathBuf, + /// Set of processed message files (to avoid reprocessing). + processed: HashSet, } impl FileTransport { /// Create a new file transport. - /// - /// `user_name` is used to create an inbox directory. - /// `base_dir` is the shared directory where all user inboxes live. - pub fn new(user_name: &str, base_dir: &Path) -> Result { + pub fn new(user_name: &str, data_dir: &PathBuf) -> Result { + let base_dir = data_dir.join("transport"); let inbox_dir = base_dir.join(user_name); + + // Create our inbox directory fs::create_dir_all(&inbox_dir) - .with_context(|| format!("Failed to create inbox dir: {:?}", inbox_dir))?; - - let (tx, rx) = mpsc::channel(); - let watcher = Self::start_watcher(&inbox_dir, tx)?; + .context("Failed to create inbox directory")?; Ok(Self { user_name: user_name.to_string(), - base_dir: base_dir.to_path_buf(), - incoming_rx: rx, - _watcher: watcher, + base_dir, + inbox_dir, + processed: HashSet::new(), }) } - /// Start watching the inbox directory for new messages. - fn start_watcher(inbox_dir: &Path, tx: Sender) -> Result { - let mut watcher = RecommendedWatcher::new( - move |res: Result| { - if let Ok(event) = res { - if matches!(event.kind, EventKind::Create(_)) { - for path in event.paths { - if path.extension().map(|e| e == "json").unwrap_or(false) { - // Small delay to ensure file is fully written - thread::sleep(Duration::from_millis(50)); - if let Ok(envelope) = Self::read_message(&path) { - let _ = tx.send(envelope); - // Delete the message after reading - let _ = fs::remove_file(&path); - } - } - } - } - } - }, - Config::default().with_poll_interval(Duration::from_millis(100)), - )?; - - watcher.watch(inbox_dir, RecursiveMode::NonRecursive)?; - Ok(watcher) - } - - /// Read a message from a file. - fn read_message(path: &Path) -> Result { - let mut file = File::open(path)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let envelope: FileEnvelope = serde_json::from_str(&contents)?; - Ok(envelope) - } - - /// Send a message to another user's inbox. + /// Send a message to a specific user. pub fn send(&self, to_user: &str, data: Vec) -> Result<()> { - let to_inbox = self.base_dir.join(to_user); - fs::create_dir_all(&to_inbox)?; + let target_dir = self.base_dir.join(to_user); + + // Create target inbox if it doesn't exist + fs::create_dir_all(&target_dir) + .context("Failed to create target inbox")?; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64; - - let envelope = FileEnvelope { + let envelope = MessageEnvelope { from: self.user_name.clone(), data, - timestamp, + timestamp: now(), }; - let filename = format!("{}_{}.json", self.user_name, timestamp); - let path = to_inbox.join(filename); - + // Write message to a unique file + let filename = format!("{}_{}.json", self.user_name, now()); + let filepath = target_dir.join(&filename); + let json = serde_json::to_string_pretty(&envelope)?; - let mut file = File::create(&path)?; - file.write_all(json.as_bytes())?; - file.sync_all()?; + fs::write(&filepath, json) + .context("Failed to write message file")?; Ok(()) } /// Try to receive an incoming message (non-blocking). - pub fn try_recv(&self) -> Option { - self.incoming_rx.try_recv().ok() + pub fn try_recv(&mut self) -> Option { + // List files in our inbox + let entries = match fs::read_dir(&self.inbox_dir) { + Ok(e) => e, + Err(_) => return None, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip non-json files + if path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + // Skip already processed files + if self.processed.contains(&filename) { + continue; + } + + // Try to read and parse the message + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(envelope) = serde_json::from_str::(&contents) { + // Mark as processed and delete + self.processed.insert(filename); + let _ = fs::remove_file(&path); + return Some(envelope); + } + } + } + + None + } + + /// List available peers (users with inbox directories). + pub fn list_peers(&self) -> Vec { + let mut peers = Vec::new(); + + if let Ok(entries) = fs::read_dir(&self.base_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + if name != self.user_name { + peers.push(name.to_string()); + } + } + } + } + } + + peers } /// Get our user name. @@ -128,26 +136,11 @@ impl FileTransport { pub fn user_name(&self) -> &str { &self.user_name } - - /// Process any existing messages in inbox on startup. - pub fn process_existing_messages(&self) -> Vec { - let inbox_dir = self.base_dir.join(&self.user_name); - let mut messages = Vec::new(); - - if let Ok(entries) = fs::read_dir(&inbox_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "json").unwrap_or(false) { - if let Ok(envelope) = Self::read_message(&path) { - messages.push(envelope); - let _ = fs::remove_file(&path); - } - } - } - } - - // Sort by timestamp - messages.sort_by_key(|m| m.timestamp); - messages - } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 } diff --git a/chat-cli/src/ui.rs b/chat-cli/src/ui.rs index 1502306..e827e22 100644 --- a/chat-cli/src/ui.rs +++ b/chat-cli/src/ui.rs @@ -54,9 +54,9 @@ pub fn draw(frame: &mut Frame, app: &ChatApp) { } fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) { - let title = match &app.remote_user { - Some(remote) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, remote), - None => format!(" 💬 {} (no active chat) ", app.user_name), + let title = match app.current_session() { + Some(session) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, session.remote_user), + None => format!(" 💬 {} (no active chat - use /connect or /chats) ", app.user_name), }; let header = Paragraph::new(title) @@ -67,35 +67,37 @@ fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) { } fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) { + let remote_name = app + .current_session() + .map(|s| s.remote_user.as_str()) + .unwrap_or("Them"); + let messages: Vec = app - .messages + .messages() .iter() .map(|msg| { let (prefix, style) = if msg.from_self { - ("You: ", Style::default().fg(Color::Green)) + ("You", Style::default().fg(Color::Green)) } else { - let name = app.remote_user.as_deref().unwrap_or("Them"); - (name, Style::default().fg(Color::Yellow)) + (remote_name, Style::default().fg(Color::Yellow)) }; - let content = if msg.from_self { - Line::from(vec![ - Span::styled(prefix, style.add_modifier(Modifier::BOLD)), - Span::raw(&msg.content), - ]) - } else { - Line::from(vec![ - Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)), - Span::raw(&msg.content), - ]) - }; + let content = Line::from(vec![ + Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)), + Span::raw(&msg.content), + ]); ListItem::new(content) }) .collect(); + let title = match app.current_session() { + Some(session) => format!(" Messages with {} ", session.remote_user), + None => " Messages ".to_string(), + }; + let messages_widget = List::new(messages) - .block(Block::default().title(" Messages ").borders(Borders::ALL)); + .block(Block::default().title(title).borders(Borders::ALL)); frame.render_widget(messages_widget, area); } @@ -134,6 +136,10 @@ pub fn handle_events(app: &mut ChatApp) -> io::Result { match key.code { KeyCode::Esc => return Ok(false), + // Handle Ctrl+C + KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + return Ok(false); + } KeyCode::Enter => { if !app.input.is_empty() { let input = std::mem::take(&mut app.input); @@ -151,7 +157,7 @@ pub fn handle_events(app: &mut ChatApp) -> io::Result { app.status = format!("Error: {}", e); } } - } else if app.current_chat_id.is_some() { + } else if app.current_session().is_some() { if let Err(e) = app.send_message(&input) { app.status = format!("Send error: {}", e); } diff --git a/conversations/src/inbox/introduction.rs b/conversations/src/inbox/introduction.rs index 9b049e8..8290b4c 100644 --- a/conversations/src/inbox/introduction.rs +++ b/conversations/src/inbox/introduction.rs @@ -39,7 +39,7 @@ impl TryFrom<&[u8]> for Introduction { let str_value = String::from_utf8_lossy(value); let parts: Vec<&str> = str_value.splitn(3, ':').collect(); - if parts[0] != "Bundle" { + if parts.len() < 3 || parts[0] != "Bundle" { return Err(ChatError::BadBundleValue( "not recognized as an introduction bundle".into(), )); @@ -51,7 +51,7 @@ impl TryFrom<&[u8]> for Introduction { .map_err(|_| ChatError::InvalidKeyLength)?; let installation_key = PublicKey::from(installation_bytes); - let ephemeral_bytes: [u8; 32] = hex::decode(parts[1]) + let ephemeral_bytes: [u8; 32] = hex::decode(parts[2]) .map_err(|_| ChatError::BadParsing("ephemeral_key"))? .try_into() .map_err(|_| ChatError::InvalidKeyLength)?;