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

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