diff --git a/src/app_network.cpp b/src/app_network.cpp index 7e17a8b..f25712f 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -4091,10 +4091,8 @@ void App::provisionChatIdentityFromSecret(std::string secret) // Baseline unread: treat everything already in the store at load as read, so only messages // that arrive while the app is open badge as unread (Q1). const auto& store = chat_service_.store(); - for (const auto& cid : store.conversationIds()) { - const auto msgs = store.conversation(cid); - if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp; - } + for (const auto& [cid, ts] : store.latestTimestampByConversation()) + chat_seen_watermark_[cid] = ts; } } else { chat_identity_unavailable_ = true; diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index e41f068..ebf83a7 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -119,11 +119,13 @@ int ChatService::ingest(const std::vector& metadata void ChatService::loadFromDatabase() { if (!db_) return; - for (const auto& message : db_->load()) { + auto rows = db_->load(); + store_.reserve(store_.size() + rows.size()); + for (auto& message : rows) { // Never surface a blocked conversation, even if a prior "delete & block" failed to remove its // rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path. - if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; - store_.append(message); + if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; // evaluate before the move + store_.append(std::move(message)); } } diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index 2284f46..1499a10 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -21,6 +21,28 @@ bool ChatStore::append(const ChatMessage& message) { return true; } +bool ChatStore::append(ChatMessage&& message) { + if (!seen_.insert(dedupKey(message)).second) return false; // reads txid/payload_position before the move + messages_.push_back(std::move(message)); + ++revision_; + return true; +} + +void ChatStore::reserve(std::size_t n) { + messages_.reserve(n); + seen_.reserve(n); +} + +std::unordered_map ChatStore::latestTimestampByConversation() const { + std::unordered_map out; + for (const auto& message : messages_) { + auto it = out.find(message.conversation_id); + if (it == out.end()) out.emplace(message.conversation_id, static_cast(message.timestamp)); + else if (static_cast(message.timestamp) > it->second) it->second = static_cast(message.timestamp); + } + return out; +} + std::vector ChatStore::conversation(const std::string& conversationId) const { std::vector out; for (const auto& message : messages_) { diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 0b99580..fee532c 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,13 @@ class ChatStore { public: // Returns true if newly inserted, false if a duplicate was ignored. bool append(const ChatMessage& message); + // Same, but moves the message into the store (dedup key is computed before the move). + bool append(ChatMessage&& message); + // Pre-size the message vector + dedup set for a bulk load of n additional messages. + void reserve(std::size_t n); + // Max timestamp per conversation in ONE pass over messages_. Semantically equal to + // conversation(cid).back().timestamp (conversation() sorts ascending) without the per-cid copy+sort. + std::unordered_map latestTimestampByConversation() const; // The dedup key of a message: ":". static std::string messageKey(const std::string& txid, std::size_t payloadPosition); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index fbfaa2d..70a2841 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -10899,6 +10899,36 @@ void testTransactionRefreshRevision() // 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 testChatStoreLatestAndMove() +{ + using namespace dragonx::chat; + auto mk = [](const std::string& txid, std::size_t pos, const std::string& cid, int64_t ts) { + ChatMessage m; + m.txid = txid; m.payload_position = pos; m.conversation_id = cid; m.timestamp = ts; m.body = "body-" + txid; + return m; + }; + ChatStore a, b; + a.reserve(8); + std::vector in{ mk("t1", 0, "x", 50), mk("t2", 0, "y", 10), mk("t3", 0, "x", 90), + mk("t4", 0, "y", 70), mk("t5", 0, "x", 20) }; + for (const auto& m : in) EXPECT_TRUE(a.append(m)); + for (auto m : in) EXPECT_TRUE(b.append(std::move(m))); + // dedup on txid:position via the move overload; moved-from message not stored twice + EXPECT_FALSE(b.append(mk("t1", 0, "x", 999))); + EXPECT_TRUE(b.append(mk("t1", 1, "x", 60))); + EXPECT_EQ(a.size(), static_cast(5)); + EXPECT_EQ(b.size(), static_cast(6)); + EXPECT_EQ(b.conversation("x")[0].body, std::string("body-t5")); + + auto latest = a.latestTimestampByConversation(); + EXPECT_EQ(latest.size(), static_cast(2)); + for (const auto& cid : a.conversationIds()) + EXPECT_EQ(latest[cid], static_cast(a.conversation(cid).back().timestamp)); + EXPECT_EQ(latest["x"], static_cast(90)); + EXPECT_EQ(latest["y"], static_cast(70)); + EXPECT_TRUE(ChatStore().latestTimestampByConversation().empty()); +} + void testChatStoreTxidIndex() { using namespace dragonx::chat; @@ -18535,6 +18565,7 @@ int main() testHushChatDatabase(); testHushChatDeleteConversation(); testChatStoreTxidIndex(); + testChatStoreLatestAndMove(); testHushChatOutgoing(); testHushChatTransport(); testHushChatShuffledReceive();