v0.0.19: contact list + message history (local, persistent)

Storage:
- contacts sled tree: auto-tracked on send/receive
  - fingerprint, alias, first_seen, last_seen, message_count
- history sled tree: all messages stored locally
  - key: hist:<peer_fp>:<timestamp>:<uuid> for ordered scan
  - sender, text, is_self, timestamp

TUI commands:
- /contacts or /c — list all contacts (sorted by most recent)
  Shows alias, fingerprint, message count
- /history or /h — show last 50 messages with current peer
- /h <fingerprint> — show history with specific peer

Auto-tracking:
- On send: touch_contact + store_message (is_self=true)
- On receive: touch_contact + store_message (is_self=false)
- Both KeyExchange and Message variants tracked

Backup: contacts + history included in export_all (encrypted backup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 20:16:22 +04:00
parent 741e6fbcfd
commit 1601decf33
4 changed files with 160 additions and 7 deletions

10
warzone/Cargo.lock generated
View File

@@ -2789,7 +2789,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.17"
version = "0.0.18"
dependencies = [
"anyhow",
"argon2",
@@ -2822,7 +2822,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.17"
version = "0.0.18"
dependencies = [
"anyhow",
"clap",
@@ -2831,7 +2831,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.17"
version = "0.0.18"
dependencies = [
"base64",
"bincode",
@@ -2856,7 +2856,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.17"
version = "0.0.18"
dependencies = [
"anyhow",
"axum",
@@ -2883,7 +2883,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.17"
version = "0.0.18"
dependencies = [
"base64",
"bincode",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.18"
version = "0.0.19"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -8,6 +8,8 @@ use x25519_dalek::StaticSecret;
pub struct LocalDb {
sessions: sled::Tree,
pre_keys: sled::Tree,
contacts: sled::Tree,
history: sled::Tree,
_db: sled::Db,
}
@@ -35,9 +37,13 @@ impl LocalDb {
};
let sessions = db.open_tree("sessions")?;
let pre_keys = db.open_tree("pre_keys")?;
let contacts = db.open_tree("contacts")?;
let history = db.open_tree("history")?;
Ok(LocalDb {
sessions,
pre_keys,
contacts,
history,
_db: db,
})
}
@@ -109,6 +115,90 @@ impl LocalDb {
}
}
// ── Contacts ──
/// Add or update a contact. Called on send/receive.
pub fn touch_contact(&self, fingerprint: &str, alias: Option<&str>) -> Result<()> {
let fp = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
let now = chrono::Utc::now().timestamp();
let mut record = match self.contacts.get(fp.as_bytes())? {
Some(data) => serde_json::from_slice::<serde_json::Value>(&data).unwrap_or_default(),
None => serde_json::json!({}),
};
let obj = record.as_object_mut().unwrap();
obj.insert("fingerprint".into(), serde_json::json!(fp));
obj.insert("last_seen".into(), serde_json::json!(now));
if let Some(a) = alias {
obj.insert("alias".into(), serde_json::json!(a));
}
if !obj.contains_key("first_seen") {
obj.insert("first_seen".into(), serde_json::json!(now));
}
let count = obj.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
obj.insert("message_count".into(), serde_json::json!(count + 1));
self.contacts.insert(fp.as_bytes(), serde_json::to_vec(&record)?)?;
Ok(())
}
/// Get all contacts sorted by last_seen (most recent first).
pub fn list_contacts(&self) -> Result<Vec<serde_json::Value>> {
let mut contacts: Vec<serde_json::Value> = self.contacts.iter()
.filter_map(|item| {
item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok())
})
.collect();
contacts.sort_by(|a, b| {
let ta = a.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0);
let tb = b.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0);
tb.cmp(&ta)
});
Ok(contacts)
}
// ── Message History ──
/// Store a message in local history.
pub fn store_message(&self, peer_fp: &str, sender: &str, text: &str, is_self: bool) -> Result<()> {
let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
let now = chrono::Utc::now().timestamp();
let id = uuid::Uuid::new_v4().to_string();
let msg = serde_json::json!({
"id": id,
"peer": fp,
"sender": sender,
"text": text,
"is_self": is_self,
"timestamp": now,
});
// Key: hist:<peer_fp>:<timestamp>:<uuid> for ordered scan
let key = format!("hist:{}:{}:{}", fp, now, id);
self.history.insert(key.as_bytes(), serde_json::to_vec(&msg)?)?;
Ok(())
}
/// Get message history with a peer (most recent N messages).
pub fn get_history(&self, peer_fp: &str, limit: usize) -> Result<Vec<serde_json::Value>> {
let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
let prefix = format!("hist:{}:", fp);
let mut messages: Vec<serde_json::Value> = self.history
.scan_prefix(prefix.as_bytes())
.filter_map(|item| {
item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok())
})
.collect();
// Take last N
if messages.len() > limit {
messages = messages.split_off(messages.len() - limit);
}
Ok(messages)
}
/// Export all data as JSON (for encrypted backup).
pub fn export_all(&self) -> Result<serde_json::Value> {
let mut sessions = serde_json::Map::new();

View File

@@ -288,6 +288,60 @@ impl App {
}
return;
}
if text == "/contacts" || text == "/c" {
match db.list_contacts() {
Ok(contacts) => {
if contacts.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None });
for c in &contacts {
let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = c.get("alias").and_then(|v| v.as_str());
let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
let label = match alias {
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
None => format!(" {}{} msgs", &fp[..fp.len().min(16)], count),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }),
}
return;
}
if text.starts_with("/history") || text.starts_with("/h ") {
let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" };
let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer };
if fp.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None });
} else {
match db.get_history(fp, 50) {
Ok(msgs) => {
if msgs.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None });
for m in &msgs {
let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?");
let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false);
self.add_message(ChatLine {
sender: sender[..sender.len().min(12)].to_string(),
text: txt.to_string(),
is_system: false,
is_self,
message_id: None,
});
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }),
}
}
return;
}
if text == "/eth" {
// Show ethereum address from seed
if let Ok(seed) = crate::keystore::load_seed_raw() {
@@ -543,6 +597,9 @@ impl App {
Ok(_) => {
// Track receipt status
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
// Store in contacts + history
let _ = db.touch_contact(&peer, None);
let _ = db.store_message(&peer, &self.our_fp, &text, true);
self.add_message(ChatLine {
sender: self.our_fp[..12].to_string(),
text: text.clone(),
@@ -1128,6 +1185,11 @@ fn process_incoming(
}
}
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
let _ = db.touch_contact(sender_fp, None);
let _ = db.store_message(sender_fp, sender_fp, text, false);
}
fn process_wire_message(
wire: WireMessage,
identity: &IdentityKeyPair,
@@ -1175,6 +1237,7 @@ fn process_wire_message(
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text,
@@ -1205,6 +1268,7 @@ fn process_wire_message(
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text,
@@ -1212,7 +1276,6 @@ fn process_wire_message(
is_self: false,
message_id: None,
});
// Send delivery receipt
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
}
Err(_) => {}