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 "../../chat/chat_service.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../material/colors.h" #include "../material/colors.h"
#include "../material/color_theme.h" // WithAlpha
#include "../material/type.h" #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 "../layout.h" // Layout::dpiScale()
#include "imgui.h" #include "imgui.h"
@@ -19,6 +22,7 @@
#include <algorithm> #include <algorithm>
#include <cfloat> #include <cfloat>
#include <cstdint>
#include <ctime> #include <ctime>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -65,6 +69,32 @@ std::string previewOf(const std::string& body) {
return out; 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 { struct ConvSummary {
std::string cid; std::string cid;
std::string peerZaddr; 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 // 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. // right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word.
const float pad = 10.0f * Layout::dpiScale(); 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* nameFont = material::Type().body2();
ImFont* metaFont = material::Type().caption(); ImFont* metaFont = material::Type().caption();
@@ -158,7 +188,13 @@ void RenderChatTab(App* app)
// ---- Left: new-conversation button + conversation list ---- // ---- Left: new-conversation button + conversation list ----
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true); 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_show_new_convo = true;
s_new_zaddr[0] = '\0'; s_new_zaddr[0] = '\0';
s_new_msg[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) ImDrawList* dl = ImGui::GetWindowDrawList(); // the list child's draw list (correct clip/z-order)
const ImU32 selBg = ImGui::GetColorU32(ImGuiCol_Header); const float dp = Layout::dpiScale();
const ImU32 hoverBg = ImGui::GetColorU32(ImGuiCol_HeaderHovered); 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) { for (std::size_t i = 0; i < convs.size(); ++i) {
const ConvSummary& c = convs[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 ImVec2 p = ImGui::GetCursorScreenPos();
const float rowW = ImGui::GetContentRegionAvail().x; const float rowW = ImGui::GetContentRegionAvail().x;
const bool clicked = ImGui::InvisibleButton("##row", ImVec2(rowW, rowH)); const bool clicked = ImGui::InvisibleButton("##row", ImVec2(rowW, rowH));
const bool hovered = ImGui::IsItemHovered(); const bool hovered = ImGui::IsItemHovered();
const bool selected = (c.cid == s_selected_cid); const bool selected = (c.cid == s_selected_cid);
if (clicked) { s_selected_cid = c.cid; s_scroll_to_cid = c.cid; } if (clicked) { s_selected_cid = c.cid; s_scroll_to_cid = c.cid; }
if (selected || hovered) if (hovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
dl->AddRectFilled(p, ImVec2(p.x + rowW, p.y + rowH), selected ? selBg : hoverBg, 6.0f);
// Name (top-left). // Row background — house tactile card style (matches contacts_tab).
dl->AddText(nameFont, nameSz, ImVec2(p.x + pad, p.y + pad), material::OnSurface(), const ImVec2 mn = p, mx(p.x + rowW, p.y + rowH);
c.peerName.c_str()); 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). // Time (top-right, muted).
const std::string when = formatTime(c.lastTs); const std::string when = formatTime(c.lastTs);
if (!when.empty()) { if (!when.empty()) {
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str()); 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()); material::OnSurfaceMedium(), when.c_str());
} }
// Preview (bottom, clipped, muted). // Preview (bottom, clipped to the text column, muted).
const std::string preview = previewOf(c.lastBody); const std::string preview = previewOf(c.lastBody);
dl->PushClipRect(p, ImVec2(p.x + rowW - pad, p.y + rowH), true); dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
dl->AddText(metaFont, metaSz, ImVec2(p.x + pad, p.y + rowH - pad - metaSz), dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
material::OnSurfaceMedium(), preview.c_str()); material::OnSurfaceMedium(), preview.c_str());
dl->PopClipRect(); dl->PopClipRect();
ImGui::PopID(); ImGui::PopID();
ImGui::Dummy(ImVec2(0.0f, 3.0f * dp)); // small gap between cards
} }
} }
ImGui::EndChild(); ImGui::EndChild();
@@ -278,12 +333,16 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor(); ImGui::PopStyleColor();
ImGui::PopFont(); ImGui::PopFont();
} else { } 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); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x);
bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose), bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose),
ImGuiInputTextFlags_EnterReturnsTrue); ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine(); 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') { if (submit && s_compose[0] != '\0') {
app->sendChatMessage(sel->cid, s_compose); app->sendChatMessage(sel->cid, s_compose);
s_compose[0] = '\0'; s_compose[0] = '\0';
@@ -296,34 +355,46 @@ void RenderChatTab(App* app)
} }
ImGui::EndChild(); 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) { if (s_show_new_convo) {
ImGui::OpenPopup("##NewConversation"); const float dp = Layout::dpiScale();
s_show_new_convo = false; material::OverlayDialogSpec ov;
} ov.title = TR("chat_new_title");
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); ov.p_open = &s_show_new_convo; // X / backdrop closes it
if (ImGui::BeginPopupModal("##NewConversation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ov.style = material::OverlayStyle::BlurFloat;
ImGui::PushFont(material::Type().subtitle1()); ov.cardWidth = 520.0f; ov.idSuffix = "chatnewconvo";
ImGui::TextUnformatted(TR("chat_new_title")); if (material::BeginOverlayDialog(ov)) {
ImGui::PopFont(); const float fieldW = ImGui::GetContentRegionAvail().x;
ImGui::Spacing(); material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
ImGui::TextUnformatted(TR("chat_new_zaddr")); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetNextItemWidth(440.0f); material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
ImGui::InputText("##newz", s_new_zaddr, sizeof(s_new_zaddr)); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
ImGui::TextUnformatted(TR("chat_new_message"));
ImGui::SetNextItemWidth(440.0f); const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
ImGui::InputText("##newm", s_new_msg, sizeof(s_new_msg)); const float actionW = std::max(130.0f * dp,
ImGui::Spacing(); ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0'; const float actionGap = Layout::spacingSm();
if (ImGui::Button(TR("chat_new_send"), ImVec2(150.0f, 0.0f)) && canSend) { material::BeginOverlayDialogFooter(actionW * 2.0f + actionGap, /*drawSeparator=*/false);
app->startChatConversation(s_new_zaddr, s_new_msg);
s_new_zaddr[0] = '\0'; if (!canSend) ImGui::BeginDisabled();
s_new_msg[0] = '\0'; ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::CloseCurrentPopup(); 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();
} }
} }