From bce69362eb16a4f86450feae6b4257c607adc8dc Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 16 Jul 2026 16:41:00 -0500 Subject: [PATCH] feat(chat): message bubbles, empty states, search, export, richer composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/windows/chat_tab.cpp | 276 +++++++++++++++++++++++++++++++----- src/util/i18n.cpp | 14 ++ 2 files changed, 254 insertions(+), 36 deletions(-) diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index f110b7b..a4def1b 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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_memzero — wipe typed plaintext on a wallet switch #include +#include #include #include +#include #include #include #include @@ -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(a)) == std::tolower(static_cast(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::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(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(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(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(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(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(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(); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 50fafe5..fdd7f60 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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";