From daae693d1b0300eb0b378c857104ea4bdd460e47 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 20 Oct 2026 08:23:35 -0500 Subject: [PATCH] feat(chat): Restore dialog for on-chain-deleted conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ui/windows/chat_tab.cpp | 108 ++++++++++++++++++++++++++++++++++++ src/util/i18n.cpp | 10 ++++ 2 files changed, 118 insertions(+) diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 87f8f74..eb759ac 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index e0f46b9..8f937e8 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -321,6 +321,16 @@ void I18n::loadBuiltinEnglish() strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain."; strings_["chat_unblock"] = "Unblock"; strings_["chat_unblocked_toast"] = "Conversation unblocked"; + strings_["chat_restore_manage"] = "Restore"; + strings_["chat_restore_title"] = "Restore conversations"; + strings_["chat_restore_desc"] = "Conversations you deleted everywhere can be brought back here - on this device or after importing your seed phrase. Restoring re-imports the messages from the chain and tells your other devices to show it again."; + strings_["chat_restore_none"] = "No deleted conversations found yet. If you deleted one on another device, try Quick scan or paste its chat address below."; + strings_["chat_restore_btn"] = "Restore"; + strings_["chat_restore_scan"] = "Quick scan"; + strings_["chat_restore_scanning"] = "Scanning the chain for deleted conversations..."; + strings_["chat_restore_toast"] = "Restoring conversation - it reappears after the next scan"; + strings_["chat_restore_paste_label"] = "Restore by chat address"; + strings_["chat_restore_paste_hint"] = "Paste the conversation's chat (z) address"; strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6"; strings_["chat_no_z_contacts"] = "No shielded-address contacts yet"; strings_["chat_contact_needs_z"] = "Needs a shielded (z) address to chat";