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
+215 -26
View File
@@ -93,6 +93,10 @@ char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = ""; // conversation-list filter (Q8)
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
std::string s_delete_cid; // conversation targeted by the delete confirm
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search[48] = ""; // emoji picker keyword filter
@@ -523,6 +527,16 @@ struct ConvSummary {
bool hidden = false; // shown only while "Show hidden" is on
};
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them
// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild.
std::vector<ConvSummary> s_convs;
int s_convsHidden = 0;
std::uint64_t s_convsKey = ~0ull;
std::string s_threadCid;
std::uint64_t s_threadRev = ~0ull;
std::vector<chat::ChatMessage> s_threadMsgs;
// Centered, muted, wrapped hint for the empty states.
void centeredHint(const char* text) {
ImVec2 avail = ImGui::GetContentRegionAvail();
@@ -725,32 +739,46 @@ void RenderChatTab(App* app)
}
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std::vector<ConvSummary> convs;
int hiddenCount = 0;
for (const auto& cid : store.conversationIds()) {
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++hiddenCount;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
// MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
// only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew
// (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a
// rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset
// in ResetChatTab on wallet switch.
const std::uint64_t convsKey =
store.revision() * 1000003ull
+ static_cast<std::uint64_t>(s_show_hidden ? 1 : 0)
+ (book.revision() << 20); // book.revision() catches in-place contact edits (rename) that keep size()
if (convsKey != s_convsKey) {
s_convs.clear();
s_convsHidden = 0;
for (const auto& cid : store.conversationIds()) {
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++s_convsHidden;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
}
const auto& last = messages.back();
c.lastBody = last.body;
c.lastTs = last.timestamp;
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
s_convs.push_back(std::move(c));
}
const auto& last = messages.back();
c.lastBody = last.body;
c.lastTs = last.timestamp;
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
convs.push_back(std::move(c));
std::sort(s_convs.begin(), s_convs.end(),
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
s_convsKey = convsKey;
}
std::sort(convs.begin(), convs.end(),
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
std::vector<ConvSummary>& convs = s_convs;
int hiddenCount = s_convsHidden;
// Keep the selection valid (only when there is something to select).
if (!convs.empty() &&
@@ -845,6 +873,15 @@ void RenderChatTab(App* app)
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
}
// Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no
// stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock.
const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0;
if (blockedCount > 0) {
const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")";
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
ImGui::PopStyleColor();
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
@@ -1018,7 +1055,7 @@ void RenderChatTab(App* app)
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
// shown whenever there's an address to save the contact under; the settings "notch" gear is
// always the rightmost icon.
const int nBtns = 4 + (hasAddr ? 1 : 0);
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
@@ -1069,6 +1106,7 @@ void RenderChatTab(App* app)
} else {
Notifications::instance().error(TR("address_book_exists"));
}
s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild
}
s_rename_cid.clear();
} else if (cancel) {
@@ -1170,6 +1208,21 @@ void RenderChatTab(App* app)
s_selected_cid.clear();
Notifications::instance().info(TR("chat_hidden_toast"));
}
s_convsKey = ~0ull; // hidden-state changed — force the conversation-list memo to rebuild
}
bx += ib + gap;
}
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
a.tooltip = TR("chat_delete");
a.hoverColor = material::Error();
if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) {
s_delete_cid = sel->cid;
s_delete_name = sel->peerName;
s_show_delete_confirm = true;
}
bx += ib + gap;
}
@@ -1278,7 +1331,15 @@ void RenderChatTab(App* app)
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
const auto messages = store.conversation(s_selected_cid);
// MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
// copies+sorts the match every call. Rebuild only when the open thread or the store changes,
// not every frame while the thread is simply being read/scrolled.
if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
s_threadMsgs = store.conversation(s_selected_cid);
s_threadCid = s_selected_cid;
s_threadRev = store.revision();
}
const auto& messages = s_threadMsgs;
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
// one meta header and stack tightly; a date pill is drawn once per calendar day.
const std::int64_t nowTs = static_cast<std::int64_t>(std::time(nullptr));
@@ -1820,6 +1881,122 @@ void RenderChatTab(App* app)
}
}
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
if (s_show_delete_confirm) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_delete_title");
ov.p_open = &s_show_delete_confirm; // X / backdrop closes it (no-op)
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name +
TR("chat_delete_body_suffix")).c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_delete_revive_note"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::TextUnformatted(TR("chat_delete_local_note"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
auto doDelete = [&](bool block) {
// Delete first — if the persisted rows can't be removed, change nothing else (no block,
// no toast) so the store and DB can't diverge.
if (!app->chatService().deleteConversation(s_delete_cid, block)) {
Notifications::instance().error(TR("chat_delete_failed"));
s_show_delete_confirm = false;
s_delete_cid.clear(); s_delete_name.clear();
return;
}
if (app->settings()) {
if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true);
app->settings()->setChatHidden(s_delete_cid, false); // clear any prior hide flag
app->settings()->save();
}
// Revive mode: forget the seen-watermark so a re-imported message badges as unread even if
// its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored
// history doesn't all re-badge.
if (!block) app->forgetChatConversationSeen(s_delete_cid);
if (s_selected_cid == s_delete_cid) s_selected_cid.clear();
Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast"));
s_show_delete_confirm = false;
s_delete_cid.clear(); s_delete_name.clear();
};
auto textW = [&](const char* t) {
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
};
const float gap2 = Layout::spacingSm();
const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm")));
const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block")));
const float wCan = std::max(90.0f * dp, textW(TR("chat_cancel")));
material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0));
ImGui::SameLine(0, gap2);
const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0));
ImGui::PopStyleColor(4);
ImGui::SameLine(0, gap2);
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
if (doDel) doDelete(false);
if (doBlk) doDelete(true);
if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); }
material::EndOverlayDialog();
}
}
// ---- Blocked-conversations manager (unblock) ----
if (s_show_blocked) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_blocked_title");
ov.p_open = &s_show_blocked;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_blocked_desc"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (app->settings()) {
// Copy so unblocking (which mutates the settings vector) during iteration is safe.
const auto blocked = app->settings()->blockedChatConversations();
std::string unblockCid;
for (const auto& b : blocked) {
ImGui::PushID(b.cid.c_str());
const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name;
const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x +
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label.c_str());
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid;
ImGui::PopID();
}
if (!unblockCid.empty()) {
app->settings()->setChatBlocked(unblockCid, "", false);
app->settings()->save();
Notifications::instance().info(TR("chat_unblocked_toast")); // re-imports on the next chat scan
if (app->settings()->blockedChatConversations().empty()) s_show_blocked = false;
}
}
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;
@@ -1883,6 +2060,18 @@ void ResetChatTab()
s_rename_focus = false;
s_show_new_convo = false;
s_show_chat_settings = false;
s_show_delete_confirm = false;
s_delete_cid.clear();
s_delete_name.clear();
s_show_blocked = false;
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
s_convs.clear();
s_convsHidden = 0;
s_convsKey = ~0ull;
s_threadCid.clear();
s_threadRev = ~0ull;
s_threadMsgs.clear();
}
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)