Files
ObsidianDragon/src/chat/chat_store.h
T
DanSandClaude Opus 4.8 a792a1ec0c feat(ux): clearer chat delivery, explorer empty/range states, honest settings label
Three next-batch UX-audit fixes:
- Chat delivery: the sending/sent glyph has higher contrast and a tooltip
  ("Sent to the network — arrives after it confirms", so a check isn't mistaken
  for "delivered"); a failed send now carries the real reason (ChatMessage::
  fail_reason, set from onChatBroadcastResult via friendlyRpcError, in-memory
  only) in the "not sent" hover tooltip.
- Explorer: a block height beyond the tip says so ("Block N is beyond the chain
  tip (M)") instead of silently snapping to the top; the empty block list shows
  Not connected / Syncing (N behind) / Loading instead of an endless "Loading".
- Settings: the misleading "Reset to Defaults" button (which only reloads the
  last-saved values) is relabeled "Discard unsaved changes" with accurate copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 09:20:47 -05:00

109 lines
5.3 KiB
C++

#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 <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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<std::string, std::int64_t> latestTimestampByConversation() const;
// The dedup key of a message: "<txid>:<payload_position>".
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<ChatMessage> 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<std::string> 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<std::string> chatTxids() const {
std::unordered_set<std::string> 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<ChatMessage> 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<bool(const std::string&)>& excluded,
const std::function<std::int64_t(const std::string&)>& 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<ChatMessage> messages_;
std::unordered_set<std::string> 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<std::string> txid_index_;
mutable std::uint64_t txid_index_rev_ = ~std::uint64_t{0};
};
} // namespace dragonx::chat