feat(ux): clearer chat delivery, explorer empty/range states, honest settings label
Three next-batch UX-audit fixes:
- Chat delivery: the sending/sent glyph has higher contrast and a tooltip
("Sent to the network — arrives after it confirms", so a check isn't mistaken
for "delivered"); a failed send now carries the real reason (ChatMessage::
fail_reason, set from onChatBroadcastResult via friendlyRpcError, in-memory
only) in the "not sent" hover tooltip.
- Explorer: a block height beyond the tip says so ("Block N is beyond the chain
tip (M)") instead of silently snapping to the top; the empty block list shows
Not connected / Syncing (N behind) / Loading instead of an endless "Loading".
- Settings: the misleading "Reset to Defaults" button (which only reloads the
last-saved values) is relabeled "Discard unsaved changes" with accurate copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -5062,7 +5062,8 @@ void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::st
|
|||||||
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
|
||||||
return; // leave at front
|
return; // leave at front
|
||||||
}
|
}
|
||||||
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed); // hard failure -> Retry affordance
|
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed,
|
||||||
|
util::friendlyRpcError(error).substr(0, 160)); // hard failure -> Retry affordance + reason
|
||||||
if (frontMatches) chat_send_queue_.pop_front();
|
if (frontMatches) chat_send_queue_.pop_front();
|
||||||
wallet::liteLog("chat: send FAILED: " + error);
|
wallet::liteLog("chat: send FAILED: " + error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ struct ChatMessage {
|
|||||||
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
|
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
|
||||||
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
|
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
|
||||||
std::size_t payload_position = 0; // together with txid, the dedup key
|
std::size_t payload_position = 0; // together with txid, the dedup key
|
||||||
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only
|
ChatDelivery delivery = ChatDelivery::Sent; // outgoing only. Sent = broadcast to the network (not
|
||||||
|
// necessarily confirmed/delivered yet).
|
||||||
|
std::string fail_reason; // outgoing Failed only; in-memory, NOT persisted
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -189,8 +189,8 @@ bool ChatService::recordOutgoingPending(const ChatMessage& message) {
|
|||||||
return appended;
|
return appended;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
|
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason) {
|
||||||
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
|
const ChatMessage* updated = store_.updateDelivery(txid, delivery, reason);
|
||||||
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
|
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public:
|
|||||||
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
|
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
|
||||||
// persisted Sending (crash mid-broadcast) loads back as Sent.
|
// persisted Sending (crash mid-broadcast) loads back as Sent.
|
||||||
bool recordOutgoingPending(const ChatMessage& message);
|
bool recordOutgoingPending(const ChatMessage& message);
|
||||||
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
|
void resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason = {});
|
||||||
|
|
||||||
const ChatStore& store() const { return store_; }
|
const ChatStore& store() const { return store_; }
|
||||||
ChatStore& store() { return store_; }
|
ChatStore& store() { return store_; }
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
|
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery,
|
||||||
|
const std::string& reason) {
|
||||||
for (auto& message : messages_) {
|
for (auto& message : messages_) {
|
||||||
if (message.txid == txid) {
|
if (message.txid == txid) {
|
||||||
message.delivery = delivery;
|
message.delivery = delivery;
|
||||||
|
message.fail_reason = (delivery == ChatDelivery::Failed) ? reason : std::string();
|
||||||
++revision_;
|
++revision_;
|
||||||
return &message;
|
return &message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ public:
|
|||||||
|
|
||||||
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
|
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
|
||||||
// message (for the caller to persist), or nullptr if no message has that txid.
|
// message (for the caller to persist), or nullptr if no message has that txid.
|
||||||
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
|
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery,
|
||||||
|
const std::string& reason = {});
|
||||||
|
|
||||||
// Distinct conversation ids, in first-seen order.
|
// Distinct conversation ids, in first-seen order.
|
||||||
std::vector<std::string> conversationIds() const;
|
std::vector<std::string> conversationIds() const;
|
||||||
|
|||||||
@@ -2927,8 +2927,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
Notifications::instance().success(TR("settings_saved"));
|
Notifications::instance().success(TR("settings_saved"));
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
|
||||||
af.next(material::ActionButtonWidth(TR("reset_to_defaults"), ICON_MD_RESTORE));
|
// This only reloads the last-saved values from disk (it doesn't apply factory defaults), so
|
||||||
if (material::ActionButton("##aboutreset", TR("reset_to_defaults"), ICON_MD_RESTORE, AT::Tertiary)) {
|
// label it honestly as "Discard unsaved changes" rather than the misleading "Reset to Defaults".
|
||||||
|
af.next(material::ActionButtonWidth(TR("discard_changes"), ICON_MD_UNDO));
|
||||||
|
if (material::ActionButton("##aboutreset", TR("discard_changes"), ICON_MD_UNDO, AT::Tertiary)) {
|
||||||
if (app->settings()) { loadSettingsPageState(app); Notifications::instance().info(TR("settings_reloaded")); }
|
if (app->settings()) { loadSettingsPageState(app); Notifications::instance().info(TR("settings_reloaded")); }
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
|
||||||
|
|||||||
@@ -1679,8 +1679,15 @@ void RenderChatTab(App* app)
|
|||||||
const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
|
const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
|
||||||
const float gs = icfSz * 0.82f;
|
const float gs = icfSz * 0.82f;
|
||||||
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, glyph);
|
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, glyph);
|
||||||
dl->AddText(icf, gs, ImVec2(bmax.x - gsz.x, bmax.y + 1.0f * dp),
|
const ImVec2 gpos(bmax.x - gsz.x, bmax.y + 1.0f * dp);
|
||||||
material::WithAlpha(material::OnSurface(), sending ? 95 : 135), glyph);
|
dl->AddText(icf, gs, gpos,
|
||||||
|
material::WithAlpha(material::OnSurface(), sending ? 170 : 200), glyph);
|
||||||
|
// Explain the glyph on hover — a check means "broadcast to the network", not "delivered".
|
||||||
|
const float hpad = 4.0f * dp;
|
||||||
|
if (ImGui::IsWindowHovered() &&
|
||||||
|
ImGui::IsMouseHoveringRect(ImVec2(gpos.x - hpad, gpos.y - hpad),
|
||||||
|
ImVec2(gpos.x + gsz.x + hpad, gpos.y + gsz.y + hpad)))
|
||||||
|
material::Tooltip("%s", TR(sending ? "chat_status_sending" : "chat_status_sent"));
|
||||||
ImGui::Dummy(ImVec2(0.0f, gs + 2.0f * dp)); // reserve the status glyph (no ItemSpacing now)
|
ImGui::Dummy(ImVec2(0.0f, gs + 2.0f * dp)); // reserve the status glyph (no ItemSpacing now)
|
||||||
}
|
}
|
||||||
// Failed send → right-aligned "not sent" + Retry (kept — it's actionable).
|
// Failed send → right-aligned "not sent" + Retry (kept — it's actionable).
|
||||||
@@ -1692,6 +1699,12 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
|
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
|
||||||
ImGui::TextUnformatted(TR("chat_send_failed"));
|
ImGui::TextUnformatted(TR("chat_send_failed"));
|
||||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||||
|
if (ImGui::IsItemHovered()) {
|
||||||
|
std::string tip = m.fail_reason.empty()
|
||||||
|
? std::string(TR("chat_failed_generic"))
|
||||||
|
: std::string(TR("chat_failed_generic")) + "\n" + m.fail_reason;
|
||||||
|
material::Tooltip("%s", tip.c_str());
|
||||||
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ImGui::SmallButton(TR("chat_retry"))) {
|
if (ImGui::SmallButton(TR("chat_retry"))) {
|
||||||
// A failed contact request must be re-sent as a request (it has no peer key
|
// A failed contact request must be re-sent as a request (it has no peer key
|
||||||
|
|||||||
@@ -424,7 +424,15 @@ static void performSearch(App* app, const std::string& query) {
|
|||||||
try { h = std::stol(query); } catch (...) { return; }
|
try { h = std::stol(query); } catch (...) { return; }
|
||||||
int tip = app->getWalletState().sync.blocks;
|
int tip = app->getWalletState().sync.blocks;
|
||||||
if (h < 1) h = 1;
|
if (h < 1) h = 1;
|
||||||
if (tip > 0 && h > tip) h = tip;
|
if (tip > 0 && h > tip) {
|
||||||
|
// Say the height is beyond the tip instead of silently snapping to the top of the list.
|
||||||
|
char msg[128];
|
||||||
|
snprintf(msg, sizeof(msg), TR("explorer_beyond_tip"),
|
||||||
|
(int)std::min<long>(h, 2147483647L), tip);
|
||||||
|
s_search_error = msg;
|
||||||
|
s_search_target_height = -1; // don't re-anchor; leave the list where it is
|
||||||
|
return;
|
||||||
|
}
|
||||||
s_search_target_height = static_cast<int>(h);
|
s_search_target_height = static_cast<int>(h);
|
||||||
s_recent_page = 0;
|
s_recent_page = 0;
|
||||||
s_search_error.clear();
|
s_search_error.clear();
|
||||||
@@ -939,7 +947,30 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
|
|
||||||
if (blocks.empty()) {
|
if (blocks.empty()) {
|
||||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||||
ImGui::TextDisabled("%s", fuzzyMode ? TR("explorer_no_results") : TR("loading"));
|
// Say WHY the list is empty — offline, syncing, or a genuine search miss — instead of an
|
||||||
|
// endless "Loading" that also shows when the node is disconnected or still catching up.
|
||||||
|
const auto& st = app->getWalletState();
|
||||||
|
auto* erpc = app->rpc();
|
||||||
|
char emptyBuf[128];
|
||||||
|
const char* msg = TR("loading");
|
||||||
|
ImU32 col = OnSurfaceMedium();
|
||||||
|
if (fuzzyMode) {
|
||||||
|
msg = TR("explorer_no_results");
|
||||||
|
} else if (!st.connected || !erpc || !erpc->isConnected()) {
|
||||||
|
msg = TR("explorer_empty_not_connected"); col = Warning();
|
||||||
|
} else if (st.sync.syncing) {
|
||||||
|
const int behind = st.sync.headers - st.sync.blocks;
|
||||||
|
if (st.sync.tip_known && behind > 0) {
|
||||||
|
snprintf(emptyBuf, sizeof(emptyBuf), TR("explorer_empty_syncing"), behind);
|
||||||
|
msg = emptyBuf;
|
||||||
|
} else {
|
||||||
|
msg = TR("explorer_empty_syncing_unknown");
|
||||||
|
}
|
||||||
|
col = Warning();
|
||||||
|
}
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, col);
|
||||||
|
ImGui::TextUnformatted(msg);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
} else {
|
} else {
|
||||||
for (size_t i = 0; i < blocks.size(); i++) {
|
for (size_t i = 0; i < blocks.size(); i++) {
|
||||||
const auto& row = blocks[i];
|
const auto& row = blocks[i];
|
||||||
|
|||||||
+10
-2
@@ -233,6 +233,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_you"] = "You";
|
strings_["chat_you"] = "You";
|
||||||
strings_["chat_contact_request"] = "contact request";
|
strings_["chat_contact_request"] = "contact request";
|
||||||
strings_["chat_send_failed"] = "not sent";
|
strings_["chat_send_failed"] = "not sent";
|
||||||
|
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
|
||||||
|
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";
|
||||||
|
strings_["chat_failed_generic"] = "Couldn't be sent. Check your connection and funds, then retry.";
|
||||||
strings_["chat_new_button"] = "New chat";
|
strings_["chat_new_button"] = "New chat";
|
||||||
strings_["chat_select_hint"] = "Select a conversation to view it.";
|
strings_["chat_select_hint"] = "Select a conversation to view it.";
|
||||||
strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do.";
|
strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do.";
|
||||||
@@ -532,6 +535,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["report_bug"] = "Report Bug";
|
strings_["report_bug"] = "Report Bug";
|
||||||
strings_["save_settings"] = "Save Settings";
|
strings_["save_settings"] = "Save Settings";
|
||||||
strings_["reset_to_defaults"] = "Reset to Defaults";
|
strings_["reset_to_defaults"] = "Reset to Defaults";
|
||||||
|
strings_["discard_changes"] = "Discard unsaved changes";
|
||||||
|
|
||||||
// Wallet settings
|
// Wallet settings
|
||||||
strings_["save_z_transactions"] = "Save shielded tx history";
|
strings_["save_z_transactions"] = "Save shielded tx history";
|
||||||
@@ -1023,7 +1027,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["tt_website"] = "Open the DragonX website";
|
strings_["tt_website"] = "Open the DragonX website";
|
||||||
strings_["tt_report_bug"] = "Report an issue on the project tracker";
|
strings_["tt_report_bug"] = "Report an issue on the project tracker";
|
||||||
strings_["tt_save_settings"] = "Save all settings to disk";
|
strings_["tt_save_settings"] = "Save all settings to disk";
|
||||||
strings_["tt_reset_settings"] = "Reload settings from disk (undo unsaved changes)";
|
strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)";
|
||||||
strings_["tt_debug_collapse"] = "Collapse debug logging options";
|
strings_["tt_debug_collapse"] = "Collapse debug logging options";
|
||||||
strings_["tt_debug_expand"] = "Expand debug logging options";
|
strings_["tt_debug_expand"] = "Expand debug logging options";
|
||||||
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
|
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
|
||||||
@@ -1592,7 +1596,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["settings_not_connected"] = "Not connected to daemon";
|
strings_["settings_not_connected"] = "Not connected to daemon";
|
||||||
strings_["settings_theme_refreshed"] = "Theme list refreshed";
|
strings_["settings_theme_refreshed"] = "Theme list refreshed";
|
||||||
strings_["settings_saved"] = "Settings saved";
|
strings_["settings_saved"] = "Settings saved";
|
||||||
strings_["settings_reloaded"] = "Settings reloaded from disk";
|
strings_["settings_reloaded"] = "Unsaved changes discarded";
|
||||||
strings_["settings_ztx_cleared"] = "Z-transaction history cleared";
|
strings_["settings_ztx_cleared"] = "Z-transaction history cleared";
|
||||||
strings_["settings_ztx_not_found"] = "No history file found";
|
strings_["settings_ztx_not_found"] = "No history file found";
|
||||||
strings_["tt_open_log_folder"] = "Open the folder containing the debug and crash logs";
|
strings_["tt_open_log_folder"] = "Open the folder containing the debug and crash logs";
|
||||||
@@ -2754,6 +2758,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
|
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
|
||||||
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
|
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
|
||||||
strings_["explorer_no_results"] = "No matching cached blocks";
|
strings_["explorer_no_results"] = "No matching cached blocks";
|
||||||
|
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
|
||||||
|
strings_["explorer_empty_not_connected"] = "Not connected to the node";
|
||||||
|
strings_["explorer_empty_syncing"] = "Syncing, %d blocks behind";
|
||||||
|
strings_["explorer_empty_syncing_unknown"] = "Syncing…";
|
||||||
|
|
||||||
// ---- i18n audit 2026-09: wrap previously-hardcoded UI strings ----
|
// ---- i18n audit 2026-09: wrap previously-hardcoded UI strings ----
|
||||||
// app_security.cpp — encryption / PIN / lock flow
|
// app_security.cpp — encryption / PIN / lock flow
|
||||||
|
|||||||
Reference in New Issue
Block a user