Until now an incoming large message was invisible until every part landed. ChatService::pendingLargeMessages() exposes the in-progress tier-3 receives (conversation, chunks gathered / chunks needed) from the reassembly buffer, and the chat tab renders a subtle centred line below the thread — "Receiving a large message… 24 / 36 parts" — that updates each scan and disappears when the message completes and surfaces as an ordinary bubble. A complete-but-withheld (sensitive vault locked) group is not reported as incoming. New string chat_receiving_large (English; 8 locales fall back). Unit test extended: after the first slice the receive is reported in progress; after the whole message arrives it is no longer pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
12 KiB
C++
198 lines
12 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 <unordered_set>
|
|
#include <utility>
|
|
#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.
|
|
// Also wires the DB's per-conversation sensitivity predicate to isConversationSensitive().
|
|
void setPersistence(ChatDatabase* 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);
|
|
|
|
// On-chain control signals (self-addressed Control memos) reduced per conversation: the latest op,
|
|
// by sender compose time with a txid tiebreak, wins. A conversation whose latest op is "del" is hidden
|
|
// on-chain — ingest() drops its messages and erases it from the store view, on every device and on a
|
|
// fresh seed import — until a later "res" restores it. The state is re-derived from chain each session
|
|
// (the control memos re-harvest on every scan), so it needs no local persistence.
|
|
bool conversationHiddenOnChain(const std::string& conversationId) const;
|
|
// Conversations whose latest on-chain control op is "del" (for the restore picker), each paired with
|
|
// the label carried in its control memo (the peer address / display name at delete time).
|
|
std::vector<std::pair<std::string, std::string>> onChainDeletedConversations() const;
|
|
|
|
// Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is
|
|
// "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld
|
|
// on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from
|
|
// chain each session, like the hide axis.
|
|
bool conversationSensitiveOnChain(const std::string& conversationId) const;
|
|
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
|
|
std::vector<std::pair<std::string, std::string>> onChainSensitiveConversations() const;
|
|
|
|
// A local "sensitive" predicate (typically Settings::isChatSensitive) giving INSTANT effect the moment
|
|
// the user marks a conversation, before the on-chain signal is harvested back. OR-combined with the
|
|
// on-chain reduction by isConversationSensitive().
|
|
void setLocalSensitivePredicate(std::function<bool(const std::string&)> pred) {
|
|
local_sensitive_pred_ = std::move(pred);
|
|
}
|
|
// True if this conversation is sensitive (locally marked OR on-chain). Its rows are stored under the
|
|
// local vault key; while that key is absent its messages are withheld from the store on ingest/load.
|
|
bool isConversationSensitive(const std::string& conversationId) const;
|
|
|
|
// Larger-messages tier 3: a message still being received across transactions (some chunks arrived,
|
|
// not yet all). Lets the UI show an "incoming large message — N of M parts" hint until it completes
|
|
// and surfaces as an ordinary message. One entry per in-progress message; empty when none.
|
|
struct Tier3Progress {
|
|
std::string conversation_id;
|
|
int received = 0; // chunks gathered so far
|
|
int total = 0; // chunks the whole message needs
|
|
};
|
|
std::vector<Tier3Progress> pendingLargeMessages() const;
|
|
|
|
// --- 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 an ENCRYPTED message, splitting it across as many transactions as
|
|
// its length needs (larger-messages tier 3). Returns one OutgoingChatMemos per transaction to send in
|
|
// order; a short message yields a single element (identical to composeMessage).
|
|
ChatComposeStatus composeMessageParts(const std::string& myReplyZaddr,
|
|
const std::string& peerPublicKeyHex,
|
|
const std::string& peerZaddr,
|
|
const std::string& conversationId,
|
|
const std::string& plaintext,
|
|
std::vector<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 std::string& reason = {});
|
|
|
|
const ChatStore& store() const { return store_; }
|
|
ChatStore& store() { return store_; }
|
|
|
|
std::uint64_t decryptAttempts() const { return decrypt_attempts_; } // tests / diagnostics
|
|
|
|
private:
|
|
ChatKeyPair identity_{};
|
|
bool has_identity_ = false;
|
|
// Decrypt failures, keyed by what the decrypt reads (bounded). A rescan re-sends every message ever
|
|
// received, so without it each poll re-ran the crypto for every undecryptable memo.
|
|
std::unordered_set<std::string> undecryptable_;
|
|
std::uint64_t decrypt_attempts_ = 0;
|
|
ChatStore store_;
|
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
|
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
|
std::function<bool(const std::string&)> local_sensitive_pred_; // true => cid locally marked sensitive
|
|
|
|
// Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain).
|
|
struct ControlState {
|
|
std::string op; // "del" / "res" / future ops
|
|
std::string label; // the control memo's payload hint (peer address / display name)
|
|
std::int64_t timestamp = 0;
|
|
std::string txid;
|
|
};
|
|
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
|
|
std::unordered_map<std::string, ControlState> sensitive_control_; // sensitive axis: sensitive / unsensitive
|
|
|
|
// Larger-messages tier 3: a big message split across several transactions arrives one slice at a
|
|
// time (siblings share a message id). We accumulate the slices here until every chunk is present,
|
|
// then reassemble + decrypt + emit the whole message. Not persisted — a rescan re-sends every slice
|
|
// so the buffer re-derives from chain (like the control reducer); a completed group is retired.
|
|
struct Tier3Pending {
|
|
int total_parts = 0;
|
|
std::string secretstream_header_hex; // "e", shared by every slice
|
|
std::string sender_public_key_hex; // "p", shared
|
|
std::string conversation_id;
|
|
std::string reply_zaddr;
|
|
std::int64_t sent_at = 0;
|
|
std::string canonical_txid; // smallest sibling txid — the reassembled message's stable key
|
|
std::unordered_map<int, std::string> chunks; // global index -> ciphertext-hex chunk
|
|
};
|
|
std::unordered_map<std::string, Tier3Pending> tier3_pending_; // message id -> accumulating slices
|
|
// Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat),
|
|
// and within an axis a later (timestamp, txid) wins so a replay of an older op never flips the state.
|
|
void applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
|
std::int64_t timestamp, const std::string& txid);
|
|
};
|
|
|
|
} // namespace dragonx::chat
|