feat(chat): raise the composer to the tier-3 cap + show the transaction count

Front end of tier-3 larger messages. The composer body cap goes from 4000 to
16000 bytes (kHushChatMaxMessageChunks = 64 chunks ⇒ up to 4 transactions), so a
message past the single-transaction ceiling now actually composes and sends via
the tier-3 path. The buffer and static_assert grow to match.

The beside-the-gauge indicator (and its hover tooltip) now distinguish the two
tiers: while a long message still fits one transaction it shows the part count as
before; once it spills across several it shows the TRANSACTION count — the real
cost the user asked to see — and the tooltip explains it is split across several
transactions, the recipient sees it only once every part has arrived, so it takes
longer to deliver and costs a fee per transaction. New strings chat_txns_label /
chat_parts_explain_multi (English; 8 locales fall back pending a translation pass).

Tier 3 is now end to end: compose → split → broadcast → reassemble → display.
A receive-side "incoming N/M parts" progress hint remains a future refinement
(an incomplete large message simply appears once whole).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:41:04 -05:00
co-authored by Claude Opus 4.8
parent e61772a825
commit acb6446076
2 changed files with 31 additions and 15 deletions
+29 -15
View File
@@ -63,17 +63,18 @@ int s_msgsel_head = 0; // drag-current byte offset
bool s_msgsel_dragging = false;
// Composer + new-conversation UI state.
char s_compose[4100] = ""; // must be >= kChatBodyMaxBytes + 1 (static_assert'd below)
char s_compose[16400] = ""; // must be >= kChatBodyMaxBytes + 1 (static_assert'd below)
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
// instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1;
// On-chain chat body cap in bytes. Larger-messages tier 2 splits the ciphertext across up to
// kHushChatMaxMessageParts (16) memo outputs in one tx, so the body can be a few KB (protocol max ~4063:
// 16 × 255 cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a
// body that compose would reject as TooLong. The composer hard-caps input here; the emoji picker too.
constexpr int kChatBodyMaxBytes = 4000;
// On-chain chat body cap in bytes. Larger messages split the ciphertext across many memo outputs: tier 2
// packs up to kHushChatMaxMessageParts (16) into ONE transaction (~4 KB), and tier 3 spills further across
// several transactions up to kHushChatMaxMessageChunks (64) chunks total (protocol max ~16303: 64 × 255
// cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a body that
// compose would reject as TooLong. The composer hard-caps input here; the emoji picker too.
constexpr int kChatBodyMaxBytes = 16000;
static_assert(sizeof(s_compose) >= kChatBodyMaxBytes + 1, "compose buffer too small for the body cap");
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
@@ -2021,11 +2022,14 @@ void RenderChatTab(App* app)
// 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.
// splits into 510-hex chunks (512 minus a 2-char part index). Up to kHushChatMaxMessageParts (16)
// chunks ride ONE transaction (tier 2); beyond that the message spills across several (tier 3).
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)
}();
// How many transactions those chunks need (tier 3 is >1): the real cost the user asked to see.
const int chatMsgTxns = (chatMsgParts + chat::kHushChatMaxMessageParts - 1) / chat::kHushChatMaxMessageParts;
// 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.
@@ -2137,30 +2141,40 @@ 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.
// Always-visible indicator beside the gauge once the message spans >1 memo output: the part
// count while it still fits one transaction (tier 2), or the TRANSACTION count once it spills
// across several (tier 3 — the real cost the user cares about). 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"));
char buf[32];
if (chatMsgTxns > 1)
std::snprintf(buf, sizeof buf, "%d %s", chatMsgTxns, TR("chat_txns_label"));
else
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);
material::WithAlpha(material::OnSurface(), chatMsgTxns > 1 ? 205 : 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");
if (overCap) {
tip = std::string(TR("chat_len_over")) + " " + tip;
} else if (chatMsgTxns > 1) {
tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label") +
" \xC2\xB7 " + std::to_string(chatMsgTxns) + " " + TR("chat_txns_label");
} 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::TextUnformatted(TR(chatMsgTxns > 1 ? "chat_parts_explain_multi" : "chat_parts_explain"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}