#pragma once // DragonX Wallet - HushChat in-memory message store: the fast read model / dedup view. Durable // persistence lives in ChatDatabase; ChatService rehydrates this store from it on unlock. #include "chat_message.h" #include #include #include #include #include #include namespace dragonx::chat { // Threads messages by conversation_id (cid) and deduplicates by (txid, payload_position) so // re-scanning the chain never double-inserts. Not thread-safe — drive from the main thread. 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); // A message with this (txid, position) is in the store (append would refuse it). bool contains(const std::string& txid, std::size_t payloadPosition) const { return seen_.count(messageKey(txid, payloadPosition)) != 0; } // Messages in a conversation, in insertion order. std::vector conversation(const std::string& conversationId) const; // Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated // message (for the caller to persist), or nullptr if no message has that txid. const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery, const std::string& reason = {}); // Distinct conversation ids, in first-seen order. std::vector conversationIds() const; // The set of on-chain txids that carried a chat message (sent or received, messages + contact // 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()); for (const auto& m : messages_) if (!m.txid.empty()) out.insert(m.txid); 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. std::vector eraseConversation(const std::string& conversationId); std::size_t size() const { return messages_.size(); } bool empty() const { return messages_.empty(); } void clear(); // Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation / // clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it // so they only rebuild when the store actually changed. std::uint64_t revision() const { return revision_; } // Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its // cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation // copy+sort that conversation() does — the count doesn't need ordering. int countUnread(const std::function& excluded, const std::function& seenFor) const; 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