diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b55c57..a472095 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -416,6 +416,7 @@ set(APP_SOURCES src/chat/chat_store.cpp src/chat/chat_service.cpp src/chat/chat_database.cpp + src/chat/chat_outgoing.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp @@ -569,6 +570,7 @@ set(APP_HEADERS src/chat/chat_store.h src/chat/chat_service.h src/chat/chat_database.h + src/chat/chat_outgoing.h src/config/version.h src/data/wallet_state.h src/data/transaction_history_cache.h @@ -1023,6 +1025,7 @@ if(BUILD_TESTING) src/chat/chat_store.cpp src/chat/chat_service.cpp src/chat/chat_database.cpp + src/chat/chat_outgoing.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp diff --git a/src/app.h b/src/app.h index d8ae448..b56de79 100644 --- a/src/app.h +++ b/src/app.h @@ -170,6 +170,11 @@ public: 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_; } + // HushChat composing (Phase 4): construct + locally echo an outgoing message (to a conversation + // whose peer key we know) / a new-conversation contact request. Network delivery is wired in a + // later phase — see broadcastChatMemos. + void sendChatMessage(const std::string& conversationId, const std::string& text); + void startChatConversation(const std::string& peerZaddr, const std::string& text); // 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). @@ -589,6 +594,9 @@ private: // feature is off, already provisioned, in flight, or unavailable. void maybeProvisionChatIdentity(); void provisionChatIdentityFromSecret(std::string secret); + std::string chatReplyZaddr() const; // a stable wallet z-addr for outgoing headers + std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid + void broadcastChatMemos(const chat::OutgoingChatMemos& memos); // Phase-5 seam (network delivery) // Lite first-run welcome prompt: dismissed for the session once the user picks an action. bool lite_firstrun_dismissed_ = false; // Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked. diff --git a/src/app_network.cpp b/src/app_network.cpp index 2a7fefc..b0e183f 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2468,6 +2468,113 @@ void App::maybeProvisionChatIdentity() } } +// A stable wallet z-address for outgoing chat headers (the "z" reply field + the send-from address). +// Prefers a spendable one so replies land somewhere we control and fees can be paid. +std::string App::chatReplyZaddr() const +{ + for (const auto& addr : state_.z_addresses) + if (addr.has_spending_key && !addr.address.empty()) return addr.address; + if (!state_.z_addresses.empty()) return state_.z_addresses.front().address; + return {}; +} + +// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for +// new conversation ids. Random — collisions are astronomically unlikely. +std::string App::generateChatLocalId(const char* prefix, int numBytes) const +{ + if (numBytes < 1) numBytes = 8; + std::vector buf(static_cast(numBytes)); + randombytes_buf(buf.data(), buf.size()); + static const char* kHex = "0123456789abcdef"; + std::string id = prefix ? prefix : ""; + for (unsigned char b : buf) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); } + return id; +} + +// Phase-5 seam: broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr +// (full-node z_sendmany via submitZSendMany with a two-recipient array; lite via the controller). +// Not wired yet — the composed message is recorded locally so the UI reflects it; over-the-wire +// delivery + real-SDXL interop verification land in the next phase. +void App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) +{ + (void)memos; +} + +void App::sendChatMessage(const std::string& conversationId, const std::string& text) +{ + if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; + if (text.empty() || conversationId.empty()) return; + + // The peer's z-address + public key come from a message we already have in this conversation. + std::string peerZaddr; + std::string peerPubKey; + for (const auto& m : chat_service_.store().conversation(conversationId)) { + if (!m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr; + if (!m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex; + } + if (peerPubKey.empty()) { + ui::Notifications::instance().info("Waiting for the contact to reply before you can message them."); + return; + } + const std::string myReply = chatReplyZaddr(); + if (myReply.empty()) { + ui::Notifications::instance().error("No z-address available to send chat from."); + return; + } + + chat::OutgoingChatMemos memos; + if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos) + != chat::ChatComposeStatus::Ok) { + ui::Notifications::instance().error("Could not compose the message (too long?)."); + return; + } + broadcastChatMemos(memos); + + chat::ChatMessage echo; + echo.direction = chat::ChatDirection::Outgoing; + echo.kind = chat::ChatMessageKind::Message; + echo.conversation_id = conversationId; + echo.peer_zaddr = peerZaddr; + echo.peer_public_key_hex = peerPubKey; + echo.body = text; + echo.timestamp = std::time(nullptr); + echo.txid = generateChatLocalId("out:", 8); + echo.payload_position = 0; + chat_service_.recordOutgoing(echo); +} + +void App::startChatConversation(const std::string& peerZaddr, const std::string& text) +{ + if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; + if (peerZaddr.empty() || text.empty()) return; + const std::string myReply = chatReplyZaddr(); + if (myReply.empty()) { + ui::Notifications::instance().error("No z-address available to send chat from."); + return; + } + + const std::string cid = generateChatLocalId("", 16); // opaque per-conversation id + chat::OutgoingChatMemos memos; + if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos) + != chat::ChatComposeStatus::Ok) { + ui::Notifications::instance().error("Could not compose the contact request (invalid address / text?)."); + return; + } + broadcastChatMemos(memos); + + chat::ChatMessage echo; + echo.direction = chat::ChatDirection::Outgoing; + echo.kind = chat::ChatMessageKind::ContactRequest; + echo.conversation_id = cid; + echo.peer_zaddr = peerZaddr; + echo.body = text; + echo.timestamp = std::time(nullptr); + echo.txid = generateChatLocalId("out:", 8); + echo.payload_position = 0; + chat_service_.recordOutgoing(echo); + ui::Notifications::instance().success("Contact request queued."); +} + void App::exportAllKeys(std::function callback) { if (!state_.connected || !rpc_) { diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp new file mode 100644 index 0000000..f6da527 --- /dev/null +++ b/src/chat/chat_outgoing.cpp @@ -0,0 +1,99 @@ +// DragonX Wallet - HushChat outgoing memo construction (implementation). + +#include "chat_outgoing.h" + +#include "chat_protocol.h" // kHushChat* constants + +#include + +namespace dragonx::chat { + +namespace { + +// Serialize the HushChat header. nlohmann emits object keys in sorted (alphabetical) order — +// cid,e,h,p,t,v,z — which is exactly SilentDragonXLite's on-wire key order. +std::string buildHeaderMemo(const std::string& replyZaddr, + const std::string& conversationId, + const char* type, + const std::string& streamHeaderHex, + const std::string& publicKeyHex) +{ + nlohmann::json header; + header["h"] = 1; // header number (>= 1) + header["v"] = kHushChatSupportedVersion; // 0 + header["z"] = replyZaddr; // where the peer should reply (my address) + header["cid"] = conversationId; + header["t"] = type; // "Memo" or "Cont" + header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont) + header["p"] = publicKeyHex; // my 64-hex crypto_kx public key + return header.dump(); +} + +bool present(const std::string& value) { return !value.empty(); } + +} // namespace + +ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine, + const std::string& myPublicKeyHex, + const std::string& myReplyZaddr, + const std::string& peerPublicKeyHex, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& plaintext, + OutgoingChatMemos& out) +{ + if (plaintext.empty()) return ChatComposeStatus::EmptyBody; + if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) || + !present(conversationId)) { + return ChatComposeStatus::MissingField; + } + if (peerPublicKeyHex.size() != kHushChatPublicKeyHexLength) return ChatComposeStatus::BadPeerKey; + + std::string streamHeaderHex; + std::string ciphertextHex; + if (encryptOutgoing(mine, peerPublicKeyHex, plaintext, streamHeaderHex, ciphertextHex) + != ChatCryptoStatus::Ok) { + return ChatComposeStatus::EncryptFailed; + } + + OutgoingChatMemos memos; + memos.recipientZaddr = peerZaddr; + memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex); + memos.payloadMemo = ciphertextHex; + if (memos.headerMemo.size() > kHushChatMemoByteLimit || + memos.payloadMemo.size() > kHushChatMemoByteLimit) { + return ChatComposeStatus::TooLong; + } + out = std::move(memos); + return ChatComposeStatus::Ok; +} + +ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex, + const std::string& myReplyZaddr, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& requestText, + OutgoingChatMemos& out) +{ + if (requestText.empty()) return ChatComposeStatus::EmptyBody; + // The receive parser treats any memo starting with '{' as a header, so a request payload must + // not start with one (see isContactPayloadCandidate in chat_protocol.cpp). + if (requestText.front() == '{') return ChatComposeStatus::BadRequestText; + if (!present(myPublicKeyHex) || !present(myReplyZaddr) || !present(peerZaddr) || + !present(conversationId)) { + return ChatComposeStatus::MissingField; + } + + OutgoingChatMemos memos; + memos.recipientZaddr = peerZaddr; + memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex); + memos.payloadMemo = requestText; + if (memos.headerMemo.size() > kHushChatMemoByteLimit || + memos.payloadMemo.size() > kHushChatMemoByteLimit) { + return ChatComposeStatus::TooLong; + } + out = std::move(memos); + return ChatComposeStatus::Ok; +} + +} // namespace dragonx::chat diff --git a/src/chat/chat_outgoing.h b/src/chat/chat_outgoing.h new file mode 100644 index 0000000..4f7de3b --- /dev/null +++ b/src/chat/chat_outgoing.h @@ -0,0 +1,53 @@ +#pragma once + +// DragonX Wallet - HushChat outgoing memo construction (the inverse of the receive parser). +// +// Given the sender's identity and the peer, produce the header memo JSON + payload memo that, +// sent as two 0-value memo outputs to the peer's z-address (header at the LOWER memo position), +// another HushChat client parses and decrypts. The byte format matches SilentDragonXLite: the +// header keys serialize alphabetically (nlohmann default) to cid,e,h,p,t,v,z. Pure — no I/O, no +// network; broadcasting the memos is the caller's job (the transport lands in a later phase). + +#include "chat_crypto.h" // ChatKeyPair + +#include + +namespace dragonx::chat { + +struct OutgoingChatMemos { + std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs) + std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire + std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest) +}; + +enum class ChatComposeStatus { + Ok, + EmptyBody, + MissingField, + BadPeerKey, + BadRequestText, // a contact request text must not start with '{' (parser would read it as a header) + EncryptFailed, + TooLong // a resulting memo exceeds the HushChat 512-byte memo limit +}; + +// Build an ENCRYPTED message to a peer whose public key you already learned from a memo they sent +// you. `mine` is the sender's identity keypair; `myPublicKeyHex` its public half (goes in header p). +ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine, + const std::string& myPublicKeyHex, + const std::string& myReplyZaddr, + const std::string& peerPublicKeyHex, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& plaintext, + OutgoingChatMemos& out); + +// Build a plaintext contact request — no peer public key needed yet; this is how the peer first +// learns your public key + reply address. The payload is the (plaintext) request text. +ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex, + const std::string& myReplyZaddr, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& requestText, + OutgoingChatMemos& out); + +} // namespace dragonx::chat diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index 039b906..ac3ba8d 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -3,6 +3,7 @@ #include "chat_service.h" #include "chat_database.h" +#include "chat_identity.h" // chatIdentityPublicKeyHex #include @@ -70,4 +71,38 @@ void ChatService::loadFromDatabase() { } } +std::string ChatService::identityPublicKeyHex() const { + if (!has_identity_) return {}; + return chatIdentityPublicKeyHex(identity_); +} + +ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr, + const std::string& peerPublicKeyHex, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& plaintext, + OutgoingChatMemos& out) const { + if (!has_identity_) return ChatComposeStatus::MissingField; + return buildOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr, + peerPublicKeyHex, peerZaddr, conversationId, plaintext, out); +} + +ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& requestText, + OutgoingChatMemos& out) const { + if (!has_identity_) return ChatComposeStatus::MissingField; + return buildOutgoingContactRequest(chatIdentityPublicKeyHex(identity_), myReplyZaddr, + peerZaddr, conversationId, requestText, out); +} + +bool ChatService::recordOutgoing(const ChatMessage& message) { + if (store_.append(message)) { + if (db_) db_->append(message); + return true; + } + return false; +} + } // namespace dragonx::chat diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index f58a42e..d386eed 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -7,6 +7,7 @@ #include "chat_crypto.h" // ChatKeyPair #include "chat_protocol.h" // HushChatTransactionMetadata +#include "chat_outgoing.h" // OutgoingChatMemos, ChatComposeStatus #include "chat_store.h" #include @@ -50,6 +51,27 @@ public: // the seed-derived key) into the in-memory store. No-op without an unlocked database. void loadFromDatabase(); + // --- Outgoing (compose) --- + // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". + std::string identityPublicKeyHex() const; + // Construct the outgoing memos for an ENCRYPTED message, using the held identity to encrypt. + ChatComposeStatus composeMessage(const std::string& myReplyZaddr, + const std::string& peerPublicKeyHex, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& plaintext, + OutgoingChatMemos& out) const; + // Construct the outgoing memos for a plaintext contact request (no peer key needed yet). + ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr, + const std::string& peerZaddr, + const std::string& conversationId, + const std::string& requestText, + OutgoingChatMemos& out) const; + // Echo a locally-composed outgoing message into the store (and DB). Returns true if new. (We + // never harvest our own sent memos — they land on the peer's address — so this echo is the + // only local record of what we sent.) + bool recordOutgoing(const ChatMessage& message); + const ChatStore& store() const { return store_; } ChatStore& store() { return store_; } diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 7818241..0aa6594 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -29,6 +29,12 @@ namespace { 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; } @@ -58,7 +64,8 @@ std::string previewOf(const std::string& body) { struct ConvSummary { std::string cid; std::string peerZaddr; - std::string peerName; // contact label if known, else a shortened z-addr / cid + 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; @@ -94,18 +101,21 @@ void RenderChatTab(App* app) return; } - // Build conversation summaries, sorted by most-recent activity. + // 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; - const auto& last = messages.back(); ConvSummary c; c.cid = cid; - c.peerZaddr = last.peer_zaddr; + 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; - 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); @@ -114,13 +124,9 @@ void RenderChatTab(App* app) 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(), + // 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; @@ -136,9 +142,25 @@ void RenderChatTab(App* app) const float nameSz = scaledSize(nameFont); const float metaSz = scaledSize(metaFont); - // ---- Left: conversation list ---- + // ---- 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); @@ -230,16 +252,64 @@ void RenderChatTab(App* app) } ImGui::EndChild(); - // Read-only footer: composing arrives in a later phase. + // Composer footer: message input + send (only once we know the peer's key), else a hint. ImGui::Separator(); - ImGui::PushFont(metaFont); - ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); - ImGui::TextUnformatted(TR("chat_readonly_note")); - ImGui::PopStyleColor(); - ImGui::PopFont(); + 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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 467cbb0..02267fb 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -217,6 +217,15 @@ void I18n::loadBuiltinEnglish() strings_["chat_you"] = "You"; strings_["chat_contact_request"] = "contact request"; strings_["chat_readonly_note"] = "Read-only — sending messages arrives in a later update."; + strings_["chat_new_button"] = "New conversation"; + strings_["chat_select_hint"] = "Select a conversation to view it."; + strings_["chat_waiting_reply"] = "Waiting for this contact to reply — you can message them once they do."; + strings_["chat_send"] = "Send"; + strings_["chat_new_title"] = "New conversation"; + strings_["chat_new_zaddr"] = "Recipient z-address"; + strings_["chat_new_message"] = "Message"; + strings_["chat_new_send"] = "Send request"; + strings_["chat_cancel"] = "Cancel"; strings_["contacts_search_placeholder"] = "Search contacts..."; strings_["contacts_search_no_match"] = "No matching contacts"; strings_["address_book_confirm_delete"] = "Confirm delete?"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index de5638d..45fa47f 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -5849,6 +5849,89 @@ void testHushChatDatabase() scrub(dbPath); scrub(dbPath2); } +// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and +// ChatService compose/recordOutgoing echoes into the store. +void testHushChatOutgoing() +{ + using namespace dragonx::chat; + + ChatKeyPair alice, bob; + ChatIdentityResult ra = deriveChatIdentityFromSecret("out-alice", alice, true); + ChatIdentityResult rb = deriveChatIdentityFromSecret("out-bob", bob, true); + const std::string aliceZ = "zs-alice-reply"; + const std::string bobZ = "zs-bob"; + const std::string cid = "conv-out"; + + // Encrypted message Alice -> Bob, then round-trip through the receive path. + OutgoingChatMemos memos; + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, + "hello bob", memos) == ChatComposeStatus::Ok); + EXPECT_EQ(memos.recipientZaddr, bobZ); + EXPECT_TRUE(!memos.headerMemo.empty() && memos.headerMemo.front() == '{'); + // Header keys serialize alphabetically (cid before e). + EXPECT_TRUE(memos.headerMemo.find("\"cid\"") < memos.headerMemo.find("\"e\"")); + + HushChatTransactionInput tx; + tx.txid = "txout1"; + tx.outputs.push_back({0, memos.headerMemo}); // header at the lower position + tx.outputs.push_back({1, memos.payloadMemo}); + auto extracted = extractHushChatTransactionMetadata(tx, true); + EXPECT_EQ((int)extracted.metadata.size(), 1); + + ChatService bobSvc; + bobSvc.setIdentity(bob); + EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 5), 1); + std::vector conv = bobSvc.store().conversation(cid); + EXPECT_EQ((int)conv.size(), 1); + EXPECT_EQ(conv[0].body, std::string("hello bob")); + EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key + EXPECT_EQ(conv[0].peer_zaddr, aliceZ); + + // Plaintext contact request Alice -> Bob. + OutgoingChatMemos creq; + EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "conv-cr", "add me?", + creq) == ChatComposeStatus::Ok); + HushChatTransactionInput tx2; + tx2.txid = "txout2"; + tx2.outputs.push_back({0, creq.headerMemo}); + tx2.outputs.push_back({1, creq.payloadMemo}); + auto ex2 = extractHushChatTransactionMetadata(tx2, true); + EXPECT_EQ((int)ex2.metadata.size(), 1); + EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest); + bobSvc.ingest(ex2.metadata, {}, 6); + EXPECT_EQ((int)bobSvc.store().conversation("conv-cr").size(), 1); + EXPECT_EQ(bobSvc.store().conversation("conv-cr")[0].body, std::string("add me?")); + + // Validation guards. + OutgoingChatMemos dummy; + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, "tooshort", bobZ, cid, "x", dummy) + == ChatComposeStatus::BadPeerKey); + EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, "", dummy) + == ChatComposeStatus::EmptyBody); + EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "c", "{bad", dummy) + == ChatComposeStatus::BadRequestText); + + // ChatService compose + recordOutgoing echo. + OutgoingChatMemos svcMemos; + EXPECT_TRUE(bobSvc.composeMessage(bobZ, ra.public_key_hex, aliceZ, cid, "reply!", svcMemos) + == ChatComposeStatus::Ok); + ChatMessage echo; + echo.direction = ChatDirection::Outgoing; + echo.kind = ChatMessageKind::Message; + echo.conversation_id = cid; + echo.peer_zaddr = aliceZ; + echo.peer_public_key_hex = ra.public_key_hex; + echo.body = "reply!"; + echo.timestamp = 7; + echo.txid = "out:local1"; + echo.payload_position = 0; + EXPECT_TRUE(bobSvc.recordOutgoing(echo)); + std::vector conv2 = bobSvc.store().conversation(cid); + EXPECT_EQ((int)conv2.size(), 2); + EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing); + EXPECT_EQ(conv2.back().body, std::string("reply!")); +} + } // namespace int main() @@ -5943,6 +6026,7 @@ int main() testHushChatReceivePath(); testHushChatService(); testHushChatDatabase(); + testHushChatOutgoing(); testAddressChecksumValidation(); testLiteServerProbeLive(); testXmrigLiveInstall();