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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user