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
+27 -10
View File
@@ -1458,18 +1458,26 @@ void App::processWalletSwitchRevert()
int App::chatUnreadCount() const
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
int unread = 0;
// This is called every frame from render() to size the Chat nav badge. Recompute only when the store
// actually changed (new/removed messages bump revision()) or after a short interval (to pick up
// mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x
// messages) scan that COPIED and stable_sorted every conversation's messages, every frame.
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
std::int64_t seen = 0;
const auto it = chat_seen_watermark_.find(cid);
if (it != chat_seen_watermark_.end()) seen = it->second;
for (const auto& m : store.conversation(cid))
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
const std::uint64_t rev = store.revision();
const double now = ImGui::GetTime();
if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) {
chat_unread_cached_ = store.countUnread(
[this](const std::string& cid) { // excluded from the badge
return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid));
},
[this](const std::string& cid) -> std::int64_t { // seen watermark
const auto it = chat_seen_watermark_.find(cid);
return it != chat_seen_watermark_.end() ? it->second : 0;
});
chat_unread_rev_ = rev;
chat_unread_computed_at_ = now;
}
return unread;
return chat_unread_cached_;
}
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
@@ -1477,6 +1485,11 @@ void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
}
void App::forgetChatConversationSeen(const std::string& cid)
{
chat_seen_watermark_.erase(cid);
}
void App::wipePendingTransactionHistoryCachePassphrase()
{
if (!pending_transaction_history_cache_passphrase_.empty()) {
@@ -3041,6 +3054,10 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
// with prior messages (decrypted at rest under a key only this seed can derive).
chat_service_.setPersistence(&chat_db_);
// A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked.
chat_service_.setBlockedPredicate([this](const std::string& cid) {
return settings_ && settings_->isChatBlocked(cid);
});
if (chat_db_.unlockWithSecret(trimmed)) {
chat_service_.loadFromDatabase();
// Baseline unread: treat everything already in the store at load as read, so only messages