feat(chat): house-style polish — modal, tactile rows + avatars, DPI

Audit B3/V1/V2/V5/B6 — bring the Chat tab up to the contacts_tab bar:
- New-Conversation modal rebuilt on the house BlurFloat OverlayDialog with
  LabeledInput fields and an accented TactileButton footer (Send disabled until
  both fields are set); wipes the sent plaintext. Replaces the raw ImGui popup and
  its hardcoded widths (V1).
- Conversation rows: leading letter-avatars (deterministic palette color + the
  peer's UTF-8 initial), Primary-tinted selected fill + border, OnSurface hover —
  matching contacts_tab's tactile card rows (V2). Stable PushID(cid) instead of the
  re-sorted loop index (B6). Taller rows.
- Send / New are accented TactileButtons with press feedback (V5).
- All hardcoded px (Send width, modal) go through Layout::dpiScale() so nothing
  clips at 150% (B3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:46:42 -05:00
parent 6f6ad89c9f
commit 46f93e380b

View File

@@ -11,7 +11,10 @@
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#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 "imgui.h"
@@ -19,6 +22,7 @@
#include <algorithm>
#include <cfloat>
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
@@ -65,6 +69,32 @@ std::string previewOf(const std::string& body) {
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]))];
}
// 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));
}
struct ConvSummary {
std::string cid;
std::string peerZaddr;
@@ -148,7 +178,7 @@ void RenderChatTab(App* app)
// 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 = 52.0f * Layout::dpiScale();
const float rowH = 58.0f * Layout::dpiScale();
ImFont* nameFont = material::Type().body2();
ImFont* metaFont = material::Type().caption();
@@ -158,7 +188,13 @@ void RenderChatTab(App* app)
// ---- Left: new-conversation button + conversation list ----
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true);
{
if (ImGui::Button(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f))) {
// Accented "New conversation" action (house tactile style).
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
const bool newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f));
ImGui::PopStyleColor(3);
if (newClicked) {
s_show_new_convo = true;
s_new_zaddr[0] = '\0';
s_new_msg[0] = '\0';
@@ -175,37 +211,56 @@ void RenderChatTab(App* app)
}
ImDrawList* dl = ImGui::GetWindowDrawList(); // the list child's draw list (correct clip/z-order)
const ImU32 selBg = ImGui::GetColorU32(ImGuiCol_Header);
const ImU32 hoverBg = ImGui::GetColorU32(ImGuiCol_HeaderHovered);
const float dp = Layout::dpiScale();
const float round = 6.0f * dp;
const float avR = rowH * 0.30f; // letter-avatar radius
for (std::size_t i = 0; i < convs.size(); ++i) {
const ConvSummary& c = convs[i];
ImGui::PushID(static_cast<int>(i));
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 (selected || hovered)
dl->AddRectFilled(p, ImVec2(p.x + rowW, p.y + rowH), selected ? selBg : hoverBg, 6.0f);
if (hovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Name (top-left).
dl->AddText(nameFont, nameSz, ImVec2(p.x + pad, p.y + pad), material::OnSurface(),
c.peerName.c_str());
// 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;
// Name (top).
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), material::OnSurface(), c.peerName.c_str());
// Time (top-right, muted).
const std::string when = formatTime(c.lastTs);
if (!when.empty()) {
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
dl->AddText(metaFont, metaSz, ImVec2(p.x + rowW - pad - wsz.x, p.y + pad + 1.0f),
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
material::OnSurfaceMedium(), when.c_str());
}
// Preview (bottom, clipped, muted).
// Preview (bottom, clipped to the text column, muted).
const std::string preview = previewOf(c.lastBody);
dl->PushClipRect(p, ImVec2(p.x + rowW - pad, p.y + rowH), true);
dl->AddText(metaFont, metaSz, ImVec2(p.x + pad, p.y + rowH - pad - metaSz),
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
}
}
ImGui::EndChild();
@@ -278,12 +333,16 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor();
ImGui::PopFont();
} else {
const float sendW = 74.0f;
const float sendW = 80.0f * Layout::dpiScale(); // scaled so the translated label never clips (B3)
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x);
bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose),
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
if (ImGui::Button(TR("chat_send"), ImVec2(sendW, 0.0f))) submit = true;
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, 0.0f))) submit = true;
ImGui::PopStyleColor(3);
if (submit && s_compose[0] != '\0') {
app->sendChatMessage(sel->cid, s_compose);
s_compose[0] = '\0';
@@ -296,34 +355,46 @@ void RenderChatTab(App* app)
}
ImGui::EndChild();
// ---- New-conversation popup (send a contact request to a z-address) ----
// ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
if (s_show_new_convo) {
ImGui::OpenPopup("##NewConversation");
s_show_new_convo = false;
}
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (ImGui::BeginPopupModal("##NewConversation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::PushFont(material::Type().subtitle1());
ImGui::TextUnformatted(TR("chat_new_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::TextUnformatted(TR("chat_new_zaddr"));
ImGui::SetNextItemWidth(440.0f);
ImGui::InputText("##newz", s_new_zaddr, sizeof(s_new_zaddr));
ImGui::TextUnformatted(TR("chat_new_message"));
ImGui::SetNextItemWidth(440.0f);
ImGui::InputText("##newm", s_new_msg, sizeof(s_new_msg));
ImGui::Spacing();
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
if (ImGui::Button(TR("chat_new_send"), ImVec2(150.0f, 0.0f)) && canSend) {
app->startChatConversation(s_new_zaddr, s_new_msg);
s_new_zaddr[0] = '\0';
s_new_msg[0] = '\0';
ImGui::CloseCurrentPopup();
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);
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 = s_new_zaddr[0] != '\0' && 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();
}
ImGui::SameLine();
if (ImGui::Button(TR("chat_cancel"), ImVec2(100.0f, 0.0f))) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
}