// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// chat_tab.cpp — HushChat view: a conversation list + the selected thread (both read
// from the App-owned ChatService store), with a composer and a new-conversation flow.

#include "chat_tab.h"
#include "../../app.h"
#include "../../data/address_book.h"
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/address_validation.h"  // isShieldedAddress — chat requires a z-address recipient
#include "../../util/platform.h"        // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h"       // per-conversation mute (Q10)
#include "../material/colors.h"
#include "../material/color_theme.h"   // WithAlpha
#include "../material/type.h"
#include "../material/draw_helpers.h"  // TactileButton / LabeledInput / BeginOverlayDialog
#include "../material/project_icons.h" // ICON_MD_*
#include "../layout.h"   // Layout::dpiScale()
#include "../notifications.h"          // Notifications — add-to-contacts confirmation
#include "imgui.h"

#include <sodium.h>      // sodium_memzero — wipe typed plaintext on a wallet switch

#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cmath>         // std::cos/std::sin — radial byte gauge in the composer
#include <cstdint>
#include <cstring>
#include <functional>
#include <ctime>
#include <string>
#include <vector>

namespace dragonx {
namespace ui {

namespace {

// Selected conversation id (cid). Empty => none selected / auto-select first.
std::string s_selected_cid;
std::string s_scroll_to_cid;  // when set, scroll the thread to the bottom next frame

// Auto-scroll: keep the thread pinned to the newest message only while the user is at the bottom.
// A wheel-up detaches it (mirrors console_tab.cpp's ScrollController); returning to the bottom re-arms it.
bool  s_chat_auto_scroll = true;
float s_chat_scroll_cooldown = 0.0f;   // seconds left before an at-bottom position may re-arm auto-scroll

// Message text selection: click-drag inside a bubble selects a byte range of its body; while a range is
// active a small copy button appears at the bubble's top on the opposite side. Offsets index m.body bytes.
std::string s_msgsel_cid;          // conversation of the selected message ("" = no selection)
int         s_msgsel_index  = -1;  // message index within that conversation
int         s_msgsel_anchor = 0;   // drag-start byte offset
int         s_msgsel_head   = 0;   // drag-current byte offset
bool        s_msgsel_dragging = false;

// Composer + new-conversation UI state.
char s_compose[512] = "";
std::string s_compose_cid;   // the conversation s_compose is a draft for; draft is wiped when it changes
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
// instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1;
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17;   // = 236
float s_composerAnimH = 0.0f;   // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame

// Composer InputText callback: insert a newline at the cursor on Shift+Enter. ImGui 1.92's Enter handling
// (imgui_widgets.cpp:5078) resolves Enter via Shortcut() with EXACT modifiers, so Shift+Enter matches
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
    // Track the live caret so the emoji picker can insert at the cursor. This callback runs under
    // CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses
    // focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position.
    s_composeCursor = data->CursorPos;
    ImGuiIO& io = ImGui::GetIO();
    if (io.KeyShift
        && (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
        && data->BufTextLen < kChatBodyMaxBytes) {
        data->InsertChars(data->CursorPos, "\n");
        s_composeCursor = data->CursorPos;   // InsertChars advanced the caret past the newline
    }
    return 0;
}
bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = "";       // conversation-list filter (Q8)
bool s_show_hidden = false;   // when on, the list also shows hidden conversations (with an Unhide action)
bool s_show_delete_confirm = false;  // "Delete conversation?" confirm overlay (revive vs block)
std::string s_delete_cid;            // conversation targeted by the delete confirm
std::string s_delete_name;           // its peer name (for the confirm copy / block-list label)
bool s_show_blocked = false;         // blocked-conversations manager overlay (unblock)
bool s_show_emoji_picker = false;  // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search[48] = "";      // emoji picker keyword filter

// Inline contact rename (Tier C): the conversation whose header name is being edited (empty = none),
// the edit buffer, and a one-shot flag to grab keyboard focus the frame the field appears.
std::string s_rename_cid;
char s_rename_buf[128] = "";   // matches the Contacts tab's label editor capacity (no silent truncation)
bool s_rename_focus = false;

// Chat customization modal (opened by the header settings "notch"). The same controls also render in
// Settings → Chat & Contacts via RenderChatSettingsControls().
bool s_show_chat_settings = false;

// Resolved once per frame from the chat/global timestamp settings — read by formatTime() (which has no
// App access). true => 12-hour clock in the Chat tab.
bool s_chat_time_12h = false;

// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }

// Case-insensitive substring match (ASCII) for the conversation search (Q8).
bool containsCI(const std::string& hay, const std::string& needle) {
    if (needle.empty()) return true;
    const auto it = std::search(hay.begin(), hay.end(), needle.begin(), needle.end(),
        [](char a, char b) {
            return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
        });
    return it != hay.end();
}

std::string shorten(const std::string& s, std::size_t head = 12, std::size_t tail = 6) {
    if (s.size() <= head + tail + 3) return s;
    return s.substr(0, head) + "..." + s.substr(s.size() - tail);
}

std::string formatTime(std::int64_t ts) {
    if (ts <= 0) return "";
    std::time_t t = static_cast<std::time_t>(ts);
    std::tm* tm = std::localtime(&t);  // UI thread only
    if (!tm) return "";
    char buf[40];
    std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%Y-%m-%d %I:%M %p" : "%Y-%m-%d %H:%M", tm);
    return buf;
}

// Time-of-day only (no date), honoring the chat clock. Used on grouped meta lines + hover (the date
// now lives in a per-day separator, so the group header doesn't repeat it). UI thread only.
std::string chatTimeOnly(std::int64_t ts) {
    if (ts <= 0) return "";
    std::time_t t = static_cast<std::time_t>(ts);
    std::tm* tm = std::localtime(&t);
    if (!tm) return "";
    char buf[24];
    std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%I:%M %p" : "%H:%M", tm);
    return buf;
}

// Local calendar-day key (YYYYMMDD) for detecting day boundaries between messages. UI thread only.
std::int64_t chatDayKey(std::int64_t ts) {
    std::time_t t = static_cast<std::time_t>(ts);
    std::tm* tm = std::localtime(&t);
    if (!tm) return -1;
    return static_cast<std::int64_t>(tm->tm_year + 1900) * 10000 +
           static_cast<std::int64_t>(tm->tm_mon + 1) * 100 + tm->tm_mday;
}

// Date-separator label: "Today" / "Yesterday" / "Jul 16, 2026". UI thread only.
std::string chatDaySeparator(std::int64_t ts, std::int64_t todayKey, std::int64_t yestKey) {
    const std::int64_t k = chatDayKey(ts);
    if (k == todayKey) return TR("chat_today");
    if (k == yestKey)  return TR("chat_yesterday");
    std::time_t t = static_cast<std::time_t>(ts);
    std::tm* tm = std::localtime(&t);
    if (!tm) return "";
    char buf[32];
    std::strftime(buf, sizeof(buf), "%b %d, %Y", tm);
    return buf;
}

// Compact relative time for the list ("now", "5m", "3h", "2d", then "Mon DD"). UI thread only.
std::string relativeTime(std::int64_t ts) {
    if (ts <= 0) return "";
    std::int64_t d = static_cast<std::int64_t>(std::time(nullptr)) - ts;
    if (d < 0) d = 0;
    if (d < 45)          return TR("chat_time_now");
    if (d < 3600)        return std::to_string(d / 60)    + "m";
    if (d < 86400)       return std::to_string(d / 3600)  + "h";
    if (d < 7 * 86400)   return std::to_string(d / 86400) + "d";
    std::time_t t = static_cast<std::time_t>(ts);
    std::tm* tm = std::localtime(&t);
    if (!tm) return "";
    char buf[16];
    std::strftime(buf, sizeof(buf), "%b %d", tm);
    return buf;
}

// One line of collapsed body text for the list preview (newlines flattened).
std::string previewOf(const std::string& body) {
    std::string out = body;
    std::replace(out.begin(), out.end(), '\n', ' ');
    std::replace(out.begin(), out.end(), '\r', ' ');
    return out;
}

// A stable, legible avatar color for a conversation (FNV-1a of the cid → a fixed material palette).
ImU32 avatarColor(const std::string& seed) {
    std::uint32_t h = 2166136261u;
    for (unsigned char ch : seed) { h ^= ch; h *= 16777619u; }
    static const ImU32 kPalette[] = {
        IM_COL32(0xEF,0x53,0x50,255), IM_COL32(0xAB,0x47,0xBC,255), IM_COL32(0x5C,0x6B,0xC0,255),
        IM_COL32(0x29,0xB6,0xF6,255), IM_COL32(0x26,0xA6,0x9A,255), IM_COL32(0x66,0xBB,0x6A,255),
        IM_COL32(0xFF,0xA7,0x26,255), IM_COL32(0x8D,0x6E,0x63,255),
    };
    return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
}

// Lay out (and optionally draw) a chat message body with paragraph spacing: each '\n'-delimited paragraph
// word-wraps at wrapW, and every paragraph after the first gets `paraGap` of extra leading ABOVE it — so
// an explicit newline reads as a paragraph break (a bit more space than a soft wrap, which stays tight).
// Returns the total laid-out size (max wrapped width × total height). When dl != nullptr, draws at `origin`.
// Both the size pass (dl == nullptr) and the draw pass MUST use identical args so the bubble fits the text.
ImVec2 layoutChatBody(ImDrawList* dl, ImFont* font, float size, const std::string& body,
                      float wrapW, float paraGap, ImVec2 origin, ImU32 col) {
    float maxW = 0.0f, y = 0.0f;
    bool first = true;
    size_t start = 0;
    for (;;) {
        const size_t nl = body.find('\n', start);
        const bool last = (nl == std::string::npos);
        const char* pbeg = body.c_str() + start;
        const char* pend = body.c_str() + (last ? body.size() : nl);
        if (!first) y += paraGap;                                   // paragraph break gets extra room
        const ImVec2 psz = font->CalcTextSizeA(size, FLT_MAX, wrapW, pbeg, pend);
        const float ph = std::max(psz.y, size);                    // a blank line still takes one line
        if (dl && pend > pbeg)
            dl->AddText(font, size, ImVec2(origin.x, origin.y + y), col, pbeg, pend, wrapW);
        y += ph;
        maxW = std::max(maxW, psz.x);
        first = false;
        if (last) break;
        start = nl + 1;
    }
    return ImVec2(maxW, y);
}

// One wrapped visual line of a laid-out chat body: byte range [b,e) into body + its y-offset from the
// body origin (each line is `size` tall). Mirrors layoutChatBody's paragraph/wrap walk so selection
// hit-testing and highlighting align with the drawn text.
struct ChatBodyLine { std::size_t b, e; float y; };

std::vector<ChatBodyLine> chatBodyLines(ImFont* font, float size, const std::string& body,
                                        float wrapW, float paraGap) {
    std::vector<ChatBodyLine> out;
    const float scale = size / std::max(1.0f, font->LegacySize);
    float y = 0.0f; bool first = true; std::size_t start = 0;
    for (;;) {
        const std::size_t nl = body.find('\n', start);
        const bool last = (nl == std::string::npos);
        const std::size_t pend = last ? body.size() : nl;
        if (!first) y += paraGap;
        std::size_t p = start;
        if (p >= pend) { out.push_back({p, p, y}); y += size; }   // blank paragraph = one empty line
        else {
            while (p < pend) {
                const char* w = font->CalcWordWrapPositionA(scale, body.c_str() + p, body.c_str() + pend, wrapW);
                std::size_t we = (w > body.c_str() + p) ? static_cast<std::size_t>(w - body.c_str()) : p + 1;
                if (we > pend) we = pend;
                out.push_back({p, we, y});
                y += size;
                p = we;
                while (p < pend && body[p] == ' ') ++p;   // AddText skips blanks at a wrap; match it
            }
        }
        first = false;
        if (last) break;
        start = nl + 1;
    }
    return out;
}

// Advance one UTF-8 codepoint (byte offset) within [.. limit).
inline std::size_t chatNextCp(const std::string& s, std::size_t i, std::size_t limit) {
    if (i >= limit) return limit;
    ++i;
    while (i < limit && (static_cast<unsigned char>(s[i]) & 0xC0) == 0x80) ++i;
    return i;
}

// Map a point to the nearest body byte offset (for drag selection). origin is the body's top-left.
std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, float wrapW,
                            float paraGap, ImVec2 origin, ImVec2 pt) {
    const auto lines = chatBodyLines(font, size, body, wrapW, paraGap);
    if (lines.empty()) return 0;
    const ChatBodyLine* L = &lines.front();
    for (const auto& ln : lines) { if (pt.y >= origin.y + ln.y) L = &ln; else break; }
    const float relX = pt.x - origin.x;
    std::size_t best = L->b; float bestDx = 1e9f;
    for (std::size_t i = L->b;; i = chatNextCp(body, i, L->e)) {
        const float w = font->CalcTextSizeA(size, FLT_MAX, 0.0f, body.c_str() + L->b, body.c_str() + i).x;
        const float dx = std::fabs(w - relX);
        if (dx < bestDx) { bestDx = dx; best = i; }
        if (i >= L->e) break;
    }
    return best;
}

// The custom-DragonX-emoji shortcode. Inserted by the emoji picker's DragonX tile; rendered inline as
// the mark in chat bubbles (see layoutChatBodyRich). Plain text on clients that don't support it.
static const char* kDrgxToken = ":drgx:";

// Lay out (and optionally draw) a message body that contains one or more ":drgx:" tokens, flowing text
// WORDS and the inline DragonX-emoji image left-to-right and wrapping at wrapW (each drgx renders as a
// `size`-square image). Only used for bodies that actually contain the token — plain bodies keep the
// tighter layoutChatBody path — so normal messages are unaffected. Returns the total laid-out size.
ImVec2 layoutChatBodyRich(ImDrawList* dl, ImFont* font, float size, const std::string& body, float wrapW,
                          float paraGap, ImVec2 origin, ImU32 col, ImTextureID drgxTex) {
    const float spaceW = font->CalcTextSizeA(size, FLT_MAX, 0.0f, " ").x;
    const float emojiW = size;                 // square, matching the line height
    const std::size_t tokLen = std::strlen(kDrgxToken);
    float maxW = 0.0f, y = 0.0f, x = 0.0f;
    bool firstPara = true, firstOnLine = true;
    // Place a token of width w on the current line, wrapping first if it doesn't fit; draw() gets the
    // token's top-left screen position.
    auto place = [&](float w, const std::function<void(float, float)>& draw) {
        const float lead = firstOnLine ? 0.0f : spaceW;
        if (!firstOnLine && x + lead + w > wrapW) { x = 0.0f; y += size; firstOnLine = true; }
        const float sx = x + (firstOnLine ? 0.0f : spaceW);
        if (dl) draw(origin.x + sx, origin.y + y);
        x = sx + w;
        if (x > maxW) maxW = x;
        firstOnLine = false;
    };
    std::size_t pstart = 0;
    for (;;) {
        const std::size_t nl = body.find('\n', pstart);
        const bool lastPara = (nl == std::string::npos);
        const std::size_t pend = lastPara ? body.size() : nl;
        if (!firstPara) y += paraGap;
        x = 0.0f; firstOnLine = true;
        std::size_t i = pstart;
        while (i < pend) {
            if (body[i] == ' ' || body[i] == '\t') { ++i; continue; }
            if (body.compare(i, tokLen, kDrgxToken) == 0) {
                place(emojiW, [&](float px, float py) {
                    if (drgxTex) dl->AddImage(drgxTex, ImVec2(px, py), ImVec2(px + emojiW, py + size));
                    else dl->AddText(font, size, ImVec2(px, py), col, kDrgxToken);   // fallback: literal
                });
                i += tokLen;
                continue;
            }
            std::size_t j = i;
            while (j < pend && body[j] != ' ' && body[j] != '\t' && body.compare(j, tokLen, kDrgxToken) != 0) ++j;
            const std::string word = body.substr(i, j - i);
            const float ww = font->CalcTextSizeA(size, FLT_MAX, 0.0f, word.c_str()).x;
            place(ww, [&, word](float px, float py) { dl->AddText(font, size, ImVec2(px, py), col, word.c_str()); });
            i = j;
        }
        y += size;
        firstPara = false;
        if (lastPara) break;
        pstart = nl + 1;
    }
    return ImVec2(maxW, y);
}

// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor(int accent) {
    switch (accent) {
        case 1: return IM_COL32(0x42, 0x85, 0xF4, 255);   // blue
        case 2: return IM_COL32(0x34, 0xA8, 0x53, 255);   // green
        case 3: return IM_COL32(0x9C, 0x27, 0xB0, 255);   // purple
        case 4: return IM_COL32(0xFF, 0xA7, 0x26, 255);   // amber
        case 5: return IM_COL32(0xEC, 0x40, 0x7A, 255);   // pink
        default: return material::Primary();               // theme
    }
}

// Uppercase first glyph of a display name (UTF-8 aware) for a letter-avatar.
std::string initialOf(const std::string& name) {
    std::size_t i = 0;
    while (i < name.size() && static_cast<unsigned char>(name[i]) <= ' ') ++i;
    if (i >= name.size()) return "?";
    const unsigned char c = name[i];
    if (c < 0x80) {
        const char u = (c >= 'a' && c <= 'z') ? static_cast<char>(c - 32) : static_cast<char>(c);
        return std::string(1, u);
    }
    const std::size_t len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : 2;  // UTF-8 lead byte → sequence length
    return name.substr(i, std::min(len, name.size() - i));
}

// Live "conversation preview" for the chat settings modal. Renders a few canned bubbles with the SAME
// style / accent / fill / rounding / density / font-size / timestamp / emoji code the real message loop
// uses (see the loop in RenderChatTab), so the modal shows exactly what chat will look like. Purely
// presentational — no hover/click handlers. Reserves a `width`-wide, content-tall block via a Dummy.
void RenderChatSettingsPreview(App* app, float width) {
    auto* cs = app ? app->settings() : nullptr;
    if (!cs) return;
    const float dp = Layout::dpiScale();
    ImDrawList* dl = ImGui::GetWindowDrawList();

    // ---- Settings-derived draw params (mirror the real loop's formulas exactly). ----
    const int   bubbleStyle = cs->getChatBubbleStyle();          // 0 rounded, 1 square, 2 minimal
    const bool  compact     = cs->getChatDensity() == 1;
    const float fscale      = cs->getChatFontScale();
    ImFont* nameFont = material::Type().body2();
    ImFont* metaFont = material::Type().caption();
    const float nameSz = scaledSize(nameFont);
    const float metaSz = scaledSize(metaFont);
    const float bodySz = nameSz * (18.0f / std::max(1.0f, Layout::kFontBody2())) * fscale;
    const float bpad     = (compact ? 6.0f : 9.0f) * dp;
    const float bround   = (bubbleStyle == 1 ? 2.0f : 9.0f) * dp;
    const int   outFill  = (bubbleStyle == 2 ? 26 : 46);
    const int   inFill   = (bubbleStyle == 2 ? 14 : 22);
    const float groupGap = (compact ? 4.0f : 7.0f) * dp;
    const float msgGap   = (compact ? 2.0f : 3.0f) * dp;
    const ImU32 accentBase = bubbleAccentColor(cs->getChatBubbleAccent());
    const float avR    = 11.0f * dp;
    const float gutter = 2.0f * avR + 6.0f * dp;

    // Resolve the chat clock the same way line ~379 does, but locally so the preview reflects the
    // timestamp control the SAME frame it changes (0 = follow global, 1 = 24h, 2 = 12h).
    const int ctf = cs->getChatTimeFormat();
    const bool prev12h = (ctf == 2) || (ctf == 0 && cs->getTimeFormat() == 1);
    const char* t1 = prev12h ? "9:41 AM" : "09:41";
    const char* t2 = prev12h ? "9:42 AM" : "09:42";

    // Canned conversation: one incoming bubble + a grouped outgoing pair (to show the flattened corner
    // of the rounded/minimal styles). Emoji in the bodies show the mono/color emoji style.
    struct PMsg { const char* body; bool outgoing; bool startGroup; bool lastInGroup; std::string meta; };
    const std::string peer = "Ava";
    const PMsg msgs[] = {
        { TR("grpb_preview_msg_payment_through"), false, true,  true,  peer + "  " + t1 },
        { TR("grpb_preview_msg_yep_confirmed"),       true,  true,  false, std::string(TR("chat_you")) + "  " + t2 },
        { TR("grpb_preview_msg_sending_rest"),         true,  false, true,  std::string() },
    };
    const int N = 3;

    // Panel geometry + per-message layout (pre-measured so the glass panel is drawn behind at the exact
    // height, since the draw list has no retained ordering).
    const float padIn = 10.0f * dp;
    const float contentW = std::max(80.0f * dp, width - 2.0f * padIn);
    const float maxBubbleW = std::max(120.0f * dp, contentW * 0.80f);
    const float innerW = maxBubbleW - 2.0f * bpad;
    struct PLay { ImVec2 tsz; float bw; float bh; };
    PLay lay[3];
    float totalH = padIn;
    for (int i = 0; i < N; ++i) {
        if (msgs[i].startGroup) totalH += (i > 0 ? groupGap : 0.0f) + metaSz + 3.0f * dp;
        else                    totalH += msgGap;
        lay[i].tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, msgs[i].body);
        lay[i].bw  = std::min(maxBubbleW, lay[i].tsz.x + 2.0f * bpad);
        lay[i].bh  = lay[i].tsz.y + 2.0f * bpad;
        totalH += lay[i].bh;
    }
    totalH += padIn;

