Files
ObsidianDragon/src/ui/windows/chat_tab.cpp
DanS d0bd55b9c1 feat(ui): settings polish — button retune, daemon card, RPC 2-row, chat preview
Settings tabs brought closer to the approved mockup:
- ActionButton/renderCardButton retune (settings-scoped): 7px radius, 9px
  padX, Primary → accent-outline chip, Secondary/card buttons more defined.
- Daemon-binary card: compact status right-aligned on the DAEMON BINARY
  heading (Up to date / Version differs / Not installed), filled/rounded
  status box, neutral danger divider (was alarming red), roomier spacing.
- RPC Connection: two-row column-aligned layout (Host | Port, then
  Username | Password) so the password no longer clips off the card edge.
- Chat settings tab: live conversation preview below the Appearance /
  Messaging cards; "Focus input on open" checkbox reflowed onto the console
  color-toggle row.
- Debug Options: "Current theme only" toggle restricts either screenshot
  sweep to the active theme instead of cycling every skin.
- Tabs fill the full content width (content-max-width cap disabled) and the
  sidebar nav panel centers within the true visible area.
- i18n: new keys for the above (untranslated keys fall back to English).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 16:59:03 -05:00

2116 lines
128 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// chat_tab.cpp — HushChat view: a conversation list + the selected thread (both read
// from the App-owned ChatService store), with a composer and a new-conversation flow.
#include "chat_tab.h"
#include "../../app.h"
#include "../../data/address_book.h"
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
#include "../material/colors.h"
#include "../material/color_theme.h" // WithAlpha
#include "../material/type.h"
#include "../material/draw_helpers.h" // TactileButton / LabeledInput / BeginOverlayDialog
#include "../material/project_icons.h" // ICON_MD_*
#include "../layout.h" // Layout::dpiScale()
#include "../notifications.h" // Notifications — add-to-contacts confirmation
#include "imgui.h"
#include <sodium.h> // sodium_memzero — wipe typed plaintext on a wallet switch
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cmath> // std::cos/std::sin — radial byte gauge in the composer
#include <cstdint>
#include <cstring>
#include <functional>
#include <ctime>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
namespace {
// Selected conversation id (cid). Empty => none selected / auto-select first.
std::string s_selected_cid;
std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next frame
// Auto-scroll: keep the thread pinned to the newest message only while the user is at the bottom.
// A wheel-up detaches it (mirrors console_tab.cpp's ScrollController); returning to the bottom re-arms it.
bool s_chat_auto_scroll = true;
float s_chat_scroll_cooldown = 0.0f; // seconds left before an at-bottom position may re-arm auto-scroll
// Message text selection: click-drag inside a bubble selects a byte range of its body; while a range is
// active a small copy button appears at the bubble's top on the opposite side. Offsets index m.body bytes.
std::string s_msgsel_cid; // conversation of the selected message ("" = no selection)
int s_msgsel_index = -1; // message index within that conversation
int s_msgsel_anchor = 0; // drag-start byte offset
int s_msgsel_head = 0; // drag-current byte offset
bool s_msgsel_dragging = false;
// Composer + new-conversation UI state.
char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
// instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1;
// On-chain chat body cap in bytes = (512 len("utf8:"))/2 secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
// Composer InputText callback: insert a newline at the cursor on Shift+Enter. ImGui 1.92's Enter handling
// (imgui_widgets.cpp:5078) resolves Enter via Shortcut() with EXACT modifiers, so Shift+Enter matches
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
// Track the live caret so the emoji picker can insert at the cursor. This callback runs under
// CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses
// focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position.
s_composeCursor = data->CursorPos;
ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n");
s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline
}
return 0;
}
bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = ""; // conversation-list filter (Q8)
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
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; }
// 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);
}
std::string formatTime(std::int64_t ts) {
if (ts <= 0) return "";
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), "%b %d, %Y", tm);
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;
std::replace(out.begin(), out.end(), '\n', ' ');
std::replace(out.begin(), out.end(), '\r', ' ');
return out;
}
// A stable, legible avatar color for a conversation (FNV-1a of the cid → a fixed material palette).
ImU32 avatarColor(const std::string& seed) {
std::uint32_t h = 2166136261u;
for (unsigned char ch : seed) { h ^= ch; h *= 16777619u; }
static const ImU32 kPalette[] = {
IM_COL32(0xEF,0x53,0x50,255), IM_COL32(0xAB,0x47,0xBC,255), IM_COL32(0x5C,0x6B,0xC0,255),
IM_COL32(0x29,0xB6,0xF6,255), IM_COL32(0x26,0xA6,0x9A,255), IM_COL32(0x66,0xBB,0x6A,255),
IM_COL32(0xFF,0xA7,0x26,255), IM_COL32(0x8D,0x6E,0x63,255),
};
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
// Lay out (and optionally draw) a chat message body with paragraph spacing: each '\n'-delimited paragraph
// word-wraps at wrapW, and every paragraph after the first gets `paraGap` of extra leading ABOVE it — so
// an explicit newline reads as a paragraph break (a bit more space than a soft wrap, which stays tight).
// Returns the total laid-out size (max wrapped width × total height). When dl != nullptr, draws at `origin`.
// Both the size pass (dl == nullptr) and the draw pass MUST use identical args so the bubble fits the text.
ImVec2 layoutChatBody(ImDrawList* dl, ImFont* font, float size, const std::string& body,
float wrapW, float paraGap, ImVec2 origin, ImU32 col) {
float maxW = 0.0f, y = 0.0f;
bool first = true;
size_t start = 0;
for (;;) {
const size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const char* pbeg = body.c_str() + start;
const char* pend = body.c_str() + (last ? body.size() : nl);
if (!first) y += paraGap; // paragraph break gets extra room
const ImVec2 psz = font->CalcTextSizeA(size, FLT_MAX, wrapW, pbeg, pend);
const float ph = std::max(psz.y, size); // a blank line still takes one line
if (dl && pend > pbeg)
dl->AddText(font, size, ImVec2(origin.x, origin.y + y), col, pbeg, pend, wrapW);
y += ph;
maxW = std::max(maxW, psz.x);
first = false;
if (last) break;
start = nl + 1;
}
return ImVec2(maxW, y);
}
// One wrapped visual line of a laid-out chat body: byte range [b,e) into body + its y-offset from the
// body origin (each line is `size` tall). Mirrors layoutChatBody's paragraph/wrap walk so selection
// hit-testing and highlighting align with the drawn text.
struct ChatBodyLine { std::size_t b, e; float y; };
std::vector<ChatBodyLine> chatBodyLines(ImFont* font, float size, const std::string& body,
float wrapW, float paraGap) {
std::vector<ChatBodyLine> out;
const float scale = size / std::max(1.0f, font->LegacySize);
float y = 0.0f; bool first = true; std::size_t start = 0;
for (;;) {
const std::size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const std::size_t pend = last ? body.size() : nl;
if (!first) y += paraGap;
std::size_t p = start;
if (p >= pend) { out.push_back({p, p, y}); y += size; } // blank paragraph = one empty line
else {
while (p < pend) {
const char* w = font->CalcWordWrapPositionA(scale, body.c_str() + p, body.c_str() + pend, wrapW);
std::size_t we = (w > body.c_str() + p) ? static_cast<std::size_t>(w - body.c_str()) : p + 1;
if (we > pend) we = pend;
out.push_back({p, we, y});
y += size;
p = we;
while (p < pend && body[p] == ' ') ++p; // AddText skips blanks at a wrap; match it
}
}
first = false;
if (last) break;
start = nl + 1;
}
return out;
}
// Advance one UTF-8 codepoint (byte offset) within [.. limit).
inline std::size_t chatNextCp(const std::string& s, std::size_t i, std::size_t limit) {
if (i >= limit) return limit;
++i;
while (i < limit && (static_cast<unsigned char>(s[i]) & 0xC0) == 0x80) ++i;
return i;
}
// Map a point to the nearest body byte offset (for drag selection). origin is the body's top-left.
std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, float wrapW,
float paraGap, ImVec2 origin, ImVec2 pt) {
const auto lines = chatBodyLines(font, size, body, wrapW, paraGap);
if (lines.empty()) return 0;
const ChatBodyLine* L = &lines.front();
for (const auto& ln : lines) { if (pt.y >= origin.y + ln.y) L = &ln; else break; }
const float relX = pt.x - origin.x;
std::size_t best = L->b; float bestDx = 1e9f;
for (std::size_t i = L->b;; i = chatNextCp(body, i, L->e)) {
const float w = font->CalcTextSizeA(size, FLT_MAX, 0.0f, body.c_str() + L->b, body.c_str() + i).x;
const float dx = std::fabs(w - relX);
if (dx < bestDx) { bestDx = dx; best = i; }
if (i >= L->e) break;
}
return best;
}
// The custom-DragonX-emoji shortcode. Inserted by the emoji picker's DragonX tile; rendered inline as
// the mark in chat bubbles (see layoutChatBodyRich). Plain text on clients that don't support it.
static const char* kDrgxToken = ":drgx:";
// Lay out (and optionally draw) a message body that contains one or more ":drgx:" tokens, flowing text
// WORDS and the inline DragonX-emoji image left-to-right and wrapping at wrapW (each drgx renders as a
// `size`-square image). Only used for bodies that actually contain the token — plain bodies keep the
// tighter layoutChatBody path — so normal messages are unaffected. Returns the total laid-out size.
ImVec2 layoutChatBodyRich(ImDrawList* dl, ImFont* font, float size, const std::string& body, float wrapW,
float paraGap, ImVec2 origin, ImU32 col, ImTextureID drgxTex) {
const float spaceW = font->CalcTextSizeA(size, FLT_MAX, 0.0f, " ").x;
const float emojiW = size; // square, matching the line height
const std::size_t tokLen = std::strlen(kDrgxToken);
float maxW = 0.0f, y = 0.0f, x = 0.0f;
bool firstPara = true, firstOnLine = true;
// Place a token of width w on the current line, wrapping first if it doesn't fit; draw() gets the
// token's top-left screen position.
auto place = [&](float w, const std::function<void(float, float)>& draw) {
const float lead = firstOnLine ? 0.0f : spaceW;
if (!firstOnLine && x + lead + w > wrapW) { x = 0.0f; y += size; firstOnLine = true; }
const float sx = x + (firstOnLine ? 0.0f : spaceW);
if (dl) draw(origin.x + sx, origin.y + y);
x = sx + w;
if (x > maxW) maxW = x;
firstOnLine = false;
};
std::size_t pstart = 0;
for (;;) {
const std::size_t nl = body.find('\n', pstart);
const bool lastPara = (nl == std::string::npos);
const std::size_t pend = lastPara ? body.size() : nl;
if (!firstPara) y += paraGap;
x = 0.0f; firstOnLine = true;
std::size_t i = pstart;
while (i < pend) {
if (body[i] == ' ' || body[i] == '\t') { ++i; continue; }
if (body.compare(i, tokLen, kDrgxToken) == 0) {
place(emojiW, [&](float px, float py) {
if (drgxTex) dl->AddImage(drgxTex, ImVec2(px, py), ImVec2(px + emojiW, py + size));
else dl->AddText(font, size, ImVec2(px, py), col, kDrgxToken); // fallback: literal
});
i += tokLen;
continue;
}
std::size_t j = i;
while (j < pend && body[j] != ' ' && body[j] != '\t' && body.compare(j, tokLen, kDrgxToken) != 0) ++j;
const std::string word = body.substr(i, j - i);
const float ww = font->CalcTextSizeA(size, FLT_MAX, 0.0f, word.c_str()).x;
place(ww, [&, word](float px, float py) { dl->AddText(font, size, ImVec2(px, py), col, word.c_str()); });
i = j;
}
y += size;
firstPara = false;
if (lastPara) break;
pstart = nl + 1;
}
return ImVec2(maxW, y);
}
// 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;
while (i < name.size() && static_cast<unsigned char>(name[i]) <= ' ') ++i;
if (i >= name.size()) return "?";
const unsigned char c = name[i];
if (c < 0x80) {
const char u = (c >= 'a' && c <= 'z') ? static_cast<char>(c - 32) : static_cast<char>(c);
return std::string(1, u);
}
const std::size_t len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : 2; // UTF-8 lead byte → sequence length
return name.substr(i, std::min(len, name.size() - i));
}
// Live "conversation preview" for the chat settings modal. Renders a few canned bubbles with the SAME
// style / accent / fill / rounding / density / font-size / timestamp / emoji code the real message loop
// uses (see the loop in RenderChatTab), so the modal shows exactly what chat will look like. Purely
// presentational — no hover/click handlers. Reserves a `width`-wide, content-tall block via a Dummy.
void RenderChatSettingsPreview(App* app, float width) {
auto* cs = app ? app->settings() : nullptr;
if (!cs) return;
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
// ---- Settings-derived draw params (mirror the real loop's formulas exactly). ----
const int bubbleStyle = cs->getChatBubbleStyle(); // 0 rounded, 1 square, 2 minimal
const bool compact = cs->getChatDensity() == 1;
const float fscale = cs->getChatFontScale();
ImFont* nameFont = material::Type().body2();
ImFont* metaFont = material::Type().caption();
const float nameSz = scaledSize(nameFont);
const float metaSz = scaledSize(metaFont);
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;
const int outFill = (bubbleStyle == 2 ? 26 : 46);
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->getChatBubbleAccent());
const float avR = 11.0f * dp;
const float gutter = 2.0f * avR + 6.0f * dp;
// Resolve the chat clock the same way line ~379 does, but locally so the preview reflects the
// timestamp control the SAME frame it changes (0 = follow global, 1 = 24h, 2 = 12h).
const int ctf = cs->getChatTimeFormat();
const bool prev12h = (ctf == 2) || (ctf == 0 && cs->getTimeFormat() == 1);
const char* t1 = prev12h ? "9:41 AM" : "09:41";
const char* t2 = prev12h ? "9:42 AM" : "09:42";
// Canned conversation: one incoming bubble + a grouped outgoing pair (to show the flattened corner
// of the rounded/minimal styles). Emoji in the bodies show the mono/color emoji style.
struct PMsg { const char* body; bool outgoing; bool startGroup; bool lastInGroup; std::string meta; };
const std::string peer = "Ava";
const PMsg msgs[] = {
{ u8"Did the payment go through? \U0001F642", false, true, true, peer + " " + t1 },
{ u8"Yep — just confirmed ✅", true, true, false, std::string(TR("chat_you")) + " " + t2 },
{ u8"Sending the rest now \U0001F44D", true, false, true, std::string() },
};
const int N = 3;
// Panel geometry + per-message layout (pre-measured so the glass panel is drawn behind at the exact
// height, since the draw list has no retained ordering).
const float padIn = 10.0f * dp;
const float contentW = std::max(80.0f * dp, width - 2.0f * padIn);
const float maxBubbleW = std::max(120.0f * dp, contentW * 0.80f);
const float innerW = maxBubbleW - 2.0f * bpad;
struct PLay { ImVec2 tsz; float bw; float bh; };
PLay lay[3];
float totalH = padIn;
for (int i = 0; i < N; ++i) {
if (msgs[i].startGroup) totalH += (i > 0 ? groupGap : 0.0f) + metaSz + 3.0f * dp;
else totalH += msgGap;
lay[i].tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, msgs[i].body);
lay[i].bw = std::min(maxBubbleW, lay[i].tsz.x + 2.0f * bpad);
lay[i].bh = lay[i].tsz.y + 2.0f * bpad;
totalH += lay[i].bh;
}
totalH += padIn;
const ImVec2 origin = ImGui::GetCursorScreenPos();
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 36;
material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g);
const float leftX = origin.x + padIn;
const float rowW = contentW;
float cy = origin.y + padIn;
for (int i = 0; i < N; ++i) {
const bool outgoing = msgs[i].outgoing;
if (msgs[i].startGroup) {
if (i > 0) cy += groupGap;
const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, msgs[i].meta.c_str());
const float mtx = outgoing ? (leftX + rowW - msz.x) : (leftX + gutter);
dl->AddText(metaFont, metaSz, ImVec2(mtx, cy), material::OnSurfaceMedium(), msgs[i].meta.c_str());
cy += metaSz + 3.0f * dp;
} else {
cy += msgGap;
}
const float bw = lay[i].bw, bh = lay[i].bh;
const float bx = outgoing ? (leftX + rowW - bw) : (leftX + gutter);
const ImVec2 bmin(bx, cy), bmax(bx + bw, cy + bh);
const ImU32 bubCol = outgoing ? material::WithAlpha(accentBase, outFill)
: material::WithAlpha(material::OnSurface(), inFill);
ImDrawFlags rf = ImDrawFlags_RoundCornersAll;
if (bubbleStyle != 1) { // square keeps uniform corners; rounded/minimal flatten stacked corners
if (outgoing) {
if (!msgs[i].startGroup) rf &= ~ImDrawFlags_RoundCornersTopRight;
if (!msgs[i].lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomRight;
} else {
if (!msgs[i].startGroup) rf &= ~ImDrawFlags_RoundCornersTopLeft;
if (!msgs[i].lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomLeft;
}
}
dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cy + bpad), material::OnSurface(),
msgs[i].body, nullptr, innerW);
if (!outgoing && msgs[i].lastInGroup) { // peer avatar beside the last incoming bubble of the run
const ImVec2 avc(leftX + avR, cy + bh - avR);
dl->AddCircleFilled(avc, avR, avatarColor("preview:" + peer), 20);
const std::string init = initialOf(peer);
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());
}
cy += bh;
}
ImGui::Dummy(ImVec2(width, totalH));
}
// Render a peer identity key (hex) as a groupable fingerprint for the verify tooltip: uppercased,
// spaced every 4 hex chars, wrapped every 16 (8 bytes) so two users can read it off line by line.
std::string keyFingerprint(const std::string& hex) {
std::string out;
out.reserve(hex.size() + hex.size() / 4);
for (std::size_t i = 0; i < hex.size(); ++i) {
const unsigned char c = static_cast<unsigned char>(hex[i]);
out += static_cast<char>(std::toupper(c));
if ((i % 4) == 3 && i + 1 < hex.size()) out += ((i % 16) == 15) ? '\n' : ' ';
}
return out;
}
struct ConvSummary {
std::string cid;
std::string peerZaddr;
std::string peerPubKey; // peer crypto_kx key (from a received memo); empty => can't reply yet
std::string peerName; // contact label if known, else a shortened z-addr / cid
std::string lastBody;
std::int64_t lastTs = 0;
int count = 0;
bool hidden = false; // shown only while "Show hidden" is on
};
// Centered, muted, wrapped hint for the empty states.
void centeredHint(const char* text) {
ImVec2 avail = ImGui::GetContentRegionAvail();
ImGui::PushFont(material::Type().body2());
const float wrap = std::min(avail.x - 40.0f, 420.0f);
const ImVec2 sz = ImGui::CalcTextSize(text, nullptr, false, wrap);
ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPos().x + std::max(0.0f, (avail.x - sz.x) * 0.5f),
ImGui::GetCursorPos().y + std::max(0.0f, (avail.y - sz.y) * 0.5f)));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
ImGui::TextUnformatted(text);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
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 * Layout::dpiScale(), 360.0f * Layout::dpiScale());
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();
}
}
// Common single-codepoint emoji (all verified present in the bundled NotoEmoji subset), each with
// keyword text for the picker's search box. Single-codepoint only — ImGui does no shaping, so ZWJ
// sequences / flags wouldn't compose.
struct EmojiEntry { const char* glyph; const char* keywords; };
static const EmojiEntry kEmoji[] = {
{u8"😀","grin happy smile"},{u8"😃","smile happy joy"},{u8"😄","laugh happy smile"},{u8"😁","grin beam happy"},
{u8"😆","laugh haha"},{u8"😅","sweat laugh nervous"},{u8"😂","joy laugh tears cry funny"},{u8"🤣","rofl laugh rolling funny"},
{u8"😊","blush smile happy"},{u8"😇","angel innocent halo"},{u8"🙂","slight smile"},{u8"🙃","upside down silly"},
{u8"😉","wink"},{u8"😌","relieved calm"},{u8"😍","heart eyes love"},{u8"🥰","love hearts adore"},{u8"😘","kiss blow"},
{u8"😗","kiss"},{u8"😙","kiss smile"},{u8"😚","kiss closed"},{u8"😋","yum tasty tongue"},{u8"😛","tongue playful"},
{u8"😝","tongue squint"},{u8"😜","wink tongue crazy"},{u8"🤪","zany crazy silly"},{u8"🤨","raised eyebrow suspicious"},
{u8"🧐","monocle thinking"},{u8"🤓","nerd geek glasses"},{u8"😎","cool sunglasses"},{u8"🥳","party celebrate hat"},
{u8"😏","smirk"},{u8"😒","unamused meh"},{u8"😞","disappointed sad"},{u8"😔","pensive sad"},{u8"😟","worried"},
{u8"😕","confused"},{u8"🙁","frown sad"},{u8"😣","persevere"},{u8"😖","confounded"},{u8"😫","tired"},{u8"😩","weary"},
{u8"🥺","pleading puppy beg"},{u8"😢","cry sad tear"},{u8"😭","sob cry bawl"},{u8"😤","huff triumph steam"},
{u8"😠","angry mad"},{u8"😡","rage mad angry"},{u8"🤬","curse swear angry"},{u8"🤯","mind blown shock"},
{u8"😳","flushed embarrassed"},{u8"🥵","hot heat sweat"},{u8"🥶","cold freezing"},{u8"😱","scream fear shock"},
{u8"😨","fearful scared"},{u8"😰","anxious sweat"},{u8"😥","sad sweat"},{u8"😓","sweat"},{u8"🤗","hug"},
{u8"🤔","think hmm"},{u8"🤭","giggle oops hand"},{u8"🤫","shush quiet"},{u8"🤥","lie"},{u8"😶","no mouth blank"},
{u8"😐","neutral meh"},{u8"😑","expressionless"},{u8"😬","grimace awkward"},{u8"🙄","eye roll"},{u8"😯","hushed surprise"},
{u8"😦","frown open"},{u8"😧","anguished"},{u8"😮","wow surprise oh"},{u8"😲","astonished shock"},{u8"🥱","yawn tired bored"},
{u8"😴","sleep zzz"},{u8"🤤","drool"},{u8"😪","sleepy"},{u8"🤐","zipper quiet secret"},{u8"🥴","woozy dizzy drunk"},
{u8"🤢","nausea sick"},{u8"🤮","vomit sick"},{u8"🤧","sneeze sick"},{u8"😷","mask sick"},{u8"🤒","sick thermometer"},
{u8"🤕","hurt bandage injured"},{u8"😈","devil imp evil"},{u8"👿","devil angry"},{u8"💀","skull dead"},{u8"👻","ghost boo"},
{u8"👽","alien"},{u8"🤖","robot bot"},{u8"😺","cat smile"},{u8"🙀","cat scream"},
// Dragon-themed set (DRGX flavour — searchable by "dragon"/"drgx"). All verified present in the
// bundled mono NotoEmoji subset + the color Twemoji font.
{u8"🐉","dragon drgx dragonx wyrm mythical creature"},{u8"🐲","dragon face drgx dragonx"},
{u8"🐍","snake serpent reptile"},{u8"🦎","lizard reptile gecko"},{u8"🐊","crocodile croc reptile gator"},
{u8"🐾","paw tracks claws prints"},{u8"🥚","egg dragon egg"},{u8"🪺","nest eggs hatch"},
{u8"🏰","castle lair fortress keep"},{u8"","swords battle crossed fight"},
{u8"🛡","shield defense guard protect"},{u8"🗡","dagger blade sword knife"},
{u8"👍","thumbs up like yes good"},{u8"👎","thumbs down dislike no bad"},{u8"👌","ok perfect"},{u8"","peace victory"},
{u8"🤞","fingers crossed luck"},{u8"🤟","love you"},{u8"🤘","rock horns"},{u8"👏","clap applause"},
{u8"🙌","raise hands celebrate"},{u8"👐","open hands"},{u8"🙏","pray thanks please"},{u8"💪","muscle strong flex"},
{u8"👋","wave hi hello bye"},{u8"🤙","call shaka"},{u8"👊","fist punch bump"},{u8"","fist raised"},{u8"🤛","fist left"},
{u8"🤜","fist right"},{u8"👆","up point"},{u8"👇","down point"},{u8"👈","left point"},{u8"👉","right point"},
{u8"","heart red love"},{u8"🧡","heart orange"},{u8"💛","heart yellow"},{u8"💚","heart green"},{u8"💙","heart blue"},
{u8"💜","heart purple"},{u8"🖤","heart black"},{u8"🤍","heart white"},{u8"💔","broken heart"},{u8"💕","hearts love"},
{u8"💞","revolving hearts"},{u8"💗","growing heart"},{u8"💖","sparkling heart"},{u8"💘","heart arrow cupid"},
{u8"💝","heart gift ribbon"},{u8"🔥","fire lit hot flame"},{u8"","star"},{u8"🎉","party tada celebrate confetti"},
{u8"🎊","confetti party"},{u8"","sparkles shiny"},{u8"💯","hundred perfect 100"},{u8"","check yes done tick"},
{u8"","cross no wrong x"},{u8"","question mark"},{u8"","exclamation mark"},{u8"👀","eyes look"},{u8"🎂","cake birthday"},
{u8"🚀","rocket launch"},{u8"💰","money bag cash"},{u8"🎁","gift present"},{u8"🍺","beer"},{u8"🍻","beers cheers"},
{u8"","coffee tea"},{u8"🍕","pizza"},{u8"👑","crown king queen"},{u8"💎","diamond gem"},{u8"🌟","glowing star"},
{u8"💥","boom explosion"},
};
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
// kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the
// caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap.
// The composer is inactive whenever the picker is open, so it renders straight from buf — splicing
// here shows immediately.
auto insertToken = [&](const char* tok) {
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
const std::size_t pos = (s_composeCursor < 0)
? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL)
if (needsSpace) buf[pos] = ' ';
std::memcpy(buf + pos + pad, tok, add);
s_composeCursor = static_cast<int>(pos + pad + add); // keep the caret after the inserted token
}
};
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##emojisearch", TR("chat_emoji_search"), s_emoji_search, sizeof(s_emoji_search));
ImGui::Separator();
const std::string q = s_emoji_search;
ImGui::BeginChild("##emojigrid", ImVec2(0, 0), false);
// Frameless cells tiled to the full pane width. The cell is kept tight (so the emoji fills it
// instead of floating in a big square); the leftover width is spread into the column gaps so the
// grid still spans edge-to-edge (Q12 polish — "scale to container width", denser variant).
ImFont* emojiFont = material::Type().subtitle1(); // emoji-capable + larger than body
const float dp = Layout::dpiScale();
const float cell = 30.0f * dp; // tight square around the glyph
const float minGap = 5.0f * dp;
const float availW = ImGui::GetContentRegionAvail().x;
const int perRow = std::max(1, static_cast<int>((availW + minGap) / (cell + minGap)));
const float spacing = perRow > 1
? std::clamp((availW - perRow * cell) / (perRow - 1), minGap, minGap * 3.0f)
: minGap;
material::IconButtonStyle es;
es.hoverBg = material::WithAlpha(material::OnSurface(), 34);
es.bgRounding = 8.0f * dp;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
int shown = 0;
// DragonX custom emoji tile (first cell) — inserts the ":drgx:" shortcode, which renders inline as the
// mark in message bubbles. Shown unless the search filter excludes "dragon"/"drgx".
if (q.empty() || containsCI("dragon drgx dragonx wyrm", q)) {
ImGui::PushID("drgxtile");
const ImVec2 tp = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##drgx", ImVec2(cell, cell));
ImDrawList* gdl = ImGui::GetWindowDrawList();
if (ImGui::IsItemHovered()) gdl->AddRectFilled(tp, ImVec2(tp.x + cell, tp.y + cell), es.hoverBg, es.bgRounding);
if (drgxTex) gdl->AddImage(drgxTex, tp, ImVec2(tp.x + cell, tp.y + cell));
else gdl->AddText(emojiFont, emojiFont->LegacySize, tp, material::OnSurface(), u8"🐉");
if (ImGui::IsItemClicked()) insertToken(kDrgxToken);
ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine();
}
for (const auto& e : kEmoji) {
if (!q.empty() && !containsCI(e.keywords, q)) continue;
ImGui::PushID(shown);
if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es))
insertToken(e.glyph);
ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine();
}
ImGui::PopStyleVar();
ImGui::EndChild();
}
} // namespace
void RenderChatTab(App* app)
{
auto& service = app->chatService();
const auto& store = service.store();
auto& book = app->addressBook();
// Not unlocked / identity not derived yet → nothing to show.
if (!service.hasIdentity()) {
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
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;
for (const auto& cid : store.conversationIds()) {
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++hiddenCount;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
}
const auto& last = messages.back();
c.lastBody = last.body;
c.lastTs = last.timestamp;
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
convs.push_back(std::move(c));
}
std::sort(convs.begin(), convs.end(),
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
// Keep the selection valid (only when there is something to select).
if (!convs.empty() &&
std::none_of(convs.begin(), convs.end(),
[](const ConvSummary& c) { return c.cid == s_selected_cid; })) {
s_selected_cid = convs.front().cid;
s_scroll_to_cid = s_selected_cid;
}
// The composer holds a single draft — wipe it when the active conversation changes so text typed
// for one contact can't be sent to another (B5).
if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose));
s_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again
s_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
}
// 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 * Layout::dpiScale(), 360.0f * Layout::dpiScale());
// Row geometry is logical px — scale by dpiScale() so rows/padding grow with the (DPI-scaled)
// fonts. Left raw, at higher DPI the row was too short for the enlarged text and the preview's
// right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word.
const float pad = 10.0f * Layout::dpiScale();
const float rowH = 58.0f * Layout::dpiScale();
ImFont* nameFont = material::Type().body2();
ImFont* metaFont = material::Type().caption();
const float nameSz = scaledSize(nameFont);
const float metaSz = scaledSize(metaFont);
// ---- Left: new-conversation button + conversation list ----
// Frosted-glass pane so the list blurs the backdrop like the rest of the app (the contacts_tab
// pattern): draw the glass behind the child, then give the child a TRANSPARENT bg. A flat ChildBg
// tint (the old approach) never samples the acrylic blur, so the raw texture showed through.
{
ImDrawList* paneDL = ImGui::GetWindowDrawList();
const ImVec2 pMin = ImGui::GetCursorScreenPos();
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 20; g.borderAlpha = 34;
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + listW, pMin.y + avail.y), g);
}
// Inner padding so the list content (buttons, search, conversation cards) doesn't hug the glass
// pane's edges now that it's a visible frosted card.
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f * Layout::dpiScale(), 10.0f * Layout::dpiScale()));
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0)); // transparent — the frosted glass shows through
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), ImGuiChildFlags_AlwaysUseWindowPadding,
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), app->getDrgxEmojiTexture());
} else {
// "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)));
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) — 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));
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_empty_hint"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
}
ImDrawList* dl = ImGui::GetWindowDrawList(); // the list child's draw list (correct clip/z-order)
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;
const bool clicked = ImGui::InvisibleButton("##row", ImVec2(rowW, rowH));
const bool hovered = ImGui::IsItemHovered();
const bool selected = (c.cid == s_selected_cid);
if (clicked) { s_selected_cid = c.cid; s_scroll_to_cid = c.cid; }
if (hovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Row background — house tactile card style (matches contacts_tab).
const ImVec2 mn = p, mx(p.x + rowW, p.y + rowH);
const ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: hovered ? material::WithAlpha(material::OnSurface(), 26)
: material::WithAlpha(material::OnSurface(), 10);
dl->AddRectFilled(mn, mx, fill, round);
if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
// Leading letter-avatar (deterministic color circle + the peer's initial).
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
dl->AddCircleFilled(avC, avR, avatarColor(c.cid), 24);
{
const std::string init = initialOf(c.peerName);
const ImVec2 isz = nameFont->CalcTextSizeA(nameSz, FLT_MAX, 0.0f, init.c_str());
dl->AddText(nameFont, nameSz, ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
IM_COL32(255, 255, 255, 235), init.c_str());
}
const float textX = avC.x + avR + pad;
// Time (top-right, muted) — compact relative form (Q5). Measure/draw it FIRST so the name can be
// clipped to the column left of it — otherwise a long peer name overruns the timestamp (worse at
// HiDPI, where the fixed-length name grows ~1.5x).
const std::string when = relativeTime(c.lastTs);
float nameRight = mx.x - pad;
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),
material::OnSurfaceMedium(), when.c_str());
nameRight = mx.x - pad - wsz.x - pad; // reserve the timestamp column + a gap
}
// Name (top), clipped to the space left of the timestamp. Hidden conversations render dimmed.
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, nameRight), mx.y), true);
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
dl->PopClipRect();
// Preview (bottom, clipped to the text column, muted).
const std::string preview = previewOf(c.lastBody);
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
material::OnSurfaceMedium(), preview.c_str());
dl->PopClipRect();
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::PopStyleVar(); // list inner WindowPadding
ImGui::SameLine(0.0f, 10.0f * Layout::dpiScale()); // breathing gutter between the list and thread panes
// ---- Right: selected conversation thread ----
// 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();
// Composer auto-grows with its content: the input word-wraps, so the box height tracks the wrapped
// line count (measured in the composer block below, where the wrap width is known) and animates so the
// thread above is smoothly pushed up/down. The vertical padding also centers a single line and, with
// the left FramePadding, keeps text off the edges. We consume last frame's measured target here (a
// 1-frame lag that's invisible under the smoothing); it falls back to one collapsed line.
const float lineH = ImGui::GetTextLineHeight();
const float composerVPad = 8.0f * tdp; // top+bottom inner padding
const float composerCtrlH = lineH + 2.0f * composerVPad; // one-line (collapsed) height; side controls match
const int kMaxComposerLines = 6; // beyond this the input scrolls internally
const float composerAnimTarget = (s_composerTargetH > 0.0f) ? s_composerTargetH : composerCtrlH;
if (s_composerAnimH <= 0.0f) s_composerAnimH = composerAnimTarget; // first frame: snap, don't animate in
{
const float dt = ImGui::GetIO().DeltaTime;
const float k = 1.0f - std::exp(-dt * 16.0f); // ~16/s exponential convergence
s_composerAnimH += (composerAnimTarget - s_composerAnimH) * k;
if (std::fabs(composerAnimTarget - s_composerAnimH) < 0.5f) s_composerAnimH = composerAnimTarget;
}
const float composerBoxH = s_composerAnimH;
// Reserve the (animated) composer strip below the box whenever a conversation is open — just the input
// row (the byte budget is a radial gauge inside the input, no separate counter row). Same height for
// the waiting hint so the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + 10.0f * tdp) : 0.0f;
// Frosted-glass thread pane (same reasoning as the list): glass behind + transparent child bg so
// the message area blurs the backdrop instead of showing the sharp texture. Replaces the old
// bordered child (which drew the flat, translucent WindowBg) — the glass supplies the border.
{
ImDrawList* paneDL = ImGui::GetWindowDrawList();
const ImVec2 pMin = ImGui::GetCursorScreenPos();
const float pW = ImGui::GetContentRegionAvail().x;
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 12; g.borderAlpha = 34;
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + pW, pMin.y + (avail.y - composerAreaH)), g);
}
// Inner padding so the header + messages don't hug the glass card's edges (the message child
// below trims its own inset to compensate so bubbles aren't double-indented).
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f * tdp, 10.0f * tdp));
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0)); // transparent — frosted glass shows through
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y - composerAreaH), ImGuiChildFlags_AlwaysUseWindowPadding);
{
if (sel) {
app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1)
// ── 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();
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();
// 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 = 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 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);
// 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).
{
ImFont* ifont = material::Type().iconMed();
material::IconButtonStyle base;
base.color = material::OnSurfaceMedium();
base.hoverColor = material::OnSurface();
base.hoverBg = material::WithAlpha(material::OnSurface(), 30);
base.bgRounding = 7.0f * hdpi;
float bx = toolbarLeft;
const float by = hp.y;
// 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 = 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;
}
// Export the decrypted conversation to a plain-text file (Q11); the tooltip warns it's plaintext.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
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"));
}
bx += ib + gap;
}
// Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
a.tooltip = muted ? TR("chat_unmute") : TR("chat_mute");
if (muted) { a.color = material::Primary(); a.hoverColor = material::Primary(); }
if (material::IconButton("##hdr_mute",
muted ? ICON_MD_NOTIFICATIONS_OFF : ICON_MD_NOTIFICATIONS,
ifont, ImVec2(ib, ib), a) && app->settings()) {
app->settings()->setChatMuted(sel->cid, !muted);
app->settings()->save();
}
bx += ib + gap;
}
// Hide / Unhide. Hiding drops it from the list (messages stay in the encrypted store);
// Unhide (shown only for a hidden conversation while "Show hidden" is on) restores it.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
a.tooltip = sel->hidden ? TR("chat_unhide") : TR("chat_hide");
if (material::IconButton("##hdr_hide",
sel->hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF,
ifont, ImVec2(ib, ib), a) && app->settings()) {
if (sel->hidden) {
app->settings()->setChatHidden(sel->cid, false);
app->settings()->save();
} else {
app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread
app->settings()->setChatHidden(sel->cid, true);
app->settings()->save();
s_selected_cid.clear();
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;
}
}
// 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());
ax += lockW;
}
// 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);
}
}
// 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();
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
// 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(4.0f * dp, 8.0f * dp));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(origSpacingX, 0.0f));
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
// Detach auto-scroll when the user wheels up inside the thread; re-armed once back at the bottom
// (below). The cooldown lets the smooth-scroll animate away before we re-check position. A direct
// mouse-in-rect test (not IsWindowHovered, which is false while the composer holds focus) matches
// ApplySmoothScroll, so scrolling up to read history while the composer is focused still detaches.
{
const ImVec2 wpos = ImGui::GetWindowPos(), wsz = ImGui::GetWindowSize();
const ImVec2 mp = ImGui::GetIO().MousePos;
const bool inThread = mp.x >= wpos.x && mp.x < wpos.x + wsz.x &&
mp.y >= wpos.y && mp.y < wpos.y + wsz.y;
if (inThread && ImGui::GetIO().MouseWheel > 0.0f) {
s_chat_auto_scroll = false;
s_chat_scroll_cooldown = 0.3f;
}
}
if (s_chat_scroll_cooldown > 0.0f) s_chat_scroll_cooldown -= ImGui::GetIO().DeltaTime;
// Was the thread at the bottom coming into this frame? (measured before this frame's content is
// laid out, so newly-arrived messages don't yank a scrolled-up reader.)
const float preScrollMaxY = ImGui::GetScrollMaxY();
const bool wasAtBottom = preScrollMaxY <= 0.0f || ImGui::GetScrollY() >= preScrollMaxY - 4.0f;
bool atBottom = true;
const ImVec2 msgWinMin = ImGui::GetWindowPos();
const ImVec2 msgWinSize = ImGui::GetWindowSize();
// Jump-to-latest pill geometry — computed here so the clear-on-empty-click below can exclude
// the pill (a pill click shouldn't drop an active selection) and the pill draw below reuses it.
const ImVec2 jumpPillMin(msgWinMin.x + msgWinSize.x - 88.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
const ImVec2 jumpPillMax(jumpPillMin.x + 78.0f * dp, jumpPillMin.y + 26.0f * dp);
{
ImDrawList* dl = ImGui::GetWindowDrawList();
// 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);
// 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;
bool anyMsgClicked = false; // did a left-click this frame land on a bubble / copy button?
const ImTextureID drgxTex = app->getDrgxEmojiTexture(); // ":drgx:" inline emoji
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;
const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending;
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::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp);
const float innerW = maxBubbleW - 2.0f * bpad;
// ── 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 = 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 + gutter, mp.y),
material::OnSurfaceMedium(), meta.c_str());
ImGui::Dummy(ImVec2(availW, metaSz + 3.0f * dp));
}
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
// Body is laid out paragraph-by-paragraph so explicit newlines get extra leading.
// A body containing ":drgx:" uses the richer inline-image flow (drgx tile → mark);
// plain bodies keep the tighter text-only path so they're unaffected.
const float paraGap = bodySz * 0.35f;
const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos;
const ImVec2 tsz = hasDrgx
? layoutChatBodyRich(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0, drgxTex)
: layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0);
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 + 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(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);
// ── Text selection: click-drag inside the bubble selects a byte range of the body.
// Skipped for ":drgx:" messages — their inline-image layout (layoutChatBodyRich) doesn't
// match the plain-text hit-test/highlight geometry, so a selection would misalign.
// (Right-click "copy" still copies the whole message.)
const ImVec2 bodyOrigin(bx + bpad, cur.y + bpad);
const bool selfSel = (!hasDrgx && s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast<int>(mi));
if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi);
s_msgsel_anchor = s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
s_msgsel_dragging = true;
anyMsgClicked = true;
}
if (selfSel && s_msgsel_dragging) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left))
s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
else
s_msgsel_dragging = false;
}
// Highlight the selected range (behind the text) — one rect per wrapped line segment.
if (selfSel && s_msgsel_anchor != s_msgsel_head) {
const int a = std::min(s_msgsel_anchor, s_msgsel_head);
const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
for (const auto& ln : chatBodyLines(nameFont, bodySz, m.body, innerW, paraGap)) {
const int ls = std::max(a, static_cast<int>(ln.b));
const int le = std::min(b2, static_cast<int>(ln.e));
if (ls >= le) continue;
const float x0 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + ls).x;
const float x1 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + le).x;
dl->AddRectFilled(ImVec2(bodyOrigin.x + x0, bodyOrigin.y + ln.y),
ImVec2(bodyOrigin.x + x1, bodyOrigin.y + ln.y + bodySz),
material::WithAlpha(material::Primary(), 80));
}
}
if (hasDrgx)
layoutChatBodyRich(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), drgxTex);
else
layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface());
// 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)) {
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();
}
// Copy button for an active selection — at the bubble TOP, on the OPPOSITE side (an
// incoming bubble sits left → button to its right; an outgoing bubble sits right →
// button to its left). Copies the selected substring of the body.
if (selfSel && s_msgsel_anchor != s_msgsel_head) {
const int a = std::min(s_msgsel_anchor, s_msgsel_head);
const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
const float cbs = 22.0f * dp;
const float cbx = outgoing ? (bmin.x - cbs - 4.0f * dp) : (bmax.x + 4.0f * dp);
const ImVec2 cbmin(cbx, bmin.y), cbmax(cbx + cbs, bmin.y + cbs);
const bool cbhov = ImGui::IsMouseHoveringRect(cbmin, cbmax);
dl->AddRectFilled(cbmin, cbmax, cbhov ? material::Primary()
: material::WithAlpha(material::Primary(), 210), 5.0f * dp);
const float gs = icfSz * 0.78f;
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, ICON_MD_CONTENT_COPY);
dl->AddText(icf, gs, ImVec2(cbmin.x + (cbs - gsz.x) * 0.5f, cbmin.y + (cbs - gsz.y) * 0.5f),
IM_COL32(255, 255, 255, 235), ICON_MD_CONTENT_COPY);
if (cbhov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImGui::SetClipboardText(m.body.substr(a, b2 - a).c_str());
anyMsgClicked = true;
}
}
}
// 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;
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"))) {
// A failed contact request must be re-sent as a request (it has no peer key
// yet) into the SAME conversation — not routed through sendChatMessage,
// which would just say "waiting for reply".
if (request) app->sendContactRequestForCid(sel->cid, m.peer_zaddr, m.body);
else app->sendChatMessage(sel->cid, m.body);
s_scroll_to_cid = sel->cid;
}
}
ImGui::Dummy(ImVec2(0.0f, lastInGroup ? msgGap : msgGap * 0.5f)); // tight within a run
ImGui::PopID();
prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day;
}
// A left-click inside the thread that hit no bubble or copy button clears the selection —
// except a click on the jump-to-latest pill, which is navigation, not a deselect.
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !anyMsgClicked && ImGui::IsWindowHovered()
&& !ImGui::IsMouseHoveringRect(jumpPillMin, jumpPillMax)) {
s_msgsel_cid.clear(); s_msgsel_index = -1; s_msgsel_dragging = false;
}
// Pin to the newest message when explicitly requested (send / conversation switch) or when
// auto-scroll is armed and the thread was already at the bottom (so new messages follow).
if (s_scroll_to_cid == s_selected_cid || (s_chat_auto_scroll && wasAtBottom)) {
ImGui::SetScrollHereY(1.0f);
s_scroll_to_cid.clear();
}
atBottom = ImGui::GetScrollMaxY() <= 0.0f || ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
// Re-arm auto-scroll once the user returns to the bottom (after the wheel cooldown).
if (atBottom && s_chat_scroll_cooldown <= 0.0f) s_chat_auto_scroll = true;
// Jump-to-latest pill when scrolled up (Q9). Drawn LAST and INSIDE the thread child (on the
// child's own draw list) so it sits on top of the messages — a parent-window draw after
// EndChild renders UNDER the child and gets covered by the text. PushClipRect keeps the
// child's content padding / scrollbar from clipping it. Hand-drawn + IsMouseHoveringRect so
// the click lands despite the child owning hover here (a real widget would still miss it).
if (!atBottom) {
const ImVec2 pmin = jumpPillMin, pmax = jumpPillMax;
const float pw = pmax.x - pmin.x, ph = pmax.y - pmin.y;
const bool hov = ImGui::IsMouseHoveringRect(pmin, pmax);
dl->PushClipRect(pmin, pmax, false);
dl->AddRectFilled(pmin, pmax,
hov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
ph * 0.5f);
ImFont* lf = ImGui::GetFont(); const float lsz = ImGui::GetFontSize();
const char* lbl = TR("chat_jump_latest");
const ImVec2 tszp = lf->CalcTextSizeA(lsz, FLT_MAX, 0.0f, lbl);
dl->AddText(lf, lsz, ImVec2(pmin.x + (pw - tszp.x) * 0.5f, pmin.y + (ph - tszp.y) * 0.5f),
IM_COL32(255, 255, 255, 235), lbl);
dl->PopClipRect();
if (hov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_scroll_to_cid = sel->cid;
s_chat_auto_scroll = true; // clicking Latest re-arms follow-the-newest
s_chat_scroll_cooldown = 0.0f;
}
}
}
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// 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);
}
}
ImGui::EndChild();
ImGui::PopStyleColor(); // ##ChatThread transparent ChildBg (GetItemRect below still reads the child)
ImGui::PopStyleVar(); // ##ChatThread inner WindowPadding
// ── 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()) {
s_composerTargetH = composerCtrlH; // no editable composer here — keep the strip collapsed
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). Input is
// hard-capped to the on-chain body byte budget; usage is shown as a radial gauge in the box.
const int kBodyMaxBytes = kChatBodyMaxBytes;
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; // defensive — the hard byte cap should prevent this
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
const float rowY = boxMax.y + composerAreaH - composerBoxH;
const float inputX = cx + emojiBtn + inGap;
const float ctrlY = rowY + composerBoxH - composerCtrlH; // bottom-aligned row for side controls
// Radial byte gauge sits in the input box's bottom-right corner; a fixed small size (tied to
// the line height, NOT the animated box height) with a reserved right slot so text never
// reaches it. Geometry first so the input width floor can guarantee room for both.
const float ringR = std::max(7.0f, lineH * 0.42f);
const float ringPad = 9.0f * tdp;
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
const float inputW = std::min(720.0f * tdp,
std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW));
const float sendX = inputX + inputW + inGap;
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
// Emoji toggle on the left, bottom-aligned with the input row (centered in the control height).
ImGui::SetCursorScreenPos(ImVec2(cx, ctrlY + (composerCtrlH - 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 — frosted so it blurs the backdrop like the panes above (glass behind + transparent
// FrameBg); a flat FrameBg showed the sharp texture.
{
ImDrawList* cdl = ImGui::GetWindowDrawList();
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 34;
material::DrawGlassPanel(cdl, ImVec2(inputX, rowY),
ImVec2(inputX + inputW, rowY + composerBoxH), g);
}
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0));
// FramePadding gives the text a left inset (so it doesn't hug the box edge) and, since the
// collapsed box is exactly one line + 2×VPad tall, vertically centers a single line.
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f * tdp, composerVPad));
ImGui::SetCursorScreenPos(ImVec2(inputX, rowY));
// Enter-to-send (setting): on → Enter sends, Shift+Enter makes a newline (Ctrl+Enter too); off
// → Enter is a newline and the Send button is the only way to send. EnterReturnsTrue needs
// CtrlEnterForNewLine alongside it or multiline plain-Enter inserts a '\n' instead of
// validating (imgui_widgets.cpp:5118). Shift+Enter is handled in composeInputCallback since
// ImGui's exact-modifier Enter shortcut ignores it. CallbackAlways runs that handler.
const bool enterSends = !app->settings() || app->settings()->getChatEnterSends();
// WordWrap: long lines wrap to the next visual line (the box grows) instead of scrolling the
// text horizontally off the left edge.
ImGuiInputTextFlags composeFlags = ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_WordWrap;
if (enterSends)
composeFlags |= ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine;
// Hard-cap the byte length at the on-chain budget: passing kBodyMaxBytes+1 as the buffer size
// stops ImGui from accepting more input (UTF-8-safe), so the user can't type past the cap.
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, kBodyMaxBytes + 1,
ImVec2(textW, composerBoxH), composeFlags, composeInputCallback);
ImGui::PopStyleVar(); // FramePadding
ImGui::PopStyleColor(3); // composer FrameBg (transparent — glass shows through)
// Measure the wrapped content height to drive the auto-grow (consumed next frame at the top).
// Wrap width mirrors ImGui's internal InputText wrap = frame inner width (textW 2×FramePadding.x),
// so CalcTextSize's wrap matches what the widget renders.
{
const float wrapW = std::max(1.0f, textW - 2.0f * 12.0f * tdp);
float contentH = (s_compose[0] == '\0') ? lineH
: ImGui::CalcTextSize(s_compose, nullptr, false, wrapW).y;
const size_t clen = std::strlen(s_compose);
if (clen > 0 && s_compose[clen - 1] == '\n') contentH += lineH; // trailing empty line
contentH = std::min(contentH, kMaxComposerLines * lineH);
s_composerTargetH = std::max(lineH, contentH) + 2.0f * composerVPad;
}
// ── Radial byte gauge (replaces the old numeric counter): a ring in the input's right edge
// that fills clockwise and shifts green→amber→red as the message nears the on-chain cap.
// Full + red at/over the cap (Send is disabled there); exact count shows on hover.
{
ImDrawList* gdl = ImGui::GetWindowDrawList();
auto lerpCol = [](ImU32 a, ImU32 b, float t) {
if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
const ImVec4 fa = ImGui::ColorConvertU32ToFloat4(a);
const ImVec4 fb = ImGui::ColorConvertU32ToFloat4(b);
return ImGui::GetColorU32(ImVec4(fa.x + (fb.x - fa.x) * t, fa.y + (fb.y - fa.y) * t,
fa.z + (fb.z - fa.z) * t, fa.w + (fb.w - fa.w) * t));
};
const ImU32 green = material::Success(), amber = material::Warning(), red = material::Error();
ImU32 fillCol;
if (frac < 0.75f) fillCol = green; // calm until genuinely near the cap
else if (frac < 1.0f) { const float t = (frac - 0.75f) / 0.25f;
fillCol = (t < 0.5f) ? lerpCol(green, amber, t / 0.5f)
: lerpCol(amber, red, (t - 0.5f) / 0.5f); }
else fillCol = red; // at/over cap
const float thick = std::max(2.0f, 2.4f * tdp);
const float dispFrac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac);
// Faint full-ring track (alpha kept high enough to read on light skins too).
gdl->PathArcTo(ringC, ringR, 0.0f, IM_PI * 2.0f, 48);
gdl->PathStroke(material::WithAlpha(material::OnSurface(), 42), 0, thick);
// Progress arc from 12 o'clock, clockwise, with a rounded head.
if (dispFrac > 0.0001f) {
const float a0 = -IM_PI * 0.5f;
const float a1 = a0 + dispFrac * IM_PI * 2.0f;
gdl->PathArcTo(ringC, ringR, a0, a1, 48);
gdl->PathStroke(fillCol, 0, thick);
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10);
}
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
ImGui::BeginTooltip();
ImGui::TextUnformatted(tip.c_str());
ImGui::EndTooltip();
}
}
// Off-mode sends only via the Send button. In enter-sends mode EnterReturnsTrue makes the
// return value mean "validated" (plain Enter only) — Shift+Enter/edits don't set it.
bool submit = enterSends && inputReturned;
// Send — fixed height, bottom-aligned with the input row (doesn't stretch when the box grows).
ImGui::SetCursorScreenPos(ImVec2(sendX, ctrlY));
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, composerCtrlH))) submit = true;
ImGui::EndDisabled();
// When Send is disabled *because the message is over the cap*, explain why on hover — the
// gauge went red but the numeric reason is no longer always-visible.
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
ImGui::PopStyleColor(3);
if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose));
s_composeCursor = -1;
s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
// reappears, and clears its search filter.
s_show_emoji_picker = false;
s_emoji_search[0] = '\0';
}
}
}
// ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
if (s_show_new_convo) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_new_title");
ov.p_open = &s_show_new_convo; // X / backdrop closes it
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "chatnewconvo";
if (material::BeginOverlayDialog(ov)) {
const float fieldW = ImGui::GetContentRegionAvail().x;
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
// Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t)
// address can't receive one. Contacts can hold t-addresses, so guard the manual field too:
// warn when the entry isn't a valid z-address and keep Send disabled below.
const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr);
if (s_new_zaddr[0] != '\0' && !newAddrIsZ) {
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_new_needs_zaddr"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
// Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
// Selecting one fills the field above (manual paste still works).
ImGui::SetNextItemWidth(fieldW);
if (ImGui::BeginCombo("##newzpick", TR("chat_pick_contact"), ImGuiComboFlags_HeightLarge)) {
const std::string activeHash = app->activeWalletScopeId(); // per-wallet contact scope
int shown = 0;
for (const auto& e : book.entries()) {
if (e.address.empty() || e.address[0] != 'z') continue; // chat requires a z-address
// Respect the same per-wallet scope the Contacts tab enforces — don't leak another
// wallet's scoped contact into this wallet's picker (global + legacy fail open).
const bool visible = e.isGlobal() ||
(e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
if (!visible) continue;
++shown;
const std::string item = e.label + " " + shorten(e.address, 16, 8);
if (ImGui::Selectable(item.c_str())) {
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
}
}
if (shown == 0) {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_no_z_contacts"));
ImGui::PopStyleColor();
}
ImGui::EndCombo();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
const bool canSend = newAddrIsZ && s_new_msg[0] != '\0';
const float actionW = std::max(130.0f * dp,
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
const float actionGap = Layout::spacingSm();
material::BeginOverlayDialogFooter(actionW * 2.0f + actionGap, /*drawSeparator=*/false);
if (!canSend) ImGui::BeginDisabled();
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 doSend = material::TactileButton(TR("chat_new_send"), ImVec2(actionW, 0));
ImGui::PopStyleColor(3);
if (doSend) {
app->startChatConversation(s_new_zaddr, s_new_msg);
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr)); // wipe the just-sent plaintext
sodium_memzero(s_new_msg, sizeof(s_new_msg));
s_show_new_convo = false;
}
if (!canSend) ImGui::EndDisabled();
ImGui::SameLine(0, actionGap);
if (material::TactileButton(TR("chat_cancel"), ImVec2(actionW, 0))) s_show_new_convo = false;
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;
// Wider than tall: a live preview sits to the LEFT of the controls (more horizontal room to work
// with than vertical). Both columns AutoResizeY so neither the preview nor the controls clip.
ov.cardWidth = 800.0f; ov.idSuffix = "chatsettings";
if (material::BeginOverlayDialog(ov)) {
const float dp = Layout::dpiScale();
const float colGap = 16.0f * dp;
const float fullW = ImGui::GetContentRegionAvail().x;
const float previewW = std::min(340.0f * dp, std::max(240.0f * dp, fullW * 0.40f));
const float controlsW = std::max(300.0f * dp, fullW - previewW - colGap);
ImGui::BeginChild("##chatPreviewCol", ImVec2(previewW, 0.0f), ImGuiChildFlags_AutoResizeY,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
RenderChatSettingsPreview(app, ImGui::GetContentRegionAvail().x);
ImGui::EndChild();
ImGui::SameLine(0.0f, colGap);
ImGui::BeginChild("##chatControlsCol", ImVec2(controlsW, 0.0f), ImGuiChildFlags_AutoResizeY,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
RenderChatSettingsControls(app, 0.0f);
ImGui::EndChild();
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
{
// Done: sized to its text (+ a little shoulder room) and centered, not full width.
const char* doneLbl = TR("chat_settings_done");
ImGui::PushFont(material::Type().button());
const float bw = ImGui::CalcTextSize(doneLbl).x + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp;
ImGui::PopFont();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - bw) * 0.5f));
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(doneLbl, ImVec2(bw, 0)))
s_show_chat_settings = false;
ImGui::PopStyleColor(3);
}
material::EndOverlayDialog();
}
}
}
void ResetChatTab()
{
// Securely wipe typed plaintext (a private message / recipient z-addr) so it can't resurface under
// the next wallet after a switch/lock, and drop the selection ids.
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, bool drawCards)
{
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 = 10.0f * dp;
// Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the
// Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false
// and keeps its plain single-surface layout — the controls themselves are identical either way.
ImDrawList* cardDL = ImGui::GetWindowDrawList();
const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f;
material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding();
float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f;
if (drawCards) {
cardTopScr = ImGui::GetCursorScreenPos().y;
cardBaseXScr = ImGui::GetCursorScreenPos().x;
cardDL->ChannelsSplit(2);
cardDL->ChannelsSetCurrent(1);
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad));
ImGui::Indent(cardPad);
}
// 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).
// leftX/rowW define the current column the rows lay out in; retargeted below
// to split Appearance | Messaging into two columns when the card is wide.
float leftX = ImGui::GetCursorPosX();
float rowW = drawCards ? (contentWidth - 2.0f * cardPad)
: ((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;
};
// Two internal columns when the card is wide enough: Appearance on the left,
// Messaging on the right — fills the width and roughly halves the height.
// (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column.
const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp);
const bool chatTwoCol = rowW > 760.0f * dp;
const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW;
const float chatBaseLeftX = leftX;
const float chatTopY = ImGui::GetCursorPosY();
float chatLeftBottomY = 0.0f;
if (chatTwoCol) rowW = chatColW; // left column width
// ── 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 (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_emoji_style"));
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 (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_style"));
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(); }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_accent"));
}
// 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 (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_density"));
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();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_font_size"));
}
// Move Messaging into the right column (float it with Indent so every row's
// line-start holds the column; retarget leftX so controls right-align in it).
if (chatTwoCol) {
chatLeftBottomY = ImGui::GetCursorPosY();
cardLeftBotScr = ImGui::GetCursorScreenPos().y; // left column bottom (screen), for its card panel
ImGui::SetCursorPosY(chatTopY);
ImGui::Indent(chatColW + chatColGap);
leftX = chatBaseLeftX + chatColW + chatColGap;
}
// ── 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();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_poll_rate"));
}
// 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 (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_timestamp"));
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(); }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_enter_sends"));
}
// Close the two-column band: un-indent and drop below the taller column.
const float cardRightBotScr = ImGui::GetCursorScreenPos().y; // right (or only) column bottom, screen
if (chatTwoCol) {
ImGui::Unindent(chatColW + chatColGap);
const float chatRightBottomY = ImGui::GetCursorPosY();
ImGui::SetCursorPosX(chatBaseLeftX);
ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY));
}
// Paint the glass card(s) behind the content, then merge the channels.
if (drawCards) {
ImGui::Unindent(cardPad);
cardDL->ChannelsSetCurrent(0);
const float cardW = (contentWidth - Layout::cardGap()) * 0.5f;
if (chatTwoCol) {
const float eqBot = std::max(cardLeftBotScr, cardRightBotScr); // equal-height cards (mockup grid stretch)
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec);
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr),
ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec);
} else {
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec);
}
cardDL->ChannelsMerge();
const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr;
// Reserve the card footprint with a Dummy so the parent scroll region grows to include it
// (a bare SetCursorScreenPos past content warns in ImGui).
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr));
ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad));
// Live conversation preview below the two cards (Settings tab only — the chat modal renders
// its own preview column beside these controls, so it passes drawCards=false and skips this).
ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd()));
RenderChatSettingsPreview(app, contentWidth);
}
}
} // namespace ui
} // namespace dragonx