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

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();