9 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 5cfa713be1 test(sweep): capture the no-peers node banner on its full-node sweep target
renderNodeStatusBanner suppresses itself during a UI sweep (the forced demo
state would otherwise paint a banner over every tab). Add a narrow opt-in
(sweep_show_node_banner_) so ONLY the overview-no-peers target shows it; every
other capture is unchanged. The target also clears connection_in_progress_ so
the evaluator classifies the state as no-peers rather than "connecting".

Verified in a full-node + DRAGONX_ENABLE_UI_SWEEP build: the banner renders as
"Connected, but no network peers — the node can't sync · Check your internet
connection and firewall, then restart the node to find peers." (Restart button
only appears with an embedded daemon, which the sweep has none of.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 23:59:03 -05:00
DanSandClaude Opus 4.8 cd9ca4cd77 test(sweep): add UI-sweep targets for the new syncing / no-peers / chat-identity states
Capture the states the earlier verification couldn't reach:
- overview-syncing: the balance "catching up — funds are safe" strip (P4).
- overview-no-peers: the node-status banner's no-peers detail (P2; full-node
  only — renders as a plain Overview in the Lite sweep binary).
- chat-identity-preparing / -unavailable: the chat empty states that used to
  all say "unlock your wallet" (P3). Setup clears the fabricated demo identity
  (clearIdentity wipes only the key; the demo message store survives);
  sweepEnsureDemoChatIdentity() re-fabricates on teardown so later targets keep
  the populated chat UI.

Also make the demo baseline a genuinely synced node (tip_known=true) so the
no-peers banner only appears on its own target, and dismiss the lite first-run
welcome prompt during the demo so it stops occluding every lite screenshot
(snapshotted + restored). Verified: all four new captures render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 17:27:22 -05:00
DanSandClaude Opus 4.8 9db73323b6 feat(ui): friendly RPC errors + pause-on-hover/ellipsis toasts
Two of the systemic error-surfacing foundations from the UX audit:

- util::friendlyRpcError() maps the common raw daemon/JSON-RPC errors
  (warming up / -28, locked / -13, method-not-found / -32601, insufficient
  funds, unreachable node) to short translated guidance with a next step,
  falling back to the trimmed raw text. Wired into the send (money) path: the
  toast shows the friendly cause, the overlay keeps the raw text below it for
  diagnostics. The helper is reusable for the remaining raw-error sites.
