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>
2116 lines
128 KiB
C++
2116 lines
128 KiB
C++
// 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
|