diff --git a/CMakeLists.txt b/CMakeLists.txt index e1449d3..4b55c57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -476,6 +476,7 @@ set(APP_SOURCES src/ui/windows/qr_popup_dialog.cpp src/ui/windows/validate_address_dialog.cpp src/ui/windows/contacts_tab.cpp + src/ui/windows/chat_tab.cpp src/ui/windows/shield_dialog.cpp src/ui/windows/request_payment_dialog.cpp src/ui/windows/block_info_dialog.cpp @@ -606,6 +607,7 @@ set(APP_HEADERS src/ui/windows/qr_popup_dialog.h src/ui/windows/validate_address_dialog.h src/ui/windows/contacts_tab.h + src/ui/windows/chat_tab.h src/ui/windows/contact_picker.h src/ui/windows/shield_dialog.h src/ui/windows/request_payment_dialog.h diff --git a/src/app.cpp b/src/app.cpp index 76c89f2..ba5ac41 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -42,6 +42,7 @@ #include "ui/windows/qr_popup_dialog.h" #include "ui/windows/validate_address_dialog.h" #include "ui/windows/contacts_tab.h" +#include "ui/windows/chat_tab.h" #include "ui/windows/shield_dialog.h" #include "ui/windows/request_payment_dialog.h" #include "ui/windows/block_info_dialog.h" @@ -1709,6 +1710,9 @@ void App::render() case ui::NavPage::Contacts: ui::RenderContactsTab(this); break; + case ui::NavPage::Chat: + ui::RenderChatTab(this); + break; case ui::NavPage::Mining: ui::RenderMiningTab(this); break; diff --git a/src/app.h b/src/app.h index 9542712..d8ae448 100644 --- a/src/app.h +++ b/src/app.h @@ -168,6 +168,8 @@ public: config::Settings* settings() { return settings_.get(); } // Lite wallet controller (non-null only in lite builds with a linked backend). wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); } + // HushChat service (identity + in-memory message store); the Chat tab reads its store. + chat::ChatService& chatService() { return chat_service_; } // Reason the lite wallet failed to auto-open this session (empty if none / opened OK). const std::string& liteOpenError() const { return lite_open_error_; } // Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet). diff --git a/src/app_network.cpp b/src/app_network.cpp index d928664..2a7fefc 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -104,6 +104,7 @@ const char* tracePageName(ui::NavPage page) case ui::NavPage::Receive: return "Receive tab"; case ui::NavPage::History: return "History tab"; case ui::NavPage::Contacts: return "Contacts tab"; + case ui::NavPage::Chat: return "Chat tab"; case ui::NavPage::Mining: return "Mining tab"; case ui::NavPage::Market: return "Market tab"; case ui::NavPage::Console: return "Console tab"; @@ -761,6 +762,7 @@ static const char* sweepPageName(ui::NavPage page) case ui::NavPage::Receive: return "receive"; case ui::NavPage::History: return "history"; case ui::NavPage::Contacts: return "contacts"; + case ui::NavPage::Chat: return "chat"; case ui::NavPage::Mining: return "mining"; case ui::NavPage::Market: return "market"; case ui::NavPage::Console: return "console"; diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index 0f8614a..1e81cfe 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -26,6 +26,7 @@ enum class NavPage { Receive, History, Contacts, + Chat, // --- separator --- Mining, Market, @@ -53,6 +54,7 @@ inline const NavItem kNavItems[] = { { "Receive", NavPage::Receive, nullptr, "receive", nullptr }, { "History", NavPage::History, nullptr, "history", nullptr }, { "Contacts", NavPage::Contacts, nullptr, "contacts", nullptr }, + { "Chat", NavPage::Chat, nullptr, "chat", nullptr }, { "Mining", NavPage::Mining, "TOOLS", "mining", "tools" }, { "Market", NavPage::Market, nullptr, "market", nullptr }, { "Console", NavPage::Console, "ADVANCED","console", "advanced" }, @@ -81,6 +83,7 @@ inline wallet::WalletUiSurface NavPageSurface(NavPage page) case NavPage::Receive: return wallet::WalletUiSurface::Receive; case NavPage::History: return wallet::WalletUiSurface::History; case NavPage::Contacts: return wallet::WalletUiSurface::Contacts; + case NavPage::Chat: return wallet::WalletUiSurface::Chat; case NavPage::Mining: return wallet::WalletUiSurface::Mining; case NavPage::Market: return wallet::WalletUiSurface::Market; case NavPage::Console: return wallet::WalletUiSurface::Console; @@ -107,6 +110,7 @@ inline const char* GetNavIconMD(NavPage page) case NavPage::Receive: return ICON_MD_CALL_RECEIVED; case NavPage::History: return ICON_MD_HISTORY; case NavPage::Contacts: return ICON_MD_CONTACTS; + case NavPage::Chat: return ICON_MD_CHAT; case NavPage::Mining: return ICON_MD_CONSTRUCTION; case NavPage::Market: return ICON_MD_TRENDING_UP; case NavPage::Console: return ICON_MD_TERMINAL; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp new file mode 100644 index 0000000..7818241 --- /dev/null +++ b/src/ui/windows/chat_tab.cpp @@ -0,0 +1,246 @@ +// 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 "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 + +// 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 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, sorted by most-recent activity. + std::vector convs; + for (const auto& cid : store.conversationIds()) { + const auto messages = store.conversation(cid); + if (messages.empty()) continue; + const auto& last = messages.back(); + ConvSummary c; + c.cid = cid; + c.peerZaddr = last.peer_zaddr; + c.lastBody = last.body; + c.lastTs = last.timestamp; + c.count = static_cast(messages.size()); + 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; }); + + if (convs.empty()) { + centeredHint(TR("chat_empty_hint")); + return; + } + + // Keep the selection valid; default to the most recent conversation. + if (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); + const float pad = 10.0f; + const float rowH = 52.0f; + + ImFont* nameFont = material::Type().body2(); + ImFont* metaFont = material::Type().caption(); + const float nameSz = scaledSize(nameFont); + const float metaSz = scaledSize(metaFont); + + // ---- Left: conversation list ---- + ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true); + { + 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); + // Meta line: who + time (+ request tag). + 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")) + "]"; + ImGui::PushFont(metaFont); + ImGui::PushStyleColor(ImGuiCol_Text, + 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(); + + // Read-only footer: composing arrives in a later phase. + ImGui::Separator(); + ImGui::PushFont(metaFont); + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextUnformatted(TR("chat_readonly_note")); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + } + ImGui::EndChild(); +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/chat_tab.h b/src/ui/windows/chat_tab.h new file mode 100644 index 0000000..c34dea4 --- /dev/null +++ b/src/ui/windows/chat_tab.h @@ -0,0 +1,27 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +namespace dragonx { + +class App; + +namespace ui { + +/** + * @brief Render the Chat tab (read-only HushChat conversation view — Phase 3). + * + * A two-pane view: a conversation list on the left and the selected thread on + * the right, both read from the App-owned ChatService store (App::chatService()). + * Peer z-addresses are resolved to contact names via the address book when known. + * Read-only for now — composing/sending arrives in a later phase. The tab is only + * reachable when built with DRAGONX_ENABLE_CHAT (gated via WalletUiSurface::Chat). + * + * @param app Pointer to the app instance. + */ +void RenderChatTab(App* app); + +} // namespace ui +} // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 4c6909a..467cbb0 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -211,6 +211,12 @@ void I18n::loadBuiltinEnglish() strings_["transactions"] = "Transactions"; strings_["history"] = "History"; strings_["contacts"] = "Contacts"; + strings_["chat"] = "Chat"; + strings_["chat_locked_hint"] = "Unlock your wallet to load your chats."; + strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here."; + strings_["chat_you"] = "You"; + strings_["chat_contact_request"] = "contact request"; + strings_["chat_readonly_note"] = "Read-only — sending messages arrives in a later update."; strings_["contacts_search_placeholder"] = "Search contacts..."; strings_["contacts_search_no_match"] = "No matching contacts"; strings_["address_book_confirm_delete"] = "Confirm delete?"; diff --git a/src/wallet/wallet_capabilities.h b/src/wallet/wallet_capabilities.h index 1c173a3..5966141 100644 --- a/src/wallet/wallet_capabilities.h +++ b/src/wallet/wallet_capabilities.h @@ -16,6 +16,10 @@ #define DRAGONX_ENABLE_LITE_BACKEND 0 #endif +#ifndef DRAGONX_ENABLE_CHAT +#define DRAGONX_ENABLE_CHAT 0 +#endif + namespace dragonx { namespace wallet { @@ -35,6 +39,7 @@ enum class WalletUiSurface { Receive, History, Contacts, // address book / chat roster; available in both variants (default: return true) + Chat, // HushChat conversations; gated on DRAGONX_ENABLE_CHAT (default OFF) Mining, Market, Console, @@ -159,6 +164,8 @@ constexpr bool isUiSurfaceAvailable(const WalletCapabilities& capabilities, case WalletUiSurface::LiteNetwork: case WalletUiSurface::LiteConsole: return !capabilities.fullNodePagesAvailable; // lite builds only + case WalletUiSurface::Chat: + return DRAGONX_ENABLE_CHAT != 0; // experimental; compiled-in only when the feature is on case WalletUiSurface::BootstrapDownload: case WalletUiSurface::SetupWizard: case WalletUiSurface::NodeSettings: