First phase of multi-wallet support: keep each wallet's data separate so loading
a different wallet no longer shows the previous one's data, and lay the metadata
groundwork for the wallet-files list.
- Address book scoped per wallet: AddressBookEntry gains a "scope" ("global" or a
wallet-identity hash); the Contacts tab shows global + current-wallet contacts
(App::activeWalletIdentityHash) with a "show in every wallet" toggle + globe
badge. Legacy entries migrate to "global". Scope-aware de-dup allows the same
address across different wallets.
- Wallet metadata index (data/wallet_index -> wallets.json): file-keyed cache of
balance / address count / identity / size / last-opened / synced-here, since
those can't be read off a wallet.dat without loading it. Populated on connect +
address refresh (change-detecting upsert). Plus an active_wallet_file setting
for the -wallet=<name> switch coming in P2.
Unit-tested (testAddressBookScope, testWalletIndex): migration, visibility filter,
scope-aware de-dup, upsert change-detection, save/reload round-trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
403 lines
18 KiB
C++
403 lines
18 KiB
C++
// 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 <algorithm>
|
|
#include <cctype>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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<char>(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<int>(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<size_t> 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<int>(i));
|
|
|
|
ImGui::TableNextColumn();
|
|
bool is_selected = (s_selected_index == static_cast<int>(i));
|
|
if (ImGui::Selectable(entry.label.c_str(), is_selected,
|
|
ImGuiSelectableFlags_SpanAllColumns)) {
|
|
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
|
|
s_selected_index = static_cast<int>(i);
|
|
}
|
|
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
|
s_selected_index = static_cast<int>(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<size_t>(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<int>(visibleRows[k]) == s_selected_index) { curPos = static_cast<int>(k); break; }
|
|
|
|
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
|
|
curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast<int>(visibleRows.size()) - 1);
|
|
s_selected_index = static_cast<int>(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<int>(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
|