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