feat(chat): message bubbles, empty states, search, export, richer composer
Chat-tab backlog from the audit:
- V3/V7/Q7/Q9 — thread overhaul: direction-aligned rounded bubbles with
sender/minute grouping and a grouped meta line; peer avatar in the header;
hover shows the full timestamp; a right-aligned "not sent · Retry" affordance
re-sends failed outgoing messages; a floating "Latest" pill appears when the
thread is scrolled up.
- V4 — centered empty states (icon + title + hint) for the locked, no-conversations,
and no-selection panes.
- V6 — faint sidebar tint on the conversation list + a tight single-line seam.
- Q5 — compact relative time ("now"/"5m"/"3h"/"2d"/"Mon DD") in the list preview.
- Q6 — multi-line composer (Enter sends, Ctrl+Enter newline) with a live byte
counter against the on-chain body cap (= (512−len"utf8:")/2 − ABYTES = 236),
Send disabled + counter reddened when over.
- Q8 — case-insensitive conversation search over name + last body (thread stays
open even when filtered out); "no matches" hint.
- Q11 — export a decrypted conversation to a plaintext file in the config dir
(restricted perms, plaintext-warning tooltip), toasting the path.
English strings added to i18n.cpp; per-language JSONs follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../chat/chat_service.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
|
||||
#include "../material/colors.h"
|
||||
#include "../material/color_theme.h" // WithAlpha
|
||||
#include "../material/type.h"
|
||||
@@ -22,8 +23,10 @@
|
||||
#include <sodium.h> // sodium_memzero — wipe typed plaintext on a wallet switch
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cfloat>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -43,10 +46,21 @@ std::string s_compose_cid; // the conversation s_compose is a draft for; draft
|
||||
bool s_show_new_convo = false;
|
||||
char s_new_zaddr[128] = "";
|
||||
char s_new_msg[256] = "";
|
||||
char s_search[80] = ""; // conversation-list filter (Q8)
|
||||
|
||||
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
|
||||
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
|
||||
|
||||
// Case-insensitive substring match (ASCII) for the conversation search (Q8).
|
||||
bool containsCI(const std::string& hay, const std::string& needle) {
|
||||
if (needle.empty()) return true;
|
||||
const auto it = std::search(hay.begin(), hay.end(), needle.begin(), needle.end(),
|
||||
[](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
return it != hay.end();
|
||||
}
|
||||
|
||||
std::string shorten(const std::string& s, std::size_t head = 12, std::size_t tail = 6) {
|
||||
if (s.size() <= head + tail + 3) return s;
|
||||
return s.substr(0, head) + "..." + s.substr(s.size() - tail);
|
||||
@@ -62,6 +76,23 @@ std::string formatTime(std::int64_t ts) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Compact relative time for the list ("now", "5m", "3h", "2d", then "Mon DD"). UI thread only.
|
||||
std::string relativeTime(std::int64_t ts) {
|
||||
if (ts <= 0) return "";
|
||||
std::int64_t d = static_cast<std::int64_t>(std::time(nullptr)) - ts;
|
||||
if (d < 0) d = 0;
|
||||
if (d < 45) return TR("chat_time_now");
|
||||
if (d < 3600) return std::to_string(d / 60) + "m";
|
||||
if (d < 86400) return std::to_string(d / 3600) + "h";
|
||||
if (d < 7 * 86400) return std::to_string(d / 86400) + "d";
|
||||
std::time_t t = static_cast<std::time_t>(ts);
|
||||
std::tm* tm = std::localtime(&t);
|
||||
if (!tm) return "";
|
||||
char buf[16];
|
||||
std::strftime(buf, sizeof(buf), "%b %d", tm);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// One line of collapsed body text for the list preview (newlines flattened).
|
||||
std::string previewOf(const std::string& body) {
|
||||
std::string out = body;
|
||||
@@ -122,6 +153,51 @@ void centeredHint(const char* text) {
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
|
||||
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const ImVec2 origin = ImGui::GetCursorPos();
|
||||
ImFont* iconF = material::Type().iconXL();
|
||||
ImFont* titleF = material::Type().subtitle1();
|
||||
ImFont* hintF = material::Type().body2();
|
||||
const float gap = 8.0f * Layout::dpiScale();
|
||||
const float wrap = std::min(avail.x - 40.0f, 360.0f);
|
||||
const float iconSz = iconF ? scaledSize(iconF) : 40.0f;
|
||||
const float iconH = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f;
|
||||
const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y;
|
||||
const float hintH = hint ? hintF->CalcTextSizeA(scaledSize(hintF), wrap, wrap, hint).y : 0.0f;
|
||||
const float totalH = iconH + gap + titleH + (hint ? gap + hintH : 0.0f);
|
||||
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
|
||||
|
||||
if (iconF) {
|
||||
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
|
||||
ImGui::PushFont(iconF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 70));
|
||||
ImGui::TextUnformatted(icon);
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
y += iconH + gap;
|
||||
}
|
||||
{
|
||||
const float tw = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
|
||||
ImGui::PushFont(titleF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(title);
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
y += titleH + gap;
|
||||
}
|
||||
if (hint) {
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
|
||||
ImGui::PushFont(hintF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 120));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
|
||||
ImGui::TextUnformatted(hint);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RenderChatTab(App* app)
|
||||
@@ -132,7 +208,7 @@ void RenderChatTab(App* app)
|
||||
|
||||
// Not unlocked / identity not derived yet → nothing to show.
|
||||
if (!service.hasIdentity()) {
|
||||
centeredHint(TR("chat_locked_hint"));
|
||||
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,6 +263,8 @@ void RenderChatTab(App* app)
|
||||
const float metaSz = scaledSize(metaFont);
|
||||
|
||||
// ---- Left: new-conversation button + conversation list ----
|
||||
// Faint sidebar tint so the list reads as distinct from the thread pane (V6).
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::OnSurface(), 10)));
|
||||
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true);
|
||||
{
|
||||
// Accented "New conversation" action (house tactile style).
|
||||
@@ -200,6 +278,12 @@ void RenderChatTab(App* app)
|
||||
s_new_zaddr[0] = '\0';
|
||||
s_new_msg[0] = '\0';
|
||||
}
|
||||
// Search filter (Q8) — only worth showing once there's more than one conversation.
|
||||
if (convs.size() > 1) {
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
|
||||
}
|
||||
const std::string search = s_search;
|
||||
ImGui::Separator();
|
||||
if (convs.empty()) {
|
||||
ImGui::PushFont(metaFont);
|
||||
@@ -215,8 +299,12 @@ void RenderChatTab(App* app)
|
||||
const float dp = Layout::dpiScale();
|
||||
const float round = 6.0f * dp;
|
||||
const float avR = rowH * 0.30f; // letter-avatar radius
|
||||
int shown = 0;
|
||||
for (std::size_t i = 0; i < convs.size(); ++i) {
|
||||
const ConvSummary& c = convs[i];
|
||||
if (!search.empty() && !containsCI(c.peerName, search) && !containsCI(c.lastBody, search))
|
||||
continue; // filtered out by search (Q8) — thread pane still keeps it open
|
||||
++shown;
|
||||
ImGui::PushID(c.cid.c_str()); // stable id — the list re-sorts by lastTs each frame (B6)
|
||||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
const float rowW = ImGui::GetContentRegionAvail().x;
|
||||
@@ -247,8 +335,8 @@ void RenderChatTab(App* app)
|
||||
|
||||
// Name (top).
|
||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), material::OnSurface(), c.peerName.c_str());
|
||||
// Time (top-right, muted).
|
||||
const std::string when = formatTime(c.lastTs);
|
||||
// Time (top-right, muted) — compact relative form (Q5).
|
||||
const std::string when = relativeTime(c.lastTs);
|
||||
if (!when.empty()) {
|
||||
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
|
||||
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
|
||||
@@ -263,10 +351,20 @@ void RenderChatTab(App* app)
|
||||
ImGui::PopID();
|
||||
ImGui::Dummy(ImVec2(0.0f, 3.0f * dp)); // small gap between cards
|
||||
}
|
||||
if (!search.empty() && shown == 0) {
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(TR("chat_no_matches"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor(); // list ChildBg tint (V6)
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::SameLine(0.0f, 1.0f * Layout::dpiScale()); // tight single-line seam between the panes
|
||||
|
||||
// ---- Right: selected conversation thread ----
|
||||
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true);
|
||||
@@ -276,8 +374,21 @@ void RenderChatTab(App* app)
|
||||
|
||||
if (sel) {
|
||||
app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1)
|
||||
// Header: peer name + z-address.
|
||||
// Header: peer avatar + name (V7).
|
||||
ImGui::PushFont(material::Type().subtitle1());
|
||||
{
|
||||
const float nameH = ImGui::GetTextLineHeight();
|
||||
const float avR = nameH * 0.6f;
|
||||
const ImVec2 hp = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* hdl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 avC(hp.x + avR, hp.y + nameH * 0.5f);
|
||||
hdl->AddCircleFilled(avC, avR, avatarColor(sel->cid), 24);
|
||||
const std::string init = initialOf(sel->peerName);
|
||||
const ImVec2 isz = ImGui::CalcTextSize(init.c_str());
|
||||
hdl->AddText(ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
|
||||
IM_COL32(255, 255, 255, 235), init.c_str());
|
||||
ImGui::SetCursorScreenPos(ImVec2(hp.x + 2.0f * avR + 8.0f * Layout::dpiScale(), hp.y));
|
||||
}
|
||||
ImGui::TextUnformatted(sel->peerName.c_str());
|
||||
ImGui::PopFont();
|
||||
if (!sel->peerZaddr.empty()) {
|
||||
@@ -296,52 +407,125 @@ void RenderChatTab(App* app)
|
||||
else Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
// Export the decrypted conversation to a plain-text file (Q11). Written with restricted
|
||||
// permissions; the tooltip warns it's plaintext.
|
||||
ImGui::SameLine();
|
||||
const bool exportClicked = ImGui::SmallButton(TR("chat_export"));
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_export_warn"));
|
||||
if (exportClicked) {
|
||||
std::string content = "DragonX chat export\n";
|
||||
content += sel->peerName + " <" + sel->peerZaddr + ">\n";
|
||||
content += "cid: " + sel->cid + "\n\n";
|
||||
for (const auto& m : store.conversation(sel->cid)) {
|
||||
const bool out = (m.direction == chat::ChatDirection::Outgoing);
|
||||
content += "[" + formatTime(m.timestamp) + "] " +
|
||||
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
|
||||
}
|
||||
std::string safe;
|
||||
for (char ch : sel->peerName)
|
||||
safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
|
||||
if (safe.empty()) safe = "chat";
|
||||
const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
|
||||
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
|
||||
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
|
||||
else
|
||||
Notifications::instance().error(TR("chat_export_failed"));
|
||||
}
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
const float footerH = ImGui::GetTextLineHeightWithSpacing() + 12.0f;
|
||||
const float dp = Layout::dpiScale();
|
||||
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
|
||||
const float footerH = composerBoxH + ImGui::GetTextLineHeight() + 20.0f * dp;
|
||||
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false);
|
||||
bool atBottom = true;
|
||||
const ImVec2 msgWinMin = ImGui::GetWindowPos();
|
||||
const ImVec2 msgWinSize = ImGui::GetWindowSize();
|
||||
{
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const float bpad = 9.0f * dp, bround = 9.0f * dp;
|
||||
const auto messages = store.conversation(s_selected_cid);
|
||||
std::int64_t prevMin = -1; int prevDir = -1;
|
||||
for (std::size_t mi = 0; mi < messages.size(); ++mi) {
|
||||
const auto& m = messages[mi];
|
||||
ImGui::PushID(static_cast<int>(mi));
|
||||
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
|
||||
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
|
||||
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
|
||||
// Meta line: who + time (+ request tag / failed marker).
|
||||
std::string who = outgoing ? std::string(TR("chat_you")) : sel->peerName;
|
||||
const std::string when = formatTime(m.timestamp);
|
||||
if (!when.empty()) who += " " + when;
|
||||
if (request) who += " [" + std::string(TR("chat_contact_request")) + "]";
|
||||
if (failed) who += " · " + std::string(TR("chat_send_failed"));
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
failed ? material::Error() : (outgoing ? material::Primary() : material::OnSurfaceMedium()));
|
||||
ImGui::TextUnformatted(who.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
// Body (wrapped). Right-click to copy (Q3).
|
||||
ImGui::PushFont(nameFont);
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextWrapped("%s", m.body.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopFont();
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right))
|
||||
ImGui::OpenPopup("##msgmenu");
|
||||
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
|
||||
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
|
||||
const std::int64_t minute = m.timestamp / 60;
|
||||
const bool startGroup = (prevDir != static_cast<int>(outgoing)) || (minute != prevMin);
|
||||
|
||||
const float availW = ImGui::GetContentRegionAvail().x;
|
||||
const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f);
|
||||
const float innerW = maxBubbleW - 2.0f * bpad;
|
||||
|
||||
// Grouped meta line (sender + time), once per sender/minute run, aligned to the sender's side.
|
||||
if (startGroup) {
|
||||
std::string meta = outgoing ? std::string(TR("chat_you")) : sel->peerName;
|
||||
const std::string when = formatTime(m.timestamp);
|
||||
if (!when.empty()) meta += " " + when;
|
||||
if (request) meta += " [" + std::string(TR("chat_contact_request")) + "]";
|
||||
const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, meta.c_str());
|
||||
const ImVec2 mp = ImGui::GetCursorScreenPos();
|
||||
dl->AddText(metaFont, metaSz, ImVec2(outgoing ? mp.x + availW - msz.x : mp.x, mp.y),
|
||||
material::OnSurfaceMedium(), meta.c_str());
|
||||
ImGui::Dummy(ImVec2(availW, metaSz + 3.0f * dp));
|
||||
}
|
||||
|
||||
// Direction-aligned rounded bubble.
|
||||
const ImVec2 tsz = nameFont->CalcTextSizeA(nameSz, innerW, innerW, m.body.c_str());
|
||||
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
|
||||
const float bh = tsz.y + 2.0f * bpad;
|
||||
const ImVec2 cur = ImGui::GetCursorScreenPos();
|
||||
const float bx = outgoing ? (cur.x + availW - bw) : cur.x;
|
||||
const ImVec2 bmin(bx, cur.y), bmax(bx + bw, cur.y + bh);
|
||||
const ImU32 bubCol = failed ? material::WithAlpha(material::Error(), 40)
|
||||
: outgoing ? material::WithAlpha(material::Primary(), 46)
|
||||
: material::WithAlpha(material::OnSurface(), 22);
|
||||
dl->AddRectFilled(bmin, bmax, bubCol, bround);
|
||||
dl->AddText(nameFont, nameSz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
|
||||
m.body.c_str(), nullptr, innerW);
|
||||
ImGui::Dummy(ImVec2(availW, bh));
|
||||
if (ImGui::IsMouseHoveringRect(bmin, bmax)) {
|
||||
material::Tooltip("%s", formatTime(m.timestamp).c_str());
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) ImGui::OpenPopup("##msgmenu");
|
||||
}
|
||||
if (ImGui::BeginPopup("##msgmenu")) {
|
||||
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0.0f, 6.0f));
|
||||
// Failed send → right-aligned "not sent" + Retry (Q7).
|
||||
if (failed) {
|
||||
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
|
||||
const float ftw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_send_failed")).x;
|
||||
const float rtw = ImGui::CalcTextSize(TR("chat_retry")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - ftw - rtw - 8.0f * dp));
|
||||
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
|
||||
ImGui::TextUnformatted(TR("chat_send_failed"));
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TR("chat_retry"))) { app->sendChatMessage(sel->cid, m.body); s_scroll_to_cid = sel->cid; }
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0.0f, 5.0f * dp));
|
||||
ImGui::PopID();
|
||||
prevMin = minute; prevDir = static_cast<int>(outgoing);
|
||||
}
|
||||
if (s_scroll_to_cid == s_selected_cid) {
|
||||
ImGui::SetScrollHereY(1.0f);
|
||||
s_scroll_to_cid.clear();
|
||||
}
|
||||
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
|
||||
}
|
||||
ImGui::EndChild();
|
||||
// Jump-to-latest pill when scrolled up (Q9).
|
||||
if (!atBottom) {
|
||||
const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
|
||||
ImGui::SetCursorScreenPos(pill);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||
if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid;
|
||||
ImGui::PopStyleColor(2);
|
||||
}
|
||||
|
||||
// Composer footer: message input + send (only once we know the peer's key), else a hint.
|
||||
ImGui::Separator();
|
||||
@@ -354,24 +538,44 @@ void RenderChatTab(App* app)
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
} else {
|
||||
// Multi-line composer (Q6): Enter sends, Ctrl+Enter inserts a newline. A live byte counter
|
||||
// tracks the effective on-chain body cap = (512 − len("utf8:"))/2 − secretstream ABYTES.
|
||||
static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp)
|
||||
const float sendW = 80.0f * Layout::dpiScale(); // scaled so the translated label never clips (B3)
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x);
|
||||
bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
const float inputW = ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x;
|
||||
const int used = static_cast<int>(std::strlen(s_compose));
|
||||
const bool overCap = used > kBodyMaxBytes;
|
||||
bool submit = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose),
|
||||
ImVec2(inputW, composerBoxH),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine);
|
||||
ImGui::SameLine();
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
|
||||
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, 0.0f))) submit = true;
|
||||
ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
|
||||
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleColor(3);
|
||||
if (submit && s_compose[0] != '\0') {
|
||||
// Byte counter (Error tint over cap) with the over-cap label.
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium());
|
||||
if (overCap) { ImGui::TextUnformatted(TR("chat_len_over")); ImGui::SameLine(); }
|
||||
const std::string counter = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
|
||||
const float cw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counter.c_str()).x;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
|
||||
std::max(0.0f, ImGui::GetContentRegionAvail().x - cw));
|
||||
ImGui::TextUnformatted(counter.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
if (submit && s_compose[0] != '\0' && !overCap) {
|
||||
app->sendChatMessage(sel->cid, s_compose);
|
||||
s_compose[0] = '\0';
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_scroll_to_cid = sel->cid;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
centeredHint(convs.empty() ? TR("chat_empty_hint") : TR("chat_select_hint"));
|
||||
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_hint"));
|
||||
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
@@ -236,6 +236,20 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
||||
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
|
||||
strings_["chat_toast_request_queued"] = "Contact request queued.";
|
||||
strings_["chat_time_now"] = "now";
|
||||
strings_["chat_retry"] = "Retry";
|
||||
strings_["chat_jump_latest"] = "Latest";
|
||||
strings_["chat_empty_title"] = "No conversations yet";
|
||||
strings_["chat_empty_hint"] = "Start one with \"New conversation\".";
|
||||
strings_["chat_search"] = "Search conversations";
|
||||
strings_["chat_no_matches"] = "No conversations match your search.";
|
||||
strings_["chat_export"] = "Export chat\xE2\x80\xA6";
|
||||
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
|
||||
strings_["chat_export_done"] = "Conversation exported";
|
||||
strings_["chat_export_failed"] = "Could not write the export file.";
|
||||
strings_["chat_len_over"] = "Message too long";
|
||||
strings_["chat_mute"] = "Mute";
|
||||
strings_["chat_unmute"] = "Unmute";
|
||||
// Seed-phrase backup (full-node)
|
||||
strings_["seed_backup_button"] = "Seed phrase";
|
||||
strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase";
|
||||
|
||||
Reference in New Issue
Block a user