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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
99
src/chat/chat_outgoing.cpp
Normal file
99
src/chat/chat_outgoing.cpp
Normal 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
53
src/chat/chat_outgoing.h
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user