diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 3132676..2c31188 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.17" +version = "0.0.18" dependencies = [ "anyhow", "argon2", @@ -2822,7 +2822,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.17" +version = "0.0.18" dependencies = [ "anyhow", "clap", @@ -2831,7 +2831,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.17" +version = "0.0.18" dependencies = [ "base64", "bincode", @@ -2856,7 +2856,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.17" +version = "0.0.18" dependencies = [ "anyhow", "axum", @@ -2883,7 +2883,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.17" +version = "0.0.18" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 5a44501..9758c53 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.18" +version = "0.0.19" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index de09991..5c992e5 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -8,6 +8,8 @@ use x25519_dalek::StaticSecret; pub struct LocalDb { sessions: sled::Tree, pre_keys: sled::Tree, + contacts: sled::Tree, + history: sled::Tree, _db: sled::Db, } @@ -35,9 +37,13 @@ impl LocalDb { }; let sessions = db.open_tree("sessions")?; let pre_keys = db.open_tree("pre_keys")?; + let contacts = db.open_tree("contacts")?; + let history = db.open_tree("history")?; Ok(LocalDb { sessions, pre_keys, + contacts, + history, _db: db, }) } @@ -109,6 +115,90 @@ impl LocalDb { } } + // ── Contacts ── + + /// Add or update a contact. Called on send/receive. + pub fn touch_contact(&self, fingerprint: &str, alias: Option<&str>) -> Result<()> { + let fp = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); + let now = chrono::Utc::now().timestamp(); + + let mut record = match self.contacts.get(fp.as_bytes())? { + Some(data) => serde_json::from_slice::(&data).unwrap_or_default(), + None => serde_json::json!({}), + }; + let obj = record.as_object_mut().unwrap(); + obj.insert("fingerprint".into(), serde_json::json!(fp)); + obj.insert("last_seen".into(), serde_json::json!(now)); + if let Some(a) = alias { + obj.insert("alias".into(), serde_json::json!(a)); + } + if !obj.contains_key("first_seen") { + obj.insert("first_seen".into(), serde_json::json!(now)); + } + let count = obj.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0); + obj.insert("message_count".into(), serde_json::json!(count + 1)); + + self.contacts.insert(fp.as_bytes(), serde_json::to_vec(&record)?)?; + Ok(()) + } + + /// Get all contacts sorted by last_seen (most recent first). + pub fn list_contacts(&self) -> Result> { + let mut contacts: Vec = self.contacts.iter() + .filter_map(|item| { + item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok()) + }) + .collect(); + contacts.sort_by(|a, b| { + let ta = a.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0); + let tb = b.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0); + tb.cmp(&ta) + }); + Ok(contacts) + } + + // ── Message History ── + + /// Store a message in local history. + pub fn store_message(&self, peer_fp: &str, sender: &str, text: &str, is_self: bool) -> Result<()> { + let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); + let now = chrono::Utc::now().timestamp(); + let id = uuid::Uuid::new_v4().to_string(); + + let msg = serde_json::json!({ + "id": id, + "peer": fp, + "sender": sender, + "text": text, + "is_self": is_self, + "timestamp": now, + }); + + // Key: hist::: for ordered scan + let key = format!("hist:{}:{}:{}", fp, now, id); + self.history.insert(key.as_bytes(), serde_json::to_vec(&msg)?)?; + Ok(()) + } + + /// Get message history with a peer (most recent N messages). + pub fn get_history(&self, peer_fp: &str, limit: usize) -> Result> { + let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); + let prefix = format!("hist:{}:", fp); + + let mut messages: Vec = self.history + .scan_prefix(prefix.as_bytes()) + .filter_map(|item| { + item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok()) + }) + .collect(); + + // Take last N + if messages.len() > limit { + messages = messages.split_off(messages.len() - limit); + } + Ok(messages) + } + /// Export all data as JSON (for encrypted backup). pub fn export_all(&self) -> Result { let mut sessions = serde_json::Map::new(); diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index 667aa14..87ce590 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -288,6 +288,60 @@ impl App { } return; } + if text == "/contacts" || text == "/c" { + match db.list_contacts() { + Ok(contacts) => { + if contacts.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None }); + for c in &contacts { + let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); + let alias = c.get("alias").and_then(|v| v.as_str()); + let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0); + let label = match alias { + Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count), + None => format!(" {} — {} msgs", &fp[..fp.len().min(16)], count), + }; + self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), + } + return; + } + if text.starts_with("/history") || text.starts_with("/h ") { + let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" }; + let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer }; + if fp.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h (or set peer first)".into(), is_system: true, is_self: false, message_id: None }); + } else { + match db.get_history(fp, 50) { + Ok(msgs) => { + if msgs.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None }); + for m in &msgs { + let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?"); + let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false); + self.add_message(ChatLine { + sender: sender[..sender.len().min(12)].to_string(), + text: txt.to_string(), + is_system: false, + is_self, + message_id: None, + }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), + } + } + return; + } if text == "/eth" { // Show ethereum address from seed if let Ok(seed) = crate::keystore::load_seed_raw() { @@ -543,6 +597,9 @@ impl App { Ok(_) => { // Track receipt status self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); + // Store in contacts + history + let _ = db.touch_contact(&peer, None); + let _ = db.store_message(&peer, &self.our_fp, &text, true); self.add_message(ChatLine { sender: self.our_fp[..12].to_string(), text: text.clone(), @@ -1128,6 +1185,11 @@ fn process_incoming( } } +fn store_received(db: &LocalDb, sender_fp: &str, text: &str) { + let _ = db.touch_contact(sender_fp, None); + let _ = db.store_message(sender_fp, sender_fp, text, false); +} + fn process_wire_message( wire: WireMessage, identity: &IdentityKeyPair, @@ -1175,6 +1237,7 @@ fn process_wire_message( let text = String::from_utf8_lossy(&plaintext).to_string(); let _ = db.save_session(&sender_fp, &state); *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + store_received(db, &sender_fingerprint, &text); messages.lock().unwrap().push(ChatLine { sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), text, @@ -1205,6 +1268,7 @@ fn process_wire_message( let text = String::from_utf8_lossy(&plaintext).to_string(); let _ = db.save_session(&sender_fp, &state); *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + store_received(db, &sender_fingerprint, &text); messages.lock().unwrap().push(ChatLine { sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), text, @@ -1212,7 +1276,6 @@ fn process_wire_message( is_self: false, message_id: None, }); - // Send delivery receipt send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); } Err(_) => {}