- Toasts now pause their auto-dismiss while hovered and ellipsize over-long
  text with the full message in a hover tooltip, instead of hard-clipping it
  mid-word (durations were already lengthened earlier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:53:37 -05:00
DanSandClaude Opus 4.8 4a8d07edce feat(balance): show a "catching up — funds are safe" strip while syncing
A syncing node skips the balance RPC, so the balance card paints a bare
0.00000000 / $-.-- that reads as lost funds after an update (a documented
support trap). Add one shared notice at the top of the Balance tab — above
every layout — shown only for the normal catching-up case (connected, network
tip known, still verifying, not a rescan/witness rebuild). Offline, no-peers,
a busy node and rescans stay the node-status banner's job, so the two never
stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:48:33 -05:00
DanSandClaude Opus 4.8 643ac7daef feat(seed,console): persistent re-nudging seed backup; confirm destructive console commands
- Seed backup: the one-time 12s toast that marked itself "reminded" the instant
  it fired (so a funded, unbacked wallet was never nudged again) is replaced by
  an actionable alert with a "Back up now" link that opens the backup dialog and
  persists in the bell panel. "Reminded" now means the user actually viewed the
  phrase (set from the backup dialog), so the nudge re-fires each session until
  they do, and escalates to a warning once a balance exists.
- Console: fund-moving / key-revealing commands (dumpprivkey, z_exportkey,
  z_sendmany, z_mergetoaddress, …) now require a confirming re-entry when typed
  or PASTED, honoring the command reference's `destructive` flag that was
  previously enforced only by the reference dialog's Run button. (Output
  masking of key dumps in the scrollback is still a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:46:09 -05:00
DanSandClaude Opus 4.8 e27d88eca4 feat(chat): explain the no-identity state and why a send is stuck
- Chat tab no longer shows "Unlock your wallet" for every missing-identity
  case. A new App::chatIdentityStatus() distinguishes Locked (real),
  Preparing (unlocked, deriving the identity) and Unavailable (view-only or a
  legacy wallet with no exportable secret — correctly latched). Each gets its
  own icon + message, and the Unavailable prompt points at Back up / Migrate
  instead of telling the user to unlock an already-unlocked wallet.
- A chat message that sits queued ("Sending") because the wallet is offline or
  has no shielded funds for the fee now surfaces the reason (throttled, after a
  short dwell), wiring the ready-made chat_toast_not_connected /
  chat_toast_need_funds strings that were never shown. The send stays queued
  and goes out when the condition clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:41:30 -05:00
DanSandClaude Opus 4.8 6efe3c0f5c feat(send,peers): show full recipient + contact label on confirm; actionable peers empty state
- send confirm: the FROM/TO cards now show the FULL address (wrapped) instead
  of a middle-truncated preview, and the saved-contact label when the address
  matches the address book ("TO  ·  Alice") — the strongest human check against
  a swapped recipient on the one screen meant to catch it. Cards auto-grow.
- peers tab: the "No connected peers" empty state now carries the same
  actionable hint as the node-status banner instead of being a bare dead-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:37:28 -05:00
DanSandClaude Opus 4.8 750771ebfb feat(network): give the "no peers" banner an honest detail and a Restart action
The no-peers banner headline ("Connected, but no network peers") was followed
by a detail line that just repeated connection_status ("Connected"/"Peers: 0"),
reading as a contradiction, and it offered no action at the exact moment the
wallet looks synced but is silently stalled at a stale height.

- NoPeers now clears the passthrough detail; App renders a translated,
  actionable hint (node_banner_no_peers_detail) for this reason instead.
- When the embedded daemon is the node, offer a Restart button (re-triggers
  peer discovery via the -addnode seeds); an external node stays action-less
  since it is the user's own to restart.
- Update + extend the evaluator unit tests (external = no action/empty detail;
  embedded = RestartNode).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:33:17 -05:00
DanSandClaude Opus 4.8 a99f71a0cd fix(ux): land quick-win UX audit fixes across settings, exports, toasts, nav
Batch of safe, isolated fixes from the 10-surface UX audit:

- settings: Auto-shield / Save-z-transactions toggles now persist on change
  (they discarded CB()'s return and relied on a Save button that only renders
  on the About tab, so the choice was silently lost on relaunch).
- exports: show the resolved Platform::getConfigDir() instead of a hardcoded
  ~/.config path (wrong on Windows/macOS) in both the all-keys and CSV
  dialogs; all-keys also reveals the file path on a partial export.
- notifications: error/warning toasts last 9s/7s (were 4s/3.5s) so failures
  are readable.
- qr popup / tx-details: copy-to-clipboard now confirms with a toast.
- about: Escape-to-close + readable heading contrast via OnSurfaceMedium().
- sidebar: suppress single-key nav (J/K/arrows/[) while an overlay dialog or
  ImGui popup is open, so it no longer switches the tab behind a modal.
- i18n: strip the stale hardcoded path out of file_save_location in i18n.cpp
  and all eight res/lang/*.json locales (additive, one key each).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 16:27:58 -05:00
32 changed files with 437 additions and 62 deletions
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "Normal",
"fetch_prices": "Preise abrufen",
"file": "Datei",
"file_save_location": "Datei wird gespeichert in: ~/.config/ObsidianDragon/",
"file_save_location": "Datei wird gespeichert in:",
"filter": "Filtern...",
"font_scale": "Schriftgröße",
"from": "Von",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "Normal",
"fetch_prices": "Obtener precios",
"file": "Archivo",
"file_save_location": "El archivo se guardará en: ~/.config/ObsidianDragon/",
"file_save_location": "El archivo se guardará en:",
"filter": "Filtrar...",
"font_scale": "Escala de fuente",
"from": "Desde",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "Normal",
"fetch_prices": "Récupérer les prix",
"file": "Fichier",
"file_save_location": "Le fichier sera enregistré dans : ~/.config/ObsidianDragon/",
"file_save_location": "Le fichier sera enregistré dans :",
"filter": "Filtrer...",
"font_scale": "Taille de police",
"from": "De",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "通常",
"fetch_prices": "価格を取得",
"file": "ファイル",
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
"file_save_location": "ファイルの保存先:",
"filter": "フィルター...",
"font_scale": "フォントサイズ",
"from": "送信元",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "보통",
"fetch_prices": "가격 조회",
"file": "파일",
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
"file_save_location": "파일 저장 위치:",
"filter": "필터...",
"font_scale": "글꼴 크기",
"from": "보낸 곳",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "Normal",
"fetch_prices": "Buscar preços",
"file": "Arquivo",
"file_save_location": "O arquivo será salvo em: ~/.config/ObsidianDragon/",
"file_save_location": "O arquivo será salvo em:",
"filter": "Filtrar...",
"font_scale": "Escala da Fonte",
"from": "De",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "Обычная",
"fetch_prices": "Получить цены",
"file": "Файл",
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
"file_save_location": "Файл будет сохранён в:",
"filter": "Фильтр...",
"font_scale": "Масштаб шрифта",
"from": "От",
+1 -1
View File
@@ -876,7 +876,7 @@
"fee_normal": "普通",
"fetch_prices": "获取价格",
"file": "文件",
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
"file_save_location": "文件将保存至:",
"filter": "筛选...",
"font_scale": "字体大小",
"from": "从",
+2 -2
View File
@@ -1229,8 +1229,8 @@ glass-border-alpha = { size = 35.0 }
progress-bar-height = { size = 2.0 }
duration-info = { size = 1.0 }
duration-success = { size = 2.5 }
duration-warning = { size = 3.5 }
duration-error = { size = 4.0 }
duration-warning = { size = 7.0 }
duration-error = { size = 9.0 }
window = { width = 350, height = 60, border-radius = 8, border-width = 1 }
viewport-padding = { size = 20.0 }
stack-gap = { size = 10.0 }
+18 -3
View File
@@ -2637,7 +2637,9 @@ void App::renderNodeStatusBanner()
// wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and
// shutdown. tryConnect() sets connection_in_progress_ before the first render on normal
// startup, so the ordinary boot path is covered by the evaluator's own in-progress guard.
if (capture_mode_ || isShuttingDown()) return;
// Suppressed during a UI sweep (its forced demo state would otherwise paint a banner over every
// tab) — except on the one sweep target that exists to capture the banner itself.
if ((capture_mode_ && !sweep_show_node_banner_) || isShuttingDown()) return;
if (getWizardPhase() != WizardPhase::None) return;
if (wallet_switch_phase_.load() != 0) return;
if (daemon_restarting_.load()) return;
@@ -2705,6 +2707,13 @@ void App::renderNodeStatusBanner()
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
// translated, actionable hint instead; every other reason keeps its live status/error text.
const std::string detailText = (banner.reason == ui::NodeBannerReason::NoPeers)
? std::string(TR("node_banner_no_peers_detail"))
: banner.detail;
const float padX = ui::Layout::spacingLg();
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol));
@@ -2741,7 +2750,7 @@ void App::renderNodeStatusBanner()
ImGui::PopStyleColor();
// Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen.
if (!banner.detail.empty()) {
if (!detailText.empty()) {
ImGui::SameLine(0.0f, ui::Layout::spacingSm());
ImGui::SetCursorPosY(txtCy);
const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
@@ -2752,7 +2761,7 @@ void App::renderNodeStatusBanner()
static util::textfit::EllipsisFitCache s_detailFit; // UI thread only (render path)
ImFont* detailFont = ImGui::GetFont();
const std::string& shown = s_detailFit.get(
banner.detail, budget, detailFont->FontId, ImGui::GetFontSize(),
detailText, budget, detailFont->FontId, ImGui::GetFontSize(),
detailFont->CurrentRasterizerDensity,
[&](const std::string& detail) {
const std::string prefix = "\xC2\xB7 "; // "· "
@@ -4647,6 +4656,12 @@ void App::renderSeedBackupDialog()
seed_backup_loading_ = false;
if (ok) {
seed_backup_phrase_ = phrase; // working copy — wiped on close
// The user is now looking at their seed via the dedicated backup flow — treat that as
// backed up, so the periodic nudge (maybeRemindSeedBackup) stops for good.
if (settings_ && !settings_->getSeedBackupReminded()) {
settings_->setSeedBackupReminded(true);
settings_->save();
}
} else if (noMnemonic) {
seed_backup_no_mnemonic_ = true;
} else {
+20
View File
@@ -302,6 +302,13 @@ public:
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
chat::ChatService& chatService() { return chat_service_; }
// Why the Chat tab has no identity yet, so it can show the right prompt instead of always
// "unlock your wallet". Locked = encrypted & locked (seed unreadable); Preparing = unlocked and
// deriving the identity (fetch in flight, or waiting on the node / address list); Unavailable =
// no usable secret for this wallet (view-only, or a legacy wallet on a daemon without the needed
// export RPC) — correctly latched, so the prompt points at Back up / Migrate instead.
enum class ChatIdentityStatus { Ready, Locked, Preparing, Unavailable };
ChatIdentityStatus chatIdentityStatus() const;
// HushChat composing: construct, broadcast (broadcastChatMemos), and locally echo an outgoing
// message (to a conversation whose peer key we know) / a new-conversation contact request.
void sendChatMessage(const std::string& conversationId, const std::string& text);
@@ -1044,6 +1051,12 @@ private:
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
int chat_verified_note_budget_ = 0;
// Stuck-send notice: when a chat message sits queued ("Sending") because the wallet is offline or
// has no shielded funds for the fee, tell the user WHY once (throttled) — the send stays queued and
// goes out when the condition clears. cause: 0=none, 1=offline, 2=unfunded. See maybeNoticeStuckChatSend.
int chat_stuck_cause_ = 0;
double chat_stuck_since_ = 0.0; // ImGui::GetTime() when this cause began (dwell gate)
double chat_stuck_notice_at_ = 0.0; // next time a repeat notice is allowed (throttle)
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
@@ -1132,6 +1145,8 @@ private:
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
// feature is off, already provisioned, in flight, or unavailable.
void maybeProvisionChatIdentity();
// Surface (throttled) why an outgoing chat message is stuck "Sending" — offline or unfunded.
void maybeNoticeStuckChatSend();
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
@@ -1391,6 +1406,8 @@ 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 seed_backup_nudged_this_session_ = false; // nudged (or definitively classified) once this session;
// unlike the persisted "reminded" flag it re-nudges next run
std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
@@ -1659,7 +1676,9 @@ private:
// The full sweep's private temp dir (demo wallet files and folders); "" when none.
std::string sweep_sandbox_;
bool sweep_chat_identity_fabricated_ = false; // seedChatDemoData() made the demo chat identity
bool sweep_show_node_banner_ = false; // let the node-status banner render on its sweep target
void applyHealthyDemoState(); // reset the connection/encryption flags to the healthy demo values
void sweepEnsureDemoChatIdentity(); // re-fabricate a demo chat identity if none (sweep teardown)
void writeSweepManifest() const;
// Snapshot of the state_ fields the demo installer mutates, restored at sweep end. Kept as a
// plain struct because WalletState has reference-alias members and isn't copy-assignable.
@@ -1675,6 +1694,7 @@ private:
double market_price_usd=0;
double market_change_24h=0;
WizardPhase wizard_phase = WizardPhase::None; // the demo state dismisses the wizard
bool lite_firstrun_dismissed = false; // the demo dismisses the lite welcome prompt
} sweep_state_snapshot_;
bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse
float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized)
+61 -7
View File
@@ -4215,6 +4215,14 @@ void App::resetChatSession()
++chat_session_generation_;
}
App::ChatIdentityStatus App::chatIdentityStatus() const
{
if (chat_service_.hasIdentity()) return ChatIdentityStatus::Ready;
if (state_.isLocked()) return ChatIdentityStatus::Locked;
if (chat_identity_unavailable_) return ChatIdentityStatus::Unavailable;
return ChatIdentityStatus::Preparing; // fetch in flight, or waiting on the node / address list
}
void App::maybeProvisionChatIdentity()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
@@ -5059,11 +5067,43 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
wallet::liteLog("chat: send FAILED: " + error);
}
// A queued chat message stays "Sending" and auto-retries while the wallet is offline or can't cover the
// fee — correct, but silent. Tell the user WHY: once per cause, after a short dwell (so brief blips stay
// quiet) and then throttled, reusing the ready-made strings that were never wired up. Informational only;
// the message is still queued and goes out when the condition clears.
void App::maybeNoticeStuckChatSend() {
int reason = 0; // 1 = offline, 2 = no shielded funds for the fee
if (!chat_send_queue_.empty() && !state_.isLocked()) {
if (!state_.connected) {
reason = 1;
} else if (inflight_op_.kind == LiteOpKind::None && chatPayFromZaddr(kChatMinFeeDrgx).empty()) {
// Connected, nothing in flight, and no z-note can cover the fee → genuinely unfunded (not
// merely a split/refill confirming, which chatPayFromZaddr/inflight_op_ would reflect).
reason = 2;
}
}
if (reason == 0) { chat_stuck_cause_ = 0; return; }
const double now = ImGui::GetTime();
if (reason != chat_stuck_cause_) { // a new cause → restart the dwell + throttle
chat_stuck_cause_ = reason;
chat_stuck_since_ = now;
chat_stuck_notice_at_ = 0.0;
}
if (now - chat_stuck_since_ < 15.0) return; // dwell: ignore transient states
if (now < chat_stuck_notice_at_) return; // throttle repeats
chat_stuck_notice_at_ = now + 45.0;
ui::Notifications::instance().warning(
TR(reason == 1 ? "chat_toast_not_connected" : "chat_toast_need_funds"));
}
// Per-frame pump: drain the chat queue against verified notes (priority 1); otherwise build/refill the
// buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest.
void App::pumpChatNoteBuffer() {
if (!chat::hushChatFeatureEnabledAtBuild()) return;
if (!chat_service_.hasIdentity() || !state_.connected) return;
if (!chat_service_.hasIdentity()) { chat_stuck_cause_ = 0; return; }
maybeNoticeStuckChatSend(); // runs even while offline — that's one of the reasons we report
if (!state_.connected) return;
// A locked wallet can't spend: submitting would return a non-transient lock error and flap the queued
// message to Failed. Hold everything (send + split) until unlock — messages stay "Sending".
if (state_.isLocked()) return;
@@ -5793,7 +5833,8 @@ void App::maybeRemindSeedBackup()
if (capture_mode_) return; // no live ops during a UI sweep
if (window_background_) return; // when someone can see it
if (lite_wallet_) return; // lite has its own seed UX
if (!settings_ || settings_->getSeedBackupReminded()) return;
if (!settings_ || settings_->getSeedBackupReminded()) return; // already backed up (saw the phrase)
if (seed_backup_nudged_this_session_) return; // already nudged/classified this run
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.isLocked()) return; // wait until unlocked to read it
if (seed_backup_reminder_in_flight_) return;
@@ -5804,16 +5845,29 @@ void App::maybeRemindSeedBackup()
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
const std::string& /*error*/) {
seed_backup_reminder_in_flight_ = false;
// Only mark "reminded" once we have a definitive answer, so a transient failure retries
// next session rather than silently suppressing the reminder forever.
if (ok || noMnemonic) {
if (ok) {
// The wallet HAS a mnemonic and the user hasn't backed it up yet. Don't mark "reminded"
// here — that permanent flag now means "the user has seen the phrase" (set from the backup
// dialog), so leaving it clear re-nudges next session until they actually do. Nudge once
// per session as an ACTIONABLE alert (survives in the bell panel with a "Back up now" link),
// not a 12s toast that's gone before it's read. Escalate to a warning once funds exist.
seed_backup_nudged_this_session_ = true;
const bool hasFunds = state_.totalBalance > 0.0;
ui::Notifications::instance().action(
TR("seed_backup_reminder"),
hasFunds ? ui::NotificationType::Warning : ui::NotificationType::Info,
[this]() { showSeedBackupDialog(); },
TR("seed_backup_reminder_action"),
hasFunds ? 15.0f : 12.0f);
} else if (noMnemonic) {
// A legacy (non-mnemonic) wallet has nothing to back up this way — suppress for good.
seed_backup_nudged_this_session_ = true;
settings_->setSeedBackupReminded(true);
settings_->save();
} else {
// Inconclusive (busy/old daemon) — retry later this session rather than suppressing forever.
seed_backup_reminder_retry_at_ = std::chrono::steady_clock::now() + std::chrono::minutes(5);
}
if (ok)
ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f);
});
}
+74
View File
@@ -38,6 +38,8 @@
#include "ui/windows/folder_picker.h"
#include "ui/pages/settings_page.h"
#include "chat/chat_protocol.h"
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret / ChatIdentityStatus (sweep identity)
#include "chat/chat_crypto.h" // ChatKeyPair / wipeChatKeyPair
#include "util/i18n.h"
#include "util/platform.h"
#include "util/private_temp_dir.h"
@@ -159,11 +161,15 @@ void App::installDemoWalletData()
s.market_price_usd = state_.market.price_usd;
s.market_change_24h = state_.market.change_24h;
s.wizard_phase = wizard_phase_;
s.lite_firstrun_dismissed = lite_firstrun_dismissed_;
s.valid = true;
applyHealthyDemoState();
state_.sync.blocks = state_.sync.headers = 3124322;
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
state_.sync.tip_known = true; // a healthy synced node knows the peer-derived tip (so the no-peers
state_.sync.building_witnesses = false; state_.sync.rescanning = false; // banner only shows on its target
lite_firstrun_dismissed_ = true; // the demo "has" a wallet — don't overlay the lite welcome prompt
// Legacy (pre-seed) wallet so the Settings "Migrate to seed" button glows in the sweep.
wallet_seed_status_ = WalletSeedStatus::NoMnemonic;
@@ -260,6 +266,20 @@ void App::applyHealthyDemoState()
wizard_phase_ = WizardPhase::None;
}
// Re-fabricate an identity-only demo chat identity (no messages — the demo store is untouched by
// clearIdentity) so a chat-identity sweep target's teardown restores the populated chat UI. Mirrors the
// identity half of seedChatDemoData(); never clobbers a real identity (only runs when none is held).
void App::sweepEnsureDemoChatIdentity()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
if (chat_service_.hasIdentity()) return;
chat::ChatKeyPair keys;
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status == chat::ChatIdentityStatus::Ready)
chat_service_.setIdentity(keys);
chat::wipeChatKeyPair(keys);
}
// Refuse a full sweep while any real workflow it would fake is in flight: its surfaces overwrite the
// migration, wallet-switch, security-workflow and seed-backup state in memory, and its teardown resets it.
const char* App::fullSweepBlockedReason() const
@@ -307,6 +327,7 @@ void App::clearDemoWalletData()
state_.market.price_history = g_marketHistorySnapshot;
g_marketHistorySnapshot.clear();
wizard_phase_ = s.wizard_phase; // applyHealthyDemoState() dismissed it
lite_firstrun_dismissed_ = s.lite_firstrun_dismissed;
s = SweepStateSnapshot{}; // invalidate
// Sandbox: close what shows it, then delete it (error_code overloads only; at worst temp debris,
@@ -703,6 +724,59 @@ void App::buildSweepCatalog()
[](App& a) { a.state_.connected = false; a.state_.encryption_state_known = false; },
[](App& a) { a.applyHealthyDemoState(); });
// Restore the healthy synced demo sync-state (applyHealthyDemoState leaves state_.sync alone).
auto resetDemoSync = [](App& a) {
a.applyHealthyDemoState();
a.state_.sync.syncing = false; a.state_.sync.building_witnesses = false;
a.state_.sync.rescanning = false; a.state_.sync.verification_progress = 1.0;
a.state_.sync.tip_known = true;
a.state_.sync.blocks = a.state_.sync.headers = 3124322;
};
// Balance tab while the node is still catching up (connected, tip known, verifying) → the shared
// "catching up — your funds are safe" strip above whichever layout renders (P4).
add("overview-syncing", ui::NavPage::Overview,
[](App& a) {
a.applyHealthyDemoState();
a.state_.sync.tip_known = true;
a.state_.sync.syncing = true;
a.state_.sync.building_witnesses = false;
a.state_.sync.rescanning = false;
a.state_.sync.verification_progress = 0.62;
a.state_.sync.blocks = 1934000; a.state_.sync.headers = 3124322;
},
resetDemoSync);
// Full node connected to the daemon but with zero network peers → the node-status banner's honest
// detail (P2). Full-node only: the Lite build has no node, so there this renders as a plain Overview.
add("overview-no-peers", ui::NavPage::Overview,
[](App& a) {
a.applyHealthyDemoState();
a.state_.sync.tip_known = false; // no peer-derived tip → NoPeers banner
a.state_.sync.syncing = false;
a.state_.sync.blocks = 3124322; a.state_.sync.headers = 3124322;
a.connection_status_ = "Connected";
a.connection_in_progress_ = false; // else the evaluator treats it as "connecting", not no-peers
a.sweep_show_node_banner_ = true; // allow the banner (suppressed on every other target)
},
[resetDemoSync](App& a) { a.sweep_show_node_banner_ = false; resetDemoSync(a); });
// Chat with no derived identity yet — the states that previously ALL said "unlock your wallet" (P3).
// Setup clears the fabricated demo identity (clearIdentity wipes only the key; the demo messages stay
// in the store); teardown re-fabricates an identity so the populated chat UI returns for later targets.
add("chat-identity-preparing", ui::NavPage::Chat,
[](App& a) {
a.chat_service_.clearIdentity();
a.chat_identity_unavailable_ = false;
a.chat_identity_fetch_in_flight_ = false;
},
[](App& a) { a.chat_identity_unavailable_ = false; a.sweepEnsureDemoChatIdentity(); });
add("chat-identity-unavailable", ui::NavPage::Chat,
[](App& a) {
a.chat_service_.clearIdentity();
a.chat_identity_unavailable_ = true; // view-only / legacy wallet with no exportable secret
a.chat_identity_fetch_in_flight_ = false;
},
[](App& a) { a.chat_identity_unavailable_ = false; a.sweepEnsureDemoChatIdentity(); });
// A mining (transparent) address as the recipient: shown as unpayable, Send disabled.
add("send-mining-recipient", ui::NavPage::Send,
[](App& a) { ui::SweepSetSendRecipient(&a, "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"); },
+9 -3
View File
@@ -116,14 +116,20 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
// synced. Surface it as a recoverable warning. A plain reconnect won't help, but restarting the
// embedded daemon re-triggers peer discovery (it re-reads the -addnode seeds), so offer that when
// we own the node; an external node is the user's own to restart, so no button there. The detail
// is left empty: here connection_status is just "Connected", which would contradict the
// "no peers" headline — App renders a translated, actionable hint for this reason instead.
if (in.connected) {
if (!in.tip_known && in.blocks > 0) {
s.show = true;
s.severity = NodeBannerSeverity::Warning;
s.reason = NodeBannerReason::NoPeers;
s.action = NodeBannerAction::None;
s.detail = in.connection_status;
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
? NodeBannerAction::RestartNode
: NodeBannerAction::None;
s.detail.clear();
}
return s;
}
+29 -2
View File
@@ -14,6 +14,11 @@ namespace ui {
void Notifications::render()
{
// Pause-on-hover: while the pointer rests on the toast, keep it alive so a long error message can
// actually be read (errors/warnings already last longer, but hovering holds any of them open).
if (toast_hovered_ && !notifications_.empty())
notifications_.back().created_at = std::chrono::steady_clock::now();
// Remove every expired notification; the newest remaining one is then both the last and a live one.
pruneExpired(notifications_, std::chrono::steady_clock::now());
@@ -146,13 +151,35 @@ void Notifications::render()
cursorX += iconW + iconGap;
}
// Message text (clipped to pill bounds)
// Message text — ellipsized to fit the (viewport-clamped) pill rather than hard-clipped mid-word,
// with the full text shown in a tooltip on hover.
bool clipped = false;
if (textFont) {
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
const float availTextW = (pMax.x - pillPadX) - cursorX;
std::string shown = notif.message;
if (availTextW > 0.0f &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
clipped = true;
const std::string ell = "\xE2\x80\xA6"; // "…"
while (!shown.empty() &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f,
(shown + ell).c_str()).x > availTextW) {
shown.pop_back();
while (!shown.empty() && (static_cast<unsigned char>(shown.back()) & 0xC0) == 0x80)
shown.pop_back(); // drop any trailing UTF-8 continuation bytes
}
shown += ell;
}
dl->PushClipRect(ImVec2(cursorX, pillY), ImVec2(pMax.x - pillPadX, pMax.y), true);
dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, notif.message.c_str());
dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, shown.c_str());
dl->PopClipRect();
}
// Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped.
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
if (toast_hovered_ && clipped)
ImGui::SetTooltip("%s", notif.message.c_str());
}
} // namespace ui
+1
View File
@@ -192,6 +192,7 @@ private:
std::deque<Notification> notifications_;
size_t max_notifications_ = 5;
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
std::chrono::steady_clock::time_point last_drawn_{};
std::function<void(const std::string&, bool)> console_callback_;
+7 -2
View File
@@ -1298,9 +1298,14 @@ void RenderSettingsPage(App* app) {
if (c == 1) { rowY += fh + gp; c = 0; } else { c = 1; }
return changed;
};
CB(TrId("save_z_transactions", "save_ztx"), &s_settingsState.save_ztxs);
// These persist immediately on toggle (like the daemon/verbose options below). The old
// code discarded CB()'s return and relied on a Save button that only renders on the About
// tab, so a user who enabled Auto-shield (a money/privacy intent) silently lost it on relaunch.
if (CB(TrId("save_z_transactions", "save_ztx"), &s_settingsState.save_ztxs))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx"));
CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield);
if (CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield))
saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield"));
{
// Not implemented: disabled and never shown as "on", even for a stored use_tor=true.
+5 -1
View File
@@ -763,7 +763,11 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
}
// ---- Keyboard navigation ----
if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
// Suppress single-key nav while a dialog/overlay (send-confirm, seed backup, unlock, …) or any
// ImGui popup is open — otherwise Arrow/J/K/[ silently switch the tab *behind* the modal.
if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl &&
!material::IsOverlayDialogBlockingInput() &&
!ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopup)) {
int idx = static_cast<int>(current);
bool nav = false;
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) || ImGui::IsKeyPressed(ImGuiKey_K)) {
+8 -2
View File
@@ -38,6 +38,12 @@ void RenderAboutDialog(App* app, bool* p_open)
return;
}
// Escape closes the dialog (click-outside and the Close button already work; this matches the FAQ
// dialog's affordance so Escape behaves consistently across modal dialogs).
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
*p_open = false;
}
// Use Body2 font for all dialog text
ImGui::PushFont(Type().body2());
@@ -107,7 +113,7 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Spacing();
// Credits
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", TR("about_credits"));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", TR("about_credits"));
ImGui::Spacing();
ImGui::BulletText("The Hush Developers");
@@ -121,7 +127,7 @@ void RenderAboutDialog(App* app, bool* p_open)
ImGui::Spacing();
// License
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", TR("about_license"));
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", TR("about_license"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("about_license_text"));
+33
View File
@@ -205,6 +205,39 @@ void RenderBalanceTab(App* app)
}
}
// Shared "still catching up" strip, above whichever layout renders, so a syncing node's 0 / stale
// balance is never mistaken for lost funds (the documented "my coins vanished after an update"
// trap). Scoped to the NORMAL catching-up case — connected, a network tip is known, still verifying
// blocks, and not in a rescan / witness rebuild. Offline, no-peers, a busy node and rescans are
// already surfaced by the node-status banner, so this never stacks a second banner on them.
{
namespace m = material;
const auto& st = app->state();
if (st.connected && st.sync.tip_known && st.sync.syncing &&
!st.sync.building_witnesses && !st.sync.rescanning) {
ImFont* ico = m::Type().iconSmall();
ImFont* txt = m::Type().body2();
const float padX = ui::Layout::spacingMd();
const float padY = ui::Layout::spacingSm();
const float availW = ImGui::GetContentRegionAvail().x;
char line[192];
snprintf(line, sizeof(line), TR("balance_catchup_notice"),
static_cast<float>(st.sync.verification_progress) * 100.0f);
const float iconW = ico->LegacySize + padX * 0.5f;
const float wrapW = std::max(1.0f, availW - padX * 2.0f - iconW);
ImVec2 tsz = txt->CalcTextSizeA(txt->LegacySize, FLT_MAX, wrapW, line);
const float h = std::max(ico->LegacySize, tsz.y) + padY * 2.0f;
ImVec2 p0 = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p0, ImVec2(p0.x + availW, p0.y + h),
m::WithAlphaF(m::Warning(), 0.13f), ui::Layout::glassRounding());
dl->AddText(ico, ico->LegacySize, ImVec2(p0.x + padX, p0.y + padY), m::Warning(), ICON_MD_SYNC);
dl->AddText(txt, txt->LegacySize, ImVec2(p0.x + padX + iconW, p0.y + padY),
m::OnSurfaceMedium(), line, nullptr, wrapW);
ImGui::Dummy(ImVec2(availW, h + ui::Layout::spacingSm()));
}
}
// Dispatch by string ID
for (const auto& entry : s_layoutDispatch) {
if (layoutId == entry.id) {
+14 -2
View File
@@ -748,9 +748,21 @@ void RenderChatTab(App* app)
const auto& store = service.store();
auto& book = app->addressBook();
// Not unlocked / identity not derived yet → nothing to show.
// No identity yet → show WHY, not a blanket "unlock your wallet" (which is wrong for an unlocked
// view-only/legacy wallet that simply can't derive a chat identity, and left it stuck forever).
if (!service.hasIdentity()) {
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
switch (app->chatIdentityStatus()) {
case App::ChatIdentityStatus::Preparing:
centeredEmptyState(ICON_MD_HOURGLASS_EMPTY, TR("chat_preparing_hint"), nullptr);
break;
case App::ChatIdentityStatus::Unavailable:
centeredEmptyState(ICON_MD_INFO_OUTLINE, TR("chat_unavailable_hint"), TR("chat_unavailable_sub"));
break;
case App::ChatIdentityStatus::Locked:
default:
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
break;
}
return;
}
+30
View File
@@ -1537,6 +1537,25 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
focus_input_pending_ = false;
}
namespace {
// Is `firstLower` (a lowercased command name) flagged destructive in the command reference? The
// `destructive` flag was previously consulted only by the reference dialog's Run button; this lets the
// typed/pasted path honor it too. Scans the full-node or lite command set per the active executor.
bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
{
const auto& cats = fullNode ? consoleCommandCategories() : liteConsoleCommandCategories();
for (const auto& cat : cats) {
for (int i = 0; i < cat.count; ++i) {
std::string n = cat.commands[i].name ? cat.commands[i].name : "";
std::transform(n.begin(), n.end(), n.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (n == firstLower) return cat.commands[i].destructive;
}
}
return false;
}
} // namespace
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{
if (cmd.empty()) return false;
@@ -1561,6 +1580,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
// pending state. Member (not a function-local static) so clear() can also cancel it — otherwise a
// toolbar/context-menu clear between the two 'stop's would leave a stale arm and skip the warning.
if (first != "stop") stop_confirm_pending_ = false;
// A pending destructive-command confirmation is armed on the EXACT line; any different input cancels it.
if (destructive_confirm_cmd_ != cmd) destructive_confirm_cmd_.clear();
auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
@@ -1586,7 +1607,15 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
// Full node connects to a daemon; the lite backend opens a wallet — word the error per variant.
addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"),
ConsoleChannel::Error);
} else if (IsDestructiveConsoleCommand(first, exec.hasRpcReference()) &&
destructive_confirm_cmd_ != cmd) {
// Fund-moving / key-revealing commands (dumpprivkey, z_sendmany, z_mergetoaddress, …) get the
// same two-step confirm as 'stop' when typed or PASTED directly — previously the destructive
// flag was honored only by the reference dialog, so a pasted command ran instantly.
destructive_confirm_cmd_ = cmd;
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
} else {
destructive_confirm_cmd_.clear();
exec.submit(cmd);
}
return true;
@@ -2185,6 +2214,7 @@ void ConsoleTab::clear()
wrap_cache_.reset();
vis_flat_valid_ = false;
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
destructive_confirm_cmd_.clear(); // likewise cancel a pending destructive-command confirmation
addLine(TR("console_cleared"), ConsoleChannel::Info);
}
+1
View File
@@ -161,6 +161,7 @@ private:
int history_index_ = -1;
char input_buffer_[4096] = {0};
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
+8 -2
View File
@@ -93,7 +93,10 @@ void ExportAllKeysDialog::render(App* app)
ImGui::Spacing();
m::LabeledInput(TR("output_filename"), "##Filename", s_filename, sizeof(s_filename));
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("file_save_location"));
// Show the real, resolved config directory (not a hardcoded ~/.config path, which was wrong on
// Windows/macOS) so the user knows where this plaintext key file lands before they export it.
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s %s",
TR("file_save_location"), util::Platform::getConfigDir().c_str());
ImGui::EndDisabled();
// Progress / result.
@@ -232,7 +235,10 @@ void ExportAllKeysDialog::render(App* app)
Notifications::instance().error(TR("export_keys_write_fail"));
} else if (exported < total) {
snprintf(buf, sizeof(buf), TR("export_keys_partial"), exported, total);
s_status = buf;
// A partial export still wrote a plaintext key file to disk — show WHERE,
// so the user can find it to move or delete it (previously only a full
// success revealed the path, leaving partial-export files orphaned).
s_status = std::string(buf) + "\n" + filepath;
snprintf(buf, sizeof(buf), TR("export_keys_partial_toast"), exported, total);
Notifications::instance().warning(buf);
} else {
@@ -81,7 +81,9 @@ void ExportTransactionsDialog::render(App* app)
// Filename + save-location hint.
m::LabeledInput(TR("output_filename"), "##Filename", s_filename, sizeof(s_filename));
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("file_save_location"));
// Show the real, resolved config directory (the old hardcoded ~/.config path was wrong on Windows/macOS).
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s %s",
TR("file_save_location"), util::Platform::getConfigDir().c_str());
// Result — success shows the saved path as a copy field; failure shows a red line.
if (!s_saved_path.empty()) {
+4 -1
View File
@@ -626,7 +626,10 @@ void RenderPeersTab(App* app)
if (!app->isConnected()) {
material::DrawEmptyState(ICON_MD_CLOUD_OFF, TR("not_connected"));
} else if (state.peers.empty()) {
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"));
// Connected to the daemon but zero peers — mirror the node-status banner's actionable
// hint so this isn't just a bare "No connected peers" dead-end.
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"),
TR("node_banner_no_peers_detail"));
} else {
const float dp = ui::Layout::dpiScale();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg();
+4
View File
@@ -11,6 +11,7 @@
#include "../material/draw_helpers.h"
#include "../layout.h"
#include "../theme.h"
#include "../notifications.h"
#include "imgui.h"
namespace dragonx {
@@ -138,6 +139,9 @@ void QRPopupDialog::render(App* app)
material::DialogActionFooter(TR("copy_address"), true, TR("close"), doCopy, doClose, btnW);
if (doCopy) {
ImGui::SetClipboardText(s_address.c_str());
// Confirm the copy — a silent copy here risks a stale clipboard being pasted into an
// exchange withdrawal. Matches the Receive tab's feedback.
Notifications::instance().info(TR("address_copied"));
}
if (doClose) {
close();
+26 -23
View File
@@ -761,33 +761,32 @@ void RenderSendConfirmPopup(App* app) {
float addrCardH = std::max(schema::UI().drawElement("tabs.send", "confirm-addr-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-addr-card-height").size * popVs);
float popGlassRound = Layout::glassRounding() * S.drawElement("tabs.send", "confirm-glass-rounding-ratio").size;
// FROM card
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("from_upper"));
// Address cards: show the FULL address (wrapped), not a middle-truncated preview — this is the
// one screen meant to catch a swapped recipient — plus the address-book label when the address
// matches a saved contact ("TO · Alice"), the strongest human-readable check against sending
// to the wrong place. The card grows to fit the wrapped text, and the dialog auto-sizes to it.
const float addrWrapW = popW - Layout::spacingMd() * 2.0f;
auto drawAddrCard = [&](const char* hdrKey, const char* addr) {
std::string hdr = TR(hdrKey);
std::string label = app->getAddressLabel(addr);
if (!label.empty()) { hdr += " \xC2\xB7 "; hdr += label; } // "·" separator
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), hdr.c_str());
ImVec2 cMin = ImGui::GetCursorScreenPos();
ImVec2 cMax(cMin.x + popW, cMin.y + addrCardH);
ImVec2 txtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, addrWrapW, addr);
float cardH = std::max(addrCardH, txtSz.y + Layout::spacingSm() * 2.0f);
ImVec2 cMax(cMin.x + popW, cMin.y + cardH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
popDl->AddText(body2, body2->LegacySize,
ImVec2(cMin.x + Layout::spacingMd(), cMin.y + Layout::spacingSm()),
Success(), util::truncateMiddle(s_from_address, (int)S.drawElement("tabs.send", "confirm-addr-trunc-len").size).c_str());
ImGui::Dummy(ImVec2(popW, addrCardH));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
Success(), addr, nullptr, addrWrapW);
ImGui::Dummy(ImVec2(popW, cardH));
};
// TO card
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("to_upper"));
ImVec2 cMin = ImGui::GetCursorScreenPos();
ImVec2 cMax(cMin.x + popW, cMin.y + addrCardH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
popDl->AddText(body2, body2->LegacySize,
ImVec2(cMin.x + Layout::spacingMd(), cMin.y + Layout::spacingSm()),
Success(), util::truncateMiddle(s_to_address, (int)S.drawElement("tabs.send", "confirm-addr-trunc-len").size).c_str());
ImGui::Dummy(ImVec2(popW, addrCardH));
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
drawAddrCard("from_upper", s_from_address);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
drawAddrCard("to_upper", s_to_address);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// Fee tier selector
{
@@ -928,10 +927,14 @@ void RenderSendConfirmPopup(App* app) {
s_memo[0] = '\0';
s_send_max = false;
} else {
s_tx_status = std::string(TR("send_error_prefix")) + result;
// Show a friendly cause in the toast; keep the raw backend text below it in
// the overlay (copyable) so the detail isn't lost for diagnostics.
const std::string friendly = util::friendlyRpcError(result);
s_tx_status = std::string(TR("send_error_prefix")) + friendly +
(friendly != result ? "\n\n" + result : std::string());
s_result_txid.clear();
s_status_success = false;
Notifications::instance().error(std::string(TR("send_tx_failed")) + result);
Notifications::instance().error(std::string(TR("send_tx_failed")) + friendly);
}
}
);
@@ -10,6 +10,7 @@
#include "../theme.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "../notifications.h"
#include "imgui.h"
#include <ctime>
@@ -126,6 +127,7 @@ void TransactionDetailsDialog::render(App* app)
ImGui::SameLine();
if (material::StyledButton((std::string(TR("grpb_copy")) + "##TxID").c_str(), ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) {
ImGui::SetClipboardText(tx.txid.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
ImGui::Spacing();
@@ -152,6 +154,7 @@ void TransactionDetailsDialog::render(App* app)
ImGui::SameLine();
if (material::StyledButton((std::string(TR("grpb_copy")) + "##Addr").c_str(), ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) {
ImGui::SetClipboardText(tx.address.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
}
+45 -2
View File
@@ -9,6 +9,7 @@
#include <fstream>
#include <cstdio>
#include <cstring>
#include <cctype>
#include <vector>
#include <nlohmann/json.hpp>
@@ -225,6 +226,9 @@ void I18n::loadBuiltinEnglish()
strings_["contacts"] = "Contacts";
strings_["chat"] = "Chat";
strings_["chat_locked_hint"] = "Unlock your wallet to load your chats.";
strings_["chat_preparing_hint"] = "Setting up secure chat…";
strings_["chat_unavailable_hint"] = "Chat isn't available for this wallet";
strings_["chat_unavailable_sub"] = "Chat needs a wallet with a spending key. A view-only wallet can't chat; a legacy wallet may need to be migrated to a seed phrase first (Settings → Back up & data).";
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
strings_["chat_you"] = "You";
strings_["chat_contact_request"] = "contact request";
@@ -354,6 +358,7 @@ void I18n::loadBuiltinEnglish()
strings_["seed_backup_save_failed"] = "Could not write ";
strings_["seed_backup_close"] = "Close";
strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security.";
strings_["seed_backup_reminder_action"] = "Back up now";
strings_["seed_migrate_button"] = "Migrate to seed…";
// Multi-wallet: wallet-files list + switcher
strings_["wallets_button"] = "Wallets…";
@@ -1233,6 +1238,7 @@ void I18n::loadBuiltinEnglish()
strings_["balance_shielded_fmt"] = "Shielded: %.8f";
strings_["total_balance_label"] = "Total Balance";
strings_["balance_syncing_pct"] = "Syncing %.1f%%";
strings_["balance_catchup_notice"] = "Catching up to the network (%.0f%%) — your balance will finish updating when sync completes. Your funds are safe.";
strings_["balance_mining_rate"] = "Mining %s";
strings_["balance_layout_switched"] = "Layout: %s";
strings_["byte_count_fmt"] = "%zu / %zu bytes";
@@ -1543,6 +1549,7 @@ void I18n::loadBuiltinEnglish()
// Persistent node-status banner (App::renderNodeStatusBanner).
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
strings_["node_banner_no_peers_title"] = "Connected, but no network peers — the node can't sync";
strings_["node_banner_no_peers_detail"] = "Check your internet connection and firewall, then restart the node to find peers.";
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
strings_["node_banner_reconnect"] = "Reconnect";
@@ -1606,7 +1613,7 @@ void I18n::loadBuiltinEnglish()
strings_["date_label"] = "Date:";
strings_["delete"] = "Delete";
strings_["fee_label"] = "Fee:";
strings_["file_save_location"] = "File will be saved in: ~/.config/ObsidianDragon/";
strings_["file_save_location"] = "File will be saved in:";
strings_["hide"] = "Hide";
strings_["label"] = "Label:";
strings_["loading"] = "Loading...";
@@ -1904,6 +1911,14 @@ void I18n::loadBuiltinEnglish()
strings_["console_last_error"] = "Last error:";
strings_["console_not_connected_lite"] = "Error: no wallet open";
strings_["console_stop_confirm_node"] = "'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm.";
strings_["console_destructive_confirm"] = "This command can move funds or reveal secret keys. Never run a command someone else gave you. Run the exact same command again to confirm.";
// Friendly mappings for common daemon/RPC errors (see util::friendlyRpcError).
strings_["err_warming_up"] = "The node is still starting up. Try again in a moment.";
strings_["err_wallet_locked"] = "Unlock your wallet first, then try again.";
strings_["err_method_missing"] = "Your node doesn't support this action. Updating the daemon may fix it.";
strings_["err_insufficient"] = "Not enough spendable balance to cover the amount and fee.";
strings_["err_cant_reach"] = "Can't reach the node. Check that it's running and try again.";
strings_["err_generic"] = "Something went wrong. Please try again.";
strings_["console_capturing_output"] = "Capturing daemon output...";
strings_["console_clear"] = "Clear";
strings_["console_clear_console"] = "Clear Console";
@@ -2928,9 +2943,37 @@ void I18n::registerLanguage(const std::string& code, const std::string& name)
for (const auto& lang : available_languages_) {
if (lang.first == code) return;
}
available_languages_.emplace_back(code, name);
}
std::string friendlyRpcError(const std::string& raw)
{
// Lowercased copy for case-insensitive matching.
std::string lc = raw;
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
auto has = [&](const char* needle) { return lc.find(needle) != std::string::npos; };
// Order matters: the most specific / most common user-facing causes first.
if (has("-28") || has("warming up") || has("loading block") || has("initializing") || has("rewinding"))
return tr("err_warming_up");
if (has("-13") || has("walletpassphrase") || has("wallet is locked") || has("please enter the wallet passphrase"))
return tr("err_wallet_locked");
if (has("-32601") || has("method not found"))
return tr("err_method_missing");
if (has("insufficient") || has("not enough funds") || has("no funds"))
return tr("err_insufficient");
if (has("connection refused") || has("couldn't connect") || has("could not connect") ||
has("failed to connect") || has("timed out") || has("timeout") || has("no response"))
return tr("err_cant_reach");
// Nothing recognized — hand back the raw text, trimmed of surrounding whitespace so a stray
// newline/indent from the daemon doesn't look like a formatting glitch.
const auto b = raw.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return tr("err_generic");
const auto e = raw.find_last_not_of(" \t\r\n");
return raw.substr(b, e - b + 1);
}
} // namespace util
} // namespace dragonx
+10
View File
@@ -79,6 +79,16 @@ inline const char* tr(const char* key) {
return I18n::instance().translate(key);
}
/**
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
*
* Recognizes the common cases a user actually hits — a warming-up node, a locked wallet, an RPC the
* daemon is too old to have, insufficient funds, and an unreachable node — and returns plain guidance
* with a next step. Falls back to the raw text (trimmed) when nothing matches, so detail is never lost.
* Keep the raw text available elsewhere (a Details view / the console) for diagnostics.
*/
std::string friendlyRpcError(const std::string& raw);
} // namespace util
} // namespace dragonx
+14 -1
View File
@@ -3535,6 +3535,9 @@ void testNodeStatusBanner()
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
}
// Connected but no peer-derived tip (0 peers) once chain info is in → amber "no peers" banner.
// An external node (no embedded controller) gets no action button, and the detail is cleared:
// connection_status ("Peers: 0"/"Connected") would contradict the headline, so App renders a
// translated actionable hint for this reason instead.
{
NodeBannerInputs in;
in.connected = true; in.tip_known = false; in.blocks = 3262059;
@@ -3544,7 +3547,17 @@ void testNodeStatusBanner()
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers);
EXPECT_TRUE(s.action == NodeBannerAction::None);
EXPECT_EQ(s.detail, std::string("Peers: 0"));
EXPECT_TRUE(s.detail.empty());
}
// ...but with the embedded daemon running, offer a Restart (re-triggers peer discovery).
{
NodeBannerInputs in;
in.connected = true; in.tip_known = false; in.blocks = 3262059;
in.using_embedded_daemon = true; in.has_daemon_controller = true; in.daemon_running = true;
NodeBannerState s = evaluateNodeStatusBanner(in);
EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers);
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
EXPECT_TRUE(s.detail.empty());
}
// Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker).
{