feat(chat): show the on-chain part count for a long message in the composer

A larger message (tier 2) is carried as one transaction split across up to 16
memo outputs, but the composer gave no sign of that. It now shows the part
count beside the byte gauge the moment the body spans more than one memo output
(computed the same way buildOutgoingMessage chunks it), and the gauge's hover
tooltip adds the count plus "sent as one transaction split into these memo
parts — it arrives as quickly as a normal message" (the honest answer: it's one
atomic 0-conf tx, not N transactions — multi-transaction splitting is the
unbuilt tier 3). New strings chat_parts_label / chat_parts_explain (English;
8 locales fall back).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:00:27 -05:00
co-authored by Claude Opus 4.8
parent 12c61bd796
commit 1729f7dfad
2 changed files with 29 additions and 0 deletions
+27
View File
@@ -2019,6 +2019,14 @@ void RenderChatTab(App* app)
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
// How this message chunks on-chain (mirrors buildOutgoingMessage): the hex ciphertext is
// (plaintext + the 17-byte secretstream tag) * 2; ≤512 hex fits one memo output, otherwise it
// splits into 510-hex chunks (512 minus a 2-char part index) — all carried in ONE transaction.
const int chatMsgParts = [&]() -> int {
const std::size_t hexLen = (static_cast<std::size_t>(used) + 17) * 2;
return hexLen <= 512 ? 1 : static_cast<int>((hexLen + 509) / 510); // ceil(hexLen/510)
}();
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
const float rowY = boxMax.y + composerAreaH - composerBoxH;
@@ -2129,14 +2137,33 @@ void RenderChatTab(App* app)
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10);
}
// Always-visible part count beside the gauge once the message spans >1 memo output, so it's
// clear how it chunks on-chain (it's still ONE transaction). Full explanation on hover.
if (chatMsgParts > 1 && !overCap) {
ImFont* cf = ImGui::GetFont();
const float cfsz = std::max(10.0f, lineH * 0.42f);
char buf[24];
std::snprintf(buf, sizeof buf, "%d %s", chatMsgParts, TR("chat_parts_label"));
const ImVec2 tsz = cf->CalcTextSizeA(cfsz, FLT_MAX, 0.0f, buf);
gdl->AddText(cf, cfsz, ImVec2(ringC.x - ringR - 6.0f * tdp - tsz.x, ringC.y - tsz.y * 0.5f),
material::WithAlpha(material::OnSurface(), 150), buf);
}
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
else if (chatMsgParts > 1) tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label");
ImGui::BeginTooltip();
ImGui::TextUnformatted(tip.c_str());
if (chatMsgParts > 1 && !overCap) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 24.0f);
ImGui::TextUnformatted(TR("chat_parts_explain"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
ImGui::EndTooltip();
}
}
+2
View File
@@ -293,6 +293,8 @@ void I18n::loadBuiltinEnglish()
strings_["chat_export_confirm_path"] = "Will be saved to:";
strings_["chat_export_confirm_btn"] = "Export";
strings_["chat_len_over"] = "Message too long";
strings_["chat_parts_label"] = "parts";
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_mute"] = "Mute";
strings_["chat_unmute"] = "Unmute";
strings_["chat_hide"] = "Hide";