Compare commits
9 Commits
5764719375
...
feature/wz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81954b1b0c | ||
|
|
7c4e6a1c1e | ||
|
|
db88282bf6 | ||
|
|
5bbc197369 | ||
|
|
87d7ab16c2 | ||
|
|
6f1dbde7cc | ||
|
|
5bc59376f5 | ||
|
|
1295f1c937 | ||
|
|
c37bd7934c |
11
warzone/Cargo.lock
generated
11
warzone/Cargo.lock
generated
@@ -2956,7 +2956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-client"
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3040,6 +3040,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sled",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
@@ -3053,7 +3054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-wasm"
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -113,6 +113,35 @@ impl ServerClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check how many one-time pre-keys remain on the server.
|
||||
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let resp: serde_json::Value = self.client
|
||||
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to check OTPK count")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse OTPK count")?;
|
||||
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
|
||||
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
|
||||
}).collect();
|
||||
self.client
|
||||
.post(format!("{}/v1/keys/replenish", self.base_url))
|
||||
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to replenish OTPKs")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll for messages addressed to us.
|
||||
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
|
||||
@@ -113,6 +113,22 @@ impl LocalDb {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the next available OTPK ID (one past the highest stored).
|
||||
pub fn next_otpk_id(&self) -> u32 {
|
||||
let mut max_id: Option<u32> = None;
|
||||
for item in self.pre_keys.iter() {
|
||||
if let Ok((k, _)) = item {
|
||||
let key_str = String::from_utf8_lossy(&k);
|
||||
if let Some(id_str) = key_str.strip_prefix("otpk:") {
|
||||
if let Ok(id) = id_str.parse::<u32>() {
|
||||
max_id = Some(max_id.map_or(id, |m: u32| m.max(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
max_id.map_or(0, |m| m + 1)
|
||||
}
|
||||
|
||||
/// Load and remove a one-time pre-key secret.
|
||||
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
|
||||
let key = format!("otpk:{}", id);
|
||||
|
||||
@@ -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::<serde_json::Value>().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,22 +122,30 @@ 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());
|
||||
let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let label = match alias {
|
||||
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
|
||||
None => format!(" {} — {} msgs", &fp[..fp.len().min(16)], count),
|
||||
// Check online status via presence endpoint
|
||||
let online = match client.client.get(format!("{}/v1/presence/{}", client.base_url, normfp(fp))).send().await {
|
||||
Ok(r) => r.json::<serde_json::Value>().await.ok()
|
||||
.and_then(|d| d.get("online").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
let status = if online { "●" } else { "○" };
|
||||
let label = match alias {
|
||||
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, 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;
|
||||
}
|
||||
@@ -145,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 <fingerprint> (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 <fingerprint> (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("");
|
||||
@@ -162,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;
|
||||
@@ -176,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;
|
||||
}
|
||||
@@ -194,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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,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 <address> 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 <address> 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));
|
||||
@@ -233,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 <address> 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 <address> 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 <address>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend <address>".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() {
|
||||
@@ -279,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 <address>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend <address>".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() {
|
||||
@@ -310,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;
|
||||
}
|
||||
@@ -322,31 +330,31 @@ impl App {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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 <device_id>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".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);
|
||||
@@ -354,13 +362,13 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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;
|
||||
}
|
||||
@@ -368,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()
|
||||
@@ -382,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;
|
||||
}
|
||||
}
|
||||
@@ -406,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
|
||||
@@ -427,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;
|
||||
@@ -451,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;
|
||||
@@ -462,7 +470,7 @@ impl App {
|
||||
text: "Switched to DM mode. Use /peer <fp>".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;
|
||||
@@ -478,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 <name> 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 <name> first".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -490,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;
|
||||
@@ -501,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;
|
||||
@@ -532,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 <fp> 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 <fp> or set a peer first.".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now() });
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -551,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;
|
||||
}
|
||||
};
|
||||
@@ -562,7 +570,8 @@ 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.add_message(ChatLine { sender: "system".into(), text: "Audio: use web client for voice (TUI audio coming soon)".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: peer_fp_clean.clone(),
|
||||
peer_display: display.clone(),
|
||||
@@ -571,7 +580,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;
|
||||
@@ -581,7 +590,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;
|
||||
}
|
||||
};
|
||||
@@ -597,7 +606,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(),
|
||||
@@ -612,7 +621,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;
|
||||
}
|
||||
};
|
||||
@@ -628,7 +637,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;
|
||||
@@ -639,7 +648,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;
|
||||
}
|
||||
};
|
||||
@@ -655,7 +664,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;
|
||||
@@ -682,7 +691,7 @@ impl App {
|
||||
text: "No peer set. Use /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;
|
||||
}
|
||||
@@ -695,7 +704,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;
|
||||
}
|
||||
@@ -708,7 +717,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;
|
||||
}
|
||||
@@ -746,11 +755,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;
|
||||
@@ -776,7 +785,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;
|
||||
}
|
||||
@@ -791,7 +800,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;
|
||||
}
|
||||
@@ -805,7 +814,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;
|
||||
}
|
||||
@@ -832,7 +841,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;
|
||||
}
|
||||
@@ -847,7 +856,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;
|
||||
}
|
||||
@@ -865,7 +874,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) => {
|
||||
@@ -874,7 +883,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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -889,13 +898,13 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,14 +917,14 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,18 +935,18 @@ impl App {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,13 +959,13 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,14 +978,14 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,7 +995,7 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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());
|
||||
@@ -995,12 +1004,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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1017,9 +1026,9 @@ impl App {
|
||||
let group_data = match client.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().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);
|
||||
@@ -1092,7 +1101,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;
|
||||
}
|
||||
|
||||
@@ -1110,11 +1119,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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1128,14 +1137,14 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,17 +1154,17 @@ impl App {
|
||||
Ok(resp) => {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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
|
||||
}
|
||||
}
|
||||
@@ -1172,17 +1181,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
|
||||
}
|
||||
}
|
||||
@@ -1195,18 +1204,18 @@ impl App {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,60 @@ use chrono::Local;
|
||||
|
||||
use super::types::{App, ReceiptStatus};
|
||||
|
||||
/// Simple markdown-to-spans converter for TUI messages.
|
||||
/// Handles: **bold**, *italic*, `code`, ```code blocks```.
|
||||
fn md_to_spans<'a>(text: &'a str, base_style: Style) -> Vec<Span<'a>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut remaining = text;
|
||||
|
||||
while !remaining.is_empty() {
|
||||
// Code: `...`
|
||||
if remaining.starts_with('`') && !remaining.starts_with("```") {
|
||||
if let Some(end) = remaining[1..].find('`') {
|
||||
spans.push(Span::styled(
|
||||
&remaining[1..1 + end],
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
remaining = &remaining[2 + end..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **...**
|
||||
if remaining.starts_with("**") {
|
||||
if let Some(end) = remaining[2..].find("**") {
|
||||
spans.push(Span::styled(
|
||||
&remaining[2..2 + end],
|
||||
base_style.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
remaining = &remaining[4 + end..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *...*
|
||||
if remaining.starts_with('*') && !remaining.starts_with("**") {
|
||||
if let Some(end) = remaining[1..].find('*') {
|
||||
spans.push(Span::styled(
|
||||
&remaining[1..1 + end],
|
||||
base_style.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
remaining = &remaining[2 + end..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Plain text until next special char
|
||||
let next = remaining.find(|c: char| c == '*' || c == '`').unwrap_or(remaining.len());
|
||||
if next > 0 {
|
||||
spans.push(Span::styled(&remaining[..next], base_style));
|
||||
remaining = &remaining[next..];
|
||||
} else {
|
||||
// Stuck on a special char that didn't match a pattern — emit it
|
||||
spans.push(Span::styled(&remaining[..1], base_style));
|
||||
remaining = &remaining[1..];
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
||||
match message_id {
|
||||
@@ -103,12 +157,12 @@ impl App {
|
||||
]));
|
||||
frame.render_widget(header, chunks[0]);
|
||||
|
||||
// Messages
|
||||
// Messages — render markdown for message bodies via tui-markdown
|
||||
let msgs = self.messages.lock().unwrap();
|
||||
let items: Vec<ListItem> = msgs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let style = if m.is_system {
|
||||
.flat_map(|m| {
|
||||
let base_style = if m.is_system {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else if m.is_self {
|
||||
Style::default().fg(Color::Green)
|
||||
@@ -117,7 +171,6 @@ impl App {
|
||||
};
|
||||
|
||||
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
|
||||
|
||||
let prefix = if m.is_system {
|
||||
"*** ".to_string()
|
||||
} else {
|
||||
@@ -131,12 +184,52 @@ impl App {
|
||||
};
|
||||
let receipt_color = self.receipt_color(&m.message_id);
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
// Split text into lines, render markdown per line
|
||||
let text_lines: Vec<&str> = m.text.split('\n').collect();
|
||||
let mut result_items = Vec::new();
|
||||
|
||||
for (i, line_text) in text_lines.iter().enumerate() {
|
||||
let mut spans = Vec::new();
|
||||
if i == 0 {
|
||||
spans.push(Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::styled(prefix.clone(), base_style.add_modifier(Modifier::BOLD)));
|
||||
} else {
|
||||
let indent = " ".repeat(timestamp.len() + prefix.len());
|
||||
spans.push(Span::raw(indent));
|
||||
}
|
||||
|
||||
// Check for code block lines (```)
|
||||
if line_text.starts_with("```") {
|
||||
spans.push(Span::styled(*line_text, Style::default().fg(Color::DarkGray)));
|
||||
} else if line_text.starts_with("# ") {
|
||||
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
|
||||
} else if line_text.starts_with("## ") {
|
||||
spans.push(Span::styled(&line_text[3..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
|
||||
} else if line_text.starts_with("> ") {
|
||||
spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray)));
|
||||
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)));
|
||||
} else if line_text.starts_with("- ") || line_text.starts_with("* ") {
|
||||
spans.push(Span::styled("• ", base_style));
|
||||
spans.extend(md_to_spans(&line_text[2..], base_style));
|
||||
} else {
|
||||
spans.extend(md_to_spans(line_text, base_style));
|
||||
}
|
||||
|
||||
// Receipt on last line
|
||||
if i == text_lines.len() - 1 {
|
||||
spans.push(Span::styled(receipt_str, Style::default().fg(receipt_color)));
|
||||
}
|
||||
result_items.push(ListItem::new(Line::from(spans)));
|
||||
}
|
||||
|
||||
if result_items.is_empty() {
|
||||
vec![ListItem::new(Line::from(vec![
|
||||
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&m.text),
|
||||
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
||||
]))
|
||||
Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)),
|
||||
]))]
|
||||
} else {
|
||||
result_items
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -303,6 +396,7 @@ mod tests {
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
|
||||
@@ -330,6 +424,7 @@ mod tests {
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
@@ -356,6 +451,7 @@ mod tests {
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::types::App;
|
||||
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help", "/info", "/eth", "/seed", "/backup",
|
||||
"/peer", "/p", "/reply", "/r", "/dm",
|
||||
"/call", "/accept", "/reject", "/hangup",
|
||||
"/alias", "/aliases", "/unalias",
|
||||
"/contacts", "/c", "/history", "/h",
|
||||
"/friend", "/unfriend",
|
||||
"/devices", "/kick",
|
||||
"/g", "/gcreate", "/gjoin", "/glist", "/gleave", "/gkick", "/gmembers",
|
||||
"/file", "/quit", "/q",
|
||||
];
|
||||
|
||||
impl App {
|
||||
/// Handle a single key event. Returns true if the event was consumed.
|
||||
pub fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
@@ -107,6 +119,31 @@ impl App {
|
||||
KeyCode::Down if self.input.is_empty() => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
// Tab: complete slash commands
|
||||
KeyCode::Tab => {
|
||||
if self.input.starts_with('/') {
|
||||
let input_lower = self.input.to_lowercase();
|
||||
let matches: Vec<&&str> = COMMANDS.iter()
|
||||
.filter(|cmd| cmd.starts_with(&input_lower) && **cmd != input_lower.as_str())
|
||||
.collect();
|
||||
if matches.len() == 1 {
|
||||
// Single match — complete it
|
||||
self.input = format!("{} ", matches[0]);
|
||||
self.cursor_pos = self.input.len();
|
||||
} else if matches.len() > 1 {
|
||||
// Multiple matches — find common prefix
|
||||
let first = matches[0];
|
||||
let common_len = matches.iter().fold(first.len(), |acc, cmd| {
|
||||
first.chars().zip(cmd.chars()).take_while(|(a, b)| a == b).count().min(acc)
|
||||
});
|
||||
if common_len > self.input.len() {
|
||||
self.input = first[..common_len].to_string();
|
||||
self.cursor_pos = self.input.len();
|
||||
}
|
||||
// TODO: show matches in a status line
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regular char: insert at cursor
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_pos, c);
|
||||
@@ -374,4 +411,44 @@ mod tests {
|
||||
app.handle_key_event(key(KeyCode::End));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
// ── Tab completion tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tab_completes_unique_command() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "/he");
|
||||
app.handle_key_event(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "/help ");
|
||||
assert_eq!(app.cursor_pos, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_completes_common_prefix_on_ambiguous() {
|
||||
let mut app = app();
|
||||
// "/g" matches /g, /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
|
||||
// but /g is an exact-length match that is filtered out since it equals input
|
||||
// Actually /g exactly matches "/g" so it's excluded. Remaining: /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
|
||||
// Common prefix is "/g" which is same length as input, so no change
|
||||
type_str(&mut app, "/gc");
|
||||
app.handle_key_event(key(KeyCode::Tab));
|
||||
// /gcreate is the only match starting with /gc
|
||||
assert_eq!(app.input, "/gcreate ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_does_nothing_without_slash() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello");
|
||||
app.handle_key_event(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_does_nothing_when_no_match() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "/zzz");
|
||||
app.handle_key_event(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "/zzz");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,22 +92,88 @@ pub async fn run_tui(
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check and replenish OTPKs if running low
|
||||
{
|
||||
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
match client.otpk_count(&fp_clean).await {
|
||||
Ok(count) => {
|
||||
if count < 3 {
|
||||
tracing::info!("OTPK supply low ({}), generating more...", count);
|
||||
let start_id = db.next_otpk_id();
|
||||
let otpks = warzone_protocol::prekey::generate_one_time_pre_keys(start_id, 10);
|
||||
let mut new_keys = Vec::new();
|
||||
for otpk in &otpks {
|
||||
let _ = db.save_one_time_pre_key(otpk.id, &otpk.secret);
|
||||
new_keys.push((otpk.id, *otpk.public.as_bytes()));
|
||||
}
|
||||
match client.replenish_otpks(&fp_clean, new_keys).await {
|
||||
Ok(_) => {
|
||||
app.add_message(types::ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Replenished OTPKs ({} -> {})", count, count + 10),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: chrono::Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!("Failed to replenish OTPKs: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("Could not check OTPK count: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -75,6 +75,30 @@ fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pre-populate the ETH cache for all known contacts.
|
||||
pub async fn prefill_eth_cache(
|
||||
db: &crate::storage::LocalDb,
|
||||
client: &ServerClient,
|
||||
eth_cache: &EthCache,
|
||||
) {
|
||||
if let Ok(contacts) = db.list_contacts() {
|
||||
for c in &contacts {
|
||||
if let Some(fp) = c.get("fingerprint").and_then(|v| v.as_str()) {
|
||||
let fp = fp.to_string();
|
||||
if eth_cache.lock().unwrap().contains_key(&fp) { continue; }
|
||||
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
|
||||
if let Ok(resp) = client.client.get(&url).send().await {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
|
||||
eth_cache.lock().unwrap().insert(fp, eth.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
|
||||
let _ = db.touch_contact(sender_fp, None);
|
||||
let _ = db.store_message(sender_fp, sender_fp, text, false);
|
||||
@@ -155,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
|
||||
@@ -172,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);
|
||||
}
|
||||
@@ -204,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
|
||||
@@ -221,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);
|
||||
}
|
||||
@@ -266,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 {
|
||||
@@ -327,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
|
||||
@@ -353,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/
|
||||
@@ -370,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) => {
|
||||
@@ -379,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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -426,6 +450,7 @@ fn process_wire_message(
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
@@ -441,6 +466,7 @@ fn process_wire_message(
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
@@ -457,6 +483,7 @@ fn process_wire_message(
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
@@ -486,6 +513,7 @@ fn process_wire_message(
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
@@ -505,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");
|
||||
@@ -516,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 => {
|
||||
@@ -525,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 => {
|
||||
@@ -534,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 => {
|
||||
@@ -543,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 => {
|
||||
@@ -552,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(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
@@ -561,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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -584,6 +612,9 @@ pub async fn poll_loop(
|
||||
let fp = normfp(&our_fp);
|
||||
let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
// Pre-populate ETH cache for known contacts
|
||||
prefill_eth_cache(&db, &client, ð_cache).await;
|
||||
|
||||
// Try WebSocket first
|
||||
let ws_url = client.base_url
|
||||
.replace("http://", "ws://")
|
||||
@@ -599,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;
|
||||
@@ -625,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");
|
||||
@@ -637,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");
|
||||
@@ -653,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;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct App {
|
||||
pub connected: Arc<AtomicBool>,
|
||||
/// Current call state: None=idle, Some(state)=active
|
||||
pub call_state: Option<CallInfo>,
|
||||
/// Message IDs for which we've already sent a Read receipt (avoid duplicates).
|
||||
pub read_receipts_sent: Arc<Mutex<std::collections::HashSet<String>>>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Sender's full fingerprint (for sending read receipts back).
|
||||
pub sender_fp: Option<String>,
|
||||
/// When this message was created/received.
|
||||
pub timestamp: DateTime<Local>,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.38"
|
||||
version = "0.0.44"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||
|
||||
@@ -28,3 +28,7 @@ bincode.workspace = true
|
||||
sha2.workspace = true
|
||||
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -47,6 +47,38 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let mut state = state::AppState::new(&cli.data_dir)?;
|
||||
|
||||
// Reload active calls from DB
|
||||
{
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let mut loaded = 0u32;
|
||||
let mut expired = 0u32;
|
||||
for item in state.db.calls.iter().flatten() {
|
||||
if let Ok(call) = serde_json::from_slice::<state::CallState>(&item.1) {
|
||||
match call.status {
|
||||
state::CallStatus::Ringing | state::CallStatus::Active => {
|
||||
if now - call.created_at > 86400 {
|
||||
let mut ended = call.clone();
|
||||
ended.status = state::CallStatus::Ended;
|
||||
ended.ended_at = Some(now);
|
||||
let _ = state.db.calls.insert(
|
||||
&item.0,
|
||||
serde_json::to_vec(&ended).unwrap_or_default(),
|
||||
);
|
||||
expired += 1;
|
||||
} else {
|
||||
state.active_calls.lock().await.insert(call.call_id.clone(), call);
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
_ => {} // Ended calls stay in DB but not in memory
|
||||
}
|
||||
}
|
||||
}
|
||||
if loaded > 0 || expired > 0 {
|
||||
tracing::info!("Calls: loaded {} active, expired {} stale", loaded, expired);
|
||||
}
|
||||
}
|
||||
|
||||
// Load federation config if provided
|
||||
if let Some(ref fed_path) = cli.federation {
|
||||
let fed_config = federation::load_config(fed_path)?;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/bot/:token/setWebhook", post(set_webhook))
|
||||
.route("/bot/:token/deleteWebhook", post(delete_webhook))
|
||||
.route("/bot/:token/getWebhookInfo", get(get_webhook_info))
|
||||
.route("/bot/:token/sendDocument", post(send_document))
|
||||
.route("/bot/:token/sendDocument", post(send_document_flexible))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -956,44 +956,104 @@ async fn get_webhook_info(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sendDocument
|
||||
// sendDocument — accepts both JSON and multipart/form-data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendDocumentRequest {
|
||||
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
|
||||
/// File path, URL, or file_id reference. In v1, the reference is stored
|
||||
/// and forwarded as-is without server-side file hosting.
|
||||
document: String,
|
||||
#[serde(default)]
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /bot/:token/sendDocument` -- send a document reference to a user.
|
||||
async fn send_document(
|
||||
///
|
||||
/// Accepts both `application/json` and `multipart/form-data` content types
|
||||
/// so Telegram bot libraries that upload files via multipart work out of the box.
|
||||
async fn send_document_flexible(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
Json(req): Json<SendDocumentRequest>,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Json<serde_json::Value> {
|
||||
let bot_info = match validate_bot_token(&state, &token) {
|
||||
Some(i) => i,
|
||||
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
|
||||
};
|
||||
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
|
||||
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
|
||||
Some(fp) => fp,
|
||||
None => {
|
||||
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
|
||||
let bot_name = bot_info["name"].as_str().unwrap_or("bot");
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let (chat_id_val, document, caption) = if content_type.contains("multipart") {
|
||||
// Parse multipart fields from raw bytes (simplified text-field extraction).
|
||||
let body_str = String::from_utf8_lossy(&body);
|
||||
let mut chat_id = String::new();
|
||||
let mut doc = String::new();
|
||||
let mut cap = String::new();
|
||||
|
||||
// Split on boundary markers (lines starting with --)
|
||||
for part in body_str.split("------") {
|
||||
if part.contains("name=\"chat_id\"") {
|
||||
if let Some(val) = part.split("\r\n\r\n").nth(1) {
|
||||
chat_id = val.trim().to_string();
|
||||
}
|
||||
}
|
||||
if part.contains("name=\"document\"") {
|
||||
if let Some(val) = part.split("\r\n\r\n").nth(1) {
|
||||
doc = val.trim().to_string();
|
||||
}
|
||||
}
|
||||
if part.contains("name=\"caption\"") {
|
||||
if let Some(val) = part.split("\r\n\r\n").nth(1) {
|
||||
cap = val.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
serde_json::Value::String(chat_id),
|
||||
doc,
|
||||
if cap.is_empty() { None } else { Some(cap) },
|
||||
)
|
||||
} else {
|
||||
// JSON body
|
||||
match serde_json::from_slice::<serde_json::Value>(&body) {
|
||||
Ok(json) => {
|
||||
let chat_id = json
|
||||
.get("chat_id")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::String(String::new()));
|
||||
let doc = json
|
||||
.get("document")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let cap = json
|
||||
.get("caption")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
(chat_id, doc, cap)
|
||||
}
|
||||
Err(e) => {
|
||||
return Json(
|
||||
serde_json::json!({"ok": false, "description": format!("invalid body: {}", e)}),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let to_fp = match resolve_chat_id(&state, &chat_id_val) {
|
||||
Some(fp) => fp,
|
||||
None => {
|
||||
return Json(serde_json::json!({"ok": false, "description": "invalid chat_id"}))
|
||||
}
|
||||
};
|
||||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
let doc_msg = serde_json::json!({
|
||||
"type": "bot_document",
|
||||
"id": msg_id,
|
||||
"from": bot_fp,
|
||||
"document": req.document,
|
||||
"caption": req.caption,
|
||||
"from_name": bot_name,
|
||||
"document": document,
|
||||
"caption": caption,
|
||||
"timestamp": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default();
|
||||
@@ -1004,8 +1064,8 @@ async fn send_document(
|
||||
"result": {
|
||||
"message_id": msg_id,
|
||||
"chat": {"id": to_fp},
|
||||
"document": {"file_name": req.document},
|
||||
"caption": req.caption,
|
||||
"document": {"file_name": document},
|
||||
"caption": caption,
|
||||
"delivered": delivered,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 {
|
||||
let hash = hasher.finalize();
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&hash[..8]);
|
||||
(i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF) // ensure positive
|
||||
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
|
||||
}
|
||||
|
||||
/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts).
|
||||
|
||||
@@ -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-v20';
|
||||
const CACHE = 'wz-v26';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
@@ -150,7 +150,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
#chat-header .tag { padding: 1px 6px; border-radius: 3px; font-size: 0.85em; }
|
||||
.tag-fp { background: #0a2e0a; color: #4ade80; }
|
||||
.tag-peer { background: #2e2e0a; color: #e6a23c; }
|
||||
.tag-server { color: #444; }
|
||||
.tag-server { color: #666; font-size: 0.8em; }
|
||||
#chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
|
||||
border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
|
||||
|
||||
@@ -248,7 +248,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
<span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span>
|
||||
<span id="hdr-eth" style="display:none"></span>
|
||||
<span>→</span>
|
||||
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
|
||||
<input id="peer-input" placeholder="ETH address, fingerprint, or @alias" autocomplete="off">
|
||||
<span class="tag-server" id="hdr-server"></span>
|
||||
</div>
|
||||
<div id="call-bar">
|
||||
@@ -281,13 +281,14 @@ let wasmIdentity = null; // WasmIdentity from WASM
|
||||
let myFingerprint = '';
|
||||
let myEthAddress = '';
|
||||
let mySeedHex = '';
|
||||
let peerEthAddr = null; // Peer's ETH address (for display; null if set by fingerprint)
|
||||
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
|
||||
let peerBundles = {}; // peerFP -> bundle bytes
|
||||
let pollTimer = null;
|
||||
let ws = null; // WebSocket connection
|
||||
let wasmReady = false;
|
||||
|
||||
const VERSION = '0.0.38';
|
||||
const VERSION = '0.0.44';
|
||||
let DEBUG = true; // toggle with /debug command
|
||||
|
||||
// ── Receipt tracking ──
|
||||
@@ -348,6 +349,23 @@ function normFP(fp) {
|
||||
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function peerDisplayName() {
|
||||
if (peerEthAddr) return peerEthAddr.slice(0, 12) + '...';
|
||||
const v = document.getElementById('peer-input').value.trim();
|
||||
return v ? v.slice(0, 16) + '...' : '?';
|
||||
}
|
||||
|
||||
function updatePeerDisplay() {
|
||||
// Resolve ETH address for display if we have a fingerprint
|
||||
const fp = document.getElementById('peer-input').value.trim();
|
||||
if (fp && !fp.startsWith('#') && !fp.startsWith('@') && !peerEthAddr) {
|
||||
// Try to get ETH address from server
|
||||
fetch(SERVER + '/v1/resolve/' + fp).then(r => r.json()).then(data => {
|
||||
if (data.eth_address) { peerEthAddr = data.eth_address; }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function makeAddressClickable(text) {
|
||||
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups)
|
||||
text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
|
||||
@@ -893,8 +911,8 @@ function renderMd(text) {
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// Bold: **...**
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic: *...*
|
||||
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
// Italic: *...* (single asterisk, not double)
|
||||
s = s.replace(/([^*]|^)\*([^*]+?)\*([^*]|$)/g, '$1<em>$2</em>$3');
|
||||
// Headers: ### ... (at line start)
|
||||
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
@@ -1084,6 +1102,7 @@ async function enterChat() {
|
||||
if (savedPeer) {
|
||||
$peerInput.value = savedPeer;
|
||||
}
|
||||
peerEthAddr = localStorage.getItem('wz-peer-eth') || null;
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
@@ -1244,18 +1263,18 @@ function updateCallUI() {
|
||||
}
|
||||
break;
|
||||
case 'calling':
|
||||
status.textContent = '\u{1F4DE} Calling ' + (callPeer || '...').slice(0, 16);
|
||||
status.textContent = '\u{1F4DE} Calling ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '...').slice(0, 16));
|
||||
status.className = 'call-status';
|
||||
btnHangup.style.display = '';
|
||||
break;
|
||||
case 'ringing':
|
||||
status.textContent = '\u{1F4DE} Incoming call from ' + (callPeer || '?').slice(0, 16);
|
||||
status.textContent = '\u{1F4DE} Incoming call from ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
|
||||
status.className = 'call-status incoming-call';
|
||||
btnAccept.style.display = '';
|
||||
btnReject.style.display = '';
|
||||
break;
|
||||
case 'active':
|
||||
status.textContent = '\u{1F50A} In call with ' + (callPeer || '?').slice(0, 16);
|
||||
status.textContent = '\u{1F50A} In call with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
|
||||
status.className = 'call-status';
|
||||
btnHangup.style.display = '';
|
||||
break;
|
||||
@@ -1263,18 +1282,29 @@ function updateCallUI() {
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
const peer = $peerInput.value.trim();
|
||||
let peer = $peerInput.value.trim();
|
||||
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); return; }
|
||||
|
||||
// Resolve ETH address or @alias to fingerprint
|
||||
if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) {
|
||||
const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer;
|
||||
try {
|
||||
const resp = await fetch(SERVER + endpoint);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Cannot resolve peer: ' + data.error); return; }
|
||||
peer = data.fingerprint;
|
||||
} catch(e) { addSys('Cannot resolve peer: ' + e.message); return; }
|
||||
}
|
||||
|
||||
callState = 'calling';
|
||||
callPeer = peer;
|
||||
updateCallUI();
|
||||
|
||||
// Send CallSignal::Offer via WS
|
||||
try {
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', normFP(peer));
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const fp = normFP(peer);
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', fp);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
|
||||
const payload = new Uint8Array(header.length + signalBytes.length);
|
||||
payload.set(header);
|
||||
@@ -1304,6 +1334,7 @@ function acceptCall() {
|
||||
payload.set(signalBytes, header.length);
|
||||
ws.send(payload);
|
||||
addSys('Call accepted');
|
||||
startAudio();
|
||||
}
|
||||
} catch(e) { addSys('Accept failed: ' + e.message); }
|
||||
}
|
||||
@@ -1321,6 +1352,7 @@ function rejectCall() {
|
||||
ws.send(payload);
|
||||
}
|
||||
} catch(e) {}
|
||||
stopAudio();
|
||||
addSys('Call rejected');
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
@@ -1340,6 +1372,7 @@ function hangupCall() {
|
||||
ws.send(payload);
|
||||
}
|
||||
} catch(e) {}
|
||||
stopAudio();
|
||||
addSys('Call ended');
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
@@ -1366,11 +1399,13 @@ function handleCallSignal(signal) {
|
||||
callState = 'active';
|
||||
updateCallUI();
|
||||
addSys('Call connected!');
|
||||
startAudio();
|
||||
}
|
||||
break;
|
||||
case 'hangup':
|
||||
case 'reject':
|
||||
if (callState !== 'idle') {
|
||||
stopAudio();
|
||||
addSys('Call ended' + (type === 'reject' ? ' (rejected)' : ''));
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
@@ -1393,6 +1428,152 @@ function handleCallSignal(signal) {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Audio Bridge (WZP integration)
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
let audioWs = null;
|
||||
let audioCtx = null;
|
||||
let mediaStream = null;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
|
||||
async function startAudio() {
|
||||
// Fetch relay config (includes auth token)
|
||||
let relayAddr, authToken;
|
||||
try {
|
||||
const resp = await fetch(SERVER + '/v1/wzp/relay-config');
|
||||
const data = await resp.json();
|
||||
relayAddr = data.relay_addr;
|
||||
authToken = data.token;
|
||||
dbg('Relay address:', relayAddr, 'token:', authToken);
|
||||
} catch(e) {
|
||||
addSys('Audio: cannot get relay config \u2014 ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Request microphone
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
});
|
||||
} catch(e) {
|
||||
addSys('Audio: mic access denied \u2014 ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
audioCtx = new AudioContext({ sampleRate: 48000 });
|
||||
|
||||
// Deterministic room: sort both fingerprints so both peers get the same room
|
||||
const myFP = normFP(myFingerprint);
|
||||
const peerFP = callPeer ? normFP(callPeer) : '';
|
||||
const roomPair = [myFP, peerFP].sort().join('-');
|
||||
const room = roomPair.slice(0, 32);
|
||||
const host = relayAddr.replace(/^https?:\/\//, '');
|
||||
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:';
|
||||
const wsUrl = proto + '//' + host + '/ws/' + room;
|
||||
|
||||
addSys('Audio: connecting to room ' + room.slice(0, 12) + '...');
|
||||
|
||||
audioWs = new WebSocket(wsUrl);
|
||||
audioWs.binaryType = 'arraybuffer';
|
||||
|
||||
audioWs.onopen = async () => {
|
||||
// Send auth token as first message (required by wzp-web --auth-url)
|
||||
audioWs.send(JSON.stringify({ type: 'auth', token: authToken }));
|
||||
addSys('Audio: connected \u2014 mic active');
|
||||
|
||||
// Capture: mic -> PCM frames -> WS
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
|
||||
// Use ScriptProcessor as fallback (AudioWorklet needs a separate file)
|
||||
const bufferSize = 960; // 20ms at 48kHz
|
||||
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
|
||||
let captureBuffer = new Float32Array(0);
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (callState !== 'active' || !audioWs || audioWs.readyState !== WebSocket.OPEN) return;
|
||||
const input = e.inputBuffer.getChannelData(0);
|
||||
|
||||
// Accumulate samples
|
||||
const combined = new Float32Array(captureBuffer.length + input.length);
|
||||
combined.set(captureBuffer);
|
||||
combined.set(input, captureBuffer.length);
|
||||
captureBuffer = combined;
|
||||
|
||||
// Send 960-sample frames (20ms)
|
||||
while (captureBuffer.length >= bufferSize) {
|
||||
const frame = captureBuffer.slice(0, bufferSize);
|
||||
captureBuffer = captureBuffer.slice(bufferSize);
|
||||
|
||||
// Convert float32 to int16
|
||||
const pcm = new Int16Array(frame.length);
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
}
|
||||
audioWs.send(pcm.buffer);
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioCtx.destination); // needed to keep processor alive
|
||||
captureNode = processor;
|
||||
|
||||
// Playback buffer
|
||||
playbackNode = { queue: [] };
|
||||
};
|
||||
|
||||
audioWs.onmessage = (event) => {
|
||||
if (!audioCtx) return;
|
||||
const pcm = new Int16Array(event.data);
|
||||
if (pcm.length === 0) return;
|
||||
|
||||
// Convert int16 to float32 and play
|
||||
const float32 = new Float32Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i++) {
|
||||
float32[i] = pcm[i] / 32768.0;
|
||||
}
|
||||
|
||||
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
|
||||
buffer.getChannelData(0).set(float32);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioCtx.destination);
|
||||
source.start();
|
||||
};
|
||||
|
||||
audioWs.onclose = () => {
|
||||
if (callState === 'active') {
|
||||
addSys('Audio: disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
audioWs.onerror = (e) => {
|
||||
addSys('Audio: connection error');
|
||||
dbg('Audio WS error:', e);
|
||||
};
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
if (audioWs) {
|
||||
audioWs.close();
|
||||
audioWs = null;
|
||||
}
|
||||
if (captureNode) {
|
||||
captureNode.disconnect();
|
||||
captureNode = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(t => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {});
|
||||
audioCtx = null;
|
||||
}
|
||||
playbackNode = null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Command Handlers
|
||||
// ═══════════════════════════════════════════════
|
||||
@@ -1513,14 +1694,20 @@ async function doSend() {
|
||||
const resp = await fetch(SERVER + endpoint);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; }
|
||||
peerEthAddr = (val.startsWith('0x') || val.startsWith('0X')) ? val : (data.eth_address || null);
|
||||
$peerInput.value = data.fingerprint;
|
||||
localStorage.setItem('wz-peer', val);
|
||||
if (peerEthAddr) localStorage.setItem('wz-peer-eth', peerEthAddr);
|
||||
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
|
||||
addSys('Peer set to ' + (peerEthAddr || data.fingerprint.slice(0,16) + '...'));
|
||||
} else {
|
||||
$peerInput.value = val;
|
||||
peerEthAddr = null;
|
||||
localStorage.setItem('wz-peer', val);
|
||||
localStorage.removeItem('wz-peer-eth');
|
||||
}
|
||||
currentGroup = null;
|
||||
localStorage.setItem('wz-peer', $peerInput.value);
|
||||
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
|
||||
addSys('Peer set to ' + peerDisplayName());
|
||||
updateCallUI();
|
||||
return;
|
||||
}
|
||||
@@ -1648,6 +1835,45 @@ $input.addEventListener('input', function() {
|
||||
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
// Peer input: Enter sets peer (like /peer command)
|
||||
document.getElementById('peer-input').addEventListener('keydown', async (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const val = e.target.value.trim();
|
||||
if (!val) return;
|
||||
// Treat as /peer command
|
||||
if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X') || /^[0-9a-fA-F]{16,}$/.test(val)) {
|
||||
const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : (val.startsWith('0x') || val.startsWith('0X')) ? '/v1/resolve/' + val : null;
|
||||
if (endpoint) {
|
||||
try {
|
||||
const resp = await fetch(SERVER + endpoint);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Cannot resolve: ' + data.error); return; }
|
||||
// Store ETH address for display, use fingerprint internally
|
||||
peerEthAddr = val;
|
||||
e.target.value = data.fingerprint;
|
||||
localStorage.setItem('wz-peer', val);
|
||||
localStorage.setItem('wz-peer-eth', val);
|
||||
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
|
||||
addSys('Peer set to ' + val.slice(0,16) + '...');
|
||||
updatePeerDisplay();
|
||||
} catch(err) { addSys('Resolve failed: ' + err.message); }
|
||||
} else {
|
||||
// Raw fingerprint
|
||||
peerEthAddr = null;
|
||||
localStorage.setItem('wz-peer', val);
|
||||
localStorage.removeItem('wz-peer-eth');
|
||||
addSys('Peer set to ' + val.slice(0,16) + '...');
|
||||
updatePeerDisplay();
|
||||
}
|
||||
} else if (val.startsWith('#')) {
|
||||
// Group shortcut
|
||||
const gname = val.replace('#','');
|
||||
e.target.value = '#' + gname;
|
||||
localStorage.setItem('wz-peer', '#' + gname);
|
||||
addSys('Switched to group #' + gname);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up buttons (module scope can't use onclick in HTML)
|
||||
document.getElementById('btn-generate').onclick = () => doGenerate();
|
||||
document.getElementById('btn-show-recover').onclick = () => document.getElementById('recover-area').style.display = 'block';
|
||||
|
||||
@@ -135,7 +135,14 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
// For simplicity: first 32 hex chars = recipient fp, rest = message
|
||||
if data.len() > 64 {
|
||||
let header = String::from_utf8_lossy(&data[..64]).to_string();
|
||||
let to_fp = normalize_fp(&header);
|
||||
let raw_fp = normalize_fp(&header);
|
||||
// The WS header is 64 hex chars (32 bytes padded with '0').
|
||||
// Fingerprints are 32 hex chars. Truncate to 32 if zero-padded.
|
||||
let to_fp = if raw_fp.len() > 32 && raw_fp[32..].chars().all(|c| c == '0') {
|
||||
raw_fp[..32].to_string()
|
||||
} else {
|
||||
raw_fp
|
||||
};
|
||||
let message = &data[64..];
|
||||
|
||||
// Dedup: skip if we already processed this message ID
|
||||
|
||||
@@ -273,3 +273,142 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> AppState {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
AppState::new(dir.path().to_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_to_client_returns_false_when_offline() {
|
||||
let state = test_state();
|
||||
assert!(!state.push_to_client("abc123", b"hello").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_ws_and_push() {
|
||||
let state = test_state();
|
||||
let (_, mut rx) = state.register_ws("test_fp", None).await.unwrap();
|
||||
|
||||
assert!(state.push_to_client("test_fp", b"hello").await);
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg, b"hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_connection_cap() {
|
||||
let state = test_state();
|
||||
// Hold receivers so senders stay open (register_ws prunes closed senders).
|
||||
let mut _holders = Vec::new();
|
||||
for i in 0..5 {
|
||||
let res = state.register_ws("same_fp", None).await;
|
||||
assert!(res.is_some(), "connection {} should succeed", i);
|
||||
_holders.push(res.unwrap());
|
||||
}
|
||||
// 6th should fail
|
||||
assert!(state.register_ws("same_fp", None).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_online_and_device_count() {
|
||||
let state = test_state();
|
||||
assert!(!state.is_online("fp1").await);
|
||||
assert_eq!(state.device_count("fp1").await, 0);
|
||||
|
||||
// Must hold receivers so the senders are not marked as closed.
|
||||
let _r1 = state.register_ws("fp1", None).await;
|
||||
assert!(state.is_online("fp1").await);
|
||||
assert_eq!(state.device_count("fp1").await, 1);
|
||||
|
||||
let _r2 = state.register_ws("fp1", None).await;
|
||||
assert_eq!(state.device_count("fp1").await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kick_device() {
|
||||
let state = test_state();
|
||||
let (device_id, _) = state.register_ws("fp1", None).await.unwrap();
|
||||
|
||||
assert!(state.kick_device("fp1", &device_id).await);
|
||||
assert!(!state.is_online("fp1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_all_except() {
|
||||
let state = test_state();
|
||||
let (id1, _rx1) = state.register_ws("fp1", None).await.unwrap();
|
||||
let (_id2, _rx2) = state.register_ws("fp1", None).await.unwrap();
|
||||
let (_id3, _rx3) = state.register_ws("fp1", None).await.unwrap();
|
||||
|
||||
let removed = state.revoke_all_except("fp1", &id1).await;
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(state.device_count("fp1").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deliver_or_queue_offline() {
|
||||
let state = test_state();
|
||||
// No WS connected -- should queue
|
||||
let delivered = state.deliver_or_queue("offline_fp", b"test message").await;
|
||||
assert!(!delivered);
|
||||
|
||||
// Check message was queued in DB
|
||||
let prefix = "queue:offline_fp";
|
||||
let count = state.db.messages.scan_prefix(prefix.as_bytes()).count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deliver_or_queue_online() {
|
||||
let state = test_state();
|
||||
let (_, mut rx) = state.register_ws("online_fp", None).await.unwrap();
|
||||
|
||||
let delivered = state.deliver_or_queue("online_fp", b"instant").await;
|
||||
assert!(delivered);
|
||||
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg, b"instant");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_state_lifecycle() {
|
||||
let state = test_state();
|
||||
|
||||
let call = CallState {
|
||||
call_id: "call-001".into(),
|
||||
caller_fp: "alice".into(),
|
||||
callee_fp: "bob".into(),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: CallStatus::Ringing,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
|
||||
state.active_calls.lock().await.insert("call-001".into(), call);
|
||||
assert_eq!(state.active_calls.lock().await.len(), 1);
|
||||
|
||||
// End the call
|
||||
if let Some(mut c) = state.active_calls.lock().await.remove("call-001") {
|
||||
c.status = CallStatus::Ended;
|
||||
c.ended_at = Some(chrono::Utc::now().timestamp());
|
||||
let _ = state.db.calls.insert(b"call-001", serde_json::to_vec(&c).unwrap());
|
||||
}
|
||||
assert_eq!(state.active_calls.lock().await.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_devices() {
|
||||
let state = test_state();
|
||||
let _r1 = state.register_ws("fp1", None).await;
|
||||
let _r2 = state.register_ws("fp1", None).await;
|
||||
|
||||
let devices = state.list_devices("fp1").await;
|
||||
assert_eq!(devices.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,10 @@ The bridge translates numeric chat_id ↔ fingerprints automatically.
|
||||
| parse_mode HTML | rendered | rendered in web client |
|
||||
| Media groups | yes | not yet |
|
||||
|
||||
## Voice Calls
|
||||
|
||||
Bots cannot initiate or participate in voice calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots.
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **Always use offset** in getUpdates — without it you reprocess messages
|
||||
|
||||
@@ -195,6 +195,40 @@ while True:
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## Voice Calls
|
||||
|
||||
### Architecture
|
||||
Call signaling flows through the featherChat WebSocket (offer/answer/hangup/reject/ringing/busy).
|
||||
Audio flows through a separate WZP relay infrastructure:
|
||||
|
||||
```
|
||||
Browser A <--WS--> wzp-web <--QUIC--> wzp-relay <--QUIC--> wzp-web <--WS--> Browser B
|
||||
| |
|
||||
featherChat server (/v1/auth/validate)
|
||||
```
|
||||
|
||||
### Key files
|
||||
- Call signaling: `warzone-server/src/routes/ws.rs` (WireMessage::CallSignal handling)
|
||||
- Call state: `warzone-server/src/state.rs` (CallState, active_calls)
|
||||
- Relay config: `warzone-server/src/routes/wzp.rs` (token issuance)
|
||||
- Web audio: `warzone-server/src/routes/web.rs` (startAudio/stopAudio functions)
|
||||
- TUI calls: `warzone-client/src/tui/commands.rs` (/call, /accept, /reject, /hangup)
|
||||
- Protocol: `warzone-protocol/src/message.rs` (CallSignal, CallSignalType)
|
||||
|
||||
### Environment
|
||||
- `WZP_RELAY_ADDR` -- tells featherChat server where wzp-web bridge is (e.g., `127.0.0.1:8080`)
|
||||
- Without this, `/v1/wzp/relay-config` returns default `127.0.0.1:4433`
|
||||
|
||||
### Commands
|
||||
|
||||
cmd | action | example
|
||||
--- | --- | ---
|
||||
/call | start voice call with current peer | /call
|
||||
/call <addr> | start voice call with specific peer | /call @alice
|
||||
/accept | accept incoming call | /accept
|
||||
/reject | reject incoming call | /reject
|
||||
/hangup | end current call | /hangup
|
||||
|
||||
## Server API (other endpoints)
|
||||
|
||||
- POST /v1/register -- upload prekey bundle
|
||||
|
||||
@@ -431,6 +431,56 @@ Telegram bot libraries can be adapted with minimal changes.
|
||||
|
||||
---
|
||||
|
||||
## Voice Calls (WZP Integration)
|
||||
|
||||
featherChat supports voice calls via the WarzonePhone (WZP) audio relay. Three components work together:
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Binary | Port | Purpose |
|
||||
|-----------|--------|------|---------|
|
||||
| featherChat server | `warzone-server` | 7700 | Signaling (offer/answer/hangup) + auth tokens |
|
||||
| WZP relay | `wzp-relay` | 4433 | QUIC audio relay (SFU) |
|
||||
| WZP web bridge | `wzp-web` | 8080 | Browser WebSocket ↔ QUIC bridge |
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# 1. WZP relay (QUIC audio)
|
||||
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
|
||||
|
||||
# 2. WZP web bridge (browser ↔ relay)
|
||||
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
|
||||
|
||||
# 3. featherChat server (with relay address)
|
||||
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server
|
||||
```
|
||||
|
||||
### TLS Requirements
|
||||
|
||||
| Scenario | TLS needed? | Why |
|
||||
|----------|-------------|-----|
|
||||
| localhost dev | No | Browser allows mic on localhost without HTTPS |
|
||||
| LAN/remote | wzp-web needs TLS | Browsers require HTTPS for `getUserMedia()` on non-localhost |
|
||||
| Production | All three should use TLS | Security best practice |
|
||||
|
||||
For production TLS on wzp-web:
|
||||
```bash
|
||||
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate --cert /path/to/cert.pem --key /path/to/key.pem
|
||||
```
|
||||
|
||||
### Auth Flow
|
||||
|
||||
1. User clicks Call -> signaling via featherChat WebSocket
|
||||
2. Call accepted -> both clients fetch `GET /v1/wzp/relay-config`
|
||||
3. Server returns `{ relay_addr, token, expires_in: 300 }`
|
||||
4. Clients connect WebSocket to `ws://relay_addr/ws/ROOM`
|
||||
5. First message: `{"type":"auth","token":"<token>"}`
|
||||
6. wzp-web validates token against featherChat `/v1/auth/validate`
|
||||
7. Audio flows: mic -> PCM -> WS -> wzp-web -> QUIC -> wzp-relay -> peer
|
||||
|
||||
---
|
||||
|
||||
## 6. Database
|
||||
|
||||
The server uses **sled** (embedded key-value store). All data lives under
|
||||
|
||||
410
warzone/docs/TESTING_E2E.md
Normal file
410
warzone/docs/TESTING_E2E.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# featherChat End-to-End Testing Guide
|
||||
|
||||
**Version:** 0.0.43
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Local Testing
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
cargo build --release --bin warzone-server --bin warzone-client
|
||||
wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg
|
||||
|
||||
# Binaries
|
||||
./target/release/warzone-server
|
||||
./target/release/warzone-client
|
||||
```
|
||||
|
||||
### Two-Server Testing (Federation)
|
||||
|
||||
```bash
|
||||
# Server Alpha
|
||||
./warzone-server --bind 0.0.0.0:7700 --federation alpha.json --enable-bots --bots-config bots.json
|
||||
|
||||
# Server Bravo
|
||||
./warzone-server --bind 0.0.0.0:7700 --federation bravo.json --enable-bots --bots-config bots.json
|
||||
```
|
||||
|
||||
### Voice Call Testing (requires WZP relay)
|
||||
|
||||
```bash
|
||||
# Terminal A: WZP relay (QUIC audio SFU)
|
||||
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
|
||||
|
||||
# Terminal B: WZP web bridge (browser WebSocket <-> QUIC)
|
||||
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
|
||||
|
||||
# Terminal C: featherChat server with relay address
|
||||
export WZP_RELAY_ADDR=127.0.0.1:8080
|
||||
./warzone-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 1: Basic Messaging (TUI ↔ TUI)
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Terminal 1: Server
|
||||
./target/release/warzone-server
|
||||
|
||||
# Terminal 2: User A
|
||||
./target/release/warzone-client init
|
||||
./target/release/warzone-client register --server http://localhost:7700
|
||||
./target/release/warzone-client tui --server http://localhost:7700
|
||||
|
||||
# Terminal 3: User B
|
||||
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client init
|
||||
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client register --server http://localhost:7700
|
||||
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client tui --server http://localhost:7700
|
||||
```
|
||||
|
||||
### Steps
|
||||
1. **User A**: Note the ETH address shown at startup (e.g., `0x85e3D8...`)
|
||||
2. **User B**: `/peer 0x85e3D8e4a6EEfc048fc80497773D440Bf3487D2b`
|
||||
3. **User B**: Type `Hello!` and press Enter
|
||||
4. **User A**: Should see the message with ✓ (sent) → ✓✓ (delivered)
|
||||
5. **User A**: `/r Hi back!` (reply)
|
||||
6. **User B**: Should see the reply
|
||||
|
||||
### Verify
|
||||
- [x] Messages delivered in real-time (< 1 second)
|
||||
- [x] ✓ appears on send, ✓✓ on delivery
|
||||
- [x] Timestamps show [HH:MM]
|
||||
- [x] ETH address shown in header
|
||||
- [x] `/info` shows both ETH and fingerprint
|
||||
|
||||
---
|
||||
|
||||
## Test 2: Basic Messaging (Web ↔ Web)
|
||||
|
||||
### Setup
|
||||
1. Open browser tab 1: `http://localhost:7700`
|
||||
2. Click "Generate Identity" → note the ETH address
|
||||
3. Open browser tab 2 (incognito): `http://localhost:7700`
|
||||
4. Click "Generate Identity"
|
||||
|
||||
### Steps
|
||||
1. **Tab 2**: Paste Tab 1's ETH address in the peer input box
|
||||
2. **Tab 2**: Type "Hello from web!" → Send
|
||||
3. **Tab 1**: Should see the message
|
||||
4. **Tab 1**: `/peer <tab2_eth_address>` → Type "Hi!" → Send
|
||||
5. **Tab 2**: Should see the reply
|
||||
|
||||
### Verify
|
||||
- [x] Messages show with 🔒 prefix (E2E encrypted)
|
||||
- [x] ETH address shown in header (click to copy)
|
||||
- [x] Markdown renders (**bold**, `code`, etc.)
|
||||
- [x] Scrollbar visible and working
|
||||
|
||||
---
|
||||
|
||||
## Test 3: TUI ↔ Web Cross-Client
|
||||
|
||||
### Steps
|
||||
1. Start TUI (User A) and Web (User B) as above
|
||||
2. **Web**: `/peer <TUI_eth_address>` → Send message
|
||||
3. **TUI**: Should see the message with terminal bell
|
||||
4. **TUI**: `/r Hello from terminal!`
|
||||
5. **Web**: Should see the reply
|
||||
|
||||
### Verify
|
||||
- [x] Cross-client encryption works (TUI encrypts, Web decrypts and vice versa)
|
||||
- [x] Receipts flow correctly between clients
|
||||
|
||||
---
|
||||
|
||||
## Test 4: Group Messaging
|
||||
|
||||
### Steps
|
||||
1. **User A**: `/gcreate testgroup`
|
||||
2. **User A**: `/g testgroup`
|
||||
3. **User B**: `/g testgroup` (auto-joins)
|
||||
4. **User A**: Type "Hello group!" → Send
|
||||
5. **User B**: Should see `UserA [#testgroup]: Hello group!`
|
||||
6. **User B**: Type "Reply!" → Send
|
||||
7. **User A**: Should see the reply
|
||||
|
||||
### Verify
|
||||
- [x] Group creation works
|
||||
- [x] Auto-join on `/g`
|
||||
- [x] Messages fan-out to all members
|
||||
- [x] `/gmembers` shows online status (● / ○)
|
||||
|
||||
---
|
||||
|
||||
## Test 5: Federation (Two Servers)
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Server Alpha (Terminal 1)
|
||||
./warzone-server --bind 0.0.0.0:7700 --federation alpha.json
|
||||
|
||||
# Server Bravo (Terminal 2)
|
||||
./warzone-server --bind 0.0.0.0:7701 --data-dir ./data-bravo --federation bravo.json
|
||||
```
|
||||
|
||||
`alpha.json`:
|
||||
```json
|
||||
{"server_id":"alpha","shared_secret":"test123","peer":{"id":"bravo","url":"http://127.0.0.1:7701"}}
|
||||
```
|
||||
|
||||
`bravo.json`:
|
||||
```json
|
||||
{"server_id":"bravo","shared_secret":"test123","peer":{"id":"alpha","url":"http://127.0.0.1:7700"}}
|
||||
```
|
||||
|
||||
### Steps
|
||||
1. **User A** connects to Alpha (port 7700)
|
||||
2. **User B** connects to Bravo (port 7701)
|
||||
3. Wait 5 seconds for federation presence sync
|
||||
4. **User A**: `/peer <UserB_eth_address>` → Send message
|
||||
5. **User B**: Should receive the message
|
||||
|
||||
### Verify
|
||||
- [x] Server logs show "Federation: connected to peer"
|
||||
- [x] `GET /v1/federation/status` returns `"peer_connected": true`
|
||||
- [x] Messages route across servers transparently
|
||||
- [x] Key bundles proxy via federation (no "Peer not registered")
|
||||
- [x] Aliases resolve across servers
|
||||
|
||||
---
|
||||
|
||||
## Test 6: File Transfer
|
||||
|
||||
### Steps
|
||||
1. Set up two peers (TUI or Web)
|
||||
2. **Sender**: `/file /path/to/small-file.txt` (must be < 10MB)
|
||||
3. **Receiver**: Should see "Incoming file..." → chunk progress → "File saved: ..."
|
||||
4. Verify the file at `~/.warzone/downloads/small-file.txt`
|
||||
|
||||
### Verify
|
||||
- [x] SHA-256 integrity check passes
|
||||
- [x] File appears in downloads directory
|
||||
- [x] Progress shown per chunk
|
||||
|
||||
---
|
||||
|
||||
## Test 7: Call Signaling
|
||||
|
||||
### Steps (Web ↔ Web)
|
||||
1. **User A**: Set peer to User B
|
||||
2. **User A**: Click 📞 Call button (or `/call`)
|
||||
3. **User B**: Should see "📞 Incoming call" with Accept/Reject buttons
|
||||
4. **User B**: Click ✓ Accept
|
||||
5. Both: Should see "Call connected!" / "🔊 In call"
|
||||
6. **Either**: Click "End Call" (or `/hangup`)
|
||||
7. Both: Should see "Call ended"
|
||||
|
||||
### Steps (TUI ↔ TUI)
|
||||
1. **User A**: `/call <peer_address>`
|
||||
2. **User A**: Header shows yellow "📞 Calling..."
|
||||
3. **User B**: "📞 Incoming call from ... — /accept or /reject"
|
||||
4. **User B**: `/accept`
|
||||
5. **User A**: Header shows green "🔊 0:00" timer
|
||||
6. **User A** or **B**: `/hangup`
|
||||
|
||||
### Verify
|
||||
- [x] Call bar appears in web when peer is set
|
||||
- [x] Incoming call notification (pulsing animation in web, bell in TUI)
|
||||
- [x] Call state updates in header (TUI) / call bar (web)
|
||||
- [x] Hangup/reject cleans up state on both sides
|
||||
|
||||
---
|
||||
|
||||
## Test 8: Voice Call Audio (requires WZP relay)
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
# Terminal 1: WZP relay (QUIC audio SFU)
|
||||
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
|
||||
|
||||
# Terminal 2: WZP web bridge (browser WebSocket <-> QUIC)
|
||||
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
|
||||
|
||||
# Terminal 3: featherChat server
|
||||
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server
|
||||
```
|
||||
|
||||
### Steps
|
||||
1. Open two browser tabs to `http://localhost:7700`
|
||||
2. **Tab 1**: Set peer to Tab 2
|
||||
3. **Tab 1**: Click 📞 Call
|
||||
4. **Tab 2**: Click ✓ Accept
|
||||
5. Both: Allow microphone access when prompted
|
||||
6. **Speak into mic** — other tab should hear audio
|
||||
7. End call
|
||||
|
||||
### Verify
|
||||
- [x] "Audio: connecting to ..." message appears
|
||||
- [x] "Audio: connected — mic active" confirms WS to relay
|
||||
- [x] Audio flows bidirectionally
|
||||
- [x] Audio stops on hangup
|
||||
- [x] No audio leak after call ends
|
||||
|
||||
---
|
||||
|
||||
## Test 9: Bot API
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Server with bots enabled
|
||||
./warzone-server --enable-bots --bots-config bots.json
|
||||
```
|
||||
|
||||
### Create a bot via BotFather
|
||||
1. Open web client
|
||||
2. `/peer @botfather`
|
||||
3. Type `/newbot TestEchoBot`
|
||||
4. Note the token from BotFather's reply
|
||||
|
||||
### Run echo bot
|
||||
```python
|
||||
import requests, time
|
||||
TOKEN = "YOUR_TOKEN_HERE"
|
||||
API = f"http://localhost:7700/v1/bot/{TOKEN}"
|
||||
offset = 0
|
||||
while True:
|
||||
r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json()
|
||||
for u in r.get("result", []):
|
||||
offset = u["update_id"] + 1
|
||||
msg = u.get("message", {})
|
||||
text, cid = msg.get("text"), msg.get("chat", {}).get("id")
|
||||
if text and cid:
|
||||
requests.post(f"{API}/sendMessage", json={"chat_id": cid, "text": f"Echo: {text}"})
|
||||
time.sleep(0.1)
|
||||
```
|
||||
|
||||
### Test messaging the bot
|
||||
1. `/peer @testechobot`
|
||||
2. Type "Hello bot!"
|
||||
3. Bot should reply "Echo: Hello bot!"
|
||||
|
||||
### Verify
|
||||
- [x] BotFather creates bot and returns token
|
||||
- [x] Bot receives plaintext messages (not encrypted)
|
||||
- [x] Bot replies appear in chat
|
||||
- [x] Markdown in bot replies renders correctly
|
||||
- [x] Inline keyboards render as clickable buttons (if bot sends reply_markup)
|
||||
|
||||
---
|
||||
|
||||
## Test 10: System Bots (from config)
|
||||
|
||||
### Verify
|
||||
1. Start server with `--bots-config bots.json`
|
||||
2. Check `data/bot-tokens.txt` exists with all tokens
|
||||
3. Open web client — welcome screen shows "Available bots: @helpbot, @codebot, ..."
|
||||
4. `/peer @helpbot` → Send "hello" → Bot should respond (if bot process is running)
|
||||
|
||||
---
|
||||
|
||||
## Test 11: Device Management
|
||||
|
||||
### Steps
|
||||
1. Connect with TUI
|
||||
2. Open web client (same identity or different)
|
||||
3. **TUI**: `/devices` — should list both sessions
|
||||
4. **TUI**: `/kick <web_device_id>`
|
||||
5. **Web**: Connection should drop
|
||||
|
||||
### Verify
|
||||
- [x] `/devices` shows device IDs and connection times
|
||||
- [x] `/kick` disconnects the target device
|
||||
- [x] Max 5 devices per identity enforced
|
||||
|
||||
---
|
||||
|
||||
## Test 12: Friend List
|
||||
|
||||
### Steps
|
||||
1. **User A**: `/friend <UserB_address>`
|
||||
2. **User A**: `/friend` (no args) — should list User B with online/offline status
|
||||
3. **User A**: `/unfriend <UserB_address>`
|
||||
4. **User A**: `/friend` — should show empty
|
||||
|
||||
### Verify
|
||||
- [x] Friend list persists across restarts (encrypted on server)
|
||||
- [x] Online/offline status shown
|
||||
- [x] Add/remove works
|
||||
|
||||
---
|
||||
|
||||
## Test 13: Session Recovery
|
||||
|
||||
### Steps
|
||||
1. Establish a session between two peers (exchange messages)
|
||||
2. Delete one peer's session DB: `rm -rf ~/.warzone/db/`
|
||||
3. Restart that peer's TUI
|
||||
4. Other peer sends a message
|
||||
5. Should see "[session reset]" and then re-establish
|
||||
|
||||
### Verify
|
||||
- [x] "[session reset]" message appears
|
||||
- [x] Subsequent messages work after re-X3DH
|
||||
|
||||
---
|
||||
|
||||
## Test 14: Auto-Backup
|
||||
|
||||
### Steps
|
||||
1. Start TUI client
|
||||
2. Wait 5 minutes (or use `/backup` for immediate)
|
||||
3. Check `~/.warzone/backups/` for `.wzbk` files
|
||||
4. Only 3 most recent should exist
|
||||
|
||||
### Verify
|
||||
- [x] `/backup` creates file immediately
|
||||
- [x] Auto-backup runs every 5 minutes
|
||||
- [x] Old backups rotated (max 3)
|
||||
|
||||
---
|
||||
|
||||
## Test 15: Protocol Versioning
|
||||
|
||||
### Steps
|
||||
1. Send a message normally — raw bincode (legacy format)
|
||||
2. Check server logs — should accept it
|
||||
3. Upgrade client to send envelope format in the future
|
||||
4. Old server should still accept legacy
|
||||
5. New server accepts both
|
||||
|
||||
### Verify
|
||||
- [x] Legacy (raw bincode) still works
|
||||
- [x] Envelope `[WZ][v1][len][payload]` accepted
|
||||
- [x] Future version envelope rejected with clear error
|
||||
|
||||
---
|
||||
|
||||
## Quick Smoke Test (5 minutes)
|
||||
|
||||
If you only have 5 minutes, test these:
|
||||
|
||||
1. `./warzone-server --enable-bots --bots-config bots.json`
|
||||
2. Open `http://localhost:7700` in two browser tabs
|
||||
3. Tab 1: Generate identity
|
||||
4. Tab 2: Generate identity, `/peer <tab1_eth_address>`
|
||||
5. Tab 2: Send "**Hello!**" → Tab 1 should see bold text
|
||||
6. Tab 1: `/peer @botfather` → `/newbot QuickBot` → Note token
|
||||
7. Start echo bot with the token (Python script above)
|
||||
8. Tab 1: `/peer @quickbot` → "test" → Should get "Echo: test"
|
||||
9. Tab 1: `/peer <tab2_address>` → Click 📞 Call → Tab 2: Accept
|
||||
10. Both: Should see "Call connected!" (audio needs WZP relay running)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| "Peer not registered" | Peer hasn't registered keys | Peer needs to open client first |
|
||||
| "[message could not be decrypted]" | Stale session or cached bundle | Clear localStorage (web) or delete session DB |
|
||||
| "alias not found" | Bot/alias doesn't exist on this server | Check `--enable-bots`, wipe data + restart |
|
||||
| No audio | WZP relay not running | Start `wzp-relay` + `wzp-web` + set `WZP_RELAY_ADDR` |
|
||||
| Federation not working | Peer server down or wrong config | Check `GET /v1/federation/status` on both |
|
||||
| "connection limit reached" | 5 devices max | `/devices` → `/kick` old ones |
|
||||
| Version mismatch (web) | Old service worker cached | Hard refresh (Cmd+Shift+R) |
|
||||
| Bot not responding | Bot process not running | Check bot process is polling getUpdates |
|
||||
@@ -287,6 +287,32 @@ The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`,
|
||||
|
||||
---
|
||||
|
||||
## Voice Calls
|
||||
|
||||
### Web Client
|
||||
1. Set a peer (paste ETH address or use `/peer @alias`)
|
||||
2. Click the Call button or type `/call`
|
||||
3. Peer sees "Incoming call" and clicks Accept
|
||||
4. Both allow microphone access
|
||||
5. Audio flows -- speak normally
|
||||
6. Click "End Call" or type `/hangup` to end
|
||||
|
||||
### TUI Client
|
||||
1. `/call <peer_address>` -- initiate call
|
||||
2. Peer sees notification and can use `/accept` or `/reject`
|
||||
3. Audio currently requires web client (TUI shows hint)
|
||||
4. `/hangup` -- end call
|
||||
|
||||
### Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/call` | Start voice call with current peer |
|
||||
| `/accept` | Accept incoming call |
|
||||
| `/reject` | Reject incoming call |
|
||||
| `/hangup` | End current call |
|
||||
|
||||
---
|
||||
|
||||
## Groups
|
||||
|
||||
### Creating and Using Groups
|
||||
|
||||
Reference in New Issue
Block a user