feat(chat): message-list rework, customization surface, smooth scroll

Chat tab overhaul:
- Header/layout: single-row header (name + key-lock + compact click-to-copy address +
  right-aligned icon toolbar with a settings "notch"), composer moved below the message
  box (emoji toggle left of a bottom-flush input), tightened list-pane controls.
- Message list: per-day date separators (Today/Yesterday/date), time-only group headers,
  tight same-sender grouping with iMessage-style merged corners, per-run peer avatar,
  delivery status (clock -> check), hover-reveal per-message time. Message base ~18px.
- Customization: a settings "notch" gear opens a modal (also under Settings ->
  Chat & Contacts) with segmented controls for emoji style / bubble style / density /
  timestamps, sliders for poll rate + text size, a bubble-accent dropdown, Enter-to-send.
  Bubble style/accent/density/text-size/timestamps all applied live in the message loop.
- Settings: app-wide clock-format control lives in Settings -> General; chat keeps a
  per-tab override. Both chat panes now use the app's smooth wheel-scroll.
- i18n: new strings across all 8 languages; CJK subset rebuilt for the added glyphs.

Inline contact rename, hide/mute, 0-conf fast-scan and the export path are carried through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:45:19 -05:00
parent 3b5db02e09
commit 2c909d35ea
13 changed files with 1060 additions and 212 deletions

View File

