feat(chat): composer + contact requests — outgoing construction (Phase 4)

Add the outgoing side of HushChat: compose messages and start conversations.
The wire construction is the byte-exact inverse of the receive parser and is
proven by a self-consistent round-trip (build → parse → decrypt).

- src/chat/chat_outgoing.{h,cpp} (pure): buildOutgoingMessage encrypts via
  encryptOutgoing and buildOutgoingContactRequest carries plaintext; both emit
  the header memo JSON ({h,v,z,cid,t,e,p}, which nlohmann serializes
  alphabetically to match SilentDragonXLite) + the payload memo, validating the
  512-byte memo limit, the 64-hex peer key, and the "no leading '{'" rule for
  request text. My public key goes in header "p"; the peer's key is the
  encryption recipient.
- ChatService: composeMessage/composeContactRequest (encrypt with the held
  identity) + recordOutgoing (echo an Outgoing ChatMessage into the store + DB —
  we never harvest our own sends, so this is the only local record).
- App: sendChatMessage(cid,text) sources the peer z-addr + public key from the
  conversation, composes, and records a random-id echo; startChatConversation
  (zaddr,text) mints a random cid + composes a plaintext contact request;
  chatReplyZaddr() picks a spendable z-addr. broadcastChatMemos() is the Phase-5
  transport seam (network delivery + real-SDXL interop verification land there).
- Chat tab: a message composer (shown once the peer's key is known, else a
  "waiting for reply" hint) + a "New conversation" modal that sends a contact
  request to a z-address.
- Tests: outgoing round-trip through the receive parser + decrypt, contact-request
  passthrough, validation guards, and ChatService compose + recordOutgoing echo.

Adversarially reviewed (crypto/interop, secret hygiene, logic, UI) — 0 findings.
Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 15:30:41 -05:00
parent 980a100edd
commit 4db609fb52
10 changed files with 509 additions and 19 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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<unsigned char> buf(static_cast<std::size_t>(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<void(const std::string&, int, int)> callback)
{
if (!state_.connected || !rpc_) {

View File

@@ -0,0 +1,99 @@
// DragonX Wallet - HushChat outgoing memo construction (implementation).
#include "chat_outgoing.h"
#include "chat_protocol.h" // kHushChat* constants
#include <nlohmann/json.hpp>
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

53
src/chat/chat_outgoing.h Normal file
View File

@@ -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 <string>
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

View File

@@ -3,6 +3,7 @@
#include "chat_service.h"
#include "chat_database.h"
#include "chat_identity.h" // chatIdentityPublicKeyHex
#include <utility>
@@ -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

View File

@@ -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 <cstdint>
@@ -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_; }

View File

@@ -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<ConvSummary> 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<int>(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<int>(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

View File

@@ -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?";

View File

@@ -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<ChatMessage> 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<ChatMessage> 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();