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:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.18"
|
||||
version = "0.0.19"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(_) => {}
|
||||
|
||||
Reference in New Issue
Block a user