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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user