// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 // // chat_tab.cpp โ€” read-only HushChat view (Phase 3): a conversation list + the // selected thread, both read from the App-owned ChatService store. #include "chat_tab.h" #include "../../app.h" #include "../../data/address_book.h" #include "../../chat/chat_service.h" #include "../../util/i18n.h" #include "../material/colors.h" #include "../material/type.h" #include "../layout.h" // Layout::dpiScale() #include "imgui.h" #include #include #include #include #include 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 // Composer + new-conversation UI state. char s_compose[512] = ""; bool s_show_new_convo = false; char s_new_zaddr[128] = ""; char s_new_msg[256] = ""; // Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize). float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; } 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(ts); std::tm* tm = std::localtime(&t); // UI thread only if (!tm) return ""; char buf[32]; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", 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; } 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; }; // 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(); } } // 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()) { centeredHint(TR("chat_locked_hint")); return; } // Build conversation summaries (single scan per conversation), sorted by most-recent activity. std::vector convs; for (const auto& cid : store.conversationIds()) { const auto messages = store.conversation(cid); if (messages.empty()) continue; ConvSummary c; c.cid = cid; c.count = static_cast(messages.size()); for (const auto& m : messages) { // last non-empty peer z-addr / key across the thread if (!m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; if (!m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; } 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); convs.push_back(std::move(c)); } std::sort(convs.begin(), convs.end(), [](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; }); // 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; } const ImVec2 avail = ImGui::GetContentRegionAvail(); const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f); // 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 = 52.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 ---- ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true); { if (ImGui::Button(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f))) { s_show_new_convo = true; s_new_zaddr[0] = '\0'; s_new_msg[0] = '\0'; } 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 ImU32 selBg = ImGui::GetColorU32(ImGuiCol_Header); const ImU32 hoverBg = ImGui::GetColorU32(ImGuiCol_HeaderHovered); for (std::size_t i = 0; i < convs.size(); ++i) { const ConvSummary& c = convs[i]; ImGui::PushID(static_cast(i)); 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); // Name (top-left). dl->AddText(nameFont, nameSz, ImVec2(p.x + pad, 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), material::OnSurfaceMedium(), when.c_str()); } // Preview (bottom, clipped, 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), material::OnSurfaceMedium(), preview.c_str()); dl->PopClipRect(); ImGui::PopID(); } } ImGui::EndChild(); ImGui::SameLine(); // ---- Right: selected conversation thread ---- ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true); { const ConvSummary* sel = nullptr; for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; } if (sel) { // Header: peer name + z-address. ImGui::PushFont(material::Type().subtitle1()); ImGui::TextUnformatted(sel->peerName.c_str()); ImGui::PopFont(); if (!sel->peerZaddr.empty()) { ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); ImGui::TextUnformatted(shorten(sel->peerZaddr, 20, 12).c_str()); ImGui::PopStyleColor(); ImGui::PopFont(); } ImGui::Separator(); const float footerH = ImGui::GetTextLineHeightWithSpacing() + 12.0f; ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false); { const auto messages = store.conversation(s_selected_cid); for (const auto& m : messages) { 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; // Meta line: who + time (+ request tag / failed marker). std::string who = outgoing ? std::string(TR("chat_you")) : sel->peerName; const std::string when = formatTime(m.timestamp); if (!when.empty()) who += " " + when; if (request) who += " [" + std::string(TR("chat_contact_request")) + "]"; if (failed) who += " ยท " + std::string(TR("chat_send_failed")); ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, failed ? material::Error() : (outgoing ? material::Primary() : material::OnSurfaceMedium())); ImGui::TextUnformatted(who.c_str()); ImGui::PopStyleColor(); ImGui::PopFont(); // Body (wrapped). ImGui::PushFont(nameFont); ImGui::PushTextWrapPos(0.0f); ImGui::TextWrapped("%s", m.body.c_str()); ImGui::PopTextWrapPos(); ImGui::PopFont(); ImGui::Dummy(ImVec2(0.0f, 6.0f)); } if (s_scroll_to_cid == s_selected_cid) { ImGui::SetScrollHereY(1.0f); s_scroll_to_cid.clear(); } } ImGui::EndChild(); // Composer footer: message input + send (only once we know the peer's key), else a hint. ImGui::Separator(); if (sel->peerPubKey.empty()) { 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 { const float sendW = 74.0f; 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; if (submit && s_compose[0] != '\0') { app->sendChatMessage(sel->cid, s_compose); s_compose[0] = '\0'; s_scroll_to_cid = sel->cid; } } } else { centeredHint(convs.empty() ? TR("chat_empty_hint") : TR("chat_select_hint")); } } ImGui::EndChild(); // ---- New-conversation popup (send a contact request to a z-address) ---- 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(); } ImGui::SameLine(); if (ImGui::Button(TR("chat_cancel"), ImVec2(100.0f, 0.0f))) ImGui::CloseCurrentPopup(); ImGui::EndPopup(); } } } // namespace ui } // namespace dragonx