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:
2026-07-16 16:41:00 -05:00
parent 7f46d9e2d5
commit bce69362eb
2 changed files with 254 additions and 36 deletions

View File

@@ -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();

View File

@@ -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";