feat(chat): show a progress hint while a tier-3 message is still arriving

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:47:37 -05:00
co-authored by Claude Opus 4.8
parent acb6446076
commit dba97c8b35
5 changed files with 51 additions and 1 deletions
+12
View File
@@ -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::Tier3Progress> ChatService::pendingLargeMessages() const {
std::vector<Tier3Progress> out;
for (const auto& entry : tier3_pending_) {
const Tier3Pending& g = entry.second;
const int received = static_cast<int>(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
+10
View File
@@ -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<Tier3Progress> pendingLargeMessages() const;
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
+21
View File
@@ -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
+1
View File
@@ -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";
+7 -1
View File
@@ -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<HushChatTransactionMetadata> 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<ChatMessage> conv = svc.store().conversation(cid);
EXPECT_EQ((int)conv.size(), 1);
EXPECT_EQ(conv[0].body, big);