@@ -12,6 +12,7 @@
#include <sodium.h>
#include "../../util/logger.h"
#include "../windows/balance_tab.h"
#include "../windows/chat_tab.h" // RenderChatSettingsControls (shared Chat & Contacts controls)
#include "../windows/console_tab.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
@@ -821,6 +822,25 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) ---
{
ImGui::PushFont(body2);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("clock_format"));
ImGui::SameLine(0, Layout::spacingMd());
int cf = app->settings() ? app->settings()->getTimeFormat() : 0;
const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") };
ImGui::SetNextItemWidth(160.0f * Layout::dpiScale());
if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) {
app->settings()->setTimeFormat(cf);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Font Scale slider (always visible) ---
{
ImGui::PushFont(body2);
@@ -1086,6 +1106,25 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) ---
{
ImGui::PushFont(body2);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("clock_format"));
ImGui::SameLine(0, Layout::spacingMd());
int cf = app->settings() ? app->settings()->getTimeFormat() : 0;
const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") };
ImGui::SetNextItemWidth(160.0f * Layout::dpiScale());
if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) {
app->settings()->setTimeFormat(cf);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Font Scale slider (always visible) ---
{
ImGui::PushFont(body2);
@@ -2445,6 +2484,21 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// CHAT & CONTACTS — card (same controls as the Chat tab's settings notch)
// ====================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("chat_settings_section"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
ImGui::PushFont(body2);
RenderChatSettingsControls(app, availWidth - pad * 2.0f); // card inner width (GlassCard doesn't narrow it)
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// ABOUT — card
// ====================================================================

View File

@@ -52,6 +52,20 @@ bool s_show_hidden = false; // when on, the list also shows hidden conversatio
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search[48] = ""; // emoji picker keyword filter
// Inline contact rename (Tier C): the conversation whose header name is being edited (empty = none),
// the edit buffer, and a one-shot flag to grab keyboard focus the frame the field appears.
std::string s_rename_cid;
char s_rename_buf[128] = ""; // matches the Contacts tab's label editor capacity (no silent truncation)
bool s_rename_focus = false;
// Chat customization modal (opened by the header settings "notch"). The same controls also render in
// Settings → Chat & Contacts via RenderChatSettingsControls().
bool s_show_chat_settings = false;
// Resolved once per frame from the chat/global timestamp settings — read by formatTime() (which has no
// App access). true => 12-hour clock in the Chat tab.
bool s_chat_time_12h = false;
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
@@ -75,8 +89,42 @@ std::string formatTime(std::int64_t ts) {
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t); // UI thread only
if (!tm) return "";
char buf[40];
std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%Y-%m-%d %I:%M %p" : "%Y-%m-%d %H:%M", tm);
return buf;
}
// Time-of-day only (no date), honoring the chat clock. Used on grouped meta lines + hover (the date
// now lives in a per-day separator, so the group header doesn't repeat it). UI thread only.
std::string chatTimeOnly(std::int64_t ts) {
if (ts <= 0) return "";
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return "";
char buf[24];
std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%I:%M %p" : "%H:%M", tm);
return buf;
}
// Local calendar-day key (YYYYMMDD) for detecting day boundaries between messages. UI thread only.
std::int64_t chatDayKey(std::int64_t ts) {
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return -1;
return static_cast<std::int64_t>(tm->tm_year + 1900) * 10000 +
static_cast<std::int64_t>(tm->tm_mon + 1) * 100 + tm->tm_mday;
}
// Date-separator label: "Today" / "Yesterday" / "Jul 16, 2026". UI thread only.
std::string chatDaySeparator(std::int64_t ts, std::int64_t todayKey, std::int64_t yestKey) {
const std::int64_t k = chatDayKey(ts);
if (k == todayKey) return TR("chat_today");
if (k == yestKey) return TR("chat_yesterday");
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return "";
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", tm);
std::strftime(buf, sizeof(buf), "%b %d, %Y", tm);
return buf;
}
@@ -117,6 +165,18 @@ ImU32 avatarColor(const std::string& seed) {
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor(int accent) {
switch (accent) {
case 1: return IM_COL32(0x42, 0x85, 0xF4, 255); // blue
case 2: return IM_COL32(0x34, 0xA8, 0x53, 255); // green
case 3: return IM_COL32(0x9C, 0x27, 0xB0, 255); // purple
case 4: return IM_COL32(0xFF, 0xA7, 0x26, 255); // amber
case 5: return IM_COL32(0xEC, 0x40, 0x7A, 255); // pink
default: return material::Primary(); // theme
}
}
// Uppercase first glyph of a display name (UTF-8 aware) for a letter-avatar.
std::string initialOf(const std::string& name) {
std::size_t i = 0;
@@ -313,6 +373,12 @@ void RenderChatTab(App* app)
return;
}
// Resolve the Chat-tab clock once per frame: the chat override wins, else the global format.
if (auto* cst = app->settings()) {
const int ctf = cst->getChatTimeFormat(); // 0=follow global, 1=24h, 2=12h
s_chat_time_12h = (ctf == 2) || (ctf == 0 && cst->getTimeFormat() == 1);
}
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std::vector<ConvSummary> convs;
int hiddenCount = 0;
@@ -354,6 +420,13 @@ void RenderChatTab(App* app)
sodium_memzero(s_compose, sizeof(s_compose));
s_compose_cid = s_selected_cid;
}
// Abandon an in-progress header rename tied to a different conversation (switch/hide), so a
// half-typed buffer can't silently reappear or land on the wrong contact (Tier C review).
if (!s_rename_cid.empty() && s_rename_cid != s_selected_cid) {
s_rename_cid.clear();
s_rename_buf[0] = '\0';
s_rename_focus = false;
}
const ImVec2 avail = ImGui::GetContentRegionAvail();
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
@@ -371,39 +444,49 @@ void RenderChatTab(App* app)
// ---- 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);
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), ImGuiChildFlags_Borders,
ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app
if (s_show_emoji_picker) {
// Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid).
renderEmojiPickerOverlay(s_compose, sizeof(s_compose));
} else {
// Accented "New conversation" action (house tactile style).
// "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when
// something is hidden; otherwise New chat spans the row.
const float ldp = Layout::dpiScale();
const bool hasHidden = hiddenCount > 0;
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)));
const bool newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f));
ImGui::PopStyleColor(3);
bool newClicked = false;
if (hasHidden) {
const std::string hlShow = std::string(TR("chat_show_hidden")) + " (" + std::to_string(hiddenCount) + ")";
const std::string hlHide = TR("chat_hide_hidden");
const std::string hl = s_show_hidden ? hlHide : hlShow;
// Size the toggle to the WIDER of its two labels so it (and New chat) don't resize on click.
const float hbw = std::max(ImGui::CalcTextSize(hlShow.c_str()).x,
ImGui::CalcTextSize(hlHide.c_str()).x) + 16.0f * ldp;
const float newW = std::max(80.0f * ldp,
ImGui::GetContentRegionAvail().x - hbw - ImGui::GetStyle().ItemSpacing.x);
newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(newW, 0.0f));
ImGui::PopStyleColor(3);
ImGui::SameLine();
if (material::TactileButton(hl.c_str(), ImVec2(hbw, 0.0f))) s_show_hidden = !s_show_hidden;
} else {
s_show_hidden = false; // nothing hidden → keep the toggle off
newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f));
ImGui::PopStyleColor(3);
}
if (newClicked) {
s_show_new_convo = true;
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) {
// Search filter (Q8) — always available (not gated by conversation count or Show hidden).
if (!convs.empty() || hasHidden) {
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
}
// Show-hidden toggle — only when there are hidden conversations. Reveals them in the list (with
// an Unhide action in the thread header).
if (hiddenCount > 0) {
ImGui::PushFont(metaFont);
const std::string lbl = s_show_hidden
? std::string(TR("chat_hide_hidden"))
: std::string(TR("chat_show_hidden")) + " (" + std::to_string(hiddenCount) + ")";
if (ImGui::SmallButton(lbl.c_str())) s_show_hidden = !s_show_hidden;
ImGui::PopFont();
} else {
s_show_hidden = false; // nothing hidden → keep the toggle off
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
@@ -489,47 +572,117 @@ void RenderChatTab(App* app)
ImGui::SameLine(0.0f, 10.0f * Layout::dpiScale()); // breathing gutter between the list and thread panes
// ---- Right: selected conversation thread ----
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true);
{
const ConvSummary* sel = nullptr;
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
// Resolve the selection up front so we can reserve room for the composer BELOW the bordered message
// box — the input sits outside/below the box now (no divider above it).
const ConvSummary* sel = nullptr;
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
const float tdp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
// Reserve a stable composer strip below the box whenever a conversation is open (thin counter row +
// the input row, which sits flush with the strip/window bottom). Same height for the waiting hint so
// the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + metaSz + 12.0f * tdp) : 0.0f;
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y - composerAreaH), true);
{
if (sel) {
app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1)
// ── Header (V7 / Tier A+B): avatar + name on the left, a right-aligned icon toolbar,
// then a click-to-copy address with a key-verify lock (or a "waiting for reply" chip).
// ── Header (single row): avatar + name + key-lock + compact address (left), a right-aligned
// icon toolbar (right). The address rides the name line to keep the header to one row.
const float hdpi = Layout::dpiScale();
ImGui::PushFont(material::Type().subtitle1());
const float nameH = ImGui::GetTextLineHeight();
const float ib = nameH + 8.0f * hdpi; // icon-button square == header row height
const float gap = 3.0f * hdpi;
const float rowH = ib;
const float avR = nameH * 0.62f;
const ImVec2 hp = ImGui::GetCursorScreenPos();
ImFont* subFont = material::Type().subtitle1();
const float subSz = scaledSize(subFont);
const float nameH = subSz; // subtitle1 line height
const float ib = nameH + 8.0f * hdpi; // icon-button square == header row height
const float gap = 3.0f * hdpi;
const float rowH = ib;
const float avR = nameH * 0.62f;
const ImVec2 hp = ImGui::GetCursorScreenPos();
const float rightX = hp.x + ImGui::GetContentRegionAvail().x;
const float textX = hp.x + 2.0f * avR + 10.0f * hdpi;
ImDrawList* hdl = ImGui::GetWindowDrawList();
const ImVec2 avC(hp.x + avR, hp.y + rowH * 0.5f); // avatar centered in the row
// Avatar (centered in the row).
const ImVec2 avC(hp.x + avR, hp.y + rowH * 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),
const ImVec2 isz = subFont->CalcTextSizeA(subSz, FLT_MAX, 0.0f, init.c_str());
hdl->AddText(subFont, subSz, ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
IM_COL32(255, 255, 255, 235), init.c_str());
}
const bool hasAddr = !sel->peerZaddr.empty();
const bool known = hasAddr && book.findByAddress(sel->peerZaddr) >= 0;
const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid);
// The toolbar's left edge is known up front (from the button count), so the name can be
// clipped to it — a long contact label can't overrun the icons (mirrors the list-pane clip).
const int nBtns = 3 + ((hasAddr && !known) ? 1 : 0);
const bool hasAddr = !sel->peerZaddr.empty();
const int bookIdx = hasAddr ? book.findByAddress(sel->peerZaddr) : -1;
const bool known = bookIdx >= 0;
const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid);
const bool renaming = (s_rename_cid == sel->cid);
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
// shown whenever there's an address to save the contact under; the settings "notch" gear is
// always the rightmost icon.
const int nBtns = 4 + (hasAddr ? 1 : 0);
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
ImGui::SetCursorScreenPos(ImVec2(textX, hp.y + (rowH - nameH) * 0.5f)); // name centered in row
ImGui::PushClipRect(ImVec2(textX, hp.y), ImVec2(toolbarLeft - gap, hp.y + rowH), true);
ImGui::TextUnformatted(sel->peerName.c_str());
ImGui::PopClipRect();
ImGui::PopFont();
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
const bool showLock = hasAddr && !sel->peerPubKey.empty();
const bool showWait = hasAddr && sel->peerPubKey.empty();
const std::string sa = hasAddr ? shorten(sel->peerZaddr, 10, 6) : std::string();
ImFont* icoFont = material::Type().iconSmall();
const float icoSz = scaledSize(icoFont);
const float addrTextW = hasAddr ? metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, sa.c_str()).x : 0.0f;
const float lockW = showLock ? icoFont->CalcTextSizeA(icoSz, FLT_MAX, 0.0f, ICON_MD_LOCK).x + 5.0f * hdpi : 0.0f;
const char* wl = showWait ? TR("chat_awaiting_key") : "";
const float waitTextW = showWait ? metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, wl).x : 0.0f;
const float waitChipW = showWait ? waitTextW + 14.0f * hdpi + 8.0f * hdpi : 0.0f; // chip pad + leading gap
const float addrGroupW = lockW + addrTextW + waitChipW;
const float nameSpacing = 12.0f * hdpi;
const float nameCY = hp.y + (rowH - nameH) * 0.5f;
const float nameW = subFont->CalcTextSizeA(subSz, FLT_MAX, 0.0f, sel->peerName.c_str()).x;
const float nameAvail = std::max(40.0f * hdpi,
toolbarLeft - gap - textX - (hasAddr ? addrGroupW + nameSpacing : 0.0f));
// Name — or an inline rename field (Tier C) that takes the whole name+address span.
if (renaming) {
const float nameMaxW = std::max(60.0f * hdpi, toolbarLeft - gap - textX);
ImGui::SetCursorScreenPos(ImVec2(textX, nameCY));
ImGui::PushFont(subFont);
ImGui::SetNextItemWidth(nameMaxW);
if (s_rename_focus) { ImGui::SetKeyboardFocusHere(); s_rename_focus = false; }
const bool commit = ImGui::InputTextWithHint("##renamepeer", TR("chat_rename_hint"),
s_rename_buf, sizeof(s_rename_buf), ImGuiInputTextFlags_EnterReturnsTrue);
const bool cancel = ImGui::IsItemDeactivated() && !commit; // Escape or click-away
ImGui::PopFont();
if (commit) {
std::string nm = s_rename_buf;
while (!nm.empty() && std::isspace(static_cast<unsigned char>(nm.front()))) nm.erase(nm.begin());
while (!nm.empty() && std::isspace(static_cast<unsigned char>(nm.back()))) nm.pop_back();
if (!nm.empty() && hasAddr) {
if (bookIdx >= 0) {
if (nm != book.entries()[bookIdx].label) { // skip a no-op rewrite/save
data::AddressBookEntry e = book.entries()[bookIdx];
e.label = nm; // keep address/notes/scope/avatar
book.updateEntry(static_cast<std::size_t>(bookIdx), e);
book.save();
Notifications::instance().success(TR("chat_renamed"));
}
} else if (book.addEntry(data::AddressBookEntry(nm, sel->peerZaddr))) {
book.save();
Notifications::instance().success(TR("chat_contact_added"));
} else {
Notifications::instance().error(TR("address_book_exists"));
}
}
s_rename_cid.clear();
} else if (cancel) {
s_rename_cid.clear();
}
} else {
ImGui::PushClipRect(ImVec2(textX, hp.y), ImVec2(textX + nameAvail, hp.y + rowH), true);
hdl->AddText(subFont, subSz, ImVec2(textX, nameCY), material::OnSurface(), sel->peerName.c_str());
ImGui::PopClipRect();
}
// Right-aligned icon toolbar: [add contact?] · export · mute · hide. Each is a frameless
// IconButton with a hover pill + tooltip (the tooltip carries the wording the old text buttons had).
@@ -544,14 +697,22 @@ void RenderChatTab(App* app)
float bx = toolbarLeft;
const float by = hp.y;
// Add to contacts — only when the peer isn't in the address book yet (Q2).
if (hasAddr && !known) {
// Rename (edit) the contact inline in the header. For a known peer it updates the label;
// for an unknown one it creates the contact under the typed name (folds in the old
// "add contact" quick action, but lets you name them) (Q2 / Tier C).
if (hasAddr) {
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base; a.tooltip = TR("chat_add_contact");
if (material::IconButton("##hdr_add", ICON_MD_PERSON_ADD, ifont, ImVec2(ib, ib), a)) {
data::AddressBookEntry e(sel->peerName, sel->peerZaddr); // label = shortened addr; rename in Contacts
if (book.addEntry(e)) Notifications::instance().success(TR("chat_contact_added"));
else Notifications::instance().error(TR("address_book_exists"));
material::IconButtonStyle a = base;
a.tooltip = known ? TR("chat_rename") : TR("chat_add_contact");
if (known) { a.color = material::OnSurfaceMedium(); }
if (material::IconButton("##hdr_rename",
known ? ICON_MD_EDIT : ICON_MD_PERSON_ADD, ifont, ImVec2(ib, ib), a)) {
s_rename_cid = sel->cid;
const std::string cur = known ? book.entries()[bookIdx].label : std::string();
const std::size_t n = std::min(cur.size(), sizeof(s_rename_buf) - 1);
std::memcpy(s_rename_buf, cur.data(), n);
s_rename_buf[n] = '\0';
s_rename_focus = true;
}
bx += ib + gap;
}
@@ -614,74 +775,101 @@ void RenderChatTab(App* app)
Notifications::instance().info(TR("chat_hidden_toast"));
}
}
bx += ib + gap;
}
// Settings "notch" — the rightmost icon, on a faint pill so it reads as a distinct
// corner tab. Opens the chat-customization modal.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
a.tooltip = TR("chat_settings_tip");
a.restBg = material::WithAlpha(material::OnSurface(), 22); // the notch backing
if (material::IconButton("##hdr_settings", ICON_MD_SETTINGS, ifont, ImVec2(ib, ib), a))
s_show_chat_settings = true;
}
}
// Row 2: a key-verify lock (Tier B) + click-to-copy address (Q3), or a "waiting for reply" chip.
ImGui::SetCursorScreenPos(ImVec2(textX, hp.y + rowH + 3.0f * hdpi));
if (hasAddr) {
// Lock glyph → we hold the peer's identity key; the tooltip shows a comparable fingerprint.
if (!sel->peerPubKey.empty()) {
ImGui::PushFont(material::Type().iconSmall());
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::Primary(), 220));
ImGui::TextUnformatted(ICON_MD_LOCK);
ImGui::PopStyleColor();
ImGui::PopFont();
if (ImGui::IsItemHovered())
// Compact address + key-lock (or waiting chip) placed right after the (capped) name, on the
// same row. Hidden while renaming (the field takes the whole span).
if (hasAddr && !renaming) {
float ax = textX + std::min(nameW, nameAvail) + nameSpacing;
// Key-verify lock — we hold the peer's identity key; hover shows a comparable fingerprint.
if (showLock) {
const float ly = hp.y + (rowH - icoSz) * 0.5f;
hdl->AddText(icoFont, icoSz, ImVec2(ax, ly),
material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK);
if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz)))
material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str());
ImGui::SameLine(0.0f, 5.0f * hdpi);
ax += lockW;
}
// Click-to-copy shortened address (replaces the old "Copy Full Address" button).
ImGui::PushFont(metaFont);
const std::string sa = shorten(sel->peerZaddr, 20, 12);
const ImVec2 tp = ImGui::GetCursorScreenPos();
const ImVec2 tsz = ImGui::CalcTextSize(sa.c_str());
const bool addrClicked = ImGui::InvisibleButton("##hdr_copyaddr", tsz);
const bool addrHov = ImGui::IsItemHovered();
ImGui::GetWindowDrawList()->AddText(
tp, addrHov ? material::OnSurface() : material::OnSurfaceMedium(), sa.c_str());
if (addrHov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("chat_copy_address_tip"));
}
if (addrClicked) {
ImGui::SetClipboardText(sel->peerZaddr.c_str());
Notifications::instance().success(TR("copied"));
}
// Waiting-for-reply chip when the peer's identity key isn't known yet (Tier B).
if (sel->peerPubKey.empty()) {
ImGui::SameLine(0.0f, 8.0f * hdpi);
const char* wl = TR("chat_awaiting_key");
const ImVec2 wsz = ImGui::CalcTextSize(wl);
const float px = 7.0f * hdpi, py = 2.0f * hdpi;
const ImVec2 cp = ImGui::GetCursorScreenPos();
const ImVec2 pmax(cp.x + wsz.x + 2.0f * px, cp.y + wsz.y + 2.0f * py);
ImDrawList* cdl = ImGui::GetWindowDrawList();
cdl->AddRectFilled(cp, pmax, material::WithAlpha(material::Primary(), 38),
(wsz.y + 2.0f * py) * 0.5f);
cdl->AddText(ImVec2(cp.x + px, cp.y + py),
// Click-to-copy compact address.
const float ay = hp.y + (rowH - metaSz) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(ax, ay));
const bool addrClicked = ImGui::InvisibleButton("##hdr_copyaddr", ImVec2(std::max(1.0f, addrTextW), metaSz));
const bool addrHov = ImGui::IsItemHovered();
hdl->AddText(metaFont, metaSz, ImVec2(ax, ay),
addrHov ? material::OnSurface() : material::OnSurfaceMedium(), sa.c_str());
if (addrHov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("chat_copy_address_tip")); }
if (addrClicked) { ImGui::SetClipboardText(sel->peerZaddr.c_str()); Notifications::instance().success(TR("copied")); }
ax += addrTextW;
// Waiting-for-reply chip when the peer's identity key isn't known yet.
if (showWait) {
const float lead = 8.0f * hdpi, px = 7.0f * hdpi, py = 2.0f * hdpi;
const ImVec2 cp(ax + lead, hp.y + (rowH - (metaSz + 2.0f * py)) * 0.5f);
const ImVec2 pmax(cp.x + waitTextW + 2.0f * px, cp.y + metaSz + 2.0f * py);
hdl->AddRectFilled(cp, pmax, material::WithAlpha(material::Primary(), 38), (metaSz + 2.0f * py) * 0.5f);
hdl->AddText(metaFont, metaSz, ImVec2(cp.x + px, cp.y + py),
material::WithAlpha(material::Primary(), 230), wl);
ImGui::Dummy(ImVec2(wsz.x + 2.0f * px, wsz.y + 2.0f * py));
}
ImGui::PopFont();
} else {
ImGui::Dummy(ImVec2(0.0f, metaSz)); // keep the header height stable when there's no address
}
// Advance the layout cursor below the single header row, then divide it from the messages.
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
ImGui::Separator();
const float dp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
// Footer = composer box + the emoji-toggle/counter row (30dp tall button) + padding.
const float footerH = composerBoxH + 30.0f * dp + 20.0f * dp;
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false);
// Inset the message list from the pane edges so bubbles don't hug the border/scrollbar (8px).
// Zero the VERTICAL item spacing: the message loop reserves its own gaps via Dummy() (tight
// within a run so the grouped/merged corners read right), so the theme's 6px would double them.
const float origSpacingX = ImGui::GetStyle().ItemSpacing.x;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f * dp, 8.0f * dp));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(origSpacingX, 0.0f));
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
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;
// Bubble style / accent / density / text-size from chat customization settings.
auto* cs = app->settings();
const int bubbleStyle = cs ? cs->getChatBubbleStyle() : 0; // 0 rounded, 1 square, 2 minimal
const bool compact = cs && cs->getChatDensity() == 1;
const float fscale = cs ? cs->getChatFontScale() : 1.0f;
// Message bodies read at ~18px by default (bigger than the 15px body2 used in the list),
// then the Text-size slider (fscale) multiplies it. nameSz is body2's draw size, so
// nameSz / kFontBody2() is the DPI/font-scale factor; ×18 gives an 18px base. ImGui 1.92
// rebakes the font at the requested size, so the larger text stays crisp.
const float bodySz = nameSz * (18.0f / std::max(1.0f, Layout::kFontBody2())) * fscale;
const float bpad = (compact ? 6.0f : 9.0f) * dp;
const float bround = (bubbleStyle == 1 ? 2.0f : 9.0f) * dp; // square vs rounded/minimal
const int outFill = (bubbleStyle == 2 ? 26 : 46); // minimal = fainter fill
const int inFill = (bubbleStyle == 2 ? 14 : 22);
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
const auto messages = store.conversation(s_selected_cid);
std::int64_t prevMin = -1; int prevDir = -1;
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
// one meta header and stack tightly; a date pill is drawn once per calendar day.
const std::int64_t nowTs = static_cast<std::int64_t>(std::time(nullptr));
const std::int64_t todayKey = chatDayKey(nowTs);
const std::int64_t yestKey = chatDayKey(nowTs - 86400);
constexpr std::int64_t kGroupWindow = 300; // 5 minutes
const float avR = 11.0f * dp; // in-thread peer avatar radius
const float gutter = 2.0f * avR + 6.0f * dp; // incoming bubbles indent past the avatar
ImFont* icf = material::Type().iconSmall();
const float icfSz = scaledSize(icf);
std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1;
for (std::size_t mi = 0; mi < messages.size(); ++mi) {
const auto& m = messages[mi];
ImGui::PushID(static_cast<int>(mi));
@@ -689,49 +877,112 @@ void RenderChatTab(App* app)
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending;
const std::int64_t minute = m.timestamp / 60;
const bool startGroup = (prevDir != static_cast<int>(outgoing)) || (minute != prevMin);
const std::int64_t day = chatDayKey(m.timestamp);
const bool newDay = (day != prevDay);
const bool startGroup = newDay || (prevDir != static_cast<int>(outgoing)) ||
(prevTs < 0) || (m.timestamp - prevTs > kGroupWindow);
// Does this bubble end its run? (for grouped-corner shaping + the avatar placement)
bool lastInGroup = true;
if (mi + 1 < messages.size()) {
const auto& n = messages[mi + 1];
const bool nOut = (n.direction == chat::ChatDirection::Outgoing);
lastInGroup = (chatDayKey(n.timestamp) != day) || (nOut != outgoing) ||
(n.timestamp - m.timestamp > kGroupWindow);
}
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.
// ── Date separator (once per calendar day): a centered pill.
if (newDay) {
if (mi > 0) ImGui::Dummy(ImVec2(0.0f, groupGap + 4.0f * dp));
const std::string ds = chatDaySeparator(m.timestamp, todayKey, yestKey);
const ImVec2 dsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, ds.c_str());
const float dpx = 9.0f * dp, dpy = 2.0f * dp;
const ImVec2 dcur = ImGui::GetCursorScreenPos();
const float cxs = dcur.x + std::max(0.0f, (availW - dsz.x) * 0.5f);
dl->AddRectFilled(ImVec2(cxs - dpx, dcur.y), ImVec2(cxs + dsz.x + dpx, dcur.y + dsz.y + 2.0f * dpy),
material::WithAlpha(material::OnSurface(), 24), (dsz.y + 2.0f * dpy) * 0.5f);
dl->AddText(metaFont, metaSz, ImVec2(cxs, dcur.y + dpy), material::OnSurfaceMedium(), ds.c_str());
ImGui::Dummy(ImVec2(availW, dsz.y + 2.0f * dpy + 4.0f * dp));
}
// ── Grouped meta line (sender + time-only), once per run, on the sender's side.
if (startGroup) {
if (mi > 0 && !newDay) ImGui::Dummy(ImVec2(0.0f, groupGap));
std::string meta = outgoing ? std::string(TR("chat_you")) : sel->peerName;
const std::string when = formatTime(m.timestamp);
const std::string when = chatTimeOnly(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),
dl->AddText(metaFont, metaSz,
ImVec2(outgoing ? mp.x + availW - msz.x : mp.x + gutter, 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());
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
const ImVec2 tsz = nameFont->CalcTextSizeA(bodySz, 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 float bx = outgoing ? (cur.x + availW - bw) : (cur.x + gutter);
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(),
: outgoing ? material::WithAlpha(accentBase, outFill)
: material::WithAlpha(material::OnSurface(), inFill);
// Flatten the corner between stacked bubbles from the same sender (iMessage-style).
ImDrawFlags rf = ImDrawFlags_RoundCornersAll;
if (bubbleStyle != 1) { // shape only rounded/minimal; square keeps its uniform corners
if (outgoing) {
if (!startGroup) rf &= ~ImDrawFlags_RoundCornersTopRight;
if (!lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomRight;
} else {
if (!startGroup) rf &= ~ImDrawFlags_RoundCornersTopLeft;
if (!lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomLeft;
}
}
dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
m.body.c_str(), nullptr, innerW);
// Peer avatar beside the LAST incoming bubble of a run (Tier 3).
if (!outgoing && lastInGroup) {
const ImVec2 avc(cur.x + avR, cur.y + bh - avR);
dl->AddCircleFilled(avc, avR, avatarColor(sel->cid), 20);
const std::string init = initialOf(sel->peerName);
const ImVec2 isz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, init.c_str());
dl->AddText(metaFont, metaSz, ImVec2(avc.x - isz.x * 0.5f, avc.y - isz.y * 0.5f),
IM_COL32(255, 255, 255, 235), init.c_str());
}
ImGui::Dummy(ImVec2(availW, bh));
// Hover: reveal this message's own time at the outer bubble edge + right-click copy.
if (ImGui::IsMouseHoveringRect(bmin, bmax)) {
material::Tooltip("%s", formatTime(m.timestamp).c_str());
const std::string ht = chatTimeOnly(m.timestamp);
const ImVec2 hsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, ht.c_str());
const float hx = outgoing ? (bmin.x - hsz.x - 6.0f * dp) : (bmax.x + 6.0f * dp);
dl->AddText(metaFont, metaSz, ImVec2(hx, bmin.y + (bh - hsz.y) * 0.5f),
material::WithAlpha(material::OnSurface(), 120), ht.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();
}
// Failed send → right-aligned "not sent" + Retry (Q7).
// Delivery status for your messages: clock while sending, check once broadcast (Tier 2).
if (outgoing && !failed && (sending || lastInGroup)) {
const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
const float gs = icfSz * 0.82f;
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, glyph);
dl->AddText(icf, gs, ImVec2(bmax.x - gsz.x, bmax.y + 1.0f * dp),
material::WithAlpha(material::OnSurface(), sending ? 95 : 135), glyph);
ImGui::Dummy(ImVec2(0.0f, gs + 2.0f * dp)); // reserve the status glyph (no ItemSpacing now)
}
// Failed send → right-aligned "not sent" + Retry (kept — it's actionable).
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;
@@ -750,18 +1001,9 @@ void RenderChatTab(App* app)
s_scroll_to_cid = sel->cid;
}
}
// In-flight send → subtle right-aligned "sending…"; the op callback resolves it.
if (sending) {
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw));
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sending"));
ImGui::PopStyleColor(); ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0.0f, 5.0f * dp));
ImGui::Dummy(ImVec2(0.0f, lastInGroup ? msgGap : msgGap * 0.5f)); // tight within a run
ImGui::PopID();
prevMin = minute; prevDir = static_cast<int>(outgoing);
prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day;
}
if (s_scroll_to_cid == s_selected_cid) {
ImGui::SetScrollHereY(1.0f);
@@ -770,6 +1012,7 @@ void RenderChatTab(App* app)
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
// the child's bottom edge; save + restore it so the composer footer below stays anchored
// (otherwise it renders ~8px too high only while the pill is shown).
@@ -783,71 +1026,7 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor(2);
ImGui::SetCursorScreenPos(savedCursor);
}
// Composer footer: message input + send (only once we know the peer's key), else a hint.
ImGui::Separator();
if (sel->peerPubKey.empty()) {
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_waiting_reply"));
ImGui::PopTextWrapPos();
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)
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)));
ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
// Emoji picker button (left) + byte counter (right) on one row. The picker opens as an
// overlay over the conversation-list pane (rendered there), not a floating window. The
// toggle is a larger frameless button (Tier A) that lights up while the picker is open.
const float emojiBtn = 30.0f * dp;
{
material::IconButtonStyle es;
es.hoverBg = material::WithAlpha(material::OnSurface(), 30);
es.bgRounding = 8.0f * dp;
if (s_show_emoji_picker) es.restBg = material::WithAlpha(material::Primary(), 46);
if (material::IconButton("##emojitoggle", u8"🙂", material::Type().subtitle1(),
ImVec2(emojiBtn, emojiBtn), es)) {
s_show_emoji_picker = !s_show_emoji_picker;
s_emoji_search[0] = '\0';
}
}
ImGui::SameLine();
// Byte counter (Error tint over cap) with the over-cap label — vertically centered on the button.
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium());
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
std::max(0.0f, (emojiBtn - ImGui::GetTextLineHeight()) * 0.5f));
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);
sodium_memzero(s_compose, sizeof(s_compose));
s_scroll_to_cid = sel->cid;
}
}
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
} else {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
@@ -855,6 +1034,92 @@ void RenderChatTab(App* app)
}
ImGui::EndChild();
// ── Composer — OUTSIDE / below the bordered message box, spanning its width. No divider above it.
// Layout (all placed by absolute screen pos so nothing wraps to the window's left edge): a thin
// byte-counter row just under the box, then the input row flush with the strip/window bottom, with
// the emoji toggle on the LEFT of the input and Send on the right.
if (sel) {
const ImVec2 boxMin = ImGui::GetItemRectMin(); // the ##ChatThread child's rect
const ImVec2 boxMax = ImGui::GetItemRectMax();
const float cx = boxMin.x;
const float cw = boxMax.x - boxMin.x;
if (sel->peerPubKey.empty()) {
ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp));
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_waiting_reply"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
} else {
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). A live byte
// counter tracks the 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 * tdp; // scaled so the translated label never clips (B3)
const float emojiBtn = 28.0f * tdp;
const float inGap = 6.0f * tdp;
const int used = static_cast<int>(std::strlen(s_compose));
const bool overCap = used > kBodyMaxBytes;
// Thin counter row (right-aligned) just under the box; over-cap tints + prefixes a label.
std::string counterLine = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) counterLine = std::string(TR("chat_len_over")) + " " + counterLine;
const float cwid = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counterLine.c_str()).x;
ImGui::GetWindowDrawList()->AddText(metaFont, metaSz,
ImVec2(cx + cw - cwid, boxMax.y + 3.0f * tdp),
overCap ? material::Error() : material::OnSurfaceMedium(), counterLine.c_str());
// Input row, flush with the strip/window bottom: [emoji] [input] [Send].
const float rowY = boxMax.y + composerAreaH - composerBoxH;
const float inputX = cx + emojiBtn + inGap;
const float inputW = std::max(60.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float sendX = inputX + inputW + inGap;
// Emoji toggle on the left, vertically centered in the input row.
ImGui::SetCursorScreenPos(ImVec2(cx, rowY + (composerBoxH - emojiBtn) * 0.5f));
{
material::IconButtonStyle es;
es.hoverBg = material::WithAlpha(material::OnSurface(), 30);
es.bgRounding = 8.0f * tdp;
if (s_show_emoji_picker) es.restBg = material::WithAlpha(material::Primary(), 46);
if (material::IconButton("##emojitoggle", u8"🙂", material::Type().subtitle1(),
ImVec2(emojiBtn, emojiBtn), es)) {
s_show_emoji_picker = !s_show_emoji_picker;
s_emoji_search[0] = '\0';
}
}
// Input.
ImGui::SetCursorScreenPos(ImVec2(inputX, rowY));
// Enter-to-send (setting): on → Enter sends + Ctrl+Enter newline; off → Enter is a newline and
// the Send button is the only way to send. NOTE: without EnterReturnsTrue, InputTextMultiline
// returns true on EVERY edit (value-changed), so in off-mode the return value must NOT drive
// submit — otherwise typing one character would fire a send.
const bool enterSends = !app->settings() || app->settings()->getChatEnterSends();
const ImGuiInputTextFlags composeFlags = enterSends
? (ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine)
: ImGuiInputTextFlags_None;
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose),
ImVec2(inputW, composerBoxH), composeFlags);
bool submit = enterSends && inputReturned; // off-mode sends only via the Send button
// Send.
ImGui::SetCursorScreenPos(ImVec2(sendX, rowY));
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)));
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' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose));
s_scroll_to_cid = sel->cid;
}
}
}
// ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
if (s_show_new_convo) {
const float dp = Layout::dpiScale();
@@ -923,6 +1188,26 @@ void RenderChatTab(App* app)
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;
ov.title = TR("chat_settings_title");
ov.p_open = &s_show_chat_settings; // X / backdrop closes it
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatsettings";
if (material::BeginOverlayDialog(ov)) {
RenderChatSettingsControls(app);
ImGui::Dummy(ImVec2(0.0f, 8.0f * Layout::dpiScale()));
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_settings_done"), ImVec2(ImGui::GetContentRegionAvail().x, 0)))
s_show_chat_settings = false;
ImGui::PopStyleColor(3);
material::EndOverlayDialog();
}
}
}
void ResetChatTab()
@@ -932,10 +1217,155 @@ void ResetChatTab()
sodium_memzero(s_compose, sizeof(s_compose));
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));
sodium_memzero(s_new_msg, sizeof(s_new_msg));
sodium_memzero(s_rename_buf, sizeof(s_rename_buf));
s_selected_cid.clear();
s_scroll_to_cid.clear();
s_compose_cid.clear();
s_rename_cid.clear();
s_rename_focus = false;
s_show_new_convo = false;
s_show_chat_settings = false;
}
void RenderChatSettingsControls(App* app, float contentWidth)
{
auto* st = app ? app->settings() : nullptr;
if (!st) return;
const float dp = Layout::dpiScale();
const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably)
const float rowGap = 5.0f * dp;
// Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
// whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
// the chat modal's dialog content region is correct, so it passes 0 (auto).
const float leftX = ImGui::GetCursorPosX();
const float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
// Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
auto beginRow = [&](const char* label) {
ImGui::Dummy(ImVec2(0.0f, rowGap));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW)));
ImGui::SetNextItemWidth(ctrlW);
};
auto section = [&](const char* key) {
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
ImGui::PushFont(material::Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::Primary(), 235));
ImGui::TextUnformatted(TR(key));
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp));
};
// Segmented control (iOS-style): a rounded track with an inset pill on the selected segment.
// Returns the (possibly changed) index. Reads the label via beginRow for a right-aligned control.
auto segmented = [&](const char* label, const char* const* items, int count, int value) -> int {
beginRow(label);
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float h = ImGui::GetFrameHeight();
const float seg = ctrlW / static_cast<float>(count);
const float round = 7.0f * dp;
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(origin, ImVec2(origin.x + ctrlW, origin.y + h),
material::WithAlpha(material::OnSurface(), 20), round);
int result = value;
ImGui::PushID(label);
for (int i = 0; i < count; ++i) {
ImGui::PushID(i);
const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h);
ImGui::SetCursorScreenPos(mn);
if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i;
const bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const bool sel = (value == i);
if (sel) {
const float in = 2.0f * dp;
dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in),
material::WithAlpha(material::Primary(), 210), std::max(1.0f, round - in));
} else if (hov) {
dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 26), round);
}
const ImVec2 ts = ImGui::CalcTextSize(items[i]);
// Center when the label fits; otherwise left-align with a small pad so the START stays
// readable (a long translation clipped on the right, not clipped on BOTH sides).
const float lpad = 4.0f * dp;
const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad;
ImGui::PushClipRect(mn, mx, true);
dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f),
sel ? IM_COL32(255, 255, 255, 236) : material::OnSurfaceMedium(), items[i]);
ImGui::PopClipRect();
ImGui::PopID();
}
ImGui::PopID();
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2(ctrlW, h)); // reserve the control's rect for layout flow
return result;
};
// ── Appearance ────────────────────────────────────────────────────────────────
section("chat_sec_appearance");
// Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows
// FreeType); requestFontRebuild swaps the atlas live.
{
int v = st->getChatEmojiColor() ? 1 : 0;
const char* items[] = { TR("chat_emoji_mono"), TR("chat_emoji_color") };
int nv = segmented(TR("chat_opt_emoji"), items, 2, v);
if (nv != v) { st->setChatEmojiColor(nv == 1); st->save(); app->requestFontRebuild(); }
}
// Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control).
{
const char* items[] = { TR("chat_bubble_rounded"), TR("chat_bubble_square"), TR("chat_bubble_minimal") };
int v = st->getChatBubbleStyle();
int nv = segmented(TR("chat_opt_bubble_style"), items, 3, v);
if (nv != v) { st->setChatBubbleStyle(nv); st->save(); }
}
{
beginRow(TR("chat_opt_bubble_accent"));
int v = st->getChatBubbleAccent();
const char* items[] = { TR("chat_accent_theme"), TR("chat_accent_blue"), TR("chat_accent_green"),
TR("chat_accent_purple"), TR("chat_accent_amber"), TR("chat_accent_pink") };
if (ImGui::Combo("##chat_baccent", &v, items, 6)) { st->setChatBubbleAccent(v); st->save(); }
}
// Message density (segmented).
{
const char* items[] = { TR("chat_density_comfortable"), TR("chat_density_compact") };
int v = st->getChatDensity();
int nv = segmented(TR("chat_opt_density"), items, 2, v);
if (nv != v) { st->setChatDensity(nv); st->save(); }
}
// Message text size (slider).
{
beginRow(TR("chat_opt_font_size"));
float v = st->getChatFontScale();
if (ImGui::SliderFloat("##chat_font", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) {
st->setChatFontScale(v); st->save();
}
}
// ── Messaging ─────────────────────────────────────────────────────────────────
section("chat_sec_messaging");
// Message poll rate (full-node 0-conf fast-scan cadence) — slider.
{
beginRow(TR("chat_opt_poll"));
float v = st->getChatPollRateSec();
if (ImGui::SliderFloat("##chat_poll", &v, 0.5f, 15.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp)) {
st->setChatPollRateSec(v); st->save();
}
}
// Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only.
{
const char* items[] = { TR("chat_ts_global_short"), TR("chat_ts_24h"), TR("chat_ts_12h") };
int v = st->getChatTimeFormat();
int nv = segmented(TR("chat_opt_timestamp"), items, 3, v);
if (nv != v) { st->setChatTimeFormat(nv); st->save(); }
}
// Enter-to-send (checkbox).
{
ImGui::Dummy(ImVec2(0.0f, rowGap));
bool v = st->getChatEnterSends();
if (ImGui::Checkbox(TR("chat_opt_enter_sends"), &v)) { st->setChatEnterSends(v); st->save(); }
}
}
} // namespace ui

