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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user