diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index fa93f08..6b169f0 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -166,15 +166,21 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label, std::int64_t timestamp, const std::string& txid) { - auto it = control_.find(cid); - if (it != control_.end()) { + // Route the op to its axis; ignore unknown future ops so they never pollute either reduction. + std::unordered_map* target = nullptr; + if (op == "del" || op == "res") target = &control_; + else if (op == "sensitive" || op == "unsensitive") target = &sensitive_control_; + else return; + + auto it = target->find(cid); + if (it != target->end()) { const ControlState& cur = it->second; // A later signal wins; equal timestamps break by txid so the reduction is deterministic and a // replay of an older op can never flip the resolved state. if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return; it->second = ControlState{op, label, timestamp, txid}; } else { - control_.emplace(cid, ControlState{op, label, timestamp, txid}); + target->emplace(cid, ControlState{op, label, timestamp, txid}); } } @@ -190,6 +196,17 @@ std::vector> ChatService::onChainDeletedConv } return out; } +bool ChatService::conversationSensitiveOnChain(const std::string& conversationId) const { + auto it = sensitive_control_.find(conversationId); + return it != sensitive_control_.end() && it->second.op == "sensitive"; +} +std::vector> ChatService::onChainSensitiveConversations() const { + std::vector> out; + for (const auto& entry : sensitive_control_) { + if (entry.second.op == "sensitive") out.emplace_back(entry.first, entry.second.label); + } + return out; +} std::string ChatService::identityPublicKeyHex() const { if (!has_identity_) return {}; diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index e7284f5..588d6f7 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -81,6 +81,14 @@ public: // the label carried in its control memo (the peer address / display name at delete time). std::vector> onChainDeletedConversations() const; + // Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is + // "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld + // on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from + // chain each session, like the hide axis. + bool conversationSensitiveOnChain(const std::string& conversationId) const; + // Conversations whose latest on-chain control op is "sensitive", each paired with its control label. + std::vector> onChainSensitiveConversations() const; + // --- Outgoing (compose) --- // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". std::string identityPublicKeyHex() const; @@ -131,8 +139,10 @@ private: std::int64_t timestamp = 0; std::string txid; }; - std::unordered_map control_; - // Monotonic reduce: a later (timestamp, txid) wins; a replay of an older op never flips the state. + std::unordered_map control_; // hide axis: del / res + std::unordered_map sensitive_control_; // sensitive axis: sensitive / unsensitive + // Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat), + // and within an axis a later (timestamp, txid) wins so a replay of an older op never flips the state. void applyControl(const std::string& cid, const std::string& op, const std::string& label, std::int64_t timestamp, const std::string& txid); }; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f327a9a..eec7838 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15133,6 +15133,50 @@ void testHushChatControlReducer() feed(dm, "d1", now + 10); EXPECT_TRUE(!svc.conversationHiddenOnChain(cid)); EXPECT_EQ((int)svc.store().conversation(cid).size(), 1); + + // ---- Sensitive axis: independent of the hide axis. Build control metadata directly with explicit + // timestamps so the monotonic ordering is deterministic (not subject to compose-time ties). ---- + auto ctl = [&](const std::string& op, const std::string& c, std::int64_t ts, const std::string& txid) { + std::vector md(1); + md[0].txid = txid; + md[0].type = HushChatHeaderType::Control; + md[0].op = op; + md[0].conversation_id = c; + md[0].reply_zaddr = "zs-me"; + md[0].sender_public_key_hex = rm.public_key_hex; + md[0].payload_memo = "Peer"; + md[0].sent_at = ts; + return svc.ingest(md, {}, ts); + }; + + EXPECT_TRUE(!svc.conversationSensitiveOnChain(cid)); + EXPECT_EQ(ctl("sensitive", cid, now + 100, "s1"), 0); // a control adds no message + EXPECT_TRUE(svc.conversationSensitiveOnChain(cid)); // marked sensitive + EXPECT_TRUE(!svc.conversationHiddenOnChain(cid)); // but NOT hidden (independent axis) + EXPECT_EQ((int)svc.store().conversation(cid).size(), 1); // and the thread is not erased + { + auto sens = svc.onChainSensitiveConversations(); + EXPECT_EQ((int)sens.size(), 1); + EXPECT_EQ(sens[0].first, cid); + } + + // A conversation can be hidden AND sensitive at the same time. + ctl("del", cid, now + 200, "d2"); + EXPECT_TRUE(svc.conversationHiddenOnChain(cid)); + EXPECT_TRUE(svc.conversationSensitiveOnChain(cid)); + EXPECT_EQ((int)svc.store().conversation(cid).size(), 0); // the hide erased the view; sensitive persists + + // unsensitive (later) clears sensitive and leaves the hide axis untouched; an older replay can't re-set. + ctl("unsensitive", cid, now + 300, "u1"); + EXPECT_TRUE(!svc.conversationSensitiveOnChain(cid)); + EXPECT_TRUE(svc.conversationHiddenOnChain(cid)); + ctl("sensitive", cid, now + 100, "s1"); // replay the older sensitive + EXPECT_TRUE(!svc.conversationSensitiveOnChain(cid)); + + // An unknown future op touches neither axis. + ctl("futurecustomop", "conv-y", now + 400, "x1"); + EXPECT_TRUE(!svc.conversationHiddenOnChain("conv-y")); + EXPECT_TRUE(!svc.conversationSensitiveOnChain("conv-y")); } // ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.