diff --git a/src/app_network.cpp b/src/app_network.cpp index 9b225d7..86a2824 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -4854,7 +4854,7 @@ bool App::shouldSplitChatBuffer() { // depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay // invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split- // in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits. - if (chat_service_.store().conversationIds().empty() && chat_send_queue_.empty()) return false; + if (chat_service_.store().empty() && chat_send_queue_.empty()) return false; // == conversationIds().empty(), O(1) if (!state_.connected || state_.isLocked()) return false; // Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set // lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index 09125bd..2284f46 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -90,6 +90,7 @@ std::vector ChatStore::eraseConversation(const std::string& convers void ChatStore::clear() { messages_.clear(); seen_.clear(); + txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo) ++revision_; } diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 6a0ada7..0b99580 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -38,7 +38,7 @@ public: std::vector conversationIds() const; // The set of on-chain txids that carried a chat message (sent or received, messages + contact - // requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies. + // requests). Builds a fresh COPY every call (O(messages) allocations) — prefer hasChatTxid() on hot paths. std::unordered_set chatTxids() const { std::unordered_set out; out.reserve(messages_.size()); @@ -47,6 +47,15 @@ public: return out; } + // True if an on-chain txid carried a chat message (History tab "Message" badge / Chat filter). Backed by a + // txid index rebuilt lazily when revision() moved (once per store mutation, O(messages)); O(1) on an empty + // store and per lookup. One txid may back several messages; a from-scratch rebuild handles erase correctly. + bool hasChatTxid(const std::string& txid) const { + if (messages_.empty() || txid.empty()) return false; + if (txid_index_rev_ != revision_) rebuildTxidIndex(); + return txid_index_.count(txid) != 0; + } + // Remove every message in a conversation from the in-memory view and return the removed messages // (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest // guard (blocked-cid predicate / DB tombstone), not here. @@ -70,9 +79,21 @@ public: private: static std::string dedupKey(const ChatMessage& message); + void rebuildTxidIndex() const { + txid_index_.clear(); + txid_index_.reserve(messages_.size()); + for (const auto& m : messages_) + if (!m.txid.empty()) txid_index_.insert(m.txid); + txid_index_rev_ = revision_; + } + std::vector messages_; std::unordered_set seen_; std::uint64_t revision_ = 0; + // Lazy memo for hasChatTxid(): valid iff txid_index_rev_ == revision_. revision_ is monotonic and never reset, + // so append/updateDelivery/eraseConversation/clear all invalidate it (~0 = never built). Main thread only. + mutable std::unordered_set txid_index_; + mutable std::uint64_t txid_index_rev_ = ~std::uint64_t{0}; }; } // namespace dragonx::chat diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index fc20e83..51b40ce 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -8473,6 +8473,90 @@ void testTransactionRefreshRevision() EXPECT_EQ(state.last_tx_update, static_cast(300)); } +// ChatStore::hasChatTxid (History "Message" badge / Chat filter): a lazy txid index keyed on the monotonic +// revision(), so every mutation (append / updateDelivery / eraseConversation / clear) is seen on the next lookup; +// clear() also drops it. store.empty() must equal conversationIds().empty() (shouldSplitChatBuffer relies on it). +void testChatStoreTxidIndex() +{ + using namespace dragonx::chat; + ChatStore store; + auto mk = [](const std::string& txid, std::size_t pos, const std::string& cid) { + ChatMessage m; + m.txid = txid; m.payload_position = pos; m.conversation_id = cid; m.body = "b"; + return m; + }; + auto emptyMatches = [&store]() { return store.empty() == store.conversationIds().empty(); }; + + EXPECT_FALSE(store.hasChatTxid("t1")); + EXPECT_FALSE(store.hasChatTxid("")); + EXPECT_TRUE(emptyMatches()); + + EXPECT_TRUE(store.append(mk("t1", 0, "a"))); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.hasChatTxid("t1")); + EXPECT_FALSE(store.hasChatTxid("t2")); + + // One txid backing two messages in two conversations: erasing one keeps the txid reported. + EXPECT_TRUE(store.append(mk("t1", 1, "b"))); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.hasChatTxid("t1")); + EXPECT_EQ(store.eraseConversation("a").size(), static_cast(1)); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.hasChatTxid("t1")); + EXPECT_EQ(store.eraseConversation("b").size(), static_cast(1)); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.empty()); + EXPECT_FALSE(store.hasChatTxid("t1")); + + // A lookup after the index was built still sees a later append (revision moved). + EXPECT_TRUE(store.append(mk("t3", 0, "c"))); + EXPECT_TRUE(store.hasChatTxid("t3")); + EXPECT_TRUE(store.append(mk("t2", 0, "c"))); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.hasChatTxid("t2")); + const std::uint64_t revBeforeClear = store.revision(); + store.clear(); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.revision() > revBeforeClear); + EXPECT_FALSE(store.hasChatTxid("t2")); + EXPECT_FALSE(store.hasChatTxid("t3")); + + // An empty txid is never reported, even when a message carries one. + EXPECT_TRUE(store.append(mk("", 0, "d"))); + EXPECT_TRUE(emptyMatches()); + EXPECT_FALSE(store.hasChatTxid("")); + + // An outgoing echo stays reported across a delivery change. + ChatMessage echo = mk("out:ab", 0, "e"); + echo.direction = ChatDirection::Outgoing; + echo.delivery = ChatDelivery::Sending; + EXPECT_TRUE(store.append(echo)); + EXPECT_TRUE(emptyMatches()); + EXPECT_TRUE(store.hasChatTxid("out:ab")); + const std::uint64_t revBeforeUpdate = store.revision(); + EXPECT_TRUE(store.updateDelivery("out:ab", ChatDelivery::Failed) != nullptr); + EXPECT_TRUE(store.revision() > revBeforeUpdate); + EXPECT_TRUE(store.hasChatTxid("out:ab")); + EXPECT_FALSE(store.hasChatTxid("out:zz")); + + store.clear(); + EXPECT_TRUE(store.empty()); + EXPECT_TRUE(emptyMatches()); + EXPECT_FALSE(store.hasChatTxid("out:ab")); + + // Erasing a conversation while the store stays non-empty (so the empty-store early return can't hide a + // stale index): with the index already built, only the erased conversation's txid disappears. + EXPECT_TRUE(store.append(mk("t1", 0, "a"))); + EXPECT_TRUE(store.append(mk("t2", 0, "c"))); + EXPECT_TRUE(store.hasChatTxid("t1")); + EXPECT_TRUE(store.hasChatTxid("t2")); + EXPECT_EQ(store.eraseConversation("a").size(), static_cast(1)); + EXPECT_FALSE(store.empty()); + EXPECT_TRUE(emptyMatches()); + EXPECT_FALSE(store.hasChatTxid("t1")); + EXPECT_TRUE(store.hasChatTxid("t2")); +} + // M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop // the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address // split, transaction typing, confirmations, and sync progress. @@ -14545,6 +14629,7 @@ int main() testHushChatService(); testHushChatDatabase(); testHushChatDeleteConversation(); + testChatStoreTxidIndex(); testHushChatOutgoing(); testHushChatTransport(); testHushChatShuffledReceive();