    const ImVec2 origin = ImGui::GetCursorScreenPos();
    material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 36;
    material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g);

    const float leftX = origin.x + padIn;
    const float rowW  = contentW;
    float cy = origin.y + padIn;
    for (int i = 0; i < N; ++i) {
        const bool outgoing = msgs[i].outgoing;
        if (msgs[i].startGroup) {
            if (i > 0) cy += groupGap;
            const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, msgs[i].meta.c_str());
            const float mtx = outgoing ? (leftX + rowW - msz.x) : (leftX + gutter);
            dl->AddText(metaFont, metaSz, ImVec2(mtx, cy), material::OnSurfaceMedium(), msgs[i].meta.c_str());
            cy += metaSz + 3.0f * dp;
        } else {
            cy += msgGap;
        }
        const float bw = lay[i].bw, bh = lay[i].bh;
        const float bx = outgoing ? (leftX + rowW - bw) : (leftX + gutter);
        const ImVec2 bmin(bx, cy), bmax(bx + bw, cy + bh);
        const ImU32 bubCol = outgoing ? material::WithAlpha(accentBase, outFill)
                                      : material::WithAlpha(material::OnSurface(), inFill);
        ImDrawFlags rf = ImDrawFlags_RoundCornersAll;
        if (bubbleStyle != 1) {   // square keeps uniform corners; rounded/minimal flatten stacked corners
            if (outgoing) {
                if (!msgs[i].startGroup)  rf &= ~ImDrawFlags_RoundCornersTopRight;
                if (!msgs[i].lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomRight;
            } else {
                if (!msgs[i].startGroup)  rf &= ~ImDrawFlags_RoundCornersTopLeft;
                if (!msgs[i].lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomLeft;
            }
        }
        dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
        dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cy + bpad), material::OnSurface(),
                    msgs[i].body, nullptr, innerW);
        if (!outgoing && msgs[i].lastInGroup) {   // peer avatar beside the last incoming bubble of the run
            const ImVec2 avc(leftX + avR, cy + bh - avR);
            dl->AddCircleFilled(avc, avR, avatarColor("preview:" + peer), 20);
            const std::string init = initialOf(peer);
            const ImVec2 isz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, init.c_str());
            dl->AddText(metaFont, metaSz, ImVec2(avc.x - isz.x * 0.5f, avc.y - isz.y * 0.5f),
                        IM_COL32(255, 255, 255, 235), init.c_str());
        }
        cy += bh;
    }
    ImGui::Dummy(ImVec2(width, totalH));
}

