feat(send): usable-while-syncing form, calmer validation, honest clear/memo/source

Send-tab UX pass (presentation/validation only — the z_sendmany submit path, its
amounts, fee, recipients and memo bytes are unchanged, and the Review button plus
the confirm-time live re-check still block any send until the wallet is synced):
- The form is now editable while syncing (so the recipient/amount/memo can be
  pre-filled); only Review/Send stays gated, with an "available once synced" note.
- The recipient's red "Invalid address" no longer flashes from the first
  keystroke — it waits until the field loses focus.
- The memo field stays visible (text preserved) with a "requires a shielded
  recipient" explainer for transparent recipients, instead of vanishing + silently
  dropping what was typed.
- "Cancel" (which only cleared the form) is relabeled "Clear form", disabled when
  empty, and drops its redundant confirm (the timed Undo already covers it).
- The source picker shows address labels, and warns when the chosen source is
  transparent (non-private on this chain).

Also adds the string keys used here and by the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:50:30 -05:00
co-authored by Claude Opus 4.8
parent ee6e99965c
commit f2d9590058
2 changed files with 74 additions and 45 deletions
+66 -45
View File
@@ -77,7 +77,6 @@ static constexpr double FEE_LOW = DRAGONX_DEFAULT_FEE * 0.5;
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
static bool s_clear_confirm_pending = false;
// Source dropdown preview string
static std::string s_source_preview;
@@ -268,9 +267,13 @@ static void RenderSourceDropdown(App* app, float width) {
const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address,
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
snprintf(buf, sizeof(buf), "%s %s — %s",
tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
std::string lbl = app->getAddressLabel(addr.address);
if (!lbl.empty())
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
else
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
s_source_preview = buf;
} else if (s_from_address[0] != '\0') {
// A pre-filled source that the dropdown list doesn't carry (zero-balance / view-only / list still
@@ -313,9 +316,13 @@ static void RenderSourceDropdown(App* app, float width) {
const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %s",
tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
std::string lbl = app->getAddressLabel(addr.address);
if (!lbl.empty())
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
else
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) {
@@ -324,9 +331,12 @@ static void RenderSourceDropdown(App* app, float width) {
addr.address.c_str());
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s\n%s",
addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
if (!lbl.empty())
material::Tooltip("%s\n%s\n%s", lbl.c_str(), addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
else
material::Tooltip("%s\n%s", addr.address.c_str(),
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
}
ImGui::PopID();
}
@@ -335,6 +345,20 @@ static void RenderSourceDropdown(App* app, float width) {
ImGui::EndCombo();
}
ImGui::PopFont();
// Warn when the chosen source is a transparent address: spending from it is not private on this
// ac_private chain (sender, amount and recipient become visible on-chain).
if (app->isConnected() && s_from_address[0] != '\0') {
bool srcT = (s_selected_from_idx >= 0 && s_selected_from_idx < (int)state.addresses.size())
? (state.addresses[s_selected_from_idx].type != "shielded")
: IsValidTransparentAddr(s_from_address);
if (srcT) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
Type().textWrapped(TypeStyle::Caption, TR("send_transparent_source_warn"));
ImGui::PopStyleColor();
}
}
}
// ============================================================================
@@ -1033,6 +1057,12 @@ static void RenderActionButtons(App* app, float width, float vScale,
float sendBtnW = width - cancelBtnW - btnGap;
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
if (state.sync.syncing) {
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
// ---- Send button ----
if (!can_send) {
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
@@ -1081,38 +1111,20 @@ static void RenderActionButtons(App* app, float width, float vScale,
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
char clearId[64];
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("cancel"), suffix);
// This button never cancels an in-progress send (there is none here) — it clears the form. Relabel it
// honestly, disable it when the form is empty, and drop the redundant confirm (ClearFormWithUndo
// already offers a timed Undo). The "##Cancel" id suffix is kept so the widget id stays stable.
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("send_clear_form"), suffix);
ImGui::BeginDisabled(!FormHasData());
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
if (FormHasData()) {
s_clear_confirm_pending = true;
} else {
ClearFormWithUndo();
}
ClearFormWithUndo();
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("send_clear_form_tip"));
ImGui::PopStyleVar(); // FrameBorderSize
ImGui::PopStyleColor(3);
// Clear confirmation popup
char confirmClearId[32];
snprintf(confirmClearId, sizeof(confirmClearId), "##ConfirmClear%s", suffix);
if (s_clear_confirm_pending) {
ImGui::OpenPopup(confirmClearId);
s_clear_confirm_pending = false;
}
if (ImGui::BeginPopup(confirmClearId)) {
ImGui::Text("%s", TR("send_clear_fields"));
ImGui::Spacing();
if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size * dp, 0), S.resolveFont("button"))) {
ClearFormWithUndo();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size * dp, 0), S.resolveFont("button"))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Undo button (shown briefly after clear)
if (s_undo_timestamp > 0.0) {
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
@@ -1270,9 +1282,9 @@ void RenderSendTab(App* app)
RenderSyncBanner(state);
bool sendSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sendSyncing);
// The form stays EDITABLE while syncing so the user can pre-fill it; only the Review/Send button is
// gated (can_send includes !syncing, and the confirm step re-checks live state), so nothing can be
// sent until the wallet is synced.
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
float hs = Layout::hScale(sendAvail.x);
float vScale = Layout::vScale(sendAvail.y);
@@ -1329,7 +1341,6 @@ void RenderSendTab(App* app)
// Zero balance CTA
if (RenderZeroBalanceCTA(app, dl, formW)) {
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard
ImGui::EndChild();
return;
}
@@ -1404,7 +1415,9 @@ void RenderSendTab(App* app)
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
if (vt && ImGui::IsItemHovered())
material::Tooltip("%s", TR("send_mining_addr_blocked"));
} else if (!checkPreview) {
} else if (!checkPreview && ImGui::GetActiveID() != ImGui::GetID("##ToAddr")) {
// Defer the "invalid" error until the field loses focus, so it doesn't flash red
// from the first keystroke while the user is still typing the address.
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
}
@@ -1612,8 +1625,11 @@ void RenderSendTab(App* app)
}
}
// ---- MEMO (shielded only) ----
if (is_valid_z || s_to_address[0] == '\0') {
// ---- MEMO (shielded only; shown disabled + explained for non-shielded recipients) ----
{
// Keep the memo visible (and the typed text intact) even for a transparent recipient, with an
// explainer, instead of silently hiding the field and dropping what the user wrote.
const bool memoDisabled = (s_to_address[0] != '\0' && !is_valid_z);
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
@@ -1628,8 +1644,10 @@ void RenderSendTab(App* app)
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
ImGui::PushItemWidth(colW);
if (memoDisabled) ImGui::BeginDisabled();
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
ImVec2(colW, memoInputH));
if (memoDisabled) ImGui::EndDisabled();
ImGui::PopItemWidth();
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
@@ -1639,6 +1657,10 @@ void RenderSendTab(App* app)
bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
if (memoDisabled) {
const char* mk = s_memo[0] ? "send_memo_will_not_be_sent" : "send_memo_requires_shielded";
Type().textColored(TypeStyle::Caption, Warning(), TR(mk));
}
}
// Divider before action buttons
@@ -1706,7 +1728,6 @@ void RenderSendTab(App* app)
RenderRecentSends(state, formW, capFont, app);
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
// when content like "recent sends" elapsed-time text changes width each frame
float measuredH = ImGui::GetCursorPosY() - contentStartY;
+8
View File
@@ -1151,6 +1151,8 @@ void I18n::loadBuiltinEnglish()
strings_["recent_sends"] = "RECENT SENDS";
strings_["recipient"] = "RECIPIENT";
strings_["memo_optional"] = "MEMO (OPTIONAL)";
strings_["send_memo_requires_shielded"] = "Memos require a shielded (z) recipient address.";
strings_["send_memo_will_not_be_sent"] = "This memo will not be sent: memos only work with shielded (z) recipients.";
strings_["no_recent_sends"] = "No recent sends";
strings_["network_fee"] = "NETWORK FEE";
strings_["amount_details"] = "AMOUNT DETAILS";
@@ -2575,6 +2577,8 @@ void I18n::loadBuiltinEnglish()
strings_["request_payment_uri"] = "Payment URI:";
strings_["request_receive_address"] = "Receive Address:";
strings_["request_select_address"] = "Select address...";
strings_["request_no_shielded"] = "You don't have a shielded address yet. Create one to receive payments.";
strings_["request_create_shielded"] = "Create a shielded address";
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
strings_["request_title"] = "Request Payment";
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
@@ -2584,6 +2588,8 @@ void I18n::loadBuiltinEnglish()
strings_["send_amount_details"] = "AMOUNT DETAILS";
strings_["send_amount_upper"] = "AMOUNT";
strings_["send_clear_fields"] = "Clear all form fields?";
strings_["send_clear_form"] = "Clear form";
strings_["send_clear_form_tip"] = "Clear the recipient, amount and memo. You can undo for a few seconds.";
strings_["send_copy_error"] = "Copy Error";
strings_["send_dismiss"] = "Dismiss";
strings_["send_error_copied"] = "Error copied to clipboard";
@@ -2600,6 +2606,7 @@ void I18n::loadBuiltinEnglish()
strings_["send_network_fee_label"] = "Network fee:";
strings_["send_total_label"] = "Total:";
strings_["send_no_balance"] = "No balance";
strings_["send_transparent_source_warn"] = "Sending from a transparent address is not private: the sender, amount and recipient are visible on-chain. Use a shielded (Z) address for privacy.";
strings_["send_no_recent"] = "No recent sends";
strings_["send_recent_sends"] = "RECENT SENDS";
strings_["send_recipient"] = "RECIPIENT";
@@ -2615,6 +2622,7 @@ void I18n::loadBuiltinEnglish()
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
strings_["send_tooltip_select_source"] = "Select a source address first";
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
strings_["send_total"] = "Total";
strings_["send_tx_failed"] = "Transaction failed";