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; bool s_msgsel_dragging = false;
// Composer + new-conversation UI state. // 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 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 // 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 // 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. // instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1; int s_composeCursor = -1;
// On-chain chat body cap in bytes. Larger-messages tier 2 splits the ciphertext across up to // On-chain chat body cap in bytes. Larger messages split the ciphertext across many memo outputs: tier 2
// kHushChatMaxMessageParts (16) memo outputs in one tx, so the body can be a few KB (protocol max ~4063: // packs up to kHushChatMaxMessageParts (16) into ONE transaction (~4 KB), and tier 3 spills further across
// 16 × 255 cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a // several transactions up to kHushChatMaxMessageChunks (64) chunks total (protocol max ~16303: 64 × 255
// body that compose would reject as TooLong. The composer hard-caps input here; the emoji picker too. // cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a body that
constexpr int kChatBodyMaxBytes = 4000; // 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"); 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_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 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 // 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 // (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 int chatMsgParts = [&]() -> int {
const std::size_t hexLen = (static_cast<std::size_t>(used) + 17) * 2; 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) 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 // 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. // 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), gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10); thick * 0.6f, fillCol, 10);
} }
// Always-visible part count beside the gauge once the message spans >1 memo output, so it's // Always-visible indicator beside the gauge once the message spans >1 memo output: the part
// clear how it chunks on-chain (it's still ONE transaction). Full explanation on hover. // 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) { if (chatMsgParts > 1 && !overCap) {
ImFont* cf = ImGui::GetFont(); ImFont* cf = ImGui::GetFont();
const float cfsz = std::max(10.0f, lineH * 0.42f); const float cfsz = std::max(10.0f, lineH * 0.42f);
char buf[24]; char buf[32];
std::snprintf(buf, sizeof buf, "%d %s", chatMsgParts, TR("chat_parts_label")); 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); 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), 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). // Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick; const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR), if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
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); std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip; if (overCap) {
else if (chatMsgParts > 1) tip += " \xC2\xB7 " + std::to_string(chatMsgParts) + " " + TR("chat_parts_label"); 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::BeginTooltip();
ImGui::TextUnformatted(tip.c_str()); ImGui::TextUnformatted(tip.c_str());
if (chatMsgParts > 1 && !overCap) { if (chatMsgParts > 1 && !overCap) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 24.0f); 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::PopTextWrapPos();
ImGui::PopStyleColor(); ImGui::PopStyleColor();
} }
+2
View File
@@ -294,7 +294,9 @@ void I18n::loadBuiltinEnglish()
strings_["chat_export_confirm_btn"] = "Export"; strings_["chat_export_confirm_btn"] = "Export";
strings_["chat_len_over"] = "Message too long"; strings_["chat_len_over"] = "Message too long";
strings_["chat_parts_label"] = "parts"; strings_["chat_parts_label"] = "parts";
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"] = "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_mute"] = "Mute"; strings_["chat_mute"] = "Mute";
strings_["chat_unmute"] = "Unmute"; strings_["chat_unmute"] = "Unmute";
strings_["chat_hide"] = "Hide"; strings_["chat_hide"] = "Hide";