//! Chat application logic. use std::collections::HashMap; use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; use arboard::Clipboard; use libchat::{ChatStorage, Context as ChatManager, Introduction, StorageConfig}; use serde::{Deserialize, Serialize}; use crate::{transport::FileTransport, utils::now}; /// A chat message for display. #[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 chats: HashMap, /// Currently active chat (remote username). pub active_chat: Option, } /// The chat application state. pub struct ChatApp { /// The logos-chat manager. pub manager: ChatManager, /// File-based transport for message passing. pub transport: FileTransport, /// Our introduction bundle (to share with others). pub intro_bundle: Option>, /// 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, } impl ChatApp { /// Create a new chat application. 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::new_from_store( user_name, ChatStorage::new(StorageConfig::Encrypted { path: db_path.to_string_lossy().to_string(), key: "123456".to_string(), })?, ) .context("Failed to open ChatManager")?; // 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.chats.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, state, global_messages: Vec::new(), input: String::new(), status, user_name: user_name.to_string(), state_path, }) } /// Load state from file. fn load_state(path: &PathBuf) -> AppState { if path.exists() && let Ok(contents) = fs::read_to_string(path) && 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.chats.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()?; let bundle_string = String::from_utf8_lossy(&intro).to_string(); self.intro_bundle = Some(intro); self.status = "Introduction bundle created. Share it with others!".to_string(); Ok(bundle_string) } /// 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.chats.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 .create_private_convo(&intro, "👋 Hello!".as_bytes())?; // Send the envelopes via file transport for envelope in envelopes { self.transport.send(remote_user, envelope.data)?; } // Create new session let mut session = ChatSession { chat_id: chat_id.clone().to_string(), remote_user: remote_user.to_string(), messages: Vec::new(), }; session.messages.push(DisplayMessage { from_self: true, content: "👋 Hello!".to_string(), timestamp: now(), }); self.state.chats.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.chats.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 )) } } /// Delete a chat session. pub fn delete_chat(&mut self, remote_user: &str) -> Result<()> { if let Some(_session) = self.state.chats.remove(remote_user) { // TODO delete not implemented in libchat // Also delete from the library's storage // if let Err(e) = self.manager.delete_chat(&session.chat_id) { // // Log but don't fail - the CLI state is already updated // self.status = format!("Warning: failed to delete crypto state: {}", e); // } // If we deleted the active chat, clear it if self.state.active_chat.as_deref() == Some(remote_user) { // Switch to another chat if available, otherwise clear self.state.active_chat = self.state.chats.keys().next().cloned(); } self.save_state()?; self.status = format!("Deleted 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 active = self .state .active_chat .clone() .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; let session = self .state .chats .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_content(&chat_id, content.as_bytes())?; for envelope in envelopes { self.transport.send(&remote_user, envelope.data)?; } // Update messages if let Some(session) = self.state.chats.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<()> { while let Some(envelope) = self.transport.try_recv() { self.handle_incoming_envelope(&envelope)?; } Ok(()) } /// Handle an incoming envelope. fn handle_incoming_envelope( &mut self, envelope: &crate::transport::MessageEnvelope, ) -> Result<()> { match self.manager.handle_payload(&envelope.data) { Ok(content) => { let from_user = &envelope.from; let content = content.ok_or(anyhow::anyhow!("Convo not exist"))?; let chat_id = content.conversation_id.clone(); // Find or create session for this user if !self.state.chats.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.chats.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() && let Some(session) = self.state.chats.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: {}", 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() && let Some(session) = self.state.chats.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(); let command = parts[0]; let args = parts.get(1).copied().unwrap_or(""); match command { "/help" => { 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("/delete - Delete chat with user"); self.add_system_message("/peers - List transport peers"); 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()?; self.add_system_message("── Your Introduction Bundle ──"); self.add_system_message(&bundle); let clipboard_msg = match Clipboard::new().and_then(|mut cb| cb.set_text(&bundle)) { Ok(()) => "Bundle copied to clipboard! Paste with Cmd+V in /connect.", Err(_) => "Share this bundle with others to connect!", }; self.add_system_message(clipboard_msg); Ok(Some("Bundle created and copied to clipboard".to_string())) } "/connect" => { let connect_parts: Vec<&str> = args.splitn(2, ' ').collect(); if connect_parts.len() < 2 { return Ok(Some("Usage: /connect ".to_string())); } let remote_user = connect_parts[0]; let bundle = connect_parts[1]; self.connect(remote_user, bundle)?; Ok(Some(format!("Connected to {}", remote_user))) } "/chats" => { let chat_names: Vec<_> = self.state.chats.keys().cloned().collect(); if chat_names.is_empty() { Ok(Some("No chats yet. Use /connect to start one.".to_string())) } else { self.add_system_message(&format!("── Your Chats ({}) ──", chat_names.len())); for name in &chat_names { 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)", chat_names.len()))) } } "/switch" => { if args.is_empty() { return Ok(Some("Usage: /switch ".to_string())); } self.switch_chat(args)?; Ok(Some(format!("Switched to {}", args))) } "/delete" => { if args.is_empty() { return Ok(Some("Usage: /delete ".to_string())); } self.delete_chat(args)?; Ok(Some(format!("Deleted chat with {}", 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 chats = self.state.chats.len(); let active = self.state.active_chat.as_deref().unwrap_or("none"); let status = format!( "User: {}\nAddress: {}\nChats: {}\nActive: {}", self.user_name, hex::encode(self.manager.installation_key().as_bytes()), chats, active ); Ok(Some(status)) } "/clear" => { if let Some(active) = &self.state.active_chat.clone() && let Some(session) = self.state.chats.get_mut(active) { session.messages.clear(); self.save_state()?; } Ok(Some("Messages cleared".to_string())) } "/quit" => Ok(None), _ => Ok(Some(format!( "Unknown command: {}. Type /help for commands.", command ))), } } }