diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index da8dca4..db3cd11 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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 #include +#include #include #include #include @@ -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(name[i]) <= ' ') ++i; + if (i >= name.size()) return "?"; + const unsigned char c = name[i]; + if (c < 0x80) { + const char u = (c >= 'a' && c <= 'z') ? static_cast(c - 32) : static_cast(c); + return std::string(1, u); + } + const std::size_t len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : 2; // UTF-8 lead byte → sequence length + return name.substr(i, std::min(len, name.size() - i)); +} + 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(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(); } }