3 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 adf61e43d6 feat(transfer): add Send-tab guards, a correct TxID label + copy, inline reasons
The address-transfer dialog (own-address moves) enabled Confirm on amount alone,
so a disconnected / syncing / locked wallet failed only at submit. It now
mirrors the Send tab's guards (strictly more restrictive — the transfer logic is
unchanged) and re-checks live state before broadcast, shows an inline reason
when blocked (enter amount / not connected / syncing / locked), and labels the
result as a "Transaction ID" (not "Operation ID") with a copy button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:44:19 -05:00
DanSandClaude Opus 4.8 fb281714f9 feat(i18n,mining): translate warmup block/tx-type labels; fix the disconnected-solo mine tooltip
- Warmup/daemon status no longer appends a hardcoded English " (Block N)" — it
  uses a translated warmup_block_fmt key.
- Recent-transaction type labels (Sent/Received/Mined) are now translated in the
  UI consumer (recentTypeLabel in balance_recent_tx.cpp) with a generic
  "Transaction" fallback, instead of the data layer's raw English getTypeDisplay().
- Mining: a disconnected solo miner hovering the dead Mine button now gets a
  "not connected to the node" tooltip instead of a misleading "Start Mining"
  (the tooltip chain had no branch for the solo !isConnected() disable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:42:29 -05:00
DanSandClaude Opus 4.8 74c497922e fix(hidpi): scale security-dialog buttons, market caption, QR inset by dpiScale
HiDPI geometry debt from the audit: 15 security-dialog buttons used a raw 40px
height (undersized vs the scaled label at 1.5x), the market "Updated" caption
used a raw SameLine(0,12), and the QR popup's 28px overlay-inset budget wasn't
scaled (wrapping the address at HiDPI). All now go through ui::Layout::dpiScale().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 11:39:45 -05:00
8 changed files with 75 additions and 27 deletions
+2 -2
View File
@@ -999,7 +999,7 @@ std::string App::applyDaemonInitStatus(bool reachableButBusy)
std::string title = wt.title;
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
if (h > 0) title += " (Block " + std::to_string(h) + ")";
if (h > 0) { char hb[48]; std::snprintf(hb, sizeof(hb), TR("warmup_block_fmt"), h); title += " "; title += hb; }
state_.warmup_status = title;
state_.warmup_description = wt.description ? wt.description : "";
@@ -1023,7 +1023,7 @@ void App::applyWarmupText(const std::string& rpcMessage)
}
// Append current block height from daemon output
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")";
if (h > 0) { char hb[48]; std::snprintf(hb, sizeof(hb), TR("warmup_block_fmt"), h); state_.warmup_status += " "; state_.warmup_status += hb; }
connection_status_ = state_.warmup_status;
}
+15 -15
View File
@@ -1521,7 +1521,7 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
std::string pass(encrypt_pass_buf_);
enc_dlg_saved_passphrase_ = pass;
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
@@ -1533,7 +1533,7 @@ void App::renderEncryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
show_encrypt_dialog_ = false;
@@ -1612,7 +1612,7 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
@@ -1655,7 +1655,7 @@ void App::renderEncryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
if (!enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
enc_dlg_saved_passphrase_.size());
@@ -1730,7 +1730,7 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
changePassphrase(std::string(change_old_pass_buf_),
std::string(change_new_pass_buf_));
}
@@ -1739,7 +1739,7 @@ void App::renderEncryptWalletDialog() {
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_change_passphrase_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_change_passphrase_ = false;
}
EndOverlayDialog();
@@ -1808,7 +1808,7 @@ void App::renderDecryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || decryptState.inProgress);
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) &&
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale())) || (enterPressed && valid)) &&
!refuseIfNodeBusy()) {
std::string passphrase(decrypt_pass_buf_);
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
@@ -2017,7 +2017,7 @@ void App::renderDecryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
show_decrypt_dialog_ = false;
}
@@ -2132,7 +2132,7 @@ void App::renderDecryptWalletDialog() {
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
ImGui::Spacing();
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
show_decrypt_dialog_ = false;
}
@@ -2149,11 +2149,11 @@ void App::renderDecryptWalletDialog() {
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
wallet_security_workflow_.reset();
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_decrypt_dialog_ = false;
}
}
@@ -2219,7 +2219,7 @@ void App::renderPinDialogs() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
// Not while a passphrase change runs: it could land after the old passphrase is verified.
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_verifying_passphrase");
@@ -2292,7 +2292,7 @@ void App::renderPinDialogs() {
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_pin_setup_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_pin_setup_ = false;
}
EndOverlayDialog();
@@ -2346,7 +2346,7 @@ void App::renderPinDialogs() {
strcmp(pin_buf_, pin_confirm_buf_) == 0;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_changing_pin");
std::string oldPin(pin_old_buf_);
@@ -2411,7 +2411,7 @@ void App::renderPinDialogs() {
ImGui::Spacing();
bool valid = strlen(pin_old_buf_) >= 4;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_verifying_pin");
std::string oldPin(pin_old_buf_);
+35 -7
View File
@@ -141,6 +141,14 @@ public:
double newFromBal = s_info.fromBalance - totalDeduct;
double newToBal = s_info.toBalance + amount;
bool amountValid = amount > 0 && totalDeduct <= s_info.fromBalance;
// Mirror the Send tab's guards (this dialog previously enabled Confirm on amount alone, so a
// disconnected / syncing / locked wallet would fail only at submit). These are own-address
// moves, but gating is strictly more restrictive — the transfer logic below is unchanged.
const auto& ws = s_app->getWalletState();
const bool connected = s_app->isConnected();
const bool syncing = ws.sync.syncing;
const bool liteLocked = s_app->liteWallet() && ws.isLocked();
const bool canTransfer = amountValid && !s_sending && connected && !syncing && !liteLocked;
ImGui::Separator();
ImGui::Spacing();
@@ -167,6 +175,19 @@ public:
ImGui::TextWrapped("%s", TR("insufficient_funds"));
ImGui::PopStyleColor();
}
if (amount <= 0.0) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::TextWrapped("%s", TR("send_tooltip_enter_amount"));
ImGui::PopStyleColor();
} else if (amountValid && (!connected || syncing || liteLocked)) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::TextWrapped("%s", !connected ? TR("send_tooltip_not_connected")
: syncing ? TR("send_tooltip_syncing")
: TR("transfer_tooltip_locked"));
ImGui::PopStyleColor();
}
if (amountValid && newFromBal < 1e-9) {
ImGui::Spacing();
// Full-balance send: same warning-icon treatment as the de-shielding header above,
@@ -204,13 +225,13 @@ public:
bool outConfirm = false;
bool outClose = false;
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
amountValid && !s_sending,
canTransfer,
cancelLabel, outConfirm, outClose, btnW);
if (outClose) {
s_open = false;
}
if (outConfirm) {
if (outConfirm && canTransfer) { // re-check live state (the dialog persists across frames)
s_sending = true;
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
amount, s_fee, "",
@@ -296,12 +317,19 @@ private:
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("transfer_sent_desc"));
ImGui::Spacing();
{
char buf[128];
snprintf(buf, sizeof(buf), TR("shield_operation_id"),
s_resultMsg.size() > 40
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10)).c_str()
: s_resultMsg.c_str());
// The result is a TRANSACTION id, not an "Operation ID" — label it correctly and make
// it copyable.
std::string disp = s_resultMsg.size() > 40
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10))
: s_resultMsg;
char buf[160];
snprintf(buf, sizeof(buf), TR("transfer_txid_label"), disp.c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
ImGui::SameLine();
if (ImGui::SmallButton(TR("grpb_copy"))) {
ImGui::SetClipboardText(s_resultMsg.c_str());
Notifications::instance().info(TR("copied_to_clipboard"));
}
}
} else {
ImGui::Spacing();
+12 -1
View File
@@ -1,6 +1,7 @@
#include "balance_recent_tx.h"
#include "../../config/version.h"
#include "../../util/text_format.h"
#include "../../util/i18n.h"
#include <cmath>
#include <cstdio>
@@ -23,12 +24,22 @@ std::string recentTimeAgo(int64_t timestamp)
{
return util::formatTimeAgoShort(timestamp);
}
// Translated type label (the data layer's getTypeDisplay() returns raw English). Never leak a raw RPC
// category: anything unrecognized (including "shield") falls back to a generic "Transaction".
std::string recentTypeLabel(const std::string& type)
{
if (type == "send") return TR("sent_type");
if (type == "receive") return TR("received_label");
if (type == "mined" || type == "generate" || type == "immature") return TR("mined_type");
return TR("tx_type_generic");
}
}
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
{
return {
tx.getTypeDisplay(),
recentTypeLabel(tx.type),
util::truncateMiddle(tx.address, addressMaxLen),
formatRecentAmount(tx.type, tx.amount),
recentTimeAgo(tx.timestamp)
+1 -1
View File
@@ -1431,7 +1431,7 @@ static void mktDrawPairSelector(App* app, const std::vector<data::ExchangeInfo>&
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
if (!market.last_updated.empty()) {
ImGui::SameLine(0, 12);
ImGui::SameLine(0, 12.0f * Layout::dpiScale());
snprintf(buf, sizeof(buf), TR("market_updated"), market.last_updated.c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
+2
View File
@@ -957,6 +957,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
material::Tooltip("%s", isMiningActive ? TR("mining_stopping_tooltip") : TR("mining_starting_tooltip"));
else if (isSyncing && !s_pool_mode)
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
else if (!s_pool_mode && !poolStillRunning && !app->isConnected())
material::Tooltip("%s", TR("mining_not_connected_tooltip")); // was falling through to "Start Mining" on a dead button
else if (poolBlockedBySolo)
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
else if (poolNeedsPayout)
+3 -1
View File
@@ -74,7 +74,9 @@ void QRPopupDialog::render(App* app)
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
// Scale the overlay inset (BeginOverlayDialog insets 28 LOGICAL px/side) into the same scaled space
// as addrBoxW before the /dp conversion below, so the address box doesn't wrap at HiDPI.
const float wantW = addrBoxW + (28.0f * 2.0f + 12.0f) * dp;
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
const float cardW = (wantW < maxW ? wantW : maxW) / dp;
material::OverlayDialogSpec ov;
+5
View File
@@ -1118,6 +1118,8 @@ void I18n::loadBuiltinEnglish()
strings_["confirm_transfer"] = "Confirm Transfer";
strings_["transfer_sent"] = "Transfer Sent";
strings_["transfer_sent_desc"] = "Your transfer has been submitted to the network.";
strings_["transfer_txid_label"] = "Transaction ID: %s";
strings_["transfer_tooltip_locked"] = "Wallet is locked — unlock it to transfer";
strings_["transfer_failed"] = "Transfer Failed";
// Misc dialog strings
@@ -1223,6 +1225,7 @@ void I18n::loadBuiltinEnglish()
strings_["shielded_self_type"] = "Shielded";
strings_["immature_type"] = "Immature";
strings_["mined_type"] = "Mined";
strings_["tx_type_generic"] = "Transaction";
strings_["mature"] = "Mature";
strings_["copy_txid"] = "Copy TxID";
strings_["view_details"] = "View Details";
@@ -1944,6 +1947,7 @@ void I18n::loadBuiltinEnglish()
strings_["toast_finish_send_first"] = "Finish or cancel the pending send before switching wallets.";
strings_["toast_rescan_complete"] = "Blockchain rescan complete.";
strings_["toast_rescan_from_fmt"] = "Rescanning the blockchain from block %d — this can take a while.";
strings_["warmup_block_fmt"] = "(Block %d)";
strings_["toast_solo_unavailable_lite"] = "Solo mining isn't available in the lite wallet.";
strings_["toast_pool_unavailable"] = "Pool mining isn't available in this build.";
strings_["toast_no_address_generate_z"] = "No wallet address yet — generate a shielded (Z) address in the Receive tab.";
@@ -2354,6 +2358,7 @@ void I18n::loadBuiltinEnglish()
strings_["mining_stopping"] = "Stopping...";
strings_["mining_stopping_tooltip"] = "Miner is stopping...";
strings_["mining_syncing_tooltip"] = "Blockchain is syncing...";
strings_["mining_not_connected_tooltip"] = "Not connected to the node. Wait for the daemon to connect before mining.";
strings_["mining_to_save"] = "to save";
strings_["mining_today"] = "Today";
strings_["mining_uptime"] = "Uptime";