Files
ObsidianDragon/src/chat/chat_service.h
T
DanSandClaude Opus 4.8 0a042df8e0 feat(chat): per-conversation delete (revive + block); memoize chat/badge render
Adds a per-conversation "delete" with two modes, and removes the per-frame
rescans of the chat history that shared these files.

Delete conversation (header trash icon → confirm dialog):
- Delete (revive-on-new-message): clears local history and tombstones the
  messages (new chat_deleted table, keyed dedup hashes) so the every-few-
  seconds memo re-scan can't re-import them; a genuinely NEW message (new
  txid) revives the thread.
- Delete & block: removes history WITHOUT a tombstone and records the cid as
  blocked (settings); ChatService::ingest drops that conversation's messages
  — old and future — until unblocked from the "Blocked" manager, which then
  re-imports the conversation from chain.
- Local-only (messages remain on-chain; the peer keeps their copy).
  deleteConversation() deletes the DB rows FIRST and only then mutates the
  store, so a failed write can't leave the two diverged.
Unit-tested (revive / tombstone-survives-reload / block / unblock) and
adversarially reviewed (store/DB divergence, half-open DB, revive-unread).

Performance (chat + badge hot paths, from the perf audit):
- ChatStore gains revision(); the Chat unread badge is now a single O(N)
  no-alloc pass cached on it (was O(conversations x messages) copy+sort every
  frame). The conversation list and open thread are memoized on revision()
  (+ show-hidden and AddressBook::revision() for peer-name resolution).
- AddressBook gains revision() so an in-place contact rename invalidates the
  chat memo (an edit keeps entries().size() constant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 22:54:51 -05:00

111 lines
6.2 KiB
C++

#pragma once
// DragonX Wallet - HushChat service: turns harvested memo metadata into decrypted, threaded
// messages. Owns the long-lived chat identity keypair (a secret) + the in-memory store.
// Move-disabled (the secret stays pinned); wipes the secret on destruction/clear.
// Not thread-safe — drive from the main thread (where refresh results are applied).
#include "chat_crypto.h" // ChatKeyPair
#include "chat_protocol.h" // HushChatTransactionMetadata
#include "chat_outgoing.h" // OutgoingChatMemos, ChatComposeStatus
#include "chat_store.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
namespace dragonx::chat {
class ChatDatabase; // optional persistent backing (Phase 2); set via setPersistence
class ChatService {
public:
ChatService() = default;
~ChatService();
ChatService(const ChatService&) = delete;
ChatService& operator=(const ChatService&) = delete;
ChatService(ChatService&&) = delete;
ChatService& operator=(ChatService&&) = delete;
// Provision (or replace) the chat identity. Copies the keypair — the caller should wipe
// its own copy afterwards (see chat_identity.h ownership note).
void setIdentity(const ChatKeyPair& keys);
bool hasIdentity() const { return has_identity_; }
void clearIdentity(); // wipes the held secret key
// Decrypt/record each metadata entry (a Message is decrypted; a ContactRequest carries its
// plaintext through) and thread it into the store. Each message is stamped with its own
// transaction time via `txTimestamps` (keyed by txid), falling back to `fallbackTimestamp`
// when the txid isn't present. Newly-added messages are also persisted (if a database is
// attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable
// Messages are dropped silently (no logging of memo/plaintext).
// When `newIncomingCids` is non-null it is filled with the conversation ids of the genuinely-new
// incoming (non-request) messages appended this call — a reliable "a new message arrived here"
// signal for notifications that doesn't depend on any timestamp/seen-watermark comparison.
int ingest(const std::vector<HushChatTransactionMetadata>& metadata,
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
std::int64_t fallbackTimestamp = 0,
std::vector<std::string>* newIncomingCids = nullptr);
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
void setPersistence(ChatDatabase* db) { db_ = db; }
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
void loadFromDatabase();
// Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
// entirely (they are never stored or persisted) until the predicate stops returning true — this is
// how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
void setBlockedPredicate(std::function<bool(const std::string&)> pred) { blocked_pred_ = std::move(pred); }
// Locally delete a conversation: remove its messages from the store and the database. When `block`
// is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
// genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
// WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
// later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
bool deleteConversation(const std::string& conversationId, bool block);
// --- Outgoing (compose) ---
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
std::string identityPublicKeyHex() const;
// Construct the outgoing memos for an ENCRYPTED message, using the held identity to encrypt.
ChatComposeStatus composeMessage(const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
OutgoingChatMemos& out) const;
// Construct the outgoing memos for a plaintext contact request (no peer key needed yet).
ChatComposeStatus composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& requestText,
OutgoingChatMemos& out) const;
// Echo a locally-composed outgoing message into the store (and DB). Returns true if new. (We
// never harvest our own sent memos — they land on the peer's address — so this echo is the
// only local record of what we sent.)
bool recordOutgoing(const ChatMessage& message);
// Two-phase echo for delivery tracking: record + persist immediately as Sending (survives an app
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
// persisted Sending (crash mid-broadcast) loads back as Sent.
bool recordOutgoingPending(const ChatMessage& message);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }
private:
ChatKeyPair identity_{};
bool has_identity_ = false;
ChatStore store_;
ChatDatabase* db_ = nullptr; // optional; not owned
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
};
} // namespace dragonx::chat