diff --git a/src/app.cpp b/src/app.cpp index b66088b..ee4d877 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -878,6 +878,9 @@ void App::update() // (a prior/unwitnessed salvage likely moved the coins into a wallet..bak). maybeWarnEmptyWalletWithFundedSiblings(); + // One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate). + maybeWarnLargeWallet(); + // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can // glow for a legacy, pre-seed-phrase wallet. probeWalletSeedStatus(); @@ -2421,10 +2424,19 @@ void App::renderAlertHistoryPanel() return; } - // Scrollable list, newest first. Height adapts to the entry count but caps so a busy session - // scrolls inside the panel instead of blowing past the popup's max height. - const float perEntry = txtF->LegacySize * 2.0f + 14.0f * dp; // message line + time line + spacing - const float listH = std::min(300.0f * dp, static_cast(hist.size()) * perEntry); + // Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages + // and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls + // inside the panel instead of blowing past the popup's max height. + const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp); + float contentH = 0.0f; + for (const auto& a : hist) { + const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y; + contentH += std::max(msgH, static_cast(icoF->LegacySize)); // icon + wrapped message + contentH += txtF->LegacySize; // relative-age line + if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize; // action-link line + contentH += 8.0f * dp; // inter-entry spacing + } + const float listH = std::min(300.0f * dp, contentH); ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false); int idx = 0; for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) { @@ -2452,6 +2464,19 @@ void App::renderAlertHistoryPanel() ImGui::TextWrapped("%s", a.message.c_str()); ImGui::PopTextWrapPos(); ImGui::PopStyleColor(); + // Optional clickable action (accent link), directly under the message so it stays prominent. + if (a.onClick && !a.actionHint.empty()) { + ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); + ImGui::PushStyleColor(ImGuiCol_Text, m::Primary()); + ImGui::TextUnformatted(a.actionHint.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary()); + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); } + } // Relative age, dim, indented under the message. ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled()); diff --git a/src/app.h b/src/app.h index 93f0730..dc1f0fa 100644 --- a/src/app.h +++ b/src/app.h @@ -817,6 +817,7 @@ private: // install) to back up their seed phrase. Cheap early-outs keep it idle until it can act. void maybeRemindSeedBackup(); void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once + void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02) void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02) @@ -1007,6 +1008,7 @@ private: bool seed_backup_loading_ = false; bool seed_backup_no_mnemonic_ = false; bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe + bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch // Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed // once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a diff --git a/src/app_network.cpp b/src/app_network.cpp index 64b0eca..718f064 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -35,6 +35,7 @@ #include "rpc/connection.h" #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch +#include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge #include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include // sodium_memzero for wiping the fetched mnemonic #include @@ -4232,6 +4233,36 @@ void App::maybeRemindSeedBackup() }); } +// One-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never +// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow +// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the +// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold. +void App::maybeWarnLargeWallet() +{ + if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat + if (!supportsFullNodeLifecycleActions() || !settings_) return; + if (!state_.connected || !state_.encryption_state_known) return; + if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; + if (large_wallet_checked_) return; // stat wallet.dat at most once per launch + large_wallet_checked_ = true; + + static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB (matches the Settings banner) + const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat"; + const uint64_t sz = util::Platform::getFileSize(walletPath); + if (sz <= kWalletBloatWarnBytes) { + // Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet). + if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); } + return; + } + if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode + settings_->setLargeWalletWarned(true); + settings_->save(); + ui::Notifications::instance().action( + TR("wallet_size_warn"), ui::NotificationType::Warning, + []() { ui::ShieldDialog::show(ui::ShieldDialog::Mode::MergeToAddress); }, + TR("wallet_size_consolidate"), 12.0f); +} + // Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it // happened on a prior run, or under an external daemon whose startup output we never captured, so // detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 7374515..fd58bc2 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -232,6 +232,7 @@ bool Settings::load(const std::string& path) } loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); + loadScalar(j, "large_wallet_warned", large_wallet_warned_); if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) { empty_wallet_warning_acked_.clear(); for (const auto& w : j["empty_wallet_warning_acked"]) @@ -506,6 +507,7 @@ bool Settings::save(const std::string& path) } j["wizard_completed"] = wizard_completed_; j["seed_backup_reminded"] = seed_backup_reminded_; + j["large_wallet_warned"] = large_wallet_warned_; j["empty_wallet_warning_acked"] = json::array(); for (const auto& w : empty_wallet_warning_acked_) j["empty_wallet_warning_acked"].push_back(w); diff --git a/src/config/settings.h b/src/config/settings.h index 2fb7129..5d12aff 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -330,6 +330,10 @@ public: bool getSeedBackupReminded() const { return seed_backup_reminded_; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } + // One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back). + bool getLargeWalletWarned() const { return large_wallet_warned_; } + void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; } + // Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds" // warning has been dismissed. Keyed per active wallet file so switching to a different empty // wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings). @@ -597,6 +601,7 @@ private: std::map address_meta_; bool wizard_completed_ = false; bool seed_backup_reminded_ = false; + bool large_wallet_warned_ = false; std::set empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed bool encryption_pending_ = false; long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt diff --git a/src/ui/notifications.h b/src/ui/notifications.h index fb628f1..25e764e 100644 --- a/src/ui/notifications.h +++ b/src/ui/notifications.h @@ -31,6 +31,8 @@ struct AlertRecord { std::string message; NotificationType type; std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display + std::function onClick; // optional: makes this bell-panel entry actionable + std::string actionHint; // optional: accent link label rendered for the action }; struct Notification { @@ -92,15 +94,25 @@ public: if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f); push(message, NotificationType::Error, duration); } + + // An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel. + // onClick fires when the user clicks the accent `actionHint` link in that panel. + void action(const std::string& message, NotificationType type, std::function onClick, + const std::string& actionHint, float duration = -1.0f) { + if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f); + push(message, type, duration, std::move(onClick), actionHint); + } - void push(const std::string& message, NotificationType type, float duration = 5.0f) { + void push(const std::string& message, NotificationType type, float duration = 5.0f, + std::function onClick = nullptr, const std::string& actionHint = "") { notifications_.emplace_back(message, type, duration); // Retain a copy in the persistent history (the toast above will fade in seconds; this // survives so the user can review what happened). Thread note: every push is on the UI // thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock, // consistent with the rest of this class. Do NOT push from a raw worker thread. - history_.push_back(AlertRecord{message, type, static_cast(std::time(nullptr))}); + history_.push_back(AlertRecord{message, type, static_cast(std::time(nullptr)), + std::move(onClick), actionHint}); ++total_pushed_; while (history_.size() > kMaxHistory) { history_.pop_front();