View File

@@ -24,6 +24,18 @@ namespace ui {
*/
void RenderChatTab(App* app);
/**
* @brief Render the chat-customization controls (emoji style, poll rate, bubble style/color,
* density, text size, chat timestamps, Enter-to-send). Shared by the Chat tab's settings
* "notch" modal and the Settings tab's Chat & Contacts section. The app-wide clock format
* (which chat timestamps fall back to) lives separately in Settings → General.
* @param app Pointer to the app instance.
* @param contentWidth Explicit row width for right-aligning controls; pass the card's inner content
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
* (use the current content region, correct inside the chat modal's dialog).
*/
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f);
/**
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
* plaintext buffers + the selected-conversation ids). Called by

View File

@@ -217,11 +217,11 @@ void I18n::loadBuiltinEnglish()
strings_["chat_you"] = "You";
strings_["chat_contact_request"] = "contact request";
strings_["chat_send_failed"] = "not sent";
strings_["chat_new_button"] = "New conversation";
strings_["chat_new_button"] = "New chat";
strings_["chat_select_hint"] = "Select a conversation to view it.";
strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do.";
strings_["chat_send"] = "Send";
strings_["chat_new_title"] = "New conversation";
strings_["chat_new_title"] = "New chat";
strings_["chat_new_zaddr"] = "Recipient z-address";
strings_["chat_new_message"] = "Message";
strings_["chat_new_send"] = "Send request";
@@ -263,6 +263,44 @@ void I18n::loadBuiltinEnglish()
strings_["chat_copy_address_tip"] = "Click to copy address";
strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify";
strings_["chat_awaiting_key"] = "Waiting for reply";
strings_["chat_rename"] = "Rename contact";
strings_["chat_rename_hint"] = "Contact name";
strings_["chat_renamed"] = "Contact renamed";
// Chat customization (gear modal + Settings → Chat & Contacts)
strings_["chat_settings_title"] = "Chat settings";
strings_["chat_settings_tip"] = "Chat customization";
strings_["chat_settings_section"] = "CHAT & CONTACTS";
strings_["chat_opt_emoji"] = "Emoji style";
strings_["chat_emoji_mono"] = "Monochrome";
strings_["chat_emoji_color"] = "Color";
strings_["chat_opt_poll"] = "Message poll rate";
strings_["chat_opt_bubble_style"] = "Bubble style";
strings_["chat_bubble_rounded"] = "Rounded";
strings_["chat_bubble_square"] = "Square";
strings_["chat_bubble_minimal"] = "Minimal";
strings_["chat_opt_bubble_accent"] = "Bubble color";
strings_["chat_accent_theme"] = "Theme";
strings_["chat_accent_blue"] = "Blue";
strings_["chat_accent_green"] = "Green";
strings_["chat_accent_purple"] = "Purple";
strings_["chat_accent_amber"] = "Amber";
strings_["chat_accent_pink"] = "Pink";
strings_["chat_opt_density"] = "Message density";
strings_["chat_density_comfortable"] = "Comfortable";
strings_["chat_density_compact"] = "Compact";
strings_["chat_opt_font_size"] = "Text size";
strings_["chat_opt_timestamp"] = "Timestamps";
strings_["chat_ts_global"] = "Follow global";
strings_["chat_ts_24h"] = "24-hour";
strings_["chat_ts_12h"] = "12-hour";
strings_["chat_opt_enter_sends"] = "Enter sends message";
strings_["chat_opt_global_clock"] = "Global clock format";
strings_["chat_settings_done"] = "Done";
strings_["chat_ts_global_short"] = "Global";
strings_["chat_sec_appearance"] = "APPEARANCE";
strings_["chat_sec_messaging"] = "MESSAGING";
strings_["chat_today"] = "Today";
strings_["chat_yesterday"] = "Yesterday";
// 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";
@@ -1148,6 +1186,8 @@ void I18n::loadBuiltinEnglish()
strings_["network"] = "Network";
strings_["theme"] = "Theme";
strings_["language"] = "Language";
strings_["clock_format"] = "Clock format";
strings_["tt_clock_format"] = "24-hour or 12-hour clock, used across the app. The Chat tab can override it in its own settings.";
strings_["dragonx_green"] = "DragonX (Green)";
strings_["dark"] = "Dark";
strings_["light"] = "Light";