feat(chat): sensitive-conversation UI (mark/unmark, setup/unlock, lock)

Wires sensitive mode into the chat tab:
- A per-conversation lock toggle in the header (next to delete) opens a dialog
  that, by vault state, sets up / unlocks the vault and marks or unmarks the
  conversation. The mark dialog shows the honest warning (lose the passphrase =
  gone forever; sent fully protected vs received still chain-recoverable; prior
  history stays recoverable).
- A "Lock sensitive" / "Unlock sensitive (N)" opener beside Blocked/Restore
  locks or unlocks the vault; unlocking surfaces the withheld sensitive threads.
- Passphrase fields use the password flag and are memset on close; marking
  requires the vault unlocked (the App layer migrates rows + broadcasts the
  on-chain signal).

~29 new English strings (fall back to English until translated). Phase B
complete (policy level); the cryptographic re-key level remains out of scope.
Needs a live smoke test (built unverified, like the delete UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:25:12 -05:00
co-authored by Claude Opus 4.8
parent 6e285bd4a4
commit f0be72ac36
2 changed files with 199 additions and 1 deletions
+170 -1
View File
@@ -106,6 +106,13 @@ std::string s_delete_name; // its peer name (for the confirm copy / bl
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_sensitive = false; // sensitive mark/unmark/setup/unlock overlay
std::string s_sensitive_cid; // target conversation (empty => "unlock the vault only", from the opener)
std::string s_sensitive_name; // peer display name (for the label)
std::string s_sensitive_peeraddr; // peer z-address (carried as the control label, for restore-by-address parity)
bool s_sensitive_unmark = false; // true => the dialog is UNmarking a currently-sensitive conversation
char s_sensitive_pass[128] = ""; // passphrase (setup / unlock)
char s_sensitive_pass2[128] = ""; // confirm passphrase (setup only)
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
@@ -976,6 +983,34 @@ void RenderChatTab(App* app)
if (ImGui::SmallButton(rl.c_str())) s_show_restore = true;
ImGui::PopStyleColor();
}
// Sensitive-vault opener: lock / unlock the local key for sensitive conversations. Shown only once
// a vault exists (it's created on the first "Make sensitive"). While locked it shows the count of
// sensitive conversations currently withheld.
{
const auto vs = app->chatSensitiveVaultState();
if (vs != App::ChatVaultState::NotConfigured) {
if (blockedCount > 0 || onChainDelCount > 0) ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (vs == App::ChatVaultState::Unlocked) {
if (ImGui::SmallButton(TR("chat_sensitive_lock_btn"))) {
app->lockChatSensitiveVault();
s_convsKey = ~0ull; // sensitive threads vanish — rebuild the list
}
} else { // Locked
const int sn = app->settings() ? (int)app->settings()->sensitiveChatConversations().size() : 0;
const std::string ul = sn > 0
? std::string(TR("chat_sensitive_unlock_btn")) + " (" + std::to_string(sn) + ")"
: std::string(TR("chat_sensitive_unlock_btn"));
if (ImGui::SmallButton(ul.c_str())) {
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
s_sensitive_unmark = false;
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
s_show_sensitive = true; // empty cid => unlock-only
}
}
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).
@@ -1171,7 +1206,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 = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
const int nBtns = 6 + (hasAddr ? 1 : 0); // export, mute, hide, sensitive, 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.
@@ -1314,6 +1349,24 @@ void RenderChatTab(App* app)
}
bx += ib + gap;
}
// Sensitive — per-conversation local-key encryption. Opens a dialog that (by vault state)
// sets up / unlocks the vault and marks or unmarks this conversation.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
const bool isSens = app->chatService().isConversationSensitive(sel->cid);
a.tooltip = isSens ? TR("chat_sensitive_unmark_tip") : TR("chat_sensitive_mark_tip");
if (material::IconButton("##hdr_sensitive",
isSens ? ICON_MD_LOCK : ICON_MD_ENHANCED_ENCRYPTION, ifont, ImVec2(ib, ib), a)) {
s_sensitive_cid = sel->cid;
s_sensitive_name = sel->peerName;
s_sensitive_peeraddr = sel->peerZaddr;
s_sensitive_unmark = isSens;
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
s_show_sensitive = true;
}
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.
{
@@ -2572,6 +2625,122 @@ void RenderChatTab(App* app)
}
}
// ---- Sensitive: setup / unlock / mark / unmark (per-conversation local-key encryption) ----
if (s_show_sensitive) {
const float dp = Layout::dpiScale();
const auto vs = app->chatSensitiveVaultState();
const bool unlockOnly = s_sensitive_cid.empty();
const bool needSetup = (vs == App::ChatVaultState::NotConfigured);
const bool needUnlock = (vs == App::ChatVaultState::Locked);
material::OverlayDialogSpec ov;
ov.title = unlockOnly ? TR("chat_sensitive_unlock_title")
: s_sensitive_unmark ? TR("chat_sensitive_unmark_title")
: TR("chat_sensitive_mark_title");
ov.p_open = &s_show_sensitive;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatsensitive";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
if (unlockOnly) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sensitive_unlock_body"));
ImGui::PopStyleColor();
} else if (s_sensitive_unmark) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sensitive_unmark_body"));
ImGui::PopStyleColor();
} else {
ImGui::TextUnformatted(TR("chat_sensitive_warn_1"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
ImGui::TextUnformatted(TR("chat_sensitive_warn_lose"));
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sensitive_warn_dir"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::TextUnformatted(TR("chat_sensitive_warn_hist"));
ImGui::PopStyleColor();
}
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (needSetup) {
ImGui::TextUnformatted(TR("chat_sensitive_setup_label"));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##senspass2", TR("chat_sensitive_pass2_hint"), s_sensitive_pass2,
sizeof(s_sensitive_pass2), ImGuiInputTextFlags_Password);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
} else if (needUnlock) {
ImGui::TextUnformatted(TR("chat_sensitive_unlock_label"));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
const char* actLabel =
needSetup ? TR("chat_sensitive_btn_setup")
: needUnlock ? (unlockOnly ? TR("chat_sensitive_btn_unlock")
: s_sensitive_unmark ? TR("chat_sensitive_btn_unlock_unmark")
: TR("chat_sensitive_btn_unlock_mark"))
: (unlockOnly ? TR("chat_sensitive_btn_unlock")
: s_sensitive_unmark ? TR("chat_sensitive_btn_unmark") : TR("chat_sensitive_btn_mark"));
auto closeWipe = [&]() {
std::memset(s_sensitive_pass, 0, sizeof(s_sensitive_pass));
std::memset(s_sensitive_pass2, 0, sizeof(s_sensitive_pass2));
s_show_sensitive = false;
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
};
const float gap2 = Layout::spacingSm();
const float wAct = std::max(160.0f * dp, ImGui::CalcTextSize(actLabel).x + 40.0f * dp);
const float wCan = std::max(90.0f * dp, ImGui::CalcTextSize(TR("chat_cancel")).x + 32.0f * dp);
material::BeginOverlayDialogFooter(wAct + wCan + gap2, /*drawSeparator=*/false);
const bool doAct = material::TactileButton(actLabel, ImVec2(wAct, 0));
ImGui::SameLine(0, gap2);
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
if (doAct) {
bool ok = true;
if (needSetup) {
const std::string p1(s_sensitive_pass), p2(s_sensitive_pass2);
if (p1.empty() || p1 != p2) {
Notifications::instance().error(TR("chat_sensitive_err_pass")); ok = false;
} else if (!app->createChatSensitiveVault(p1)) {
Notifications::instance().error(TR("chat_sensitive_err_create")); ok = false;
}
} else if (needUnlock) {
const std::string p1(s_sensitive_pass);
if (p1.empty() || !app->unlockChatSensitiveVault(p1)) {
Notifications::instance().error(TR("chat_sensitive_err_unlock")); ok = false;
}
}
if (ok) {
if (!unlockOnly) {
if (s_sensitive_unmark) {
app->unmarkChatConversationSensitive(s_sensitive_cid);
Notifications::instance().info(TR("chat_sensitive_toast_unmarked"));
} else {
app->markChatConversationSensitive(s_sensitive_cid, s_sensitive_peeraddr);
Notifications::instance().info(TR("chat_sensitive_toast_marked"));
}
} else {
Notifications::instance().info(TR("chat_sensitive_toast_unlocked"));
}
s_convsKey = ~0ull; // sensitive threads appear/disappear — rebuild the list
closeWipe();
}
}
if (doCancel) closeWipe();
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;
+29
View File
@@ -331,6 +331,35 @@ void I18n::loadBuiltinEnglish()
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_sensitive_mark_tip"] = "Make sensitive";
strings_["chat_sensitive_unmark_tip"] = "Sensitive - tap to change";
strings_["chat_sensitive_mark_title"] = "Make this conversation sensitive?";
strings_["chat_sensitive_unmark_title"] = "Make this conversation normal?";
strings_["chat_sensitive_unlock_title"] = "Unlock sensitive conversations";
strings_["chat_sensitive_warn_1"] = "Its messages will be encrypted on this device under a separate passphrase - not your seed phrase. Anyone who later restores your wallet from your seed alone won't see this conversation.";
strings_["chat_sensitive_warn_lose"] = "If you lose this passphrase, these messages are gone forever - they can't be recovered from your seed.";
strings_["chat_sensitive_warn_dir"] = "Your sent messages here are protected completely. Received messages are still on the blockchain and could, in theory, be recovered by someone who has your seed and other software - DragonX itself will never show them without your passphrase.";
strings_["chat_sensitive_warn_hist"] = "Messages already in this conversation stay recoverable from the chain; this fully protects messages from here on.";
strings_["chat_sensitive_unmark_body"] = "This conversation will no longer be encrypted under your sensitive-chat passphrase. New messages will be stored like any other conversation.";
strings_["chat_sensitive_unlock_body"] = "Enter your sensitive-chat passphrase to show your sensitive conversations again.";
strings_["chat_sensitive_setup_label"] = "Create a passphrase for sensitive conversations";
strings_["chat_sensitive_unlock_label"] = "Enter your sensitive-chat passphrase";
strings_["chat_sensitive_pass_hint"] = "Passphrase";
strings_["chat_sensitive_pass2_hint"] = "Confirm passphrase";
strings_["chat_sensitive_btn_setup"] = "Set up & make sensitive";
strings_["chat_sensitive_btn_mark"] = "Make sensitive";
strings_["chat_sensitive_btn_unmark"] = "Make normal";
strings_["chat_sensitive_btn_unlock"] = "Unlock";
strings_["chat_sensitive_btn_unlock_mark"] = "Unlock & make sensitive";
strings_["chat_sensitive_btn_unlock_unmark"] = "Unlock & make normal";
strings_["chat_sensitive_lock_btn"] = "Lock sensitive";
strings_["chat_sensitive_unlock_btn"] = "Unlock sensitive";
strings_["chat_sensitive_toast_marked"] = "Conversation is now sensitive";
strings_["chat_sensitive_toast_unmarked"] = "Conversation is no longer sensitive";
strings_["chat_sensitive_toast_unlocked"] = "Sensitive conversations unlocked";
strings_["chat_sensitive_err_pass"] = "Passphrases don't match, or are empty.";
strings_["chat_sensitive_err_unlock"] = "Wrong passphrase.";
strings_["chat_sensitive_err_create"] = "Couldn't set up the sensitive-chat vault.";
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";