feat(chat): explain the no-identity state and why a send is stuck
- Chat tab no longer shows "Unlock your wallet" for every missing-identity
case. A new App::chatIdentityStatus() distinguishes Locked (real),
Preparing (unlocked, deriving the identity) and Unavailable (view-only or a
legacy wallet with no exportable secret — correctly latched). Each gets its
own icon + message, and the Unavailable prompt points at Back up / Migrate
instead of telling the user to unlock an already-unlocked wallet.
- A chat message that sits queued ("Sending") because the wallet is offline or
has no shielded funds for the fee now surfaces the reason (throttled, after a
short dwell), wiring the ready-made chat_toast_not_connected /
chat_toast_need_funds strings that were never shown. The send stays queued
and goes out when the condition clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -302,6 +302,13 @@ public:
|
|||||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||||
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
||||||
chat::ChatService& chatService() { return chat_service_; }
|
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
|
// HushChat composing: construct, broadcast (broadcastChatMemos), and locally echo an outgoing
|
||||||
// message (to a conversation whose peer key we know) / a new-conversation contact request.
|
// message (to a conversation whose peer key we know) / a new-conversation contact request.
|
||||||
void sendChatMessage(const std::string& conversationId, const std::string& text);
|
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
|
// 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.
|
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
||||||
int chat_verified_note_budget_ = 0;
|
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)
|
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)
|
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
|
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
|
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||||
// feature is off, already provisioned, in flight, or unavailable.
|
// feature is off, already provisioned, in flight, or unavailable.
|
||||||
void maybeProvisionChatIdentity();
|
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
|
// 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)
|
// 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.
|
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
|
||||||
|
|||||||
+41
-1
@@ -4215,6 +4215,14 @@ void App::resetChatSession()
|
|||||||
++chat_session_generation_;
|
++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()
|
void App::maybeProvisionChatIdentity()
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
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);
|
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
|
// 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.
|
// buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest.
|
||||||
void App::pumpChatNoteBuffer() {
|
void App::pumpChatNoteBuffer() {
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return;
|
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
|
// 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".
|
// message to Failed. Hold everything (send + split) until unlock — messages stay "Sending".
|
||||||
if (state_.isLocked()) return;
|
if (state_.isLocked()) return;
|
||||||
|
|||||||
@@ -748,9 +748,21 @@ void RenderChatTab(App* app)
|
|||||||
const auto& store = service.store();
|
const auto& store = service.store();
|
||||||
auto& book = app->addressBook();
|
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()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["contacts"] = "Contacts";
|
strings_["contacts"] = "Contacts";
|
||||||
strings_["chat"] = "Chat";
|
strings_["chat"] = "Chat";
|
||||||
strings_["chat_locked_hint"] = "Unlock your wallet to load your chats.";
|
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_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
|
||||||
strings_["chat_you"] = "You";
|
strings_["chat_you"] = "You";
|
||||||
strings_["chat_contact_request"] = "contact request";
|
strings_["chat_contact_request"] = "contact request";
|
||||||
|
|||||||
Reference in New Issue
Block a user