// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "contacts_tab.h" #include "../../app.h" #include "../../data/address_book.h" #include "../../util/i18n.h" #include "../../util/text_format.h" #include "../notifications.h" #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" #include "imgui.h" #include #include #include #include #include namespace dragonx { namespace ui { // Tab state (formerly AddressBookDialog's class statics). s_selected_index is a // STORAGE index into book.entries() — decoupled from the visible row order so // search + sort can't corrupt edit/delete/copy targets. static int s_selected_index = -1; static bool s_show_add_dialog = false; static bool s_show_edit_dialog = false; static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms static char s_edit_label[128] = ""; static char s_edit_address[512] = ""; static char s_edit_notes[512] = ""; static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle static char s_search[128] = ""; static void copyEditField(char* dest, size_t destSize, const std::string& source) { if (destSize == 0) return; std::strncpy(dest, source.c_str(), destSize - 1); dest[destSize - 1] = '\0'; } static std::string toLower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; } static bool matchesSearch(const data::AddressBookEntry& e, const std::string& needleLower) { if (needleLower.empty()) return true; return toLower(e.label).find(needleLower) != std::string::npos || toLower(e.address).find(needleLower) != std::string::npos || toLower(e.notes).find(needleLower) != std::string::npos; } static bool isShieldedAddr(const std::string& a) { return !a.empty() && a[0] == 'z'; } void RenderContactsTab(App* app) { auto& S = schema::UI(); // Reuse the existing address-book schema/column config for the table + add/edit form. auto addrTable = S.table("dialogs.address-book", "address-table"); auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label"); auto addrBackLbl = S.label("dialogs.address-book", "address-back-label"); auto addrInput = S.input("dialogs.address-book", "address-input"); auto notesInput = S.input("dialogs.address-book", "notes-input"); auto actionBtn = S.button("dialogs.address-book", "action-button"); auto& book = app->addressBook(); // The active wallet's identity hash scopes which contacts show + tags newly-added ones. // Empty pre-connect (addresses unknown) — then we don't filter, to avoid hiding contacts. const std::string activeHash = app->activeWalletIdentityHash(); auto clearEditFields = []() { s_edit_label[0] = '\0'; s_edit_address[0] = '\0'; s_edit_notes[0] = '\0'; s_edit_global = false; // new contacts default to the current wallet }; auto loadEditFields = [](const data::AddressBookEntry& entry) { copyEditField(s_edit_label, sizeof(s_edit_label), entry.label); copyEditField(s_edit_address, sizeof(s_edit_address), entry.address); copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes); s_edit_global = entry.isGlobal(); }; bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast(book.size()); // Shared delete/copy actions (used by both the toolbar buttons and keyboard shortcuts). auto doDelete = [&]() { if (!has_selection) return; if (s_confirm_delete_idx == s_selected_index) { book.removeEntry(s_selected_index); s_selected_index = -1; s_confirm_delete_idx = -1; Notifications::instance().success(TR("address_book_deleted")); } else { // Require a second, deliberate confirm (no undo for a removed contact). s_confirm_delete_idx = s_selected_index; } }; auto doCopy = [&]() { if (!has_selection) return; ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str()); Notifications::instance().info(TR("address_copied")); }; auto openEdit = [&]() { if (!has_selection) return; loadEditFields(book.entries()[s_selected_index]); s_show_edit_dialog = true; s_focus_edit_field = true; }; // Add/edit form — a modal popup layered over the tab. auto renderEntryDialog = [&]() { bool isEdit = s_show_edit_dialog; bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog; if (!*open) return; const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add"); const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd"; float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth()); float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth(); float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth(); float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap(); float notesH = notesInput.height > 0 ? notesInput.height : 60.0f; if (material::BeginOverlayDialog(title, open, dialogW, 0.94f, Layout::kDialogCompactBottomRatio(), id)) { // Focus the first field the frame the dialog opens so it's keyboard-ready. if (s_focus_edit_field) { ImGui::SetKeyboardFocusHere(); s_focus_edit_field = false; } material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel", s_edit_label, sizeof(s_edit_label), formW); ImGui::Spacing(); material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress", s_edit_address, sizeof(s_edit_address), formW); if (!isEdit) { ImGui::SameLine(); if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { const char* clipboard = ImGui::GetClipboardText(); if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard); } } ImGui::Spacing(); material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH)); ImGui::Spacing(); ImGui::Checkbox(TR("contact_global"), &s_edit_global); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt")); bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0; float totalActionsW = actionW * 2.0f + actionGap; material::BeginOverlayDialogFooter(totalActionsW); if (!canSubmit) ImGui::BeginDisabled(); const char* primaryLabel = isEdit ? TR("save") : TR("add"); if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is. auto trimAB = [](std::string s) { while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin()); while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back(); return s; }; data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes); // Global if the user asked, or as a safe fallback when we don't yet know the wallet // (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet. entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash; if (isEdit) { if (app->addressBook().updateEntry(s_selected_index, entry)) { Notifications::instance().success(TR("address_book_updated")); s_show_edit_dialog = false; } else { Notifications::instance().error(TR("address_book_update_failed")); } } else { if (app->addressBook().addEntry(entry)) { Notifications::instance().success(TR("address_book_added")); s_show_add_dialog = false; } else { Notifications::instance().error(TR("address_book_exists")); } } } if (!canSubmit) ImGui::EndDisabled(); ImGui::SameLine(0, actionGap); if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { *open = false; } material::EndOverlayDialog(); } }; // Inline tab content lives in a scroll child (mirrors peers_tab / explorer_tab). ImVec2 avail = ImGui::GetContentRegionAvail(); ImGui::BeginChild("##ContactsScroll", avail, false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); // Toolbar if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { s_show_add_dialog = true; s_focus_edit_field = true; clearEditFields(); } ImGui::SameLine(); if (!has_selection) ImGui::BeginDisabled(); if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) openEdit(); ImGui::SameLine(); // Delete — relabel to a visible confirm prompt while armed (not just a toast). bool armed = has_selection && s_confirm_delete_idx == s_selected_index; const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete"); if (material::StyledButton(delLabel, ImVec2(0,0), S.resolveFont(actionBtn.font))) doDelete(); ImGui::SameLine(); if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font))) doCopy(); if (!has_selection) ImGui::EndDisabled(); // Search / filter ImGui::Spacing(); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"), s_search, sizeof(s_search)); bool searchActive = ImGui::IsItemActive(); std::string needle = toLower(s_search); ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); // Filtered, sorted view — storage indices only (keeps s_selected_index meaningful). std::vector visibleRows; visibleRows.reserve(book.size()); for (size_t i = 0; i < book.size(); ++i) { const auto& e = book.entries()[i]; if (!matchesSearch(e, needle)) continue; // Scope filter: global contacts + this wallet's contacts. Pre-connect (activeHash empty) // we don't know the wallet yet, so show everything rather than hide contacts. if (!activeHash.empty() && !e.visibleInWallet(activeHash)) continue; visibleRows.push_back(i); } // Address list — size the table to fill the tab, leaving room for the count footer. float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; float tableH = ImGui::GetContentRegionAvail().y - footerH; if (tableH < 120.0f) tableH = 120.0f; if (ImGui::BeginTable("AddressBookTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY, ImVec2(0, tableH))) { float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150; float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150; ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, labelColW); ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); // Sort the visible view by the active column (indices stay storage indices). if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) { if (specs->SpecsCount > 0) { const ImGuiTableColumnSortSpecs& sc = specs->Specs[0]; bool asc = sc.SortDirection != ImGuiSortDirection_Descending; const auto& entries = book.entries(); std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) { const std::string* ka; const std::string* kb; switch (sc.ColumnIndex) { case 1: ka = &entries[a].address; kb = &entries[b].address; break; case 2: ka = &entries[a].notes; kb = &entries[b].notes; break; default: ka = &entries[a].label; kb = &entries[b].label; break; } int cmp = toLower(*ka).compare(toLower(*kb)); return asc ? (cmp < 0) : (cmp > 0); }); } } if (book.empty()) { ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::TextDisabled("%s", TR("address_book_empty")); } else if (visibleRows.empty()) { ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::TextDisabled("%s", TR("contacts_search_no_match")); } else { for (size_t vi = 0; vi < visibleRows.size(); ++vi) { size_t i = visibleRows[vi]; const auto& entry = book.entries()[i]; ImGui::TableNextRow(); ImGui::PushID(static_cast(i)); ImGui::TableNextColumn(); bool is_selected = (s_selected_index == static_cast(i)); if (ImGui::Selectable(entry.label.c_str(), is_selected, ImGuiSelectableFlags_SpanAllColumns)) { if (s_selected_index != static_cast(i)) s_confirm_delete_idx = -1; s_selected_index = static_cast(i); } if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { s_selected_index = static_cast(i); openEdit(); } ImGui::TableNextColumn(); // Z/T type badge + address in normal (legible) text — not muted. // Darker/more-saturated variants on light skins so the badge doesn't wash out. bool shielded = isShieldedAddr(entry.address); bool lightTheme = material::IsLightTheme(); ImVec4 zCol = lightTheme ? ImVec4(0.10f, 0.55f, 0.38f, 1.0f) : ImVec4(0.35f, 0.80f, 0.60f, 1.0f); ImVec4 tCol = lightTheme ? ImVec4(0.72f, 0.48f, 0.05f, 1.0f) : ImVec4(0.95f, 0.72f, 0.30f, 1.0f); ImGui::TextColored(shielded ? zCol : tCol, "%s", shielded ? "Z" : "T"); ImGui::SameLine(0.0f, 6.0f); std::string addr_display = entry.address; int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) ? addrTable.columns.at("address").truncate : 40; if (addr_display.length() > static_cast(addrTruncLen)) { addr_display = util::truncateMiddle(addr_display, addrFrontLbl.truncate, addrBackLbl.truncate); } ImGui::TextUnformatted(addr_display.c_str()); if (ImGui::IsItemHovered()) { material::Tooltip("%s", entry.address.c_str()); } // Globe badge marks a global contact (shown in every wallet). if (entry.isGlobal()) { ImGui::SameLine(0.0f, 8.0f); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", ICON_MD_PUBLIC); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt")); } ImGui::TableNextColumn(); ImGui::TextUnformatted(entry.notes.c_str()); ImGui::PopID(); } } ImGui::EndTable(); } // Status line ImGui::TextDisabled(TR("address_book_count"), book.size()); // Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up). if (!searchActive && !ImGui::IsAnyItemActive() && !s_show_add_dialog && !s_show_edit_dialog && !visibleRows.empty()) { // Current selection's position within the visible view. int curPos = -1; for (size_t k = 0; k < visibleRows.size(); ++k) if (static_cast(visibleRows[k]) == s_selected_index) { curPos = static_cast(k); break; } if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast(visibleRows.size()) - 1); s_selected_index = static_cast(visibleRows[curPos]); s_confirm_delete_idx = -1; } else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { curPos = (curPos < 0) ? 0 : std::max(curPos - 1, 0); s_selected_index = static_cast(visibleRows[curPos]); s_confirm_delete_idx = -1; } else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { openEdit(); } else if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { doDelete(); } else if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C, false)) { doCopy(); } } ImGui::EndChild(); renderEntryDialog(); } } // namespace ui } // namespace dragonx