diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 0e78f9f..6b4d617 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "argon2", @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "clap", @@ -2688,7 +2688,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.5" +version = "0.0.6" dependencies = [ "base64", "bincode", @@ -2711,7 +2711,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "axum", @@ -2738,7 +2738,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.5" +version = "0.0.6" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index c30babb..1f5104b 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.5" +version = "0.0.6" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/cli/recv.rs b/warzone/crates/warzone-client/src/cli/recv.rs index 943c2bf..7d9eb46 100644 --- a/warzone/crates/warzone-client/src/cli/recv.rs +++ b/warzone/crates/warzone-client/src/cli/recv.rs @@ -29,6 +29,7 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> { for raw in &messages { match bincode::deserialize::(raw) { Ok(WireMessage::KeyExchange { + id: _, sender_fingerprint, sender_identity_encryption_key, ephemeral_public, @@ -80,6 +81,7 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> { } } Ok(WireMessage::Message { + id: _, sender_fingerprint, ratchet_message, }) => { @@ -105,6 +107,16 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> { } } } + Ok(WireMessage::Receipt { + sender_fingerprint, + message_id, + receipt_type, + }) => { + println!( + " [receipt] {} acknowledged message {} ({:?})", + sender_fingerprint, message_id, receipt_type + ); + } Err(e) => { eprintln!(" failed to deserialize message: {}", e); } diff --git a/warzone/crates/warzone-client/src/cli/send.rs b/warzone/crates/warzone-client/src/cli/send.rs index 6437650..ea421e3 100644 --- a/warzone/crates/warzone-client/src/cli/send.rs +++ b/warzone/crates/warzone-client/src/cli/send.rs @@ -27,6 +27,7 @@ pub async fn run(recipient_fp: &str, message: &str, server_url: &str, identity: db.save_session(&recipient, state)?; WireMessage::Message { + id: uuid::Uuid::new_v4().to_string(), sender_fingerprint: our_pub.fingerprint.to_string(), ratchet_message: encrypted, } @@ -51,6 +52,7 @@ pub async fn run(recipient_fp: &str, message: &str, server_url: &str, identity: db.save_session(&recipient, &state)?; WireMessage::KeyExchange { + id: uuid::Uuid::new_v4().to_string(), sender_fingerprint: our_pub.fingerprint.to_string(), sender_identity_encryption_key: *our_pub.encryption.as_bytes(), ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index 52ef0ff..b312468 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -9,15 +10,23 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; use warzone_protocol::identity::IdentityKeyPair; +use warzone_protocol::message::{ReceiptType, WireMessage}; use warzone_protocol::ratchet::RatchetState; use warzone_protocol::types::Fingerprint; use warzone_protocol::x3dh; use x25519_dalek::PublicKey; -use warzone_protocol::message::WireMessage; use crate::net::ServerClient; use crate::storage::LocalDb; +/// Receipt status for a sent message. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReceiptStatus { + Sent, + Delivered, + Read, +} + pub struct App { pub input: String, pub messages: Arc>>, @@ -25,6 +34,8 @@ pub struct App { pub peer_fp: Option, pub server_url: String, pub should_quit: bool, + /// Track receipt status for messages we sent, keyed by message ID. + pub receipts: Arc>>, } #[derive(Clone)] @@ -33,6 +44,8 @@ pub struct ChatLine { pub text: String, pub is_system: bool, pub is_self: bool, + /// Message ID (for sent messages, used to track receipts). + pub message_id: Option, } impl App { @@ -42,6 +55,7 @@ impl App { text: format!("You are {}", our_fp), is_system: true, is_self: false, + message_id: None, }])); if let Some(ref peer) = peer_fp { @@ -50,6 +64,7 @@ impl App { text: format!("Chatting with {}", peer), is_system: true, is_self: false, + message_id: None, }); } else { messages.lock().unwrap().push(ChatLine { @@ -57,6 +72,7 @@ impl App { text: "No peer set. Use /peer , /peer @alias, or /g ".into(), is_system: true, is_self: false, + message_id: None, }); } @@ -65,6 +81,7 @@ impl App { text: "Commands: /alias , /peer , /g , /info, /quit".into(), is_system: true, is_self: false, + message_id: None, }); App { @@ -74,6 +91,7 @@ impl App { peer_fp, server_url, should_quit: false, + receipts: Arc::new(Mutex::new(HashMap::new())), } } @@ -81,6 +99,34 @@ impl App { self.messages.lock().unwrap().push(line); } + fn receipt_indicator(&self, message_id: &Option) -> &'static str { + match message_id { + Some(id) => { + let receipts = self.receipts.lock().unwrap(); + match receipts.get(id) { + Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read) + Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered) + Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent) + } + } + None => "", + } + } + + fn receipt_color(&self, message_id: &Option) -> Color { + match message_id { + Some(id) => { + let receipts = self.receipts.lock().unwrap(); + match receipts.get(id) { + Some(ReceiptStatus::Read) => Color::Blue, + Some(ReceiptStatus::Delivered) => Color::White, + Some(ReceiptStatus::Sent) | None => Color::DarkGray, + } + } + None => Color::DarkGray, + } + } + pub fn draw(&self, frame: &mut Frame) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -127,9 +173,17 @@ impl App { format!("{}: ", &m.sender[..m.sender.len().min(12)]) }; + let receipt_str = if m.is_self && m.message_id.is_some() { + self.receipt_indicator(&m.message_id) + } else { + "" + }; + let receipt_color = self.receipt_color(&m.message_id); + ListItem::new(Line::from(vec![ Span::styled(prefix, style.add_modifier(Modifier::BOLD)), Span::raw(&m.text), + Span::styled(receipt_str, Style::default().fg(receipt_color)), ])) }) .collect(); @@ -178,6 +232,7 @@ impl App { text: format!("Your fingerprint: {}", self.our_fp), is_system: true, is_self: false, + message_id: None, }); return; } @@ -205,6 +260,7 @@ impl App { text: format!("Peer set to {}", fp), is_system: true, is_self: false, + message_id: None, }); self.peer_fp = Some(fp); return; @@ -228,6 +284,7 @@ impl App { text: format!("Switched to group #{}", name), is_system: true, is_self: false, + message_id: None, }); self.peer_fp = Some(format!("#{}", name)); return; @@ -238,6 +295,7 @@ impl App { text: "Switched to DM mode. Use /peer ".into(), is_system: true, is_self: false, + message_id: None, }); self.peer_fp = None; return; @@ -262,6 +320,7 @@ impl App { text: "No peer set. Use /peer ".into(), is_system: true, is_self: false, + message_id: None, }); return; } @@ -275,11 +334,13 @@ impl App { text: "Invalid peer fingerprint".into(), is_system: true, is_self: false, + message_id: None, }); return; } }; + let msg_id = uuid::Uuid::new_v4().to_string(); let our_pub = identity.public_identity(); let mut ratchet = db.load_session(&peer_fp).ok().flatten(); @@ -288,6 +349,7 @@ impl App { Ok(encrypted) => { let _ = db.save_session(&peer_fp, state); WireMessage::Message { + id: msg_id.clone(), sender_fingerprint: our_pub.fingerprint.to_string(), ratchet_message: encrypted, } @@ -298,6 +360,7 @@ impl App { text: format!("Encrypt failed: {}", e), is_system: true, is_self: false, + message_id: None, }); return; } @@ -312,6 +375,7 @@ impl App { text: format!("Failed to fetch bundle: {}", e), is_system: true, is_self: false, + message_id: None, }); return; } @@ -325,6 +389,7 @@ impl App { text: format!("X3DH failed: {}", e), is_system: true, is_self: false, + message_id: None, }); return; } @@ -337,6 +402,7 @@ impl App { Ok(encrypted) => { let _ = db.save_session(&peer_fp, &state); WireMessage::KeyExchange { + id: msg_id.clone(), sender_fingerprint: our_pub.fingerprint.to_string(), sender_identity_encryption_key: *our_pub.encryption.as_bytes(), ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), @@ -350,6 +416,7 @@ impl App { text: format!("Encrypt failed: {}", e), is_system: true, is_self: false, + message_id: None, }); return; } @@ -364,6 +431,7 @@ impl App { text: format!("Serialize failed: {}", e), is_system: true, is_self: false, + message_id: None, }); return; } @@ -371,11 +439,14 @@ impl App { match client.send_message(&peer, Some(&self.our_fp), &encoded).await { Ok(_) => { + // Track receipt status + self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); self.add_message(ChatLine { sender: self.our_fp[..12].to_string(), text: text.clone(), is_system: false, is_self: true, + message_id: Some(msg_id), }); } Err(e) => { @@ -384,6 +455,7 @@ impl App { text: format!("Send failed: {}", e), is_system: true, is_self: false, + message_id: None, }); } } @@ -398,13 +470,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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None }); } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), 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 }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), } } @@ -417,14 +489,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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None }); } 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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), 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 }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), } } @@ -435,18 +507,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 }); + self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None }); } 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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), 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 }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), } } @@ -463,9 +535,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 }); return; } + Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); return; } }, - Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; } + Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); return; } }; let my_fp = normfp(&self.our_fp); @@ -491,6 +563,7 @@ impl App { Ok(encrypted) => { let _ = db.save_session(&member_fp, state); WireMessage::Message { + id: uuid::Uuid::new_v4().to_string(), sender_fingerprint: our_pub.fingerprint.to_string(), ratchet_message: encrypted, } @@ -513,6 +586,7 @@ impl App { Ok(encrypted) => { let _ = db.save_session(&member_fp, &state); WireMessage::KeyExchange { + id: uuid::Uuid::new_v4().to_string(), sender_fingerprint: our_pub.fingerprint.to_string(), sender_identity_encryption_key: *our_pub.encryption.as_bytes(), ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), @@ -536,7 +610,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 }); + self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None }); return; } @@ -554,10 +628,11 @@ impl App { text: text.to_string(), is_system: false, is_self: true, + message_id: None, }); } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None }); } } } @@ -571,14 +646,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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None }); } 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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), 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 }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), } } @@ -588,17 +663,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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None }); 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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None }); } } None } Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); + self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); None } } @@ -611,18 +686,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 }); + self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None }); } 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 }); + self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), 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 }), + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), } } } @@ -631,15 +706,43 @@ fn normfp(fp: &str) -> String { fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() } +/// Send a delivery receipt for a message back to its sender. +fn send_receipt( + our_fp: &str, + sender_fp: &str, + message_id: &str, + receipt_type: ReceiptType, + client: &ServerClient, +) { + let receipt = WireMessage::Receipt { + sender_fingerprint: our_fp.to_string(), + message_id: message_id.to_string(), + receipt_type, + }; + let encoded = match bincode::serialize(&receipt) { + Ok(e) => e, + Err(_) => return, + }; + let client = client.clone(); + let to = sender_fp.to_string(); + let from = our_fp.to_string(); + tokio::spawn(async move { + let _ = client.send_message(&to, Some(&from), &encoded).await; + }); +} + /// Process a single incoming raw message (shared by WS and HTTP paths). fn process_incoming( raw: &[u8], identity: &IdentityKeyPair, db: &LocalDb, messages: &Arc>>, + receipts: &Arc>>, + our_fp: &str, + client: &ServerClient, ) { match bincode::deserialize::(raw) { - Ok(wire) => process_wire_message(wire, identity, db, messages), + Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, our_fp, client), Err(_) => {} } } @@ -649,9 +752,13 @@ fn process_wire_message( identity: &IdentityKeyPair, db: &LocalDb, messages: &Arc>>, + receipts: &Arc>>, + our_fp: &str, + client: &ServerClient, ) { match wire { WireMessage::KeyExchange { + id, sender_fingerprint, sender_identity_encryption_key, ephemeral_public, @@ -666,8 +773,8 @@ fn process_wire_message( Ok(Some(s)) => s, _ => return, }; - let otpk_secret = if let Some(id) = used_one_time_pre_key_id { - db.take_one_time_pre_key(id).ok().flatten() + let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id { + db.take_one_time_pre_key(otpk_id).ok().flatten() } else { None }; @@ -689,12 +796,16 @@ fn process_wire_message( text, is_system: false, is_self: false, + message_id: None, }); + // Send delivery receipt + send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); } Err(_) => {} } } WireMessage::Message { + id, sender_fingerprint, ratchet_message, } => { @@ -715,17 +826,43 @@ fn process_wire_message( text, is_system: false, is_self: false, + message_id: None, }); + // Send delivery receipt + send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); } Err(_) => {} } } + WireMessage::Receipt { + sender_fingerprint: _, + message_id, + receipt_type, + } => { + // Update receipt status for the referenced message + let mut r = receipts.lock().unwrap(); + let current = r.get(&message_id); + let should_update = match (&receipt_type, current) { + (ReceiptType::Read, _) => true, + (ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true, + (ReceiptType::Delivered, None) => true, + _ => false, + }; + if should_update { + let new_status = match receipt_type { + ReceiptType::Delivered => ReceiptStatus::Delivered, + ReceiptType::Read => ReceiptStatus::Read, + }; + r.insert(message_id, new_status); + } + } } } /// Real-time message loop via WebSocket (falls back to HTTP polling). pub async fn poll_loop( messages: Arc>>, + receipts: Arc>>, our_fp: String, identity: IdentityKeyPair, db: Arc, @@ -747,6 +884,7 @@ pub async fn poll_loop( text: "Real-time connection established".into(), is_system: true, is_self: false, + message_id: None, }); use futures_util::StreamExt; @@ -754,7 +892,7 @@ pub async fn poll_loop( while let Some(Ok(msg)) = read.next().await { if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg { - process_incoming(&data, &identity, &db, &messages); + process_incoming(&data, &identity, &db, &messages, &receipts, &our_fp, &client); } } @@ -763,6 +901,7 @@ pub async fn poll_loop( text: "Connection lost, reconnecting...".into(), is_system: true, is_self: false, + message_id: None, }); tokio::time::sleep(Duration::from_secs(3)).await; } @@ -774,7 +913,7 @@ pub async fn poll_loop( Err(_) => continue, }; for raw in &raw_msgs { - process_incoming(raw, &identity, &db, &messages); + process_incoming(raw, &identity, &db, &messages, &receipts, &our_fp, &client); } } } @@ -799,12 +938,13 @@ pub async fn run_tui( // Derive a second identity for the poll loop (can't clone IdentityKeyPair) let poll_identity = poll_seed.derive_identity(); let poll_messages = app.messages.clone(); + let poll_receipts = app.receipts.clone(); let poll_client = client.clone(); let poll_db = db.clone(); let poll_fp = our_fp.clone(); tokio::spawn(async move { - poll_loop(poll_messages, poll_fp, poll_identity, poll_db, poll_client).await; + poll_loop(poll_messages, poll_receipts, poll_fp, poll_identity, poll_db, poll_client).await; }); loop { diff --git a/warzone/crates/warzone-protocol/src/message.rs b/warzone/crates/warzone-protocol/src/message.rs index 4ad3c22..65d1616 100644 --- a/warzone/crates/warzone-protocol/src/message.rs +++ b/warzone/crates/warzone-protocol/src/message.rs @@ -34,12 +34,20 @@ pub enum MessageContent { Receipt { message_id: MessageId }, } +/// Receipt type: delivered (received + decrypted) or read (user viewed). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ReceiptType { + Delivered, + Read, +} + /// Wire message format for transport between clients. /// Used by both CLI and WASM — MUST be identical for interop. #[derive(Clone, Serialize, Deserialize)] pub enum WireMessage { /// First message to a peer: X3DH key exchange + first ratchet message. KeyExchange { + id: String, sender_fingerprint: String, sender_identity_encryption_key: [u8; 32], ephemeral_public: [u8; 32], @@ -48,7 +56,14 @@ pub enum WireMessage { }, /// Subsequent messages: ratchet-encrypted. Message { + id: String, sender_fingerprint: String, ratchet_message: crate::ratchet::RatchetMessage, }, + /// Delivery / read receipt (plaintext, not encrypted). + Receipt { + sender_fingerprint: String, + message_id: String, + receipt_type: ReceiptType, + }, } diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index e09b837..a67af7f 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -143,7 +143,7 @@ const WEB_HTML: &str = r##"