// Render a peer identity key (hex) as a groupable fingerprint for the verify tooltip: uppercased,
// spaced every 4 hex chars, wrapped every 16 (8 bytes) so two users can read it off line by line.
std::string keyFingerprint(const std::string& hex) {
    std::string out;
    out.reserve(hex.size() + hex.size() / 4);
    for (std::size_t i = 0; i < hex.size(); ++i) {
        const unsigned char c = static_cast<unsigned char>(hex[i]);
        out += static_cast<char>(std::toupper(c));
        if ((i % 4) == 3 && i + 1 < hex.size()) out += ((i % 16) == 15) ? '\n' : ' ';
    }
    return out;
}

struct ConvSummary {
    std::string cid;
    std::string peerZaddr;
    std::string peerPubKey;  // peer crypto_kx key (from a received memo); empty => can't reply yet
    std::string peerName;    // contact label if known, else a shortened z-addr / cid
    std::string lastBody;
    std::int64_t lastTs = 0;
    int count = 0;
    bool hidden = false;     // shown only while "Show hidden" is on
};

// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them
// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild.
std::vector<ConvSummary> s_convs;
int s_convsHidden = 0;
std::uint64_t s_convsKey = ~0ull;
std::string s_threadCid;
std::uint64_t s_threadRev = ~0ull;
std::vector<chat::ChatMessage> s_threadMsgs;

// Centered, muted, wrapped hint for the empty states.
void centeredHint(const char* text) {
    ImVec2 avail = ImGui::GetContentRegionAvail();
    ImGui::PushFont(material::Type().body2());
    const float wrap = std::min(avail.x - 40.0f, 420.0f);
    const ImVec2 sz = ImGui::CalcTextSize(text, nullptr, false, wrap);
    ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPos().x + std::max(0.0f, (avail.x - sz.x) * 0.5f),
                               ImGui::GetCursorPos().y + std::max(0.0f, (avail.y - sz.y) * 0.5f)));
    ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
    ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
    ImGui::TextUnformatted(text);
    ImGui::PopTextWrapPos();
    ImGui::PopStyleColor();
    ImGui::PopFont();
}

// Centered empty state: big muted icon + title + optional wrapped hint (V4).
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
    const ImVec2 avail = ImGui::GetContentRegionAvail();
    const ImVec2 origin = ImGui::GetCursorPos();
    ImFont* iconF  = material::Type().iconXL();
    ImFont* titleF = material::Type().subtitle1();
    ImFont* hintF  = material::Type().body2();
    const float gap  = 8.0f * Layout::dpiScale();
    const float wrap = std::min(avail.x - 40.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
    const float iconSz = iconF ? scaledSize(iconF) : 40.0f;
    const float iconH  = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f;
    const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y;
    const float hintH  = hint ? hintF->CalcTextSizeA(scaledSize(hintF), wrap, wrap, hint).y : 0.0f;
    const float totalH = iconH + gap + titleH + (hint ? gap + hintH : 0.0f);
    float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);

    if (iconF) {
        const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).x;
        ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
        ImGui::PushFont(iconF);
        ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 70));
        ImGui::TextUnformatted(icon);
        ImGui::PopStyleColor(); ImGui::PopFont();
        y += iconH + gap;
    }
    {
        const float tw = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).x;
        ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
        ImGui::PushFont(titleF);
        ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
        ImGui::TextUnformatted(title);
        ImGui::PopStyleColor(); ImGui::PopFont();
        y += titleH + gap;
    }
    if (hint) {
        ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
        ImGui::PushFont(hintF);
        ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 120));
        ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
        ImGui::TextUnformatted(hint);
        ImGui::PopTextWrapPos();
        ImGui::PopStyleColor(); ImGui::PopFont();
    }
}

// Common single-codepoint emoji (all verified present in the bundled NotoEmoji subset), each with
// keyword text for the picker's search box. Single-codepoint only — ImGui does no shaping, so ZWJ
// sequences / flags wouldn't compose.
struct EmojiEntry { const char* glyph; const char* keywords; };
static const EmojiEntry kEmoji[] = {
    {u8"😀","grin happy smile"},{u8"😃","smile happy joy"},{u8"😄","laugh happy smile"},{u8"😁","grin beam happy"},
    {u8"😆","laugh haha"},{u8"😅","sweat laugh nervous"},{u8"😂","joy laugh tears cry funny"},{u8"🤣","rofl laugh rolling funny"},
    {u8"😊","blush smile happy"},{u8"😇","angel innocent halo"},{u8"🙂","slight smile"},{u8"🙃","upside down silly"},
    {u8"😉","wink"},{u8"😌","relieved calm"},{u8"😍","heart eyes love"},{u8"🥰","love hearts adore"},{u8"😘","kiss blow"},
    {u8"😗","kiss"},{u8"😙","kiss smile"},{u8"😚","kiss closed"},{u8"😋","yum tasty tongue"},{u8"😛","tongue playful"},
    {u8"😝","tongue squint"},{u8"😜","wink tongue crazy"},{u8"🤪","zany crazy silly"},{u8"🤨","raised eyebrow suspicious"},
    {u8"🧐","monocle thinking"},{u8"🤓","nerd geek glasses"},{u8"😎","cool sunglasses"},{u8"🥳","party celebrate hat"},
    {u8"😏","smirk"},{u8"😒","unamused meh"},{u8"😞","disappointed sad"},{u8"😔","pensive sad"},{u8"😟","worried"},
    {u8"😕","confused"},{u8"🙁","frown sad"},{u8"😣","persevere"},{u8"😖","confounded"},{u8"😫","tired"},{u8"😩","weary"},
    {u8"🥺","pleading puppy beg"},{u8"😢","cry sad tear"},{u8"😭","sob cry bawl"},{u8"😤","huff triumph steam"},
    {u8"😠","angry mad"},{u8"😡","rage mad angry"},{u8"🤬","curse swear angry"},{u8"🤯","mind blown shock"},
    {u8"😳","flushed embarrassed"},{u8"🥵","hot heat sweat"},{u8"🥶","cold freezing"},{u8"😱","scream fear shock"},
    {u8"😨","fearful scared"},{u8"😰","anxious sweat"},{u8"😥","sad sweat"},{u8"😓","sweat"},{u8"🤗","hug"},
    {u8"🤔","think hmm"},{u8"🤭","giggle oops hand"},{u8"🤫","shush quiet"},{u8"🤥","lie"},{u8"😶","no mouth blank"},
    {u8"😐","neutral meh"},{u8"😑","expressionless"},{u8"😬","grimace awkward"},{u8"🙄","eye roll"},{u8"😯","hushed surprise"},
    {u8"😦","frown open"},{u8"😧","anguished"},{u8"😮","wow surprise oh"},{u8"😲","astonished shock"},{u8"🥱","yawn tired bored"},
    {u8"😴","sleep zzz"},{u8"🤤","drool"},{u8"😪","sleepy"},{u8"🤐","zipper quiet secret"},{u8"🥴","woozy dizzy drunk"},
    {u8"🤢","nausea sick"},{u8"🤮","vomit sick"},{u8"🤧","sneeze sick"},{u8"😷","mask sick"},{u8"🤒","sick thermometer"},
    {u8"🤕","hurt bandage injured"},{u8"😈","devil imp evil"},{u8"👿","devil angry"},{u8"💀","skull dead"},{u8"👻","ghost boo"},
    {u8"👽","alien"},{u8"🤖","robot bot"},{u8"😺","cat smile"},{u8"🙀","cat scream"},
    // Dragon-themed set (DRGX flavour — searchable by "dragon"/"drgx"). All verified present in the
    // bundled mono NotoEmoji subset + the color Twemoji font.
    {u8"🐉","dragon drgx dragonx wyrm mythical creature"},{u8"🐲","dragon face drgx dragonx"},
    {u8"🐍","snake serpent reptile"},{u8"🦎","lizard reptile gecko"},{u8"🐊","crocodile croc reptile gator"},
    {u8"🐾","paw tracks claws prints"},{u8"🥚","egg dragon egg"},{u8"🪺","nest eggs hatch"},
    {u8"🏰","castle lair fortress keep"},{u8"⚔","swords battle crossed fight"},
    {u8"🛡","shield defense guard protect"},{u8"🗡","dagger blade sword knife"},
    {u8"👍","thumbs up like yes good"},{u8"👎","thumbs down dislike no bad"},{u8"👌","ok perfect"},{u8"✌","peace victory"},
    {u8"🤞","fingers crossed luck"},{u8"🤟","love you"},{u8"🤘","rock horns"},{u8"👏","clap applause"},
    {u8"🙌","raise hands celebrate"},{u8"👐","open hands"},{u8"🙏","pray thanks please"},{u8"💪","muscle strong flex"},
    {u8"👋","wave hi hello bye"},{u8"🤙","call shaka"},{u8"👊","fist punch bump"},{u8"✊","fist raised"},{u8"🤛","fist left"},
    {u8"🤜","fist right"},{u8"👆","up point"},{u8"👇","down point"},{u8"👈","left point"},{u8"👉","right point"},
    {u8"❤","heart red love"},{u8"🧡","heart orange"},{u8"💛","heart yellow"},{u8"💚","heart green"},{u8"💙","heart blue"},
    {u8"💜","heart purple"},{u8"🖤","heart black"},{u8"🤍","heart white"},{u8"💔","broken heart"},{u8"💕","hearts love"},
    {u8"💞","revolving hearts"},{u8"💗","growing heart"},{u8"💖","sparkling heart"},{u8"💘","heart arrow cupid"},
    {u8"💝","heart gift ribbon"},{u8"🔥","fire lit hot flame"},{u8"⭐","star"},{u8"🎉","party tada celebrate confetti"},
    {u8"🎊","confetti party"},{u8"✨","sparkles shiny"},{u8"💯","hundred perfect 100"},{u8"✅","check yes done tick"},
    {u8"❌","cross no wrong x"},{u8"❓","question mark"},{u8"❗","exclamation mark"},{u8"👀","eyes look"},{u8"🎂","cake birthday"},
    {u8"🚀","rocket launch"},{u8"💰","money bag cash"},{u8"🎁","gift present"},{u8"🍺","beer"},{u8"🍻","beers cheers"},
    {u8"☕","coffee tea"},{u8"🍕","pizza"},{u8"👑","crown king queen"},{u8"💎","diamond gem"},{u8"🌟","glowing star"},
    {u8"💥","boom explosion"},
};

// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
    // Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
    // kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the
    // caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap.
    // The composer is inactive whenever the picker is open, so it renders straight from buf — splicing
    // here shows immediately.
    auto insertToken = [&](const char* tok) {
        const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
        const std::size_t pos = (s_composeCursor < 0)
            ? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
        const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
        const std::size_t pad = needsSpace ? 1 : 0;
        if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
            std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1);  // shift tail right (incl NUL)
            if (needsSpace) buf[pos] = ' ';
            std::memcpy(buf + pos + pad, tok, add);
            s_composeCursor = static_cast<int>(pos + pad + add);   // keep the caret after the inserted token
        }
    };
    if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
    ImGui::SameLine();
    ImGui::SetNextItemWidth(-FLT_MIN);
    ImGui::InputTextWithHint("##emojisearch", TR("chat_emoji_search"), s_emoji_search, sizeof(s_emoji_search));
    ImGui::Separator();

    const std::string q = s_emoji_search;
    ImGui::BeginChild("##emojigrid", ImVec2(0, 0), false);
    // Frameless cells tiled to the full pane width. The cell is kept tight (so the emoji fills it
    // instead of floating in a big square); the leftover width is spread into the column gaps so the
    // grid still spans edge-to-edge (Q12 polish — "scale to container width", denser variant).
    ImFont* emojiFont = material::Type().subtitle1();   // emoji-capable + larger than body
    const float dp = Layout::dpiScale();
    const float cell   = 30.0f * dp;                    // tight square around the glyph
    const float minGap = 5.0f * dp;
    const float availW = ImGui::GetContentRegionAvail().x;
    const int   perRow = std::max(1, static_cast<int>((availW + minGap) / (cell + minGap)));
    const float spacing = perRow > 1
        ? std::clamp((availW - perRow * cell) / (perRow - 1), minGap, minGap * 3.0f)
        : minGap;
    material::IconButtonStyle es;
    es.hoverBg    = material::WithAlpha(material::OnSurface(), 34);
    es.bgRounding = 8.0f * dp;
    ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
    int shown = 0;
    // DragonX custom emoji tile (first cell) — inserts the ":drgx:" shortcode, which renders inline as the
    // mark in message bubbles. Shown unless the search filter excludes "dragon"/"drgx".
    if (q.empty() || containsCI("dragon drgx dragonx wyrm", q)) {
        ImGui::PushID("drgxtile");
        const ImVec2 tp = ImGui::GetCursorScreenPos();
        ImGui::InvisibleButton("##drgx", ImVec2(cell, cell));
        ImDrawList* gdl = ImGui::GetWindowDrawList();
        if (ImGui::IsItemHovered()) gdl->AddRectFilled(tp, ImVec2(tp.x + cell, tp.y + cell), es.hoverBg, es.bgRounding);
        if (drgxTex) gdl->AddImage(drgxTex, tp, ImVec2(tp.x + cell, tp.y + cell));
        else        gdl->AddText(emojiFont, emojiFont->LegacySize, tp, material::OnSurface(), u8"🐉");
        if (ImGui::IsItemClicked()) insertToken(kDrgxToken);
        ImGui::PopID();
        if (++shown % perRow != 0) ImGui::SameLine();
    }
    for (const auto& e : kEmoji) {
        if (!q.empty() && !containsCI(e.keywords, q)) continue;
        ImGui::PushID(shown);
        if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es))
            insertToken(e.glyph);
        ImGui::PopID();
        if (++shown % perRow != 0) ImGui::SameLine();
    }
    ImGui::PopStyleVar();
    ImGui::EndChild();
}

} // namespace

