feat(chat): Accept/Block card for unsolicited contact requests

An unsolicited first message from a stranger (an incoming ContactRequest, with
nothing sent by us and the peer not in contacts) now shows an Accept / Block
card above the thread instead of looking like a normal conversation. Accept adds
the peer to the address book (which clears the request state); Block deletes and
blocks the conversation (reversible from the Blocked manager). ConvSummary gains
a derived pendingRequest flag. (The list-row "Request" pill and a header Block
button were left out to avoid the fragile hand-drawn header-toolbar geometry.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 13:12:57 -05:00
co-authored by Claude Opus 4.8
parent 572b98cbcf
commit e9eeaf00c3
2 changed files with 50 additions and 0 deletions
+45
View File
@@ -537,6 +537,7 @@ struct ConvSummary {
std::int64_t lastTs = 0; std::int64_t lastTs = 0;
int count = 0; int count = 0;
bool hidden = false; // shown only while "Show hidden" is on bool hidden = false; // shown only while "Show hidden" is on
bool pendingRequest = false; // unsolicited incoming contact request from a stranger: awaiting Accept/Block
}; };
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise // Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
@@ -808,9 +809,11 @@ void RenderChatTab(App* app)
c.cid = cid; c.cid = cid;
c.hidden = hidden; c.hidden = hidden;
c.count = static_cast<int>(messages.size()); c.count = static_cast<int>(messages.size());
bool sentAny = false;
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD) if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
if (m.direction == chat::ChatDirection::Outgoing) sentAny = true;
} }
const auto& last = messages.back(); const auto& last = messages.back();
c.lastBody = last.body; c.lastBody = last.body;
@@ -818,6 +821,12 @@ void RenderChatTab(App* app)
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr); const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid); : shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
// Unsolicited: first message is an incoming contact request, we've never replied, and the peer
// isn't a saved contact → show an Accept/Block card instead of treating it as a normal chat.
const auto& first = messages.front();
c.pendingRequest = (idx < 0) && !sentAny && !c.peerZaddr.empty() &&
first.direction == chat::ChatDirection::Incoming &&
first.kind == chat::ChatMessageKind::ContactRequest;
s_convs.push_back(std::move(c)); s_convs.push_back(std::move(c));
} }
std::sort(s_convs.begin(), s_convs.end(), std::sort(s_convs.begin(), s_convs.end(),
@@ -1340,6 +1349,42 @@ void RenderChatTab(App* app)
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi)); ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
ImGui::Separator(); ImGui::Separator();
// Stranger contact-request card: Accept (add to contacts) or Block (delete + block). Normal
// ImGui flow, so ##ChatMessages below (sized from the remaining content height) shrinks to fit.
if (sel->pendingRequest) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
ImGui::TextUnformatted(TR("chat_request_title"));
ImGui::PopStyleColor();
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", TR("chat_request_body"));
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (ImGui::SmallButton(TR("chat_request_accept"))) {
data::AddressBookEntry e;
e.address = sel->peerZaddr;
e.label = sel->peerName;
book.addEntry(e); // book.revision() bumps → the request flag clears on the next rebuild
Notifications::instance().info(TR("chat_request_accepted"));
}
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_request_block"))) {
const std::string cid = sel->cid, nm = sel->peerName; // copy before deleteConversation
if (app->chatService().deleteConversation(cid, /*block=*/true)) {
if (app->settings()) {
app->settings()->setChatBlocked(cid, nm, true);
app->settings()->setChatHidden(cid, false);
app->settings()->save();
}
if (s_selected_cid == cid) s_selected_cid.clear();
Notifications::instance().info(TR("chat_blocked_toast"));
} else {
Notifications::instance().error(TR("chat_delete_failed"));
}
}
ImGui::Separator();
}
const float dp = Layout::dpiScale(); const float dp = Layout::dpiScale();
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off // Small message-list inset (the ##ChatThread WindowPadding above already holds content off
// the glass edge; keep a little here so bubbles don't hug the scrollbar). // the glass edge; keep a little here so bubbles don't hug the scrollbar).
+5
View File
@@ -232,6 +232,11 @@ void I18n::loadBuiltinEnglish()
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here."; strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
strings_["chat_you"] = "You"; strings_["chat_you"] = "You";
strings_["chat_contact_request"] = "contact request"; strings_["chat_contact_request"] = "contact request";
strings_["chat_request_title"] = "New contact request";
strings_["chat_request_body"] = "This person isn't in your contacts. Accept to add them, or block to delete this conversation and ignore them.";
strings_["chat_request_accept"] = "Accept";
strings_["chat_request_block"] = "Block";
strings_["chat_request_accepted"] = "Contact added.";
strings_["chat_send_failed"] = "not sent"; strings_["chat_send_failed"] = "not sent";
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it"; strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)"; strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";