Compare commits
9
Commits
9ff64b1846
...
5cfa713be1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cfa713be1 | ||
|
|
cd9ca4cd77 | ||
|
|
9db73323b6 | ||
|
|
4a8d07edce | ||
|
|
643ac7daef | ||
|
|
e27d88eca4 | ||
|
|
6efe3c0f5c | ||
|
|
750771ebfb | ||
|
|
a99f71a0cd |
+1
-1
@@ -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
@@ -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
@@ -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
@@ -876,7 +876,7 @@
|
||||
"fee_normal": "通常",
|
||||
"fetch_prices": "価格を取得",
|
||||
"file": "ファイル",
|
||||
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
|
||||
"file_save_location": "ファイルの保存先:",
|
||||
"filter": "フィルター...",
|
||||
"font_scale": "フォントサイズ",
|
||||
"from": "送信元",
|
||||
|
||||
+1
-1
@@ -876,7 +876,7 @@
|
||||
"fee_normal": "보통",
|
||||
"fetch_prices": "가격 조회",
|
||||
"file": "파일",
|
||||
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "파일 저장 위치:",
|
||||
"filter": "필터...",
|
||||
"font_scale": "글꼴 크기",
|
||||
"from": "보낸 곳",
|
||||
|
||||
+1
-1
@@ -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
@@ -876,7 +876,7 @@
|
||||
"fee_normal": "Обычная",
|
||||
"fetch_prices": "Получить цены",
|
||||
"file": "Файл",
|
||||
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
|
||||
"file_save_location": "Файл будет сохранён в:",
|
||||
"filter": "Фильтр...",
|
||||
"font_scale": "Масштаб шрифта",
|
||||
"from": "От",
|
||||
|
||||
+1
-1
@@ -876,7 +876,7 @@
|
||||
"fee_normal": "普通",
|
||||
"fetch_prices": "获取价格",
|
||||
"file": "文件",
|
||||
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
|
||||
"file_save_location": "文件将保存至:",
|
||||
"filter": "筛选...",
|
||||
"font_scale": "字体大小",
|
||||
"from": "从",
|
||||
|
||||
+2
-2
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"); },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user