void RenderChatTab(App* app)
{
    auto& service = app->chatService();
    const auto& store = service.store();
    auto& book = app->addressBook();

    // Not unlocked / identity not derived yet → nothing to show.
    if (!service.hasIdentity()) {
        centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
        return;
    }

    // Resolve the Chat-tab clock once per frame: the chat override wins, else the global format.
    if (auto* cst = app->settings()) {
        const int ctf = cst->getChatTimeFormat();   // 0=follow global, 1=24h, 2=12h
        s_chat_time_12h = (ctf == 2) || (ctf == 0 && cst->getTimeFormat() == 1);
    }

    // Build conversation summaries (single scan per conversation), sorted by most-recent activity.
    // MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
    // only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew
    // (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a
    // rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset
    // in ResetChatTab on wallet switch.
    const std::uint64_t convsKey =
        store.revision() * 1000003ull
        + static_cast<std::uint64_t>(s_show_hidden ? 1 : 0)
        + (book.revision() << 20);   // book.revision() catches in-place contact edits (rename) that keep size()
    if (convsKey != s_convsKey) {
        s_convs.clear();
        s_convsHidden = 0;
        for (const auto& cid : store.conversationIds()) {
            const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
            if (hidden) ++s_convsHidden;
            if (hidden && !s_show_hidden) continue;   // filtered out unless "Show hidden" is on
            const auto messages = store.conversation(cid);
            if (messages.empty()) continue;
            ConvSummary c;
            c.cid = cid;
            c.hidden = hidden;
            c.count = static_cast<int>(messages.size());
            for (const auto& m : messages) {  // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
                if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr;   // memo header rides
                if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
            }
            const auto& last = messages.back();
            c.lastBody = last.body;
            c.lastTs = last.timestamp;
            const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
            c.peerName = (idx >= 0) ? book.entries()[idx].label
                                    : shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
            s_convs.push_back(std::move(c));
        }
        std::sort(s_convs.begin(), s_convs.end(),
                  [](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
        s_convsKey = convsKey;
    }
    std::vector<ConvSummary>& convs = s_convs;
    int hiddenCount = s_convsHidden;

    // 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));
        }
        // Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no
        // stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock.
        const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0;
        if (blockedCount > 0) {
            const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")";
            ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
            if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
            ImGui::PopStyleColor();
        }
        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       = 5 + (hasAddr ? 1 : 0);  // export, mute, hide, delete, settings (+rename)
            const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);

            // Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
            const bool showLock  = hasAddr && !sel->peerPubKey.empty();
            const bool showWait  = hasAddr &&  sel->peerPubKey.empty();
            const std::string sa = hasAddr ? shorten(sel->peerZaddr, 10, 6) : std::string();
            ImFont* icoFont = material::Type().iconSmall();
            const float icoSz = scaledSize(icoFont);
            const float addrTextW = hasAddr ? metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, sa.c_str()).x : 0.0f;
            const float lockW     = showLock ? icoFont->CalcTextSizeA(icoSz, FLT_MAX, 0.0f, ICON_MD_LOCK).x + 5.0f * hdpi : 0.0f;
            const char* wl        = showWait ? TR("chat_awaiting_key") : "";
            const float waitTextW = showWait ? metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, wl).x : 0.0f;
            const float waitChipW = showWait ? waitTextW + 14.0f * hdpi + 8.0f * hdpi : 0.0f;   // chip pad + leading gap
            const float addrGroupW = lockW + addrTextW + waitChipW;
            const float nameSpacing = 12.0f * hdpi;
            const float nameCY  = hp.y + (rowH - nameH) * 0.5f;
            const float nameW   = subFont->CalcTextSizeA(subSz, FLT_MAX, 0.0f, sel->peerName.c_str()).x;
            const float nameAvail = std::max(40.0f * hdpi,
                toolbarLeft - gap - textX - (hasAddr ? addrGroupW + nameSpacing : 0.0f));

            // Name — or an inline rename field (Tier C) that takes the whole name+address span.
            if (renaming) {
                const float nameMaxW = std::max(60.0f * hdpi, toolbarLeft - gap - textX);
                ImGui::SetCursorScreenPos(ImVec2(textX, nameCY));
                ImGui::PushFont(subFont);
                ImGui::SetNextItemWidth(nameMaxW);
                if (s_rename_focus) { ImGui::SetKeyboardFocusHere(); s_rename_focus = false; }
                const bool commit = ImGui::InputTextWithHint("##renamepeer", TR("chat_rename_hint"),
                                        s_rename_buf, sizeof(s_rename_buf), ImGuiInputTextFlags_EnterReturnsTrue);
                const bool cancel = ImGui::IsItemDeactivated() && !commit;   // Escape or click-away
                ImGui::PopFont();
                if (commit) {
                    std::string nm = s_rename_buf;
                    while (!nm.empty() && std::isspace(static_cast<unsigned char>(nm.front()))) nm.erase(nm.begin());
                    while (!nm.empty() && std::isspace(static_cast<unsigned char>(nm.back())))  nm.pop_back();
                    if (!nm.empty() && hasAddr) {
                        if (bookIdx >= 0) {
                            if (nm != book.entries()[bookIdx].label) {   // skip a no-op rewrite/save
                                data::AddressBookEntry e = book.entries()[bookIdx];
                                e.label = nm;                   // keep address/notes/scope/avatar
                                book.updateEntry(static_cast<std::size_t>(bookIdx), e);
                                book.save();
                                Notifications::instance().success(TR("chat_renamed"));
                            }
                        } else if (book.addEntry(data::AddressBookEntry(nm, sel->peerZaddr))) {
                            book.save();
                            Notifications::instance().success(TR("chat_contact_added"));
                        } else {
                            Notifications::instance().error(TR("address_book_exists"));
                        }
                        s_convsKey = ~0ull;   // peerName changed — force the conversation-list memo to rebuild
                    }
                    s_rename_cid.clear();
                } else if (cancel) {
                    s_rename_cid.clear();
                }
            } else {
                ImGui::PushClipRect(ImVec2(textX, hp.y), ImVec2(textX + nameAvail, hp.y + rowH), true);
                hdl->AddText(subFont, subSz, ImVec2(textX, nameCY), material::OnSurface(), sel->peerName.c_str());
                ImGui::PopClipRect();
            }

            // Right-aligned icon toolbar: [add contact?] · export · mute · hide. Each is a frameless
            // IconButton with a hover pill + tooltip (the tooltip carries the wording the old text buttons had).
            {
                ImFont* ifont = material::Type().iconMed();
                material::IconButtonStyle base;
                base.color      = material::OnSurfaceMedium();
                base.hoverColor = material::OnSurface();
                base.hoverBg    = material::WithAlpha(material::OnSurface(), 30);
                base.bgRounding = 7.0f * hdpi;

                float bx = toolbarLeft;
                const float by = hp.y;

                // Rename (edit) the contact inline in the header. For a known peer it updates the label;
                // for an unknown one it creates the contact under the typed name (folds in the old
                // "add contact" quick action, but lets you name them) (Q2 / Tier C).
                if (hasAddr) {
                    ImGui::SetCursorScreenPos(ImVec2(bx, by));
                    material::IconButtonStyle a = base;
                    a.tooltip = known ? TR("chat_rename") : TR("chat_add_contact");
                    if (known) { a.color = material::OnSurfaceMedium(); }
                    if (material::IconButton("##hdr_rename",
                            known ? ICON_MD_EDIT : ICON_MD_PERSON_ADD, ifont, ImVec2(ib, ib), a)) {
                        s_rename_cid = sel->cid;
                        const std::string cur = known ? book.entries()[bookIdx].label : std::string();
                        const std::size_t n = std::min(cur.size(), sizeof(s_rename_buf) - 1);
                        std::memcpy(s_rename_buf, cur.data(), n);
                        s_rename_buf[n] = '\0';
                        s_rename_focus = true;
                    }
                    bx += ib + gap;
                }
                // Export the decrypted conversation to a plain-text file (Q11); the tooltip warns it's plaintext.
                {
                    ImGui::SetCursorScreenPos(ImVec2(bx, by));
                    material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
                    if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
                        std::string content = "DragonX chat export\n";
                        content += sel->peerName + "  <" + sel->peerZaddr + ">\n";
                        content += "cid: " + sel->cid + "\n\n";
                        for (const auto& m : store.conversation(sel->cid)) {
                            const bool out = (m.direction == chat::ChatDirection::Outgoing);
                            content += "[" + formatTime(m.timestamp) + "] " +
                                       (out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
                        }
                        std::string safe;
                        for (char ch : sel->peerName)
                            safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
                        if (safe.empty()) safe = "chat";
                        const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
                        if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
                            Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
                        else
                            Notifications::instance().error(TR("chat_export_failed"));
                    }
                    bx += ib + gap;
                }
                // Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings.
                {
                    ImGui::SetCursorScreenPos(ImVec2(bx, by));
                    material::IconButtonStyle a = base;
                    a.tooltip = muted ? TR("chat_unmute") : TR("chat_mute");
                    if (muted) { a.color = material::Primary(); a.hoverColor = material::Primary(); }
                    if (material::IconButton("##hdr_mute",
                            muted ? ICON_MD_NOTIFICATIONS_OFF : ICON_MD_NOTIFICATIONS,
                            ifont, ImVec2(ib, ib), a) && app->settings()) {
                        app->settings()->setChatMuted(sel->cid, !muted);
                        app->settings()->save();
                    }
                    bx += ib + gap;
                }
                // Hide / Unhide. Hiding drops it from the list (messages stay in the encrypted store);
                // Unhide (shown only for a hidden conversation while "Show hidden" is on) restores it.
                {
                    ImGui::SetCursorScreenPos(ImVec2(bx, by));
                    material::IconButtonStyle a = base;
                    a.tooltip = sel->hidden ? TR("chat_unhide") : TR("chat_hide");
                    if (material::IconButton("##hdr_hide",
                            sel->hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF,
                            ifont, ImVec2(ib, ib), a) && app->settings()) {
                        if (sel->hidden) {
                            app->settings()->setChatHidden(sel->cid, false);
                            app->settings()->save();
                        } else {
                            app->markChatConversationSeen(sel->cid, sel->lastTs);  // don't leave a phantom unread
                            app->settings()->setChatHidden(sel->cid, true);
                            app->settings()->save();
                            s_selected_cid.clear();
                            Notifications::instance().info(TR("chat_hidden_toast"));
                        }
                        s_convsKey = ~0ull;   // hidden-state changed — force the conversation-list memo to rebuild
                    }
                    bx += ib + gap;
                }
                // Delete — clears this conversation's LOCAL history. Destructive (and offers a
                // "delete & block" variant), so it opens a confirm dialog rather than acting inline.
                {
                    ImGui::SetCursorScreenPos(ImVec2(bx, by));
                    material::IconButtonStyle a = base;
                    a.tooltip = TR("chat_delete");
                    a.hoverColor = material::Error();
                    if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) {
                        s_delete_cid  = sel->cid;
                        s_delete_name = sel->peerName;
                        s_show_delete_confirm = true;
                    }
                    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);
                // MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
                // copies+sorts the match every call. Rebuild only when the open thread or the store changes,
                // not every frame while the thread is simply being read/scrolled.
                if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
                    s_threadMsgs = store.conversation(s_selected_cid);
                    s_threadCid  = s_selected_cid;
                    s_threadRev  = store.revision();
                }
                const auto& messages = s_threadMsgs;
                // Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
                // one meta header and stack tightly; a date pill is drawn once per calendar day.
                const std::int64_t nowTs    = static_cast<std::int64_t>(std::time(nullptr));
                const std::int64_t todayKey = chatDayKey(nowTs);
                const std::int64_t yestKey  = chatDayKey(nowTs - 86400);
                constexpr std::int64_t kGroupWindow = 300;   // 5 minutes
                const float avR    = 11.0f * dp;             // in-thread peer avatar radius
                const float gutter = 2.0f * avR + 6.0f * dp; // incoming bubbles indent past the avatar
                ImFont* icf = material::Type().iconSmall();
                const float icfSz = scaledSize(icf);
                std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1;
                bool anyMsgClicked = false;   // did a left-click this frame land on a bubble / copy button?
                const ImTextureID drgxTex = app->getDrgxEmojiTexture();   // ":drgx:" inline emoji
                for (std::size_t mi = 0; mi < messages.size(); ++mi) {
                    const auto& m = messages[mi];
                    ImGui::PushID(static_cast<int>(mi));
                    const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
                    const bool request  = (m.kind == chat::ChatMessageKind::ContactRequest);
                    const bool failed   = outgoing && m.delivery == chat::ChatDelivery::Failed;
                    const bool sending  = outgoing && m.delivery == chat::ChatDelivery::Sending;
                    const std::int64_t day = chatDayKey(m.timestamp);
                    const bool newDay = (day != prevDay);
                    const bool startGroup = newDay || (prevDir != static_cast<int>(outgoing)) ||
                                            (prevTs < 0) || (m.timestamp - prevTs > kGroupWindow);
                    // Does this bubble end its run? (for grouped-corner shaping + the avatar placement)
                    bool lastInGroup = true;
                    if (mi + 1 < messages.size()) {
                        const auto& n = messages[mi + 1];
                        const bool nOut = (n.direction == chat::ChatDirection::Outgoing);
                        lastInGroup = (chatDayKey(n.timestamp) != day) || (nOut != outgoing) ||
                                      (n.timestamp - m.timestamp > kGroupWindow);
                    }

                    const float availW = ImGui::GetContentRegionAvail().x;
                    const float maxBubbleW = std::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp);
                    const float innerW = maxBubbleW - 2.0f * bpad;

                    // ── Date separator (once per calendar day): a centered pill.
                    if (newDay) {
                        if (mi > 0) ImGui::Dummy(ImVec2(0.0f, groupGap + 4.0f * dp));
                        const std::string ds = chatDaySeparator(m.timestamp, todayKey, yestKey);
                        const ImVec2 dsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, ds.c_str());
                        const float dpx = 9.0f * dp, dpy = 2.0f * dp;
                        const ImVec2 dcur = ImGui::GetCursorScreenPos();
                        const float cxs = dcur.x + std::max(0.0f, (availW - dsz.x) * 0.5f);
                        dl->AddRectFilled(ImVec2(cxs - dpx, dcur.y), ImVec2(cxs + dsz.x + dpx, dcur.y + dsz.y + 2.0f * dpy),
                                          material::WithAlpha(material::OnSurface(), 24), (dsz.y + 2.0f * dpy) * 0.5f);
                        dl->AddText(metaFont, metaSz, ImVec2(cxs, dcur.y + dpy), material::OnSurfaceMedium(), ds.c_str());
                        ImGui::Dummy(ImVec2(availW, dsz.y + 2.0f * dpy + 4.0f * dp));
                    }

                    // ── Grouped meta line (sender + time-only), once per run, on the sender's side.
                    if (startGroup) {
                        if (mi > 0 && !newDay) ImGui::Dummy(ImVec2(0.0f, groupGap));
                        std::string meta = outgoing ? std::string(TR("chat_you")) : sel->peerName;
                        const std::string when = chatTimeOnly(m.timestamp);
                        if (!when.empty()) meta += "  " + when;
                        if (request) meta += "  [" + std::string(TR("chat_contact_request")) + "]";
                        const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, meta.c_str());
                        const ImVec2 mp = ImGui::GetCursorScreenPos();
                        dl->AddText(metaFont, metaSz,
                                    ImVec2(outgoing ? mp.x + availW - msz.x : mp.x + gutter, mp.y),
                                    material::OnSurfaceMedium(), meta.c_str());
                        ImGui::Dummy(ImVec2(availW, metaSz + 3.0f * dp));
                    }

                    // ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
                    // Body is laid out paragraph-by-paragraph so explicit newlines get extra leading.
                    // A body containing ":drgx:" uses the richer inline-image flow (drgx tile → mark);
                    // plain bodies keep the tighter text-only path so they're unaffected.
                    const float paraGap = bodySz * 0.35f;
                    const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos;
                    const ImVec2 tsz = hasDrgx
                        ? layoutChatBodyRich(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0, drgxTex)
                        : layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0);
                    const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
                    const float bh = tsz.y + 2.0f * bpad;
                    const ImVec2 cur = ImGui::GetCursorScreenPos();
                    const float bx = outgoing ? (cur.x + availW - bw) : (cur.x + gutter);
                    const ImVec2 bmin(bx, cur.y), bmax(bx + bw, cur.y + bh);
                    const ImU32 bubCol = failed   ? material::WithAlpha(material::Error(), 40)
                                       : outgoing ? material::WithAlpha(accentBase, outFill)
                                                  : material::WithAlpha(material::OnSurface(), inFill);
                    // Flatten the corner between stacked bubbles from the same sender (iMessage-style).
                    ImDrawFlags rf = ImDrawFlags_RoundCornersAll;
                    if (bubbleStyle != 1) {   // shape only rounded/minimal; square keeps its uniform corners
                        if (outgoing) {
                            if (!startGroup)  rf &= ~ImDrawFlags_RoundCornersTopRight;
                            if (!lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomRight;
                        } else {
                            if (!startGroup)  rf &= ~ImDrawFlags_RoundCornersTopLeft;
                            if (!lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomLeft;
                        }
                    }
                    dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);

                    // ── Text selection: click-drag inside the bubble selects a byte range of the body.
                    // Skipped for ":drgx:" messages — their inline-image layout (layoutChatBodyRich) doesn't
                    // match the plain-text hit-test/highlight geometry, so a selection would misalign.
                    // (Right-click "copy" still copies the whole message.)
                    const ImVec2 bodyOrigin(bx + bpad, cur.y + bpad);
                    const bool selfSel = (!hasDrgx && s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast<int>(mi));
                    if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
                        s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi);
                        s_msgsel_anchor = s_msgsel_head = static_cast<int>(
                            chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
                        s_msgsel_dragging = true;
                        anyMsgClicked = true;
                    }
                    if (selfSel && s_msgsel_dragging) {
                        if (ImGui::IsMouseDown(ImGuiMouseButton_Left))
                            s_msgsel_head = static_cast<int>(
                                chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
                        else
                            s_msgsel_dragging = false;
                    }
                    // Highlight the selected range (behind the text) — one rect per wrapped line segment.
                    if (selfSel && s_msgsel_anchor != s_msgsel_head) {
                        const int a = std::min(s_msgsel_anchor, s_msgsel_head);
                        const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
                        for (const auto& ln : chatBodyLines(nameFont, bodySz, m.body, innerW, paraGap)) {
                            const int ls = std::max(a, static_cast<int>(ln.b));
                            const int le = std::min(b2, static_cast<int>(ln.e));
                            if (ls >= le) continue;
                            const float x0 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + ls).x;
                            const float x1 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + le).x;
                            dl->AddRectFilled(ImVec2(bodyOrigin.x + x0, bodyOrigin.y + ln.y),
                                              ImVec2(bodyOrigin.x + x1, bodyOrigin.y + ln.y + bodySz),
                                              material::WithAlpha(material::Primary(), 80));
                        }
                    }
                    if (hasDrgx)
                        layoutChatBodyRich(dl, nameFont, bodySz, m.body, innerW, paraGap,
                                           ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), drgxTex);
                    else
                        layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap,
                                       ImVec2(bx + bpad, cur.y + bpad), material::OnSurface());

                    // Peer avatar beside the LAST incoming bubble of a run (Tier 3).
                    if (!outgoing && lastInGroup) {
                        const ImVec2 avc(cur.x + avR, cur.y + bh - avR);
                        dl->AddCircleFilled(avc, avR, avatarColor(sel->cid), 20);
                        const std::string init = initialOf(sel->peerName);
                        const ImVec2 isz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, init.c_str());
                        dl->AddText(metaFont, metaSz, ImVec2(avc.x - isz.x * 0.5f, avc.y - isz.y * 0.5f),
                                    IM_COL32(255, 255, 255, 235), init.c_str());
                    }
                    ImGui::Dummy(ImVec2(availW, bh));

                    // Hover: reveal this message's own time at the outer bubble edge + right-click copy.
                    if (ImGui::IsMouseHoveringRect(bmin, bmax)) {
                        const std::string ht = chatTimeOnly(m.timestamp);
                        const ImVec2 hsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, ht.c_str());
                        const float hx = outgoing ? (bmin.x - hsz.x - 6.0f * dp) : (bmax.x + 6.0f * dp);
                        dl->AddText(metaFont, metaSz, ImVec2(hx, bmin.y + (bh - hsz.y) * 0.5f),
                                    material::WithAlpha(material::OnSurface(), 120), ht.c_str());
                        if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) ImGui::OpenPopup("##msgmenu");
                    }
                    if (ImGui::BeginPopup("##msgmenu")) {
                        if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
                        ImGui::EndPopup();
                    }

                    // Copy button for an active selection — at the bubble TOP, on the OPPOSITE side (an
                    // incoming bubble sits left → button to its right; an outgoing bubble sits right →
                    // button to its left). Copies the selected substring of the body.
                    if (selfSel && s_msgsel_anchor != s_msgsel_head) {
                        const int a = std::min(s_msgsel_anchor, s_msgsel_head);
                        const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
                        const float cbs = 22.0f * dp;
                        const float cbx = outgoing ? (bmin.x - cbs - 4.0f * dp) : (bmax.x + 4.0f * dp);
                        const ImVec2 cbmin(cbx, bmin.y), cbmax(cbx + cbs, bmin.y + cbs);
                        const bool cbhov = ImGui::IsMouseHoveringRect(cbmin, cbmax);
                        dl->AddRectFilled(cbmin, cbmax, cbhov ? material::Primary()
                                                              : material::WithAlpha(material::Primary(), 210), 5.0f * dp);
                        const float gs = icfSz * 0.78f;
                        const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, ICON_MD_CONTENT_COPY);
                        dl->AddText(icf, gs, ImVec2(cbmin.x + (cbs - gsz.x) * 0.5f, cbmin.y + (cbs - gsz.y) * 0.5f),
                                    IM_COL32(255, 255, 255, 235), ICON_MD_CONTENT_COPY);
                        if (cbhov) {
                            ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                            if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
                                ImGui::SetClipboardText(m.body.substr(a, b2 - a).c_str());
                                anyMsgClicked = true;
                            }
                        }
                    }

                    // Delivery status for your messages: clock while sending, check once broadcast (Tier 2).
                    if (outgoing && !failed && (sending || lastInGroup)) {
                        const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
                        const float gs = icfSz * 0.82f;
                        const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, glyph);
                        dl->AddText(icf, gs, ImVec2(bmax.x - gsz.x, bmax.y + 1.0f * dp),
                                    material::WithAlpha(material::OnSurface(), sending ? 95 : 135), glyph);
                        ImGui::Dummy(ImVec2(0.0f, gs + 2.0f * dp));   // reserve the status glyph (no ItemSpacing now)
                    }
                    // Failed send → right-aligned "not sent" + Retry (kept — it's actionable).
                    if (failed) {
                        ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
                        const float ftw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_send_failed")).x;
                        const float rtw = ImGui::CalcTextSize(TR("chat_retry")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
                        ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - ftw - rtw - 8.0f * dp));
                        ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
                        ImGui::TextUnformatted(TR("chat_send_failed"));
                        ImGui::PopStyleColor(); ImGui::PopFont();
                        ImGui::SameLine();
                        if (ImGui::SmallButton(TR("chat_retry"))) {
                            // A failed contact request must be re-sent as a request (it has no peer key
                            // yet) into the SAME conversation — not routed through sendChatMessage,
                            // which would just say "waiting for reply".
                            if (request) app->sendContactRequestForCid(sel->cid, m.peer_zaddr, m.body);
                            else         app->sendChatMessage(sel->cid, m.body);
                            s_scroll_to_cid = sel->cid;
                        }
                    }
                    ImGui::Dummy(ImVec2(0.0f, lastInGroup ? msgGap : msgGap * 0.5f));   // tight within a run
                    ImGui::PopID();
                    prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day;
                }
                // A left-click inside the thread that hit no bubble or copy button clears the selection —
                // except a click on the jump-to-latest pill, which is navigation, not a deselect.
                if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !anyMsgClicked && ImGui::IsWindowHovered()
                    && !ImGui::IsMouseHoveringRect(jumpPillMin, jumpPillMax)) {
                    s_msgsel_cid.clear(); s_msgsel_index = -1; s_msgsel_dragging = false;
                }
                // Pin to the newest message when explicitly requested (send / conversation switch) or when
                // auto-scroll is armed and the thread was already at the bottom (so new messages follow).
                if (s_scroll_to_cid == s_selected_cid || (s_chat_auto_scroll && wasAtBottom)) {
                    ImGui::SetScrollHereY(1.0f);
                    s_scroll_to_cid.clear();
                }
                atBottom = ImGui::GetScrollMaxY() <= 0.0f || ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
                // Re-arm auto-scroll once the user returns to the bottom (after the wheel cooldown).
                if (atBottom && s_chat_scroll_cooldown <= 0.0f) s_chat_auto_scroll = true;

                // Jump-to-latest pill when scrolled up (Q9). Drawn LAST and INSIDE the thread child (on the
                // child's own draw list) so it sits on top of the messages — a parent-window draw after
                // EndChild renders UNDER the child and gets covered by the text. PushClipRect keeps the
                // child's content padding / scrollbar from clipping it. Hand-drawn + IsMouseHoveringRect so
                // the click lands despite the child owning hover here (a real widget would still miss it).
                if (!atBottom) {
                    const ImVec2 pmin = jumpPillMin, pmax = jumpPillMax;
                    const float pw = pmax.x - pmin.x, ph = pmax.y - pmin.y;
                    const bool hov = ImGui::IsMouseHoveringRect(pmin, pmax);
                    dl->PushClipRect(pmin, pmax, false);
                    dl->AddRectFilled(pmin, pmax,
                                      hov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
                                      ph * 0.5f);
                    ImFont* lf = ImGui::GetFont(); const float lsz = ImGui::GetFontSize();
                    const char* lbl = TR("chat_jump_latest");
                    const ImVec2 tszp = lf->CalcTextSizeA(lsz, FLT_MAX, 0.0f, lbl);
                    dl->AddText(lf, lsz, ImVec2(pmin.x + (pw - tszp.x) * 0.5f, pmin.y + (ph - tszp.y) * 0.5f),
                                IM_COL32(255, 255, 255, 235), lbl);
                    dl->PopClipRect();
                    if (hov) {
                        ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
                        if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
                            s_scroll_to_cid = sel->cid;
                            s_chat_auto_scroll = true;      // clicking Latest re-arms follow-the-newest
                            s_chat_scroll_cooldown = 0.0f;
                        }
                    }
                }
            }
            ImGui::EndChild();
            ImGui::PopStyleVar(2);  // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
            // Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
        } else {
            if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
            else               centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
        }
    }
    ImGui::EndChild();
    ImGui::PopStyleColor();   // ##ChatThread transparent ChildBg (GetItemRect below still reads the child)
    ImGui::PopStyleVar();     // ##ChatThread inner WindowPadding

    // ── Composer — OUTSIDE / below the bordered message box, spanning its width. No divider above it.
    // Layout (all placed by absolute screen pos so nothing wraps to the window's left edge): a thin
    // byte-counter row just under the box, then the input row flush with the strip/window bottom, with
    // the emoji toggle on the LEFT of the input and Send on the right.
    if (sel) {
        const ImVec2 boxMin = ImGui::GetItemRectMin();   // the ##ChatThread child's rect
        const ImVec2 boxMax = ImGui::GetItemRectMax();
        const float cx = boxMin.x;
        const float cw = boxMax.x - boxMin.x;
        if (sel->peerPubKey.empty()) {
            s_composerTargetH = composerCtrlH;   // no editable composer here — keep the strip collapsed
            ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp));
            ImGui::PushFont(metaFont);
            ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextUnformatted(TR("chat_waiting_reply"));
            ImGui::PopTextWrapPos();
            ImGui::PopStyleColor();
            ImGui::PopFont();
        } else {
            // Multi-line composer (Q6). Enter behavior is setting-dependent (see below). Input is
            // hard-capped to the on-chain body byte budget; usage is shown as a radial gauge in the box.
            const int   kBodyMaxBytes = kChatBodyMaxBytes;
            const float sendW   = 80.0f * tdp;   // scaled so the translated label never clips (B3)
            const float emojiBtn = 28.0f * tdp;
            const float inGap   = 6.0f * tdp;
            const int   used    = static_cast<int>(std::strlen(s_compose));
            const bool  overCap = used > kBodyMaxBytes;   // defensive — the hard byte cap should prevent this

            // Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
            const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;

            // Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
            // it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
            const float rowY   = boxMax.y + composerAreaH - composerBoxH;
            const float inputX = cx + emojiBtn + inGap;
            const float ctrlY  = rowY + composerBoxH - composerCtrlH;   // bottom-aligned row for side controls
            // Radial byte gauge sits in the input box's bottom-right corner; a fixed small size (tied to
            // the line height, NOT the animated box height) with a reserved right slot so text never
            // reaches it. Geometry first so the input width floor can guarantee room for both.
            const float ringR    = std::max(7.0f, lineH * 0.42f);
            const float ringPad  = 9.0f * tdp;
            const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
            const float inputW = std::min(720.0f * tdp,
                                           std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW));
            const float sendX  = inputX + inputW + inGap;
            const float textW  = std::max(40.0f * tdp, inputW - ringSlot);   // input area, left of the ring
            const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);

            // Emoji toggle on the left, bottom-aligned with the input row (centered in the control height).
            ImGui::SetCursorScreenPos(ImVec2(cx, ctrlY + (composerCtrlH - emojiBtn) * 0.5f));
            {
                material::IconButtonStyle es;
                es.hoverBg    = material::WithAlpha(material::OnSurface(), 30);
                es.bgRounding = 8.0f * tdp;
                if (s_show_emoji_picker) es.restBg = material::WithAlpha(material::Primary(), 46);
                if (material::IconButton("##emojitoggle", u8"🙂", material::Type().subtitle1(),
                                         ImVec2(emojiBtn, emojiBtn), es)) {
                    s_show_emoji_picker = !s_show_emoji_picker;
                    s_emoji_search[0] = '\0';
                }
            }
            // Input — frosted so it blurs the backdrop like the panes above (glass behind + transparent
            // FrameBg); a flat FrameBg showed the sharp texture.
            {
                ImDrawList* cdl = ImGui::GetWindowDrawList();
                material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 34;
                material::DrawGlassPanel(cdl, ImVec2(inputX, rowY),
                                         ImVec2(inputX + inputW, rowY + composerBoxH), g);
            }
            ImGui::PushStyleColor(ImGuiCol_FrameBg,        IM_COL32(0, 0, 0, 0));
            ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
            ImGui::PushStyleColor(ImGuiCol_FrameBgActive,  IM_COL32(0, 0, 0, 0));
            // FramePadding gives the text a left inset (so it doesn't hug the box edge) and, since the
            // collapsed box is exactly one line + 2×VPad tall, vertically centers a single line.
            ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f * tdp, composerVPad));
            ImGui::SetCursorScreenPos(ImVec2(inputX, rowY));
            // Enter-to-send (setting): on → Enter sends, Shift+Enter makes a newline (Ctrl+Enter too); off
            // → Enter is a newline and the Send button is the only way to send. EnterReturnsTrue needs
            // CtrlEnterForNewLine alongside it or multiline plain-Enter inserts a '\n' instead of
            // validating (imgui_widgets.cpp:5118). Shift+Enter is handled in composeInputCallback since
            // ImGui's exact-modifier Enter shortcut ignores it. CallbackAlways runs that handler.
            const bool enterSends = !app->settings() || app->settings()->getChatEnterSends();
            // WordWrap: long lines wrap to the next visual line (the box grows) instead of scrolling the
            // text horizontally off the left edge.
            ImGuiInputTextFlags composeFlags = ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_WordWrap;
            if (enterSends)
                composeFlags |= ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine;
            // Hard-cap the byte length at the on-chain budget: passing kBodyMaxBytes+1 as the buffer size
            // stops ImGui from accepting more input (UTF-8-safe), so the user can't type past the cap.
            const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, kBodyMaxBytes + 1,
                              ImVec2(textW, composerBoxH), composeFlags, composeInputCallback);
            ImGui::PopStyleVar();      // FramePadding
            ImGui::PopStyleColor(3);   // composer FrameBg (transparent — glass shows through)

            // Measure the wrapped content height to drive the auto-grow (consumed next frame at the top).
            // Wrap width mirrors ImGui's internal InputText wrap = frame inner width (textW − 2×FramePadding.x),
            // so CalcTextSize's wrap matches what the widget renders.
            {
                const float wrapW = std::max(1.0f, textW - 2.0f * 12.0f * tdp);
                float contentH = (s_compose[0] == '\0') ? lineH
                                 : ImGui::CalcTextSize(s_compose, nullptr, false, wrapW).y;
                const size_t clen = std::strlen(s_compose);
                if (clen > 0 && s_compose[clen - 1] == '\n') contentH += lineH;   // trailing empty line
                contentH = std::min(contentH, kMaxComposerLines * lineH);
                s_composerTargetH = std::max(lineH, contentH) + 2.0f * composerVPad;
            }

            // ── Radial byte gauge (replaces the old numeric counter): a ring in the input's right edge
            //    that fills clockwise and shifts green→amber→red as the message nears the on-chain cap.
            //    Full + red at/over the cap (Send is disabled there); exact count shows on hover.
            {
                ImDrawList* gdl = ImGui::GetWindowDrawList();
                auto lerpCol = [](ImU32 a, ImU32 b, float t) {
                    if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
                    const ImVec4 fa = ImGui::ColorConvertU32ToFloat4(a);
                    const ImVec4 fb = ImGui::ColorConvertU32ToFloat4(b);
                    return ImGui::GetColorU32(ImVec4(fa.x + (fb.x - fa.x) * t, fa.y + (fb.y - fa.y) * t,
                                                     fa.z + (fb.z - fa.z) * t, fa.w + (fb.w - fa.w) * t));
                };
                const ImU32 green = material::Success(), amber = material::Warning(), red = material::Error();
                ImU32 fillCol;
                if (frac < 0.75f)      fillCol = green;                      // calm until genuinely near the cap
                else if (frac < 1.0f) { const float t = (frac - 0.75f) / 0.25f;
                                        fillCol = (t < 0.5f) ? lerpCol(green, amber, t / 0.5f)
                                                             : lerpCol(amber, red, (t - 0.5f) / 0.5f); }
                else                   fillCol = red;                        // at/over cap

                const float thick = std::max(2.0f, 2.4f * tdp);
                const float dispFrac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac);
                // Faint full-ring track (alpha kept high enough to read on light skins too).
                gdl->PathArcTo(ringC, ringR, 0.0f, IM_PI * 2.0f, 48);
                gdl->PathStroke(material::WithAlpha(material::OnSurface(), 42), 0, thick);
                // Progress arc from 12 o'clock, clockwise, with a rounded head.
                if (dispFrac > 0.0001f) {
                    const float a0 = -IM_PI * 0.5f;
                    const float a1 = a0 + dispFrac * IM_PI * 2.0f;
                    gdl->PathArcTo(ringC, ringR, a0, a1, 48);
                    gdl->PathStroke(fillCol, 0, thick);
                    gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
                                         thick * 0.6f, fillCol, 10);
                }
                // Exact count on hover (progressive disclosure; over-cap prefixes a label).
                const float hitR = ringR + thick;
                if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
                                               ImVec2(ringC.x + hitR, ringC.y + hitR))) {
                    std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
                    if (overCap) tip = std::string(TR("chat_len_over")) + "   " + tip;
                    ImGui::BeginTooltip();
                    ImGui::TextUnformatted(tip.c_str());
                    ImGui::EndTooltip();
                }
            }
            // Off-mode sends only via the Send button. In enter-sends mode EnterReturnsTrue makes the
            // return value mean "validated" (plain Enter only) — Shift+Enter/edits don't set it.
            bool submit = enterSends && inputReturned;
            // Send — fixed height, bottom-aligned with the input row (doesn't stretch when the box grows).
            ImGui::SetCursorScreenPos(ImVec2(sendX, ctrlY));
            ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
            ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
            ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
            ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
            if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerCtrlH))) submit = true;
            ImGui::EndDisabled();
            // When Send is disabled *because the message is over the cap*, explain why on hover — the
            // gauge went red but the numeric reason is no longer always-visible.
            if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
                ImGui::SetTooltip("%s  %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
            ImGui::PopStyleColor(3);

            if (submit && s_compose[0] != '\0' && !overCap) {
                app->sendChatMessage(sel->cid, s_compose);
                sodium_memzero(s_compose, sizeof(s_compose));
                s_composeCursor = -1;
                s_scroll_to_cid = sel->cid;
                s_composerAnimH = 0.0f;   // snap back to collapsed instead of animating while unfocused
                // Sending closes the emoji picker (it takes over the conversation-list pane) so the list
                // reappears, and clears its search filter.
                s_show_emoji_picker = false;
                s_emoji_search[0] = '\0';
            }
        }
    }

    // ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
    if (s_show_new_convo) {
        const float dp = Layout::dpiScale();
        material::OverlayDialogSpec ov;
        ov.title = TR("chat_new_title");
        ov.p_open = &s_show_new_convo;   // X / backdrop closes it
        ov.style = material::OverlayStyle::BlurFloat;
        ov.cardWidth = 520.0f; ov.idSuffix = "chatnewconvo";
        if (material::BeginOverlayDialog(ov)) {
            const float fieldW = ImGui::GetContentRegionAvail().x;
            material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
            // Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t)
            // address can't receive one. Contacts can hold t-addresses, so guard the manual field too:
            // warn when the entry isn't a valid z-address and keep Send disabled below.
            const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr);
            if (s_new_zaddr[0] != '\0' && !newAddrIsZ) {
                ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
                ImGui::PushTextWrapPos(0.0f);
                ImGui::TextUnformatted(TR("chat_new_needs_zaddr"));
                ImGui::PopTextWrapPos();
                ImGui::PopStyleColor();
            }
            // Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
            // Selecting one fills the field above (manual paste still works).
            ImGui::SetNextItemWidth(fieldW);
            if (ImGui::BeginCombo("##newzpick", TR("chat_pick_contact"), ImGuiComboFlags_HeightLarge)) {
                const std::string activeHash = app->activeWalletScopeId();  // per-wallet contact scope
                int shown = 0;
                for (const auto& e : book.entries()) {
                    if (e.address.empty() || e.address[0] != 'z') continue;   // chat requires a z-address
                    // Respect the same per-wallet scope the Contacts tab enforces — don't leak another
                    // wallet's scoped contact into this wallet's picker (global + legacy fail open).
                    const bool visible = e.isGlobal() ||
                        (e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
                    if (!visible) continue;
                    ++shown;
                    const std::string item = e.label + "   " + shorten(e.address, 16, 8);
                    if (ImGui::Selectable(item.c_str())) {
                        std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
                        s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
                    }
                }
                if (shown == 0) {
                    ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
                    ImGui::TextUnformatted(TR("chat_no_z_contacts"));
                    ImGui::PopStyleColor();
                }
                ImGui::EndCombo();
            }
            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
            material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
            ImGui::Dummy(ImVec2(0, Layout::spacingMd()));

            const bool canSend = newAddrIsZ && s_new_msg[0] != '\0';
            const float actionW = std::max(130.0f * dp,
                ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
            const float actionGap = Layout::spacingSm();
            material::BeginOverlayDialogFooter(actionW * 2.0f + actionGap, /*drawSeparator=*/false);

            if (!canSend) ImGui::BeginDisabled();
            ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
            ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
            ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
            const bool doSend = material::TactileButton(TR("chat_new_send"), ImVec2(actionW, 0));
            ImGui::PopStyleColor(3);
            if (doSend) {
                app->startChatConversation(s_new_zaddr, s_new_msg);
                sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));  // wipe the just-sent plaintext
                sodium_memzero(s_new_msg, sizeof(s_new_msg));
                s_show_new_convo = false;
            }
            if (!canSend) ImGui::EndDisabled();

            ImGui::SameLine(0, actionGap);
            if (material::TactileButton(TR("chat_cancel"), ImVec2(actionW, 0))) s_show_new_convo = false;

            material::EndOverlayDialog();
        }
    }

    // ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
    if (s_show_delete_confirm) {
        const float dp = Layout::dpiScale();
        material::OverlayDialogSpec ov;
        ov.title = TR("chat_delete_title");
        ov.p_open = &s_show_delete_confirm;   // X / backdrop closes it (no-op)
        ov.style = material::OverlayStyle::BlurFloat;
        ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete";
        if (material::BeginOverlayDialog(ov)) {
            ImGui::PushTextWrapPos(0.0f);
            ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name +
                                    TR("chat_delete_body_suffix")).c_str());
            ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
            ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
            ImGui::TextUnformatted(TR("chat_delete_revive_note"));
            ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
            ImGui::TextUnformatted(TR("chat_delete_local_note"));
            ImGui::PopStyleColor();
            ImGui::PopTextWrapPos();
            ImGui::Dummy(ImVec2(0, Layout::spacingMd()));

            auto doDelete = [&](bool block) {
                // Delete first — if the persisted rows can't be removed, change nothing else (no block,
                // no toast) so the store and DB can't diverge.
                if (!app->chatService().deleteConversation(s_delete_cid, block)) {
                    Notifications::instance().error(TR("chat_delete_failed"));
                    s_show_delete_confirm = false;
                    s_delete_cid.clear(); s_delete_name.clear();
                    return;
                }
                if (app->settings()) {
                    if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true);
                    app->settings()->setChatHidden(s_delete_cid, false);   // clear any prior hide flag
                    app->settings()->save();
                }
                // Revive mode: forget the seen-watermark so a re-imported message badges as unread even if
                // its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored
                // history doesn't all re-badge.
                if (!block) app->forgetChatConversationSeen(s_delete_cid);
                if (s_selected_cid == s_delete_cid) s_selected_cid.clear();
                Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast"));
                s_show_delete_confirm = false;
                s_delete_cid.clear(); s_delete_name.clear();
            };

            auto textW = [&](const char* t) {
                return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
            };
            const float gap2 = Layout::spacingSm();
            const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm")));
            const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block")));
            const float wCan = std::max(90.0f * dp,  textW(TR("chat_cancel")));
            material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false);

            ImGui::PushStyleColor(ImGuiCol_Button,        ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
            ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
            ImGui::PushStyleColor(ImGuiCol_ButtonActive,  ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
            ImGui::PushStyleColor(ImGuiCol_Text,          ImGui::ColorConvertU32ToFloat4(material::OnError()));
            const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0));
            ImGui::SameLine(0, gap2);
            const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0));
            ImGui::PopStyleColor(4);
            ImGui::SameLine(0, gap2);
            const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));

            if (doDel)    doDelete(false);
            if (doBlk)    doDelete(true);
            if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); }

            material::EndOverlayDialog();
        }
    }

    // ---- Blocked-conversations manager (unblock) ----
    if (s_show_blocked) {
        const float dp = Layout::dpiScale();
        material::OverlayDialogSpec ov;
        ov.title = TR("chat_blocked_title");
        ov.p_open = &s_show_blocked;
        ov.style = material::OverlayStyle::BlurFloat;
        ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked";
        if (material::BeginOverlayDialog(ov)) {
            ImGui::PushTextWrapPos(0.0f);
            ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
            ImGui::TextUnformatted(TR("chat_blocked_desc"));
            ImGui::PopStyleColor();
            ImGui::PopTextWrapPos();
            ImGui::Dummy(ImVec2(0, Layout::spacingSm()));

            if (app->settings()) {
                // Copy so unblocking (which mutates the settings vector) during iteration is safe.
                const auto blocked = app->settings()->blockedChatConversations();
                std::string unblockCid;
                for (const auto& b : blocked) {
                    ImGui::PushID(b.cid.c_str());
                    const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name;
                    const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x +
                                     ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
                    ImGui::AlignTextToFramePadding();
                    ImGui::TextUnformatted(label.c_str());
                    ImGui::SameLine();
                    ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
                    if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid;
                    ImGui::PopID();
                }
                if (!unblockCid.empty()) {
                    app->settings()->setChatBlocked(unblockCid, "", false);
                    app->settings()->save();
                    Notifications::instance().info(TR("chat_unblocked_toast"));  // re-imports on the next chat scan
                    if (app->settings()->blockedChatConversations().empty()) s_show_blocked = 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;
    s_show_delete_confirm = false;
    s_delete_cid.clear();
    s_delete_name.clear();
    s_show_blocked = false;
    // Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
    // conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
    s_convs.clear();
    s_convsHidden = 0;
    s_convsKey = ~0ull;
    s_threadCid.clear();
    s_threadRev = ~0ull;
    s_threadMsgs.clear();
}

void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
{
    auto* st = app ? app->settings() : nullptr;
    if (!st) return;
    const float dp = Layout::dpiScale();
    const float ctrlW = 250.0f * dp;   // control column width (fits a 3-segment control comfortably)
    const float rowGap = 10.0f * dp;

    // Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the
    // Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false
    // and keeps its plain single-surface layout — the controls themselves are identical either way.
    ImDrawList* cardDL = ImGui::GetWindowDrawList();
    const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f;
    material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding();
    float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f;
    if (drawCards) {
        cardTopScr   = ImGui::GetCursorScreenPos().y;
        cardBaseXScr = ImGui::GetCursorScreenPos().x;
        cardDL->ChannelsSplit(2);
        cardDL->ChannelsSetCurrent(1);
        ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad));
        ImGui::Indent(cardPad);
    }
    // Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
    // whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
    // the chat modal's dialog content region is correct, so it passes 0 (auto).
    // leftX/rowW define the current column the rows lay out in; retargeted below
    // to split Appearance | Messaging into two columns when the card is wide.
    float leftX = ImGui::GetCursorPosX();
    float rowW  = drawCards ? (contentWidth - 2.0f * cardPad)
                            : ((contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x);

    // Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
    auto beginRow = [&](const char* label) {
        ImGui::Dummy(ImVec2(0.0f, rowGap));
        ImGui::AlignTextToFramePadding();
        ImGui::TextUnformatted(label);
        ImGui::SameLine();
        ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW)));
        ImGui::SetNextItemWidth(ctrlW);
    };
    auto section = [&](const char* key) {
        ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
        ImGui::PushFont(material::Type().caption());
        ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::Primary(), 235));
        ImGui::TextUnformatted(TR(key));
        ImGui::PopStyleColor();
        ImGui::PopFont();
        ImGui::Dummy(ImVec2(0.0f, 2.0f * dp));
    };
    // Segmented control (iOS-style): a rounded track with an inset pill on the selected segment.
    // Returns the (possibly changed) index. Reads the label via beginRow for a right-aligned control.
    auto segmented = [&](const char* label, const char* const* items, int count, int value) -> int {
        beginRow(label);
        const ImVec2 origin = ImGui::GetCursorScreenPos();
        const float h = ImGui::GetFrameHeight();
        const float seg = ctrlW / static_cast<float>(count);
        const float round = 7.0f * dp;
        ImDrawList* dl = ImGui::GetWindowDrawList();
        dl->AddRectFilled(origin, ImVec2(origin.x + ctrlW, origin.y + h),
                          material::WithAlpha(material::OnSurface(), 20), round);
        int result = value;
        ImGui::PushID(label);
        for (int i = 0; i < count; ++i) {
            ImGui::PushID(i);
            const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h);
            ImGui::SetCursorScreenPos(mn);
            if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i;
            const bool hov = ImGui::IsItemHovered();
            if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
            const bool sel = (value == i);
            if (sel) {
                const float in = 2.0f * dp;
                dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in),
                                  material::WithAlpha(material::Primary(), 210), std::max(1.0f, round - in));
            } else if (hov) {
                dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 26), round);
            }
            const ImVec2 ts = ImGui::CalcTextSize(items[i]);
            // Center when the label fits; otherwise left-align with a small pad so the START stays
            // readable (a long translation clipped on the right, not clipped on BOTH sides).
            const float lpad = 4.0f * dp;
            const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad;
            ImGui::PushClipRect(mn, mx, true);
            dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f),
                        sel ? IM_COL32(255, 255, 255, 236) : material::OnSurfaceMedium(), items[i]);
            ImGui::PopClipRect();
            ImGui::PopID();
        }
        ImGui::PopID();
        ImGui::SetCursorScreenPos(origin);
        ImGui::Dummy(ImVec2(ctrlW, h));   // reserve the control's rect for layout flow
        return result;
    };

    // Two internal columns when the card is wide enough: Appearance on the left,
    // Messaging on the right — fills the width and roughly halves the height.
    // (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column.
    const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp);
    const bool  chatTwoCol = rowW > 760.0f * dp;
    const float chatColW   = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW;
    const float chatBaseLeftX = leftX;
    const float chatTopY = ImGui::GetCursorPosY();
    float chatLeftBottomY = 0.0f;
    if (chatTwoCol) rowW = chatColW;   // left column width

    // ── Appearance ────────────────────────────────────────────────────────────────
    section("chat_sec_appearance");
    // Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows
    // FreeType); requestFontRebuild swaps the atlas live.
    {
        int v = st->getChatEmojiColor() ? 1 : 0;
        const char* items[] = { TR("chat_emoji_mono"), TR("chat_emoji_color") };
        int nv = segmented(TR("chat_opt_emoji"), items, 2, v);
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_emoji_style"));
        if (nv != v) { st->setChatEmojiColor(nv == 1); st->save(); app->requestFontRebuild(); }
    }
    // Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control).
    {
        const char* items[] = { TR("chat_bubble_rounded"), TR("chat_bubble_square"), TR("chat_bubble_minimal") };
        int v = st->getChatBubbleStyle();
        int nv = segmented(TR("chat_opt_bubble_style"), items, 3, v);
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_style"));
        if (nv != v) { st->setChatBubbleStyle(nv); st->save(); }
    }
    {
        beginRow(TR("chat_opt_bubble_accent"));
        int v = st->getChatBubbleAccent();
        const char* items[] = { TR("chat_accent_theme"), TR("chat_accent_blue"), TR("chat_accent_green"),
                                TR("chat_accent_purple"), TR("chat_accent_amber"), TR("chat_accent_pink") };
        if (ImGui::Combo("##chat_baccent", &v, items, 6)) { st->setChatBubbleAccent(v); st->save(); }
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_accent"));
    }
    // Message density (segmented).
    {
        const char* items[] = { TR("chat_density_comfortable"), TR("chat_density_compact") };
        int v = st->getChatDensity();
        int nv = segmented(TR("chat_opt_density"), items, 2, v);
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_density"));
        if (nv != v) { st->setChatDensity(nv); st->save(); }
    }
    // Message text size (slider).
    {
        beginRow(TR("chat_opt_font_size"));
        float v = st->getChatFontScale();
        if (ImGui::SliderFloat("##chat_font", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) {
            st->setChatFontScale(v); st->save();
        }
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_font_size"));
    }

    // Move Messaging into the right column (float it with Indent so every row's
    // line-start holds the column; retarget leftX so controls right-align in it).
    if (chatTwoCol) {
        chatLeftBottomY = ImGui::GetCursorPosY();
        cardLeftBotScr  = ImGui::GetCursorScreenPos().y;   // left column bottom (screen), for its card panel
        ImGui::SetCursorPosY(chatTopY);
        ImGui::Indent(chatColW + chatColGap);
        leftX = chatBaseLeftX + chatColW + chatColGap;
    }

    // ── Messaging ─────────────────────────────────────────────────────────────────
    section("chat_sec_messaging");
    // Message poll rate (full-node 0-conf fast-scan cadence) — slider.
    {
        beginRow(TR("chat_opt_poll"));
        float v = st->getChatPollRateSec();
        if (ImGui::SliderFloat("##chat_poll", &v, 0.5f, 15.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp)) {
            st->setChatPollRateSec(v); st->save();
        }
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_poll_rate"));
    }
    // Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only.
    {
        const char* items[] = { TR("chat_ts_global_short"), TR("chat_ts_24h"), TR("chat_ts_12h") };
        int v = st->getChatTimeFormat();
        int nv = segmented(TR("chat_opt_timestamp"), items, 3, v);
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_timestamp"));
        if (nv != v) { st->setChatTimeFormat(nv); st->save(); }
    }
    // Enter-to-send (checkbox).
    {
        ImGui::Dummy(ImVec2(0.0f, rowGap));
        bool v = st->getChatEnterSends();
        if (ImGui::Checkbox(TR("chat_opt_enter_sends"), &v)) { st->setChatEnterSends(v); st->save(); }
        if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_enter_sends"));
    }

    // Close the two-column band: un-indent and drop below the taller column.
    const float cardRightBotScr = ImGui::GetCursorScreenPos().y;   // right (or only) column bottom, screen
    if (chatTwoCol) {
        ImGui::Unindent(chatColW + chatColGap);
        const float chatRightBottomY = ImGui::GetCursorPosY();
        ImGui::SetCursorPosX(chatBaseLeftX);
        ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY));
    }

    // Paint the glass card(s) behind the content, then merge the channels.
    if (drawCards) {
        ImGui::Unindent(cardPad);
        cardDL->ChannelsSetCurrent(0);
        const float cardW = (contentWidth - Layout::cardGap()) * 0.5f;
        if (chatTwoCol) {
            const float eqBot = std::max(cardLeftBotScr, cardRightBotScr);   // equal-height cards (mockup grid stretch)
            material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
                ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec);
            material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr),
                ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec);
        } else {
            material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
                ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec);
        }
        cardDL->ChannelsMerge();
        const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr;
        // Reserve the card footprint with a Dummy so the parent scroll region grows to include it
        // (a bare SetCursorScreenPos past content warns in ImGui).
        ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr));
        ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad));

        // Live conversation preview below the two cards (Settings tab only — the chat modal renders
        // its own preview column beside these controls, so it passes drawCards=false and skips this).
        ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd()));
        RenderChatSettingsPreview(app, contentWidth);
    }
}

} // namespace ui
} // namespace dragonx
