// 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_memzero — wipe typed plaintext on a wallet switch #include #include #include #include // std::cos/std::sin — radial byte gauge in the composer #include #include #include #include #include #include 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(a)) == std::tolower(static_cast(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(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(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(ts); std::tm* tm = std::localtime(&t); if (!tm) return -1; return static_cast(tm->tm_year + 1900) * 10000 + static_cast(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(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::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(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 chatBodyLines(ImFont* font, float size, const std::string& body, float wrapW, float paraGap) { std::vector 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(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(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& 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(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(c - 32) : static_cast(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(hex[i]); out += static_cast(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(s_composeCursor), cur); const bool needsSpace = pos > 0 && static_cast(buf[pos - 1]) > ' '; const std::size_t pad = needsSpace ? 1 : 0; if (cur + pad + add <= static_cast(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(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((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 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(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(nm.front()))) nm.erase(nm.begin()); while (!nm.empty() && std::isspace(static_cast(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(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(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::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(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(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(mi)); if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast(mi); s_msgsel_anchor = s_msgsel_head = static_cast( 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( 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(ln.b)); const int le = std::min(b2, static_cast(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(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(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(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(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