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:
+215
-26
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user