From e27d88eca4267f23220c4e64edb27c8210d18c7f Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 17 Oct 2026 16:41:30 -0500 Subject: [PATCH] feat(chat): explain the no-identity state and why a send is stuck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app.h | 15 +++++++++++++ src/app_network.cpp | 42 ++++++++++++++++++++++++++++++++++++- src/ui/windows/chat_tab.cpp | 16 ++++++++++++-- src/util/i18n.cpp | 3 +++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/app.h b/src/app.h index 3635489..8083024 100644 --- a/src/app.h +++ b/src/app.h @@ -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. diff --git a/src/app_network.cpp b/src/app_network.cpp index 795461d..99b01c4 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 63d6360..2288dc9 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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; } diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 13333b7..a1c1e6d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -225,6 +225,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";