From f2d9590058e9596d0750c5e7f151e8b5b4198634 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 18 Oct 2026 22:50:30 -0500 Subject: [PATCH] feat(send): usable-while-syncing form, calmer validation, honest clear/memo/source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ui/windows/send_tab.cpp | 111 +++++++++++++++++++++--------------- src/util/i18n.cpp | 8 +++ 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index cf0c06e..fde2e76 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -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(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(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; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 2a41c18..47f1a41 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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";