From 5bc59376f5659046c4112a23b73348720ca44265 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 18:50:47 +0400 Subject: [PATCH] =?UTF-8?q?v0.0.41:=20FC-P6-T2=20=E2=80=94=20read=20receip?= =?UTF-8?q?ts=20when=20messages=20are=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatLine gains sender_fp field for tracking who sent each message - App gains read_receipts_sent HashSet to avoid duplicate receipts - After each draw(), visible received messages get a Read receipt sent - Only fires once per message_id, skips system/self messages - Sender sees blue ✓✓ (existing display logic already handles Read) - All ChatLine literals across 6 files updated with sender_fp field Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- .../crates/warzone-client/src/tui/commands.rs | 212 +++++++++--------- warzone/crates/warzone-client/src/tui/draw.rs | 3 + .../warzone-client/src/tui/file_transfer.rs | 36 +-- warzone/crates/warzone-client/src/tui/mod.rs | 40 +++- .../crates/warzone-client/src/tui/network.rs | 42 ++-- .../crates/warzone-client/src/tui/types.rs | 11 + warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 4 +- 10 files changed, 208 insertions(+), 154 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 77bb10c..bd0da75 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.39" +version = "0.0.40" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.39" +version = "0.0.40" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.39" +version = "0.0.40" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.39" +version = "0.0.40" dependencies = [ "anyhow", "axum", @@ -3054,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.39" +version = "0.0.40" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 17dab0e..f65ae05 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.40" +version = "0.0.41" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 2e40b99..f0d79d1 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -35,9 +35,9 @@ impl App { } if text == "/info" { if !self.our_eth.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: format!("Identity: {}", self.our_eth), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Identity: {}", self.our_eth), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } - self.add_message(ChatLine { sender: "system".into(), text: format!("Fingerprint: {}", self.our_fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Fingerprint: {}", self.our_fp), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } if text == "/help" || text == "/?" { @@ -87,7 +87,7 @@ impl App { text: line.to_string(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } return; @@ -109,12 +109,12 @@ impl App { { Ok(resp) => if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } }, - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } @@ -122,9 +122,9 @@ impl App { 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, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); 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()); @@ -141,11 +141,11 @@ impl App { Some(a) => format!(" {} @{} ({}) — {} msgs", status, a, &fp[..fp.len().min(12)], count), None => format!(" {} {} — {} msgs", status, &fp[..fp.len().min(16)], count), }; - self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } @@ -153,14 +153,14 @@ impl App { 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, timestamp: Local::now() }); + 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, sender_fp: None, timestamp: Local::now() }); } 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, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); 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(""); @@ -170,12 +170,12 @@ impl App { text: txt.to_string(), is_system: false, is_self, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } return; @@ -184,17 +184,17 @@ impl App { // Show ethereum address from seed if let Ok(seed) = crate::keystore::load_seed_raw() { let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); - self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } if text == "/seed" { if let Ok(seed) = crate::keystore::load_seed_raw() { let mnemonic = warzone_protocol::identity::Seed::from_bytes(seed).to_mnemonic(); - self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); - self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } @@ -202,10 +202,10 @@ impl App { if let Ok(seed) = crate::keystore::load_seed_raw() { match db.create_backup(&seed) { Ok(path) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Backup saved: {}", path.display()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Backup saved: {}", path.display()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Backup failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Backup failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } @@ -224,9 +224,9 @@ impl App { match warzone_protocol::friends::FriendList::decrypt(&seed, &blob) { Ok(list) => { if list.friends.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for f in &list.friends { // Check presence let presence_url = format!("{}/v1/presence/{}", client.base_url, normfp(&f.address)); @@ -241,28 +241,28 @@ impl App { Some(a) => format!(" @{} ({}) — {}", a, &f.address[..f.address.len().min(16)], status), None => format!(" {} — {}", &f.address[..f.address.len().min(16)], status), }; - self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } } _ => { - self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text.starts_with("/friend ") { let addr = text[8..].trim().to_string(); if addr.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend
".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend
".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } if let Ok(seed) = crate::keystore::load_seed_raw() { @@ -287,14 +287,14 @@ impl App { let encrypted = list.encrypt(&seed); let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; - self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } if text.starts_with("/unfriend ") { let addr = text[10..].trim().to_string(); if addr.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend
".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend
".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } if let Ok(seed) = crate::keystore::load_seed_raw() { @@ -318,7 +318,7 @@ impl App { let encrypted = list.encrypt(&seed); let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; - self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } return; } @@ -330,31 +330,31 @@ impl App { if let Ok(data) = resp.json::().await { if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) { if devices.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for d in devices { let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?"); let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0); let when = chrono::DateTime::from_timestamp(connected, 0) .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| "?".to_string()); - self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } else if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } if text.starts_with("/kick ") { let device_id = text[6..].trim(); if device_id.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick ".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick ".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id); @@ -362,13 +362,13 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) { - self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } return; } @@ -376,7 +376,7 @@ impl App { let last = self.last_dm_peer.lock().unwrap().clone(); if let Some(ref peer) = last { self.peer_fp = Some(peer.clone()); - self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); // If there's a message after /r, mutate text and fall through to send let reply_msg = if text.starts_with("/reply ") { text[7..].trim().to_string() @@ -390,7 +390,7 @@ impl App { } text = reply_msg; // Fall through to send logic below } else { - self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } } @@ -414,7 +414,7 @@ impl App { raw }; if normfp(&fp) == normfp(&self.our_fp) { - self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } // Resolve peer ETH for display @@ -435,7 +435,7 @@ impl App { text: format!("Peer set to {}", display), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); self.peer_fp = Some(fp); return; @@ -459,7 +459,7 @@ impl App { text: format!("Switched to group #{}", name), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); self.peer_fp = Some(format!("#{}", name)); return; @@ -470,7 +470,7 @@ impl App { text: "Switched to DM mode. Use /peer ".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); self.peer_fp = None; return; @@ -486,7 +486,7 @@ impl App { self.group_leave(&name, client).await; self.peer_fp = None; } else { - self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g first".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g first".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; @@ -498,7 +498,7 @@ impl App { let target = text[7..].trim().to_string(); self.group_kick(&name, &target, client).await; } else { - self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; @@ -509,7 +509,7 @@ impl App { let name = peer[1..].to_string(); self.group_members(&name, client).await; } else { - self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; @@ -540,7 +540,7 @@ impl App { let peer = match peer { Some(p) if !p.starts_with('#') => p, _ => { - self.add_message(ChatLine { sender: "system".into(), text: "No peer to call. Use /call or set a peer first.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No peer to call. Use /call or set a peer first.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; @@ -559,7 +559,7 @@ impl App { let encoded = match bincode::serialize(&wire) { Ok(e) => e, Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; @@ -570,7 +570,7 @@ impl App { .or(Some(&peer)) .map(|s| if s.len() > 16 { format!("{}...", &s[..16]) } else { s.to_string() }) .unwrap_or_default(); - self.add_message(ChatLine { sender: "system".into(), text: format!("📞 Calling {}...", display), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("📞 Calling {}...", display), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = Some(super::types::CallInfo { peer_fp: peer_fp_clean.clone(), peer_display: display.clone(), @@ -579,7 +579,7 @@ impl App { }); } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Call failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; @@ -589,7 +589,7 @@ impl App { let peer = match self.last_dm_peer.lock().unwrap().clone() { Some(p) => p, None => { - self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to accept".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to accept".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; @@ -605,7 +605,7 @@ impl App { }; if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; - self.add_message(ChatLine { sender: "system".into(), text: "✓ Call accepted".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "✓ Call accepted".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = Some(super::types::CallInfo { peer_fp: normfp(&peer), peer_display: peer[..peer.len().min(16)].to_string(), @@ -620,7 +620,7 @@ impl App { let peer = match self.last_dm_peer.lock().unwrap().clone() { Some(p) => p, None => { - self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to reject".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No incoming call to reject".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; @@ -636,7 +636,7 @@ impl App { }; if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; - self.add_message(ChatLine { sender: "system".into(), text: "✗ Call rejected".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "✗ Call rejected".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = None; } return; @@ -647,7 +647,7 @@ impl App { let peer = match peer { Some(p) if !p.starts_with('#') => p, _ => { - self.add_message(ChatLine { sender: "system".into(), text: "No active call".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No active call".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; @@ -663,7 +663,7 @@ impl App { }; if let Ok(encoded) = bincode::serialize(&wire) { let _ = client.send_message(&normfp(&peer), Some(&self.our_fp), &encoded).await; - self.add_message(ChatLine { sender: "system".into(), text: "Call ended".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "Call ended".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); self.call_state = None; } return; @@ -690,7 +690,7 @@ impl App { text: "No peer set. Use /peer ".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -703,7 +703,7 @@ impl App { text: "Cannot send messages to yourself".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -716,7 +716,7 @@ impl App { text: "Invalid peer fingerprint".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -754,11 +754,11 @@ impl App { text: text.clone(), is_system: false, is_self: true, - message_id: Some(msg_id), timestamp: Local::now(), + message_id: Some(msg_id), sender_fp: None, timestamp: Local::now(), }); } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } return; @@ -784,7 +784,7 @@ impl App { text: format!("Encrypt failed: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -799,7 +799,7 @@ impl App { text: format!("Failed to fetch bundle: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -813,7 +813,7 @@ impl App { text: format!("X3DH failed: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -840,7 +840,7 @@ impl App { text: format!("Encrypt failed: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -855,7 +855,7 @@ impl App { text: format!("Serialize failed: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -873,7 +873,7 @@ impl App { text: text.clone(), is_system: false, is_self: true, - message_id: Some(msg_id), timestamp: Local::now(), + message_id: Some(msg_id), sender_fp: None, timestamp: Local::now(), }); } Err(e) => { @@ -882,7 +882,7 @@ impl App { text: format!("Send failed: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } } @@ -897,13 +897,13 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -916,14 +916,14 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0); - self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -934,18 +934,18 @@ impl App { if let Ok(data) = resp.json::().await { if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) { if groups.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { for g in groups { let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0); - self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -958,13 +958,13 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -977,14 +977,14 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?"); - self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -994,7 +994,7 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(members) = data.get("members").and_then(|v| v.as_array()) { - self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); for m in members { let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); let alias = m.get("alias").and_then(|v| v.as_str()); @@ -1003,12 +1003,12 @@ impl App { Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { " ★" } else { "" }), None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { " ★" } else { "" }), }; - self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -1025,9 +1025,9 @@ impl App { let group_data = match client.client.get(&url).send().await { Ok(resp) => match resp.json::().await { Ok(d) => d, - Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; } + Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }, - Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; } + Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } }; let my_fp = normfp(&self.our_fp); @@ -1100,7 +1100,7 @@ impl App { } if wire_messages.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return; } @@ -1118,11 +1118,11 @@ impl App { text: text.to_string(), is_system: false, is_self: true, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } @@ -1136,14 +1136,14 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name); - self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } @@ -1153,17 +1153,17 @@ impl App { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { - self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return Some(fp.to_string()); } if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } None } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); None } } @@ -1180,17 +1180,17 @@ impl App { let formatted: String = fp.chars().enumerate() .flat_map(|(i, c)| if i > 0 && i % 4 == 0 { vec![':', c] } else { vec![c] }) .collect(); - self.add_message(ChatLine { sender: "system".into(), text: format!("{} → {}", addr, formatted), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("{} → {}", addr, formatted), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); return Some(fp.to_string()); } if let Some(err) = data.get("error") { - self.add_message(ChatLine { sender: "system".into(), text: format!("Cannot resolve {}: {}", addr, err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Cannot resolve {}: {}", addr, err), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } None } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); None } } @@ -1203,18 +1203,18 @@ impl App { if let Ok(data) = resp.json::().await { if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) { if aliases.is_empty() { - self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } else { for a in aliases { let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?"); let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); - self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }); } } } } } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() }), } } } diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index 3e45857..ff67860 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -345,6 +345,7 @@ mod tests { is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); @@ -372,6 +373,7 @@ mod tests { is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } @@ -398,6 +400,7 @@ mod tests { is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } diff --git a/warzone/crates/warzone-client/src/tui/file_transfer.rs b/warzone/crates/warzone-client/src/tui/file_transfer.rs index 4c53f11..fad37b6 100644 --- a/warzone/crates/warzone-client/src/tui/file_transfer.rs +++ b/warzone/crates/warzone-client/src/tui/file_transfer.rs @@ -25,7 +25,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("File not found: {}", path_str), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -36,7 +36,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Cannot read file: {}", e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -47,7 +47,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -58,7 +58,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to read file: {}", e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -83,7 +83,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: "Set a peer or group first".into(), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -95,7 +95,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Sending '{}' to group #{}...", filename, group_name), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); // Get members @@ -147,7 +147,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("File '{}' sent to group #{}", filename, group_name), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; }; @@ -158,7 +158,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: "Invalid peer fingerprint".into(), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -170,7 +170,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); // Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data) @@ -189,7 +189,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Serialize header failed: {}", e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -199,7 +199,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to send file header: {}", e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -222,7 +222,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Serialize chunk failed: {}", e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -232,7 +232,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Encrypt chunk {} failed: {}", i, e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -241,7 +241,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: "No ratchet session. Send a text message first to establish one.".into(), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; }; @@ -261,7 +261,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Serialize chunk {} failed: {}", i, e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -271,7 +271,7 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); return; } @@ -279,14 +279,14 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename), - is_system: true, is_self: false, message_id: None, timestamp: Local::now(), + is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(), }); } self.add_message(ChatLine { sender: self.our_fp[..12.min(self.our_fp.len())].to_string(), text: format!("Sent file: {} ({} bytes)", filename, file_size), - is_system: false, is_self: true, message_id: None, timestamp: Local::now(), + is_system: false, is_self: true, message_id: None, sender_fp: None, timestamp: Local::now(), }); } } diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index 7cff1f9..2f6ef75 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -83,6 +83,7 @@ pub async fn run_tui( is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: chrono::Local::now(), }); @@ -91,13 +92,13 @@ pub async fn run_tui( if let Ok(data) = resp.json::().await { if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) { if !bots.is_empty() { - app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); + app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() }); for b in bots { let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let desc = b.get("description").and_then(|v| v.as_str()).unwrap_or(""); - app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); + app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() }); } - app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); + app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() }); } } } @@ -126,6 +127,7 @@ pub async fn run_tui( is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: chrono::Local::now(), }); } @@ -140,6 +142,38 @@ pub async fn run_tui( loop { terminal.draw(|frame| app.draw(frame))?; + // Send Read receipts for visible messages + { + let msgs = app.messages.lock().unwrap(); + let total = msgs.len(); + let visible_end = total.saturating_sub(app.scroll_offset); + let visible_height = 20; // approximate + let visible_start = visible_end.saturating_sub(visible_height); + + let mut sent = app.read_receipts_sent.lock().unwrap(); + for msg in &msgs[visible_start..visible_end] { + if msg.is_system || msg.is_self { continue; } + if let (Some(ref msg_id), Some(ref sfp)) = (&msg.message_id, &msg.sender_fp) { + if sent.contains(msg_id) { continue; } + sent.insert(msg_id.clone()); + // Fire-and-forget Read receipt + let receipt = warzone_protocol::message::WireMessage::Receipt { + sender_fingerprint: app.our_fp.clone(), + message_id: msg_id.clone(), + receipt_type: warzone_protocol::message::ReceiptType::Read, + }; + if let Ok(encoded) = bincode::serialize(&receipt) { + let client = client.clone(); + let to = sfp.clone(); + let from = app.our_fp.clone(); + tokio::spawn(async move { + let _ = client.send_message(&to, Some(&from), &encoded).await; + }); + } + } + } + } + if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Enter { diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs index c2df8ee..570c665 100644 --- a/warzone/crates/warzone-client/src/tui/network.rs +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -179,7 +179,7 @@ fn process_wire_message( text, is_system: false, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(), }); send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); // Terminal bell for incoming DM @@ -196,7 +196,7 @@ fn process_wire_message( ), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e); } @@ -228,7 +228,7 @@ fn process_wire_message( text, is_system: false, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(), }); send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); // Terminal bell for incoming DM @@ -245,7 +245,7 @@ fn process_wire_message( ), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e); } @@ -290,7 +290,7 @@ fn process_wire_message( ), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); let transfer = PendingFileTransfer { @@ -351,7 +351,7 @@ fn process_wire_message( ), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); // Check if all chunks received @@ -377,7 +377,7 @@ fn process_wire_message( ), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } else { // Save to data_dir/downloads/ @@ -394,7 +394,7 @@ fn process_wire_message( ), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } Err(e) => { @@ -403,7 +403,7 @@ fn process_wire_message( text: format!("Failed to save file: {}", e), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } } @@ -450,6 +450,7 @@ fn process_wire_message( is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } @@ -465,6 +466,7 @@ fn process_wire_message( is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } @@ -481,6 +483,7 @@ fn process_wire_message( is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } @@ -510,6 +513,7 @@ fn process_wire_message( is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } @@ -529,7 +533,7 @@ fn process_wire_message( text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); // Terminal bell for incoming call print!("\x07"); @@ -540,7 +544,7 @@ fn process_wire_message( text: format!("\u{2713} {} accepted the call", sender_short), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } CallSignalType::Hangup => { @@ -549,7 +553,7 @@ fn process_wire_message( text: "Call ended".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } CallSignalType::Reject => { @@ -558,7 +562,7 @@ fn process_wire_message( text: format!("{} rejected the call", sender_short), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } CallSignalType::Ringing => { @@ -567,7 +571,7 @@ fn process_wire_message( text: "Ringing...".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } CallSignalType::Busy => { @@ -576,7 +580,7 @@ fn process_wire_message( text: format!("{} is busy", sender_short), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } _ => { @@ -585,7 +589,7 @@ fn process_wire_message( text: format!("\u{1f4de} Call signal: {:?}", signal_type), is_system: false, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); } } @@ -626,7 +630,7 @@ pub async fn poll_loop( text: "Real-time connection established".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); use futures_util::StreamExt; @@ -652,6 +656,7 @@ pub async fn poll_loop( is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); print!("\x07"); @@ -664,6 +669,7 @@ pub async fn poll_loop( is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); print!("\x07"); @@ -680,7 +686,7 @@ pub async fn poll_loop( text: "Connection lost, reconnecting...".into(), is_system: true, is_self: false, - message_id: None, timestamp: Local::now(), + message_id: None, sender_fp: None, timestamp: Local::now(), }); tokio::time::sleep(Duration::from_secs(3)).await; } diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index e88f7cd..a253eb8 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -67,6 +67,8 @@ pub struct App { pub connected: Arc, /// Current call state: None=idle, Some(state)=active pub call_state: Option, + /// Message IDs for which we've already sent a Read receipt (avoid duplicates). + pub read_receipts_sent: Arc>>, } #[derive(Clone)] @@ -77,6 +79,8 @@ pub struct ChatLine { pub is_self: bool, /// Message ID (for sent messages, used to track receipts). pub message_id: Option, + /// Sender's full fingerprint (for sending read receipts back). + pub sender_fp: Option, /// When this message was created/received. pub timestamp: DateTime, } @@ -99,6 +103,7 @@ impl App { is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }])); @@ -109,6 +114,7 @@ impl App { is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } else { @@ -118,6 +124,7 @@ impl App { is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); } @@ -128,6 +135,7 @@ impl App { is_system: true, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); @@ -147,6 +155,7 @@ impl App { scroll_offset: 0, connected: Arc::new(AtomicBool::new(false)), call_state: None, + read_receipts_sent: Arc::new(Mutex::new(std::collections::HashSet::new())), } } @@ -210,6 +219,7 @@ mod tests { is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }; // Timestamp should be within the last second @@ -227,6 +237,7 @@ mod tests { is_system: false, is_self: false, message_id: None, + sender_fp: None, timestamp: Local::now(), }); let new_count = app.messages.lock().unwrap().len(); diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 11cbf80..a199453 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.40" +version = "0.0.41" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 1ccca03..18bb689 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v22'; +const CACHE = 'wz-v23'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -287,7 +287,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.40'; +const VERSION = '0.0.41'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ──