From dba97c8b3568f298e797ccc39b663cebd5911764 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 26 Oct 2026 12:47:37 -0500 Subject: [PATCH] feat(chat): show a progress hint while a tier-3 message is still arriving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now an incoming large message was invisible until every part landed. ChatService::pendingLargeMessages() exposes the in-progress tier-3 receives (conversation, chunks gathered / chunks needed) from the reassembly buffer, and the chat tab renders a subtle centred line below the thread — "Receiving a large message… 24 / 36 parts" — that updates each scan and disappears when the message completes and surfaces as an ordinary bubble. A complete-but-withheld (sensitive vault locked) group is not reported as incoming. New string chat_receiving_large (English; 8 locales fall back). Unit test extended: after the first slice the receive is reported in progress; after the whole message arrives it is no longer pending. Co-Authored-By: Claude Opus 4.8 --- src/chat/chat_service.cpp | 12 ++++++++++++ src/chat/chat_service.h | 10 ++++++++++ src/ui/windows/chat_tab.cpp | 21 +++++++++++++++++++++ src/util/i18n.cpp | 1 + tests/test_phase4.cpp | 8 +++++++- 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index 60d551c..774bdd6 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -274,6 +274,18 @@ bool ChatService::isConversationSensitive(const std::string& conversationId) con if (conversationSensitiveOnChain(conversationId)) return true; return local_sensitive_pred_ && local_sensitive_pred_(conversationId); } +std::vector ChatService::pendingLargeMessages() const { + std::vector out; + for (const auto& entry : tier3_pending_) { + const Tier3Pending& g = entry.second; + const int received = static_cast(g.chunks.size()); + // Report only the genuinely in-progress ones. A complete group waiting on the sensitive vault key + // (received == total but withheld) is done receiving, so it isn't shown as "incoming". + if (g.total_parts > 0 && received < g.total_parts) + out.push_back(Tier3Progress{ g.conversation_id, received, g.total_parts }); + } + return out; +} void ChatService::setPersistence(ChatDatabase* db) { db_ = db; // The DB picks the per-row key by this predicate: a sensitive conversation's rows are stored under the diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index eac78ae..e459d96 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -100,6 +100,16 @@ public: // local vault key; while that key is absent its messages are withheld from the store on ingest/load. bool isConversationSensitive(const std::string& conversationId) const; + // Larger-messages tier 3: a message still being received across transactions (some chunks arrived, + // not yet all). Lets the UI show an "incoming large message — N of M parts" hint until it completes + // and surfaces as an ordinary message. One entry per in-progress message; empty when none. + struct Tier3Progress { + std::string conversation_id; + int received = 0; // chunks gathered so far + int total = 0; // chunks the whole message needs + }; + std::vector pendingLargeMessages() const; + // --- Outgoing (compose) --- // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". std::string identityPublicKeyHex() const; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index ef68a4e..efee673 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -1952,6 +1952,27 @@ void RenderChatTab(App* app) } ImGui::EndChild(); ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing + // Larger-messages tier 3: while a big incoming message is still arriving across transactions, + // show a subtle centred progress line (it surfaces as an ordinary bubble once every part lands). + { + int rcv = 0, tot = 0; + for (const auto& pr : app->chatService().pendingLargeMessages()) + if (pr.conversation_id == sel->cid) { rcv = pr.received; tot = pr.total; break; } + if (tot > 0) { + const float dp2 = Layout::dpiScale(); + ImGui::Dummy(ImVec2(0.0f, 2.0f * dp2)); + char buf[96]; + std::snprintf(buf, sizeof buf, "%s %d / %d %s", + TR("chat_receiving_large"), rcv, tot, TR("chat_parts_label")); + ImFont* f = ImGui::GetFont(); + const ImVec2 tsz = f->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, buf); + const float availX = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - tsz.x) * 0.5f)); + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextUnformatted(buf); + ImGui::PopStyleColor(); + } + } // Composer is rendered OUTSIDE this box, directly below it (see after EndChild). } else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) { // First-run intro: explain how chat works + let the user copy their address, instead of a bare diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 0869785..56b88d8 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -297,6 +297,7 @@ void I18n::loadBuiltinEnglish() strings_["chat_txns_label"] = "txns"; strings_["chat_parts_explain"] = "This long message is sent as one transaction split into these memo parts — it arrives as quickly as a normal message."; strings_["chat_parts_explain_multi"] = "This message is too big for one transaction, so it is split across several. The recipient sees it once every part has arrived, so it takes longer to deliver and costs a network fee per transaction."; + strings_["chat_receiving_large"] = "Receiving a large message…"; strings_["chat_mute"] = "Mute"; strings_["chat_unmute"] = "Unmute"; strings_["chat_hide"] = "Hide"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a5e6648..2b8055d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15862,13 +15862,19 @@ void testHushChatTier3Reassembly() ChatService svc; svc.setIdentity(bob); - // Stage 1: only the FIRST transaction's slice has arrived — the message is incomplete, nothing surfaces. + // Stage 1: only the FIRST transaction's slice has arrived — the message is incomplete, nothing surfaces, + // but the in-progress receive is reported so the UI can show an "incoming large message" hint. std::vector firstOnly(all.begin(), all.begin() + 1); EXPECT_EQ(svc.ingest(firstOnly, {}, 1000), 0); EXPECT_EQ((int)svc.store().conversation(cid).size(), 0); + auto prog = svc.pendingLargeMessages(); + EXPECT_EQ((int)prog.size(), 1); + EXPECT_EQ(prog[0].conversation_id, cid); + EXPECT_TRUE(prog[0].received > 0 && prog[0].received < prog[0].total); // Stage 2: the whole history re-arrives (every slice) — reassembled, decrypted and surfaced once. EXPECT_EQ(svc.ingest(all, {}, 1000), 1); + EXPECT_EQ((int)svc.pendingLargeMessages().size(), 0); // completed -> no longer "incoming" std::vector conv = svc.store().conversation(cid); EXPECT_EQ((int)conv.size(), 1); EXPECT_EQ(conv[0].body, big);