feat(chat): Restore dialog for on-chain-deleted conversations

Adds a "Restore" opener (shown when a delete is known from chain — so it works
on a wallet freshly restored from seed — or something is locally blocked) and a
Restore dialog that: lists chain-derived deleted conversations
(onChainDeletedConversations) each with a Restore button; accepts a pasted chat
(z) address, matched against the address each delete signal carries (else
treated as a raw cid); and offers Quick scan (noteWalletMutation → re-run the
change-gated scans → re-harvest control memos). Restoring clears any local
block/hide and broadcasts Control("res") so every device un-hides; the messages
re-import from chain on the next scan.

New English strings fall back cleanly until translated (chat_restore_*).
Phase A complete: protocol -> compose -> reducer -> broadcast -> delete+restore UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 08:23:35 -05:00
co-authored by Claude Opus 4.8
parent 2f1b666c20
commit daae693d1b
2 changed files with 118 additions and 0 deletions
+108
View File
@@ -104,6 +104,8 @@ bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (
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_restore = false; // restore-conversations overlay (reverse an on-chain delete)
char s_restore_addr[128] = ""; // paste-a-chat-address buffer in the restore overlay
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
std::string s_export_cid; // conversation targeted by the export confirm
bool s_show_fingerprint = false; // key-fingerprint verification dialog
@@ -961,6 +963,19 @@ void RenderChatTab(App* app)
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
ImGui::PopStyleColor();
}
// Restore opener — reverse an on-chain "Delete everywhere". Shown when a delete is known from chain
// (works on a fresh seed import, which has no local state) or something is locally blocked; the
// dialog also offers paste-by-address + a quick scan for deletes made on another device.
const int onChainDelCount = (int)app->chatService().onChainDeletedConversations().size();
if (onChainDelCount > 0 || blockedCount > 0) {
if (blockedCount > 0) ImGui::SameLine();
const std::string rl = onChainDelCount > 0
? std::string(TR("chat_restore_manage")) + " (" + std::to_string(onChainDelCount) + ")"
: std::string(TR("chat_restore_manage"));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (ImGui::SmallButton(rl.c_str())) s_show_restore = true;
ImGui::PopStyleColor();
}
// "Copy my chat address" — the address others use to reach this user. The conversation header
// only copies a PEER's address, so without this there's no way to hand out your own. Copies on
// click only (chatMyAddress may persist a freshly chosen address).
@@ -2464,6 +2479,99 @@ void RenderChatTab(App* app)
}
}
// ---- Restore conversations (reverse an on-chain "Delete everywhere") ----
if (s_show_restore) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_restore_title");
ov.p_open = &s_show_restore;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatrestore";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_restore_desc"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Restore: clear any local block/hide here AND broadcast Control("res") so every device un-hides.
// The messages re-import from the chain on the next scan (the reducer no longer hides the cid).
auto doRestore = [&](const std::string& cid, const std::string& label) {
if (cid.empty()) return;
if (app->settings()) {
app->settings()->setChatBlocked(cid, "", false);
app->settings()->setChatHidden(cid, false);
app->settings()->save();
}
app->forgetChatConversationSeen(cid);
app->sendChatControl(cid, "res", label.empty() ? cid : label);
Notifications::instance().info(TR("chat_restore_toast"));
};
// Chain-derived deleted conversations — present even on a wallet freshly restored from seed.
const auto deleted = app->chatService().onChainDeletedConversations();
if (deleted.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_restore_none"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
} else {
std::string restoreCid, restoreLabel;
for (const auto& d : deleted) {
ImGui::PushID(d.first.c_str());
const std::string label = d.second.empty() ? shorten(d.first, 10, 6) : shorten(d.second, 12, 6);
const float bw = ImGui::CalcTextSize(TR("chat_restore_btn")).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_restore_btn"), ImVec2(bw, 0))) {
restoreCid = d.first; restoreLabel = d.second;
}
ImGui::PopID();
}
if (!restoreCid.empty()) doRestore(restoreCid, restoreLabel);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Separator();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Paste the chat (z) address to restore a conversation not in the list (matched against the
// address each delete signal carries; otherwise the input is treated as a raw conversation id).
ImGui::TextUnformatted(TR("chat_restore_paste_label"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatrestoreaddr", TR("chat_restore_paste_hint"),
s_restore_addr, sizeof(s_restore_addr));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
const float gap = Layout::spacingSm();
const float wPaste = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_btn")).x + 44.0f * dp);
const float wScan = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_scan")).x + 44.0f * dp);
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(wPaste, 0))) {
std::string input(s_restore_addr);
while (!input.empty() && (input.front() == ' ' || input.front() == '\n' || input.front() == '\t')) input.erase(input.begin());
while (!input.empty() && (input.back() == ' ' || input.back() == '\n' || input.back() == '\t')) input.pop_back();
if (!input.empty()) {
std::string cid, label;
for (const auto& d : deleted) { if (d.second == input) { cid = d.first; label = d.second; break; } }
if (cid.empty()) { cid = input; label = input; } // not in the list — treat as a raw cid
doRestore(cid, label);
s_restore_addr[0] = '\0';
}
}
ImGui::SameLine(0, gap);
if (material::TactileButton(TR("chat_restore_scan"), ImVec2(wScan, 0))) {
app->noteWalletMutation(); // re-run the change-gated scans → re-harvest control memos
Notifications::instance().info(TR("chat_restore_scanning"));
}
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;