From e9eeaf00c352db4fd276e48ded71c9a78f370a35 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 18 Oct 2026 13:12:57 -0500 Subject: [PATCH] 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 --- src/ui/windows/chat_tab.cpp | 45 +++++++++++++++++++++++++++++++++++++ src/util/i18n.cpp | 5 +++++ 2 files changed, 50 insertions(+) diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 15ad532..dabae3f 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -537,6 +537,7 @@ struct ConvSummary { std::int64_t lastTs = 0; int count = 0; 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 @@ -808,9 +809,11 @@ void RenderChatTab(App* app) c.cid = cid; c.hidden = hidden; c.count = static_cast(messages.size()); + bool sentAny = false; 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.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(); c.lastBody = last.body; @@ -818,6 +821,12 @@ void RenderChatTab(App* app) 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); + // 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)); } 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::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(); // 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). diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index a6e8e93..44e3c97 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -232,6 +232,11 @@ void I18n::loadBuiltinEnglish() strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here."; strings_["chat_you"] = "You"; 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_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)";