v0.0.6: Delivery receipts (sent/delivered/read)
Protocol:
- WireMessage::Receipt { sender_fingerprint, message_id, receipt_type }
- ReceiptType enum: Delivered, Read
- id field added to KeyExchange and Message variants
- Receipts are plaintext (not encrypted) — contain only ID + type
Web client:
- Auto-sends Delivered receipt on successful decrypt
- Tracks sent message IDs with receipt status
- Displays: ✓ (sent, gray), ✓✓ (delivered, white), ✓✓ (read, blue)
- Receipt indicators update live via DOM reference
CLI TUI:
- Auto-sends Delivered receipt back to sender on decrypt
- Tracks receipt status per message ID
- Displays receipt indicators after sent messages
WASM:
- create_receipt() function for web client
- encrypt_with_id/encrypt_key_exchange_with_id for tracking
- decrypt_wire_message handles Receipt variant
17/17 protocol tests pass. Zero warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -2647,7 +2647,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-client"
|
name = "warzone-client"
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2679,7 +2679,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2688,7 +2688,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -2711,7 +2711,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2738,7 +2738,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.5"
|
version = "0.0.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
|||||||
for raw in &messages {
|
for raw in &messages {
|
||||||
match bincode::deserialize::<WireMessage>(raw) {
|
match bincode::deserialize::<WireMessage>(raw) {
|
||||||
Ok(WireMessage::KeyExchange {
|
Ok(WireMessage::KeyExchange {
|
||||||
|
id: _,
|
||||||
sender_fingerprint,
|
sender_fingerprint,
|
||||||
sender_identity_encryption_key,
|
sender_identity_encryption_key,
|
||||||
ephemeral_public,
|
ephemeral_public,
|
||||||
@@ -80,6 +81,7 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(WireMessage::Message {
|
Ok(WireMessage::Message {
|
||||||
|
id: _,
|
||||||
sender_fingerprint,
|
sender_fingerprint,
|
||||||
ratchet_message,
|
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) => {
|
Err(e) => {
|
||||||
eprintln!(" failed to deserialize message: {}", e);
|
eprintln!(" failed to deserialize message: {}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub async fn run(recipient_fp: &str, message: &str, server_url: &str, identity:
|
|||||||
db.save_session(&recipient, state)?;
|
db.save_session(&recipient, state)?;
|
||||||
|
|
||||||
WireMessage::Message {
|
WireMessage::Message {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
ratchet_message: encrypted,
|
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)?;
|
db.save_session(&recipient, &state)?;
|
||||||
|
|
||||||
WireMessage::KeyExchange {
|
WireMessage::KeyExchange {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -9,15 +10,23 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use warzone_protocol::identity::IdentityKeyPair;
|
use warzone_protocol::identity::IdentityKeyPair;
|
||||||
|
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||||
use warzone_protocol::ratchet::RatchetState;
|
use warzone_protocol::ratchet::RatchetState;
|
||||||
use warzone_protocol::types::Fingerprint;
|
use warzone_protocol::types::Fingerprint;
|
||||||
use warzone_protocol::x3dh;
|
use warzone_protocol::x3dh;
|
||||||
use x25519_dalek::PublicKey;
|
use x25519_dalek::PublicKey;
|
||||||
|
|
||||||
use warzone_protocol::message::WireMessage;
|
|
||||||
use crate::net::ServerClient;
|
use crate::net::ServerClient;
|
||||||
use crate::storage::LocalDb;
|
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 struct App {
|
||||||
pub input: String,
|
pub input: String,
|
||||||
pub messages: Arc<Mutex<Vec<ChatLine>>>,
|
pub messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||||
@@ -25,6 +34,8 @@ pub struct App {
|
|||||||
pub peer_fp: Option<String>,
|
pub peer_fp: Option<String>,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
|
/// Track receipt status for messages we sent, keyed by message ID.
|
||||||
|
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -33,6 +44,8 @@ pub struct ChatLine {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
pub is_system: bool,
|
pub is_system: bool,
|
||||||
pub is_self: bool,
|
pub is_self: bool,
|
||||||
|
/// Message ID (for sent messages, used to track receipts).
|
||||||
|
pub message_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -42,6 +55,7 @@ impl App {
|
|||||||
text: format!("You are {}", our_fp),
|
text: format!("You are {}", our_fp),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
}]));
|
}]));
|
||||||
|
|
||||||
if let Some(ref peer) = peer_fp {
|
if let Some(ref peer) = peer_fp {
|
||||||
@@ -50,6 +64,7 @@ impl App {
|
|||||||
text: format!("Chatting with {}", peer),
|
text: format!("Chatting with {}", peer),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
messages.lock().unwrap().push(ChatLine {
|
messages.lock().unwrap().push(ChatLine {
|
||||||
@@ -57,6 +72,7 @@ impl App {
|
|||||||
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +81,7 @@ impl App {
|
|||||||
text: "Commands: /alias <name>, /peer <fp|@alias>, /g <group>, /info, /quit".into(),
|
text: "Commands: /alias <name>, /peer <fp|@alias>, /g <group>, /info, /quit".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
App {
|
App {
|
||||||
@@ -74,6 +91,7 @@ impl App {
|
|||||||
peer_fp,
|
peer_fp,
|
||||||
server_url,
|
server_url,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
|
receipts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +99,34 @@ impl App {
|
|||||||
self.messages.lock().unwrap().push(line);
|
self.messages.lock().unwrap().push(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn receipt_indicator(&self, message_id: &Option<String>) -> &'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<String>) -> 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) {
|
pub fn draw(&self, frame: &mut Frame) {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
@@ -127,9 +173,17 @@ impl App {
|
|||||||
format!("{}: ", &m.sender[..m.sender.len().min(12)])
|
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![
|
ListItem::new(Line::from(vec![
|
||||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||||
Span::raw(&m.text),
|
Span::raw(&m.text),
|
||||||
|
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -178,6 +232,7 @@ impl App {
|
|||||||
text: format!("Your fingerprint: {}", self.our_fp),
|
text: format!("Your fingerprint: {}", self.our_fp),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -205,6 +260,7 @@ impl App {
|
|||||||
text: format!("Peer set to {}", fp),
|
text: format!("Peer set to {}", fp),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
self.peer_fp = Some(fp);
|
self.peer_fp = Some(fp);
|
||||||
return;
|
return;
|
||||||
@@ -228,6 +284,7 @@ impl App {
|
|||||||
text: format!("Switched to group #{}", name),
|
text: format!("Switched to group #{}", name),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
self.peer_fp = Some(format!("#{}", name));
|
self.peer_fp = Some(format!("#{}", name));
|
||||||
return;
|
return;
|
||||||
@@ -238,6 +295,7 @@ impl App {
|
|||||||
text: "Switched to DM mode. Use /peer <fp>".into(),
|
text: "Switched to DM mode. Use /peer <fp>".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
self.peer_fp = None;
|
self.peer_fp = None;
|
||||||
return;
|
return;
|
||||||
@@ -262,6 +320,7 @@ impl App {
|
|||||||
text: "No peer set. Use /peer <fingerprint>".into(),
|
text: "No peer set. Use /peer <fingerprint>".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -275,11 +334,13 @@ impl App {
|
|||||||
text: "Invalid peer fingerprint".into(),
|
text: "Invalid peer fingerprint".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||||
let our_pub = identity.public_identity();
|
let our_pub = identity.public_identity();
|
||||||
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
||||||
|
|
||||||
@@ -288,6 +349,7 @@ impl App {
|
|||||||
Ok(encrypted) => {
|
Ok(encrypted) => {
|
||||||
let _ = db.save_session(&peer_fp, state);
|
let _ = db.save_session(&peer_fp, state);
|
||||||
WireMessage::Message {
|
WireMessage::Message {
|
||||||
|
id: msg_id.clone(),
|
||||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
ratchet_message: encrypted,
|
ratchet_message: encrypted,
|
||||||
}
|
}
|
||||||
@@ -298,6 +360,7 @@ impl App {
|
|||||||
text: format!("Encrypt failed: {}", e),
|
text: format!("Encrypt failed: {}", e),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -312,6 +375,7 @@ impl App {
|
|||||||
text: format!("Failed to fetch bundle: {}", e),
|
text: format!("Failed to fetch bundle: {}", e),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -325,6 +389,7 @@ impl App {
|
|||||||
text: format!("X3DH failed: {}", e),
|
text: format!("X3DH failed: {}", e),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -337,6 +402,7 @@ impl App {
|
|||||||
Ok(encrypted) => {
|
Ok(encrypted) => {
|
||||||
let _ = db.save_session(&peer_fp, &state);
|
let _ = db.save_session(&peer_fp, &state);
|
||||||
WireMessage::KeyExchange {
|
WireMessage::KeyExchange {
|
||||||
|
id: msg_id.clone(),
|
||||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||||
@@ -350,6 +416,7 @@ impl App {
|
|||||||
text: format!("Encrypt failed: {}", e),
|
text: format!("Encrypt failed: {}", e),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,6 +431,7 @@ impl App {
|
|||||||
text: format!("Serialize failed: {}", e),
|
text: format!("Serialize failed: {}", e),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -371,11 +439,14 @@ impl App {
|
|||||||
|
|
||||||
match client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
match client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
// Track receipt status
|
||||||
|
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
|
||||||
self.add_message(ChatLine {
|
self.add_message(ChatLine {
|
||||||
sender: self.our_fp[..12].to_string(),
|
sender: self.our_fp[..12].to_string(),
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
is_system: false,
|
is_system: false,
|
||||||
is_self: true,
|
is_self: true,
|
||||||
|
message_id: Some(msg_id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -384,6 +455,7 @@ impl App {
|
|||||||
text: format!("Send failed: {}", e),
|
text: format!("Send failed: {}", e),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,13 +470,13 @@ impl App {
|
|||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
if let Some(err) = data.get("error") {
|
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 {
|
} 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) => {
|
Ok(resp) => {
|
||||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
if let Some(err) = data.get("error") {
|
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 {
|
} else {
|
||||||
let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
|
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::<serde_json::Value>().await {
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) {
|
if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) {
|
||||||
if groups.is_empty() {
|
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 {
|
} else {
|
||||||
for g in groups {
|
for g in groups {
|
||||||
let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
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);
|
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 {
|
let group_data = match client.client.get(&url).send().await {
|
||||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||||
Ok(d) => d,
|
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);
|
let my_fp = normfp(&self.our_fp);
|
||||||
@@ -491,6 +563,7 @@ impl App {
|
|||||||
Ok(encrypted) => {
|
Ok(encrypted) => {
|
||||||
let _ = db.save_session(&member_fp, state);
|
let _ = db.save_session(&member_fp, state);
|
||||||
WireMessage::Message {
|
WireMessage::Message {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
ratchet_message: encrypted,
|
ratchet_message: encrypted,
|
||||||
}
|
}
|
||||||
@@ -513,6 +586,7 @@ impl App {
|
|||||||
Ok(encrypted) => {
|
Ok(encrypted) => {
|
||||||
let _ = db.save_session(&member_fp, &state);
|
let _ = db.save_session(&member_fp, &state);
|
||||||
WireMessage::KeyExchange {
|
WireMessage::KeyExchange {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||||
@@ -536,7 +610,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if wire_messages.is_empty() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,10 +628,11 @@ impl App {
|
|||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
is_system: false,
|
is_system: false,
|
||||||
is_self: true,
|
is_self: true,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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) => {
|
Ok(resp) => {
|
||||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
if let Some(err) = data.get("error") {
|
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 {
|
} else {
|
||||||
let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name);
|
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) => {
|
Ok(resp) => {
|
||||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
|
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());
|
return Some(fp.to_string());
|
||||||
}
|
}
|
||||||
if let Some(err) = data.get("error") {
|
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
|
None
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,18 +686,18 @@ impl App {
|
|||||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) {
|
if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) {
|
||||||
if aliases.is_empty() {
|
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 {
|
} else {
|
||||||
for a in aliases {
|
for a in aliases {
|
||||||
let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?");
|
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("?");
|
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::<String>().to_lowercase()
|
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().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).
|
/// Process a single incoming raw message (shared by WS and HTTP paths).
|
||||||
fn process_incoming(
|
fn process_incoming(
|
||||||
raw: &[u8],
|
raw: &[u8],
|
||||||
identity: &IdentityKeyPair,
|
identity: &IdentityKeyPair,
|
||||||
db: &LocalDb,
|
db: &LocalDb,
|
||||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
|
our_fp: &str,
|
||||||
|
client: &ServerClient,
|
||||||
) {
|
) {
|
||||||
match bincode::deserialize::<WireMessage>(raw) {
|
match bincode::deserialize::<WireMessage>(raw) {
|
||||||
Ok(wire) => process_wire_message(wire, identity, db, messages),
|
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, our_fp, client),
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,9 +752,13 @@ fn process_wire_message(
|
|||||||
identity: &IdentityKeyPair,
|
identity: &IdentityKeyPair,
|
||||||
db: &LocalDb,
|
db: &LocalDb,
|
||||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
|
our_fp: &str,
|
||||||
|
client: &ServerClient,
|
||||||
) {
|
) {
|
||||||
match wire {
|
match wire {
|
||||||
WireMessage::KeyExchange {
|
WireMessage::KeyExchange {
|
||||||
|
id,
|
||||||
sender_fingerprint,
|
sender_fingerprint,
|
||||||
sender_identity_encryption_key,
|
sender_identity_encryption_key,
|
||||||
ephemeral_public,
|
ephemeral_public,
|
||||||
@@ -666,8 +773,8 @@ fn process_wire_message(
|
|||||||
Ok(Some(s)) => s,
|
Ok(Some(s)) => s,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
let otpk_secret = if let Some(id) = used_one_time_pre_key_id {
|
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
|
||||||
db.take_one_time_pre_key(id).ok().flatten()
|
db.take_one_time_pre_key(otpk_id).ok().flatten()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -689,12 +796,16 @@ fn process_wire_message(
|
|||||||
text,
|
text,
|
||||||
is_system: false,
|
is_system: false,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
|
// Send delivery receipt
|
||||||
|
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||||
}
|
}
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WireMessage::Message {
|
WireMessage::Message {
|
||||||
|
id,
|
||||||
sender_fingerprint,
|
sender_fingerprint,
|
||||||
ratchet_message,
|
ratchet_message,
|
||||||
} => {
|
} => {
|
||||||
@@ -715,17 +826,43 @@ fn process_wire_message(
|
|||||||
text,
|
text,
|
||||||
is_system: false,
|
is_system: false,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
|
// Send delivery receipt
|
||||||
|
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||||
}
|
}
|
||||||
Err(_) => {}
|
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).
|
/// Real-time message loop via WebSocket (falls back to HTTP polling).
|
||||||
pub async fn poll_loop(
|
pub async fn poll_loop(
|
||||||
messages: Arc<Mutex<Vec<ChatLine>>>,
|
messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
our_fp: String,
|
our_fp: String,
|
||||||
identity: IdentityKeyPair,
|
identity: IdentityKeyPair,
|
||||||
db: Arc<LocalDb>,
|
db: Arc<LocalDb>,
|
||||||
@@ -747,6 +884,7 @@ pub async fn poll_loop(
|
|||||||
text: "Real-time connection established".into(),
|
text: "Real-time connection established".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -754,7 +892,7 @@ pub async fn poll_loop(
|
|||||||
|
|
||||||
while let Some(Ok(msg)) = read.next().await {
|
while let Some(Ok(msg)) = read.next().await {
|
||||||
if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg {
|
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(),
|
text: "Connection lost, reconnecting...".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
});
|
});
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
}
|
}
|
||||||
@@ -774,7 +913,7 @@ pub async fn poll_loop(
|
|||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
for raw in &raw_msgs {
|
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)
|
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
|
||||||
let poll_identity = poll_seed.derive_identity();
|
let poll_identity = poll_seed.derive_identity();
|
||||||
let poll_messages = app.messages.clone();
|
let poll_messages = app.messages.clone();
|
||||||
|
let poll_receipts = app.receipts.clone();
|
||||||
let poll_client = client.clone();
|
let poll_client = client.clone();
|
||||||
let poll_db = db.clone();
|
let poll_db = db.clone();
|
||||||
let poll_fp = our_fp.clone();
|
let poll_fp = our_fp.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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 {
|
loop {
|
||||||
|
|||||||
@@ -34,12 +34,20 @@ pub enum MessageContent {
|
|||||||
Receipt { message_id: MessageId },
|
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.
|
/// Wire message format for transport between clients.
|
||||||
/// Used by both CLI and WASM — MUST be identical for interop.
|
/// Used by both CLI and WASM — MUST be identical for interop.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub enum WireMessage {
|
pub enum WireMessage {
|
||||||
/// First message to a peer: X3DH key exchange + first ratchet message.
|
/// First message to a peer: X3DH key exchange + first ratchet message.
|
||||||
KeyExchange {
|
KeyExchange {
|
||||||
|
id: String,
|
||||||
sender_fingerprint: String,
|
sender_fingerprint: String,
|
||||||
sender_identity_encryption_key: [u8; 32],
|
sender_identity_encryption_key: [u8; 32],
|
||||||
ephemeral_public: [u8; 32],
|
ephemeral_public: [u8; 32],
|
||||||
@@ -48,7 +56,14 @@ pub enum WireMessage {
|
|||||||
},
|
},
|
||||||
/// Subsequent messages: ratchet-encrypted.
|
/// Subsequent messages: ratchet-encrypted.
|
||||||
Message {
|
Message {
|
||||||
|
id: String,
|
||||||
sender_fingerprint: String,
|
sender_fingerprint: String,
|
||||||
ratchet_message: crate::ratchet::RatchetMessage,
|
ratchet_message: crate::ratchet::RatchetMessage,
|
||||||
},
|
},
|
||||||
|
/// Delivery / read receipt (plaintext, not encrypted).
|
||||||
|
Receipt {
|
||||||
|
sender_fingerprint: String,
|
||||||
|
message_id: String,
|
||||||
|
receipt_type: ReceiptType,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info } from '/wasm/warzone_wasm.js';
|
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt } from '/wasm/warzone_wasm.js';
|
||||||
|
|
||||||
const SERVER = window.location.origin;
|
const SERVER = window.location.origin;
|
||||||
const $messages = document.getElementById('messages');
|
const $messages = document.getElementById('messages');
|
||||||
@@ -160,9 +160,59 @@ let pollTimer = null;
|
|||||||
let ws = null; // WebSocket connection
|
let ws = null; // WebSocket connection
|
||||||
let wasmReady = false;
|
let wasmReady = false;
|
||||||
|
|
||||||
const VERSION = '0.0.5';
|
const VERSION = '0.0.6';
|
||||||
let DEBUG = true; // toggle with /debug command
|
let DEBUG = true; // toggle with /debug command
|
||||||
|
|
||||||
|
// ── Receipt tracking ──
|
||||||
|
let sentMsgReceipts = {}; // messageId -> { status: 'sent'|'delivered'|'read', el: DOM element }
|
||||||
|
|
||||||
|
function receiptIndicator(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'read': return '\u2713\u2713';
|
||||||
|
case 'delivered': return '\u2713\u2713';
|
||||||
|
case 'sent': default: return '\u2713';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptColor(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'read': return '#67c7eb';
|
||||||
|
case 'delivered': return '#ccc';
|
||||||
|
case 'sent': default: return '#555';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReceiptDisplay(messageId, status) {
|
||||||
|
const entry = sentMsgReceipts[messageId];
|
||||||
|
if (!entry) return;
|
||||||
|
// Only upgrade status: sent -> delivered -> read
|
||||||
|
const order = { sent: 0, delivered: 1, read: 2 };
|
||||||
|
if ((order[status] || 0) <= (order[entry.status] || 0)) return;
|
||||||
|
entry.status = status;
|
||||||
|
if (entry.el) {
|
||||||
|
entry.el.textContent = ' ' + receiptIndicator(status);
|
||||||
|
entry.el.style.color = receiptColor(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendReceipt(toPeerFP, messageId, receiptType) {
|
||||||
|
try {
|
||||||
|
const receiptBytes = create_receipt(normFP(myFingerprint), messageId, receiptType);
|
||||||
|
const fp = normFP(toPeerFP);
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) }));
|
||||||
|
} else {
|
||||||
|
fetch(SERVER + '/v1/messages/send', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ to: fp, from: normFP(myFingerprint), message: Array.from(receiptBytes) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dbg('Sent', receiptType, 'receipt for', messageId, 'to', fp);
|
||||||
|
} catch(e) {
|
||||||
|
dbg('Failed to send receipt:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dbg(...args) {
|
function dbg(...args) {
|
||||||
if (DEBUG) console.log('[WZ]', ...args);
|
if (DEBUG) console.log('[WZ]', ...args);
|
||||||
}
|
}
|
||||||
@@ -250,6 +300,13 @@ async function fetchPeerBundle(peerFP) {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateMsgId() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function sendEncrypted(peerFP, plaintext) {
|
async function sendEncrypted(peerFP, plaintext) {
|
||||||
const fp = normFP(peerFP);
|
const fp = normFP(peerFP);
|
||||||
dbg('sendEncrypted to:', fp, 'text length:', plaintext.length);
|
dbg('sendEncrypted to:', fp, 'text length:', plaintext.length);
|
||||||
@@ -257,23 +314,24 @@ async function sendEncrypted(peerFP, plaintext) {
|
|||||||
const bundleBytes = await fetchPeerBundle(fp);
|
const bundleBytes = await fetchPeerBundle(fp);
|
||||||
dbg('Got peer bundle, size:', bundleBytes.length);
|
dbg('Got peer bundle, size:', bundleBytes.length);
|
||||||
|
|
||||||
|
const msgId = generateMsgId();
|
||||||
let wireBytes;
|
let wireBytes;
|
||||||
if (sessions[fp]) {
|
if (sessions[fp]) {
|
||||||
dbg('Using existing session for', fp);
|
dbg('Using existing session for', fp);
|
||||||
try {
|
try {
|
||||||
const sess = WasmSession.restore(sessions[fp].data);
|
const sess = WasmSession.restore(sessions[fp].data);
|
||||||
wireBytes = sess.encrypt(wasmIdentity, plaintext);
|
wireBytes = sess.encrypt_with_id(wasmIdentity, plaintext, msgId);
|
||||||
sessions[fp].data = sess.save();
|
sessions[fp].data = sess.save();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
dbg('Existing session encrypt failed, creating new:', e.message);
|
dbg('Existing session encrypt failed, creating new:', e.message);
|
||||||
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
||||||
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
|
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
|
||||||
sessions[fp] = { data: sess.save() };
|
sessions[fp] = { data: sess.save() };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dbg('New session (X3DH) for', fp);
|
dbg('New session (X3DH) for', fp);
|
||||||
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
||||||
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
|
wireBytes = sess.encrypt_key_exchange_with_id(wasmIdentity, bundleBytes, plaintext, msgId);
|
||||||
sessions[fp] = { data: sess.save() };
|
sessions[fp] = { data: sess.save() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +339,7 @@ async function sendEncrypted(peerFP, plaintext) {
|
|||||||
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
));
|
));
|
||||||
|
|
||||||
dbg('Sending wire message, size:', wireBytes.length);
|
dbg('Sending wire message, size:', wireBytes.length, 'id:', msgId);
|
||||||
|
|
||||||
// Prefer WebSocket, fall back to HTTP
|
// Prefer WebSocket, fall back to HTTP
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -303,6 +361,8 @@ async function sendEncrypted(peerFP, plaintext) {
|
|||||||
});
|
});
|
||||||
dbg('Sent via HTTP (WS not connected)');
|
dbg('Sent via HTTP (WS not connected)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return msgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
@@ -339,11 +399,18 @@ function connectWebSocket() {
|
|||||||
async function handleIncomingMessage(bytes) {
|
async function handleIncomingMessage(bytes) {
|
||||||
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
|
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
|
||||||
|
|
||||||
// First try: KeyExchange (no existing session needed)
|
// Quick check: try to parse as Receipt first (no session needed, no decrypt)
|
||||||
let decrypted = false;
|
|
||||||
try {
|
try {
|
||||||
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
|
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
|
||||||
const result = JSON.parse(resultStr);
|
const result = JSON.parse(resultStr);
|
||||||
|
|
||||||
|
if (result.type === 'receipt') {
|
||||||
|
dbg('Received', result.receipt_type, 'receipt for', result.message_id, 'from', result.sender);
|
||||||
|
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It was a KeyExchange
|
||||||
dbg('Decrypted (KeyExchange) from:', result.sender);
|
dbg('Decrypted (KeyExchange) from:', result.sender);
|
||||||
|
|
||||||
const senderFP = normFP(result.sender);
|
const senderFP = normFP(result.sender);
|
||||||
@@ -360,44 +427,50 @@ async function handleIncomingMessage(bytes) {
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
addMsg(fromLabel, result.text, false);
|
addMsg(fromLabel, result.text, false);
|
||||||
decrypted = true;
|
// Send delivery receipt
|
||||||
|
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
||||||
|
return;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
dbg('KeyExchange failed:', e.message || e);
|
dbg('KeyExchange/Receipt parse failed:', e.message || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second try: existing sessions
|
// Second try: existing sessions
|
||||||
if (!decrypted) {
|
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
||||||
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
try {
|
||||||
try {
|
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
|
||||||
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
|
const result = JSON.parse(resultStr);
|
||||||
const result = JSON.parse(resultStr);
|
|
||||||
dbg('Decrypted with session', senderFP);
|
|
||||||
|
|
||||||
sessions[senderFP] = { data: result.session_data };
|
if (result.type === 'receipt') {
|
||||||
localStorage.setItem('wz-sessions', JSON.stringify(
|
dbg('Received', result.receipt_type, 'receipt for', result.message_id);
|
||||||
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||||
));
|
return;
|
||||||
|
|
||||||
let fromLabel = result.sender.slice(0, 19);
|
|
||||||
try {
|
|
||||||
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
|
||||||
const ad = await ar.json();
|
|
||||||
if (ad.alias) fromLabel = '@' + ad.alias;
|
|
||||||
} catch(e2) {}
|
|
||||||
|
|
||||||
addMsg(fromLabel, result.text, false);
|
|
||||||
decrypted = true;
|
|
||||||
break;
|
|
||||||
} catch(e2) {
|
|
||||||
dbg('Session', senderFP, 'failed:', e2.message || e2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbg('Decrypted with session', senderFP);
|
||||||
|
|
||||||
|
sessions[senderFP] = { data: result.session_data };
|
||||||
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
|
));
|
||||||
|
|
||||||
|
let fromLabel = result.sender.slice(0, 19);
|
||||||
|
try {
|
||||||
|
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
||||||
|
const ad = await ar.json();
|
||||||
|
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||||
|
} catch(e2) {}
|
||||||
|
|
||||||
|
addMsg(fromLabel, result.text, false);
|
||||||
|
// Send delivery receipt
|
||||||
|
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
||||||
|
return;
|
||||||
|
} catch(e2) {
|
||||||
|
dbg('Session', senderFP, 'failed:', e2.message || e2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decrypted) {
|
dbg('ALL decrypt attempts failed');
|
||||||
dbg('ALL decrypt attempts failed');
|
addSys('[message could not be decrypted]');
|
||||||
addSys('[message could not be decrypted]');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved sessions
|
// Load saved sessions
|
||||||
@@ -431,14 +504,25 @@ function peerColor(name) {
|
|||||||
return PEER_COLORS[Math.abs(h) % PEER_COLORS.length];
|
return PEER_COLORS[Math.abs(h) % PEER_COLORS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMsg(from, text, isSelf) {
|
function addMsg(from, text, isSelf, messageId) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.className = 'msg';
|
d.className = 'msg';
|
||||||
const color = isSelf ? '#4ade80' : peerColor(from);
|
const color = isSelf ? '#4ade80' : peerColor(from);
|
||||||
const lock = isSelf ? '' : '<span class="lock">🔒 </span>';
|
const lock = isSelf ? '' : '<span class="lock">🔒 </span>';
|
||||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text);
|
let receiptHtml = '';
|
||||||
|
if (isSelf && messageId) {
|
||||||
|
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
|
||||||
|
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
|
||||||
|
}
|
||||||
|
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text) + receiptHtml;
|
||||||
$messages.appendChild(d);
|
$messages.appendChild(d);
|
||||||
$messages.scrollTop = $messages.scrollHeight;
|
$messages.scrollTop = $messages.scrollHeight;
|
||||||
|
// Store reference to the receipt span so we can update it later
|
||||||
|
if (isSelf && messageId) {
|
||||||
|
const receiptEl = d.querySelector('.receipt');
|
||||||
|
if (!sentMsgReceipts[messageId]) sentMsgReceipts[messageId] = { status: 'sent', el: null };
|
||||||
|
sentMsgReceipts[messageId].el = receiptEl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSys(text) {
|
function addSys(text) {
|
||||||
@@ -580,7 +664,7 @@ async function sendToGroup(groupName, text) {
|
|||||||
body: JSON.stringify({ from: myFP, messages })
|
body: JSON.stringify({ from: myFP, messages })
|
||||||
});
|
});
|
||||||
|
|
||||||
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true);
|
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Send handler ──
|
// ── Send handler ──
|
||||||
@@ -677,8 +761,9 @@ async function doSend() {
|
|||||||
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEncrypted(peer, text);
|
const msgId = await sendEncrypted(peer, text);
|
||||||
addMsg(myFingerprint.slice(0, 19), text, true);
|
sentMsgReceipts[msgId] = { status: 'sent', el: null };
|
||||||
|
addMsg(myFingerprint.slice(0, 19), text, true, msgId);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
addSys('Send failed: ' + e.message);
|
addSys('Send failed: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use warzone_protocol::identity::{IdentityKeyPair, PublicIdentity, Seed};
|
use warzone_protocol::identity::{IdentityKeyPair, PublicIdentity, Seed};
|
||||||
use warzone_protocol::message::WireMessage;
|
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||||
use warzone_protocol::prekey::{
|
use warzone_protocol::prekey::{
|
||||||
generate_signed_pre_key, PreKeyBundle,
|
generate_signed_pre_key, PreKeyBundle,
|
||||||
};
|
};
|
||||||
@@ -155,6 +155,16 @@ impl WasmSession {
|
|||||||
identity: &WasmIdentity,
|
identity: &WasmIdentity,
|
||||||
their_bundle_bytes: &[u8],
|
their_bundle_bytes: &[u8],
|
||||||
plaintext: &str,
|
plaintext: &str,
|
||||||
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
|
self.encrypt_key_exchange_with_id(identity, their_bundle_bytes, plaintext, &uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_key_exchange_with_id(
|
||||||
|
&mut self,
|
||||||
|
identity: &WasmIdentity,
|
||||||
|
their_bundle_bytes: &[u8],
|
||||||
|
plaintext: &str,
|
||||||
|
msg_id: &str,
|
||||||
) -> Result<Vec<u8>, JsValue> {
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
@@ -165,6 +175,7 @@ impl WasmSession {
|
|||||||
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||||
|
|
||||||
let wire = WireMessage::KeyExchange {
|
let wire = WireMessage::KeyExchange {
|
||||||
|
id: msg_id.to_string(),
|
||||||
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||||
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
|
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
|
||||||
ephemeral_public: *result.ephemeral_public.as_bytes(),
|
ephemeral_public: *result.ephemeral_public.as_bytes(),
|
||||||
@@ -175,9 +186,14 @@ impl WasmSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn encrypt(&mut self, identity: &WasmIdentity, plaintext: &str) -> Result<Vec<u8>, JsValue> {
|
pub fn encrypt(&mut self, identity: &WasmIdentity, plaintext: &str) -> Result<Vec<u8>, JsValue> {
|
||||||
|
self.encrypt_with_id(identity, plaintext, &uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_with_id(&mut self, identity: &WasmIdentity, plaintext: &str, msg_id: &str) -> Result<Vec<u8>, JsValue> {
|
||||||
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
||||||
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||||
let wire = WireMessage::Message {
|
let wire = WireMessage::Message {
|
||||||
|
id: msg_id.to_string(),
|
||||||
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||||
ratchet_message: encrypted,
|
ratchet_message: encrypted,
|
||||||
};
|
};
|
||||||
@@ -198,6 +214,30 @@ impl WasmSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Receipt creation ──
|
||||||
|
|
||||||
|
/// Create a Receipt wire message (plaintext, not encrypted).
|
||||||
|
/// `receipt_type`: "delivered" or "read".
|
||||||
|
/// Returns bincode-serialized bytes.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn create_receipt(
|
||||||
|
sender_fingerprint: &str,
|
||||||
|
message_id: &str,
|
||||||
|
receipt_type: &str,
|
||||||
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
|
let rt = match receipt_type {
|
||||||
|
"delivered" => ReceiptType::Delivered,
|
||||||
|
"read" => ReceiptType::Read,
|
||||||
|
_ => return Err(JsValue::from_str("receipt_type must be 'delivered' or 'read'")),
|
||||||
|
};
|
||||||
|
let wire = WireMessage::Receipt {
|
||||||
|
sender_fingerprint: sender_fingerprint.to_string(),
|
||||||
|
message_id: message_id.to_string(),
|
||||||
|
receipt_type: rt,
|
||||||
|
};
|
||||||
|
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Self-test (verifies full encrypt/decrypt cycle within WASM) ──
|
// ── Self-test (verifies full encrypt/decrypt cycle within WASM) ──
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -241,6 +281,7 @@ pub fn self_test() -> Result<String, JsValue> {
|
|||||||
let encrypted_clone = encrypted.clone();
|
let encrypted_clone = encrypted.clone();
|
||||||
|
|
||||||
let _wire = WireMessage::KeyExchange {
|
let _wire = WireMessage::KeyExchange {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
sender_fingerprint: alice_pub.fingerprint.to_string(),
|
sender_fingerprint: alice_pub.fingerprint.to_string(),
|
||||||
sender_identity_encryption_key: *alice_pub.encryption.as_bytes(),
|
sender_identity_encryption_key: *alice_pub.encryption.as_bytes(),
|
||||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||||
@@ -304,7 +345,8 @@ pub fn debug_bundle_info(identity: &mut WasmIdentity) -> Result<String, JsValue>
|
|||||||
|
|
||||||
/// Decrypt a bincode WireMessage. `spk_secret_hex` is the signed pre-key secret
|
/// Decrypt a bincode WireMessage. `spk_secret_hex` is the signed pre-key secret
|
||||||
/// (stored in localStorage, generated during identity creation).
|
/// (stored in localStorage, generated during identity creation).
|
||||||
/// Returns JSON: { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64..." }
|
/// Returns JSON: { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64...", "message_id": "..." }
|
||||||
|
/// For Receipt messages: { "type": "receipt", "sender": "fp", "message_id": "...", "receipt_type": "delivered"|"read" }
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn decrypt_wire_message(
|
pub fn decrypt_wire_message(
|
||||||
identity_hex_seed: &str,
|
identity_hex_seed: &str,
|
||||||
@@ -324,6 +366,7 @@ pub fn decrypt_wire_message(
|
|||||||
|
|
||||||
match wire {
|
match wire {
|
||||||
WireMessage::KeyExchange {
|
WireMessage::KeyExchange {
|
||||||
|
id: msg_id,
|
||||||
sender_fingerprint,
|
sender_fingerprint,
|
||||||
sender_identity_encryption_key,
|
sender_identity_encryption_key,
|
||||||
ephemeral_public,
|
ephemeral_public,
|
||||||
@@ -357,9 +400,11 @@ pub fn decrypt_wire_message(
|
|||||||
"text": String::from_utf8_lossy(&plain),
|
"text": String::from_utf8_lossy(&plain),
|
||||||
"new_session": true,
|
"new_session": true,
|
||||||
"session_data": session_b64,
|
"session_data": session_b64,
|
||||||
|
"message_id": msg_id,
|
||||||
}).to_string())
|
}).to_string())
|
||||||
}
|
}
|
||||||
WireMessage::Message {
|
WireMessage::Message {
|
||||||
|
id: msg_id,
|
||||||
sender_fingerprint,
|
sender_fingerprint,
|
||||||
ratchet_message,
|
ratchet_message,
|
||||||
} => {
|
} => {
|
||||||
@@ -384,6 +429,23 @@ pub fn decrypt_wire_message(
|
|||||||
"text": String::from_utf8_lossy(&plain),
|
"text": String::from_utf8_lossy(&plain),
|
||||||
"new_session": false,
|
"new_session": false,
|
||||||
"session_data": session_b64,
|
"session_data": session_b64,
|
||||||
|
"message_id": msg_id,
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
WireMessage::Receipt {
|
||||||
|
sender_fingerprint,
|
||||||
|
message_id,
|
||||||
|
receipt_type,
|
||||||
|
} => {
|
||||||
|
let rt_str = match receipt_type {
|
||||||
|
ReceiptType::Delivered => "delivered",
|
||||||
|
ReceiptType::Read => "read",
|
||||||
|
};
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "receipt",
|
||||||
|
"sender": sender_fingerprint,
|
||||||
|
"message_id": message_id,
|
||||||
|
"receipt_type": rt_str,
|
||||||
}).to_string())
|
}).to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user