feat(chat): per-conversation delete (revive + block); memoize chat/badge render

Adds a per-conversation "delete" with two modes, and removes the per-frame
rescans of the chat history that shared these files.

Delete conversation (header trash icon → confirm dialog):
- Delete (revive-on-new-message): clears local history and tombstones the
  messages (new chat_deleted table, keyed dedup hashes) so the every-few-
  seconds memo re-scan can't re-import them; a genuinely NEW message (new
  txid) revives the thread.
- Delete & block: removes history WITHOUT a tombstone and records the cid as
  blocked (settings); ChatService::ingest drops that conversation's messages
  — old and future — until unblocked from the "Blocked" manager, which then
  re-imports the conversation from chain.
- Local-only (messages remain on-chain; the peer keeps their copy).
  deleteConversation() deletes the DB rows FIRST and only then mutates the
  store, so a failed write can't leave the two diverged.
Unit-tested (revive / tombstone-survives-reload / block / unblock) and
adversarially reviewed (store/DB divergence, half-open DB, revive-unread).

Performance (chat + badge hot paths, from the perf audit):
- ChatStore gains revision(); the Chat unread badge is now a single O(N)
  no-alloc pass cached on it (was O(conversations x messages) copy+sort every
  frame). The conversation list and open thread are memoized on revision()
  (+ show-hidden and AddressBook::revision() for peer-name resolution).
- AddressBook gains revision() so an in-place contact rename invalidates the
  chat memo (an edit keeps entries().size() constant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 22:54:51 -05:00
co-authored by Claude Opus 4.8
parent a60a2f8e39
commit 0a042df8e0
15 changed files with 611 additions and 45 deletions
+29
View File
@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
std::int64_t fallbackTimestamp,
std::vector<std::string>* newIncomingCids) {
if (!has_identity_) return 0;
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
// Skip until the DB is usable — chat is degraded anyway without its store.
if (db_ && !db_->hasKey()) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
}
message.payload_position = meta.payload_position;
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
// deleted with "revive on new message", so only that exact message is skipped — a new message
// in the same conversation has a different txid and flows through normally.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
if (db_ && db_->isTombstoned(message)) continue;
if (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
message.body = meta.payload_memo; // plaintext request text
@@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
void ChatService::loadFromDatabase() {
if (!db_) return;
for (const auto& message : db_->load()) {
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
store_.append(message);
}
}
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
// survive — and on the next reload the conversation silently reappears with no tombstone.
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
// until unblocked, at which point the conversation re-imports from chain.
if (db_) {
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
}
store_.eraseConversation(conversationId);
return true;
}
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);