Files
ObsidianDragon/src/chat/chat_service.cpp
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

198 lines
10 KiB
C++

// DragonX Wallet - HushChat service (implementation).
#include "chat_service.h"
#include "chat_database.h"
#include "chat_identity.h" // chatIdentityPublicKeyHex
#include <utility>
namespace dragonx::chat {
ChatService::~ChatService() {
clearIdentity();
}
void ChatService::setIdentity(const ChatKeyPair& keys) {
identity_ = keys;
has_identity_ = true;
undecryptable_.clear(); // another identity may decrypt them
}
void ChatService::clearIdentity() {
wipeChatKeyPair(identity_);
has_identity_ = false;
undecryptable_.clear();
}
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
std::int64_t fallbackTimestamp,
std::vector<std::string>* newIncomingCids) {
if (!has_identity_) return 0;
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
// Skip until the DB is usable — chat is degraded anyway without its store.
if (db_ && !db_->hasKey()) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
int added = 0;
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
for (const auto& meta : metadata) {
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
// decrypt for all but the new messages.
if (store_.contains(meta.txid, meta.payload_position)) continue;
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
// our identity, so every message would otherwise loop back.)
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
ChatMessage message;
message.direction = ChatDirection::Incoming;
message.txid = meta.txid;
message.conversation_id = meta.conversation_id;
message.peer_zaddr = meta.reply_zaddr;
message.peer_public_key_hex = meta.sender_public_key_hex;
// Reference time: the tx/receive time (block time if confirmed, else the receiver's wall clock for
// a mempool receive).
const auto timeIt = txTimestamps.find(meta.txid);
const std::int64_t refTime = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
// Prefer the sender's stamped compose time (header "ts") — the true send time, shown identically on
// both ends. But REJECT a value implausibly in the FUTURE vs the reference: a wrong/ahead peer clock
// would otherwise pin their messages to the bottom of the thread forever. A compose time in the
// PAST is fine — the note buffer can broadcast a queued message long after it was composed, and a
// confirmed tx's block time is always >= the compose time.
constexpr std::int64_t kSenderTsFutureToleranceSec = 3600; // 1 hour of clock skew tolerated
if (meta.sent_at > 0 && (refTime <= 0 || meta.sent_at <= refTime + kSenderTsFutureToleranceSec)) {
message.timestamp = meta.sent_at;
} else {
message.timestamp = refTime;
}
message.payload_position = meta.payload_position;
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
// deleted with "revive on new message", so only that exact message is skipped — a new message
// in the same conversation has a different txid and flows through normally.
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
if (db_ && db_->isTombstoned(message)) continue;
if (meta.type == HushChatHeaderType::ContactRequest) {
message.kind = ChatMessageKind::ContactRequest;
message.body = meta.payload_memo; // plaintext request text
} else {
message.kind = ChatMessageKind::Message;
// Keyed by the decrypt's inputs, not only (txid, position): another scan path that pairs a
// different header with this payload gets its own try.
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
if (undecryptable_.count(failKey)) continue;
++decrypt_attempts_;
std::string plaintext;
const ChatCryptoStatus status = decryptIncoming(
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
meta.payload_memo, plaintext);
if (status != ChatCryptoStatus::Ok) { // drop undecryptable silently, and don't try it again
if (status != ChatCryptoStatus::SodiumInitFailed) { // not a property of the message
if (undecryptable_.size() >= 4096) undecryptable_.clear();
undecryptable_.insert(failKey);
}
continue;
}
message.body = std::move(plaintext);
}
// In-memory store dedups (txid+position); only persist the genuinely new ones. On the next
// session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but
// the store dedup prevents a duplicate write.
if (store_.append(message)) {
fresh.push_back(message);
++added;
// Every ingested message is incoming — report its cid so the caller can notify without
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
}
}
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
return added;
}
void ChatService::loadFromDatabase() {
if (!db_) return;
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; // evaluate before the move
store_.append(std::move(message));
}
}
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
// survive — and on the next reload the conversation silently reappears with no tombstone.
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
// until unblocked, at which point the conversation re-imports from chain.
if (db_) {
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
}
store_.eraseConversation(conversationId);
return true;
}
std::string ChatService::identityPublicKeyHex() const {
if (!has_identity_) return {};
return chatIdentityPublicKeyHex(identity_);
}
ChatComposeStatus ChatService::composeMessage(const std::string& myReplyZaddr,
const std::string& peerPublicKeyHex,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& plaintext,
OutgoingChatMemos& out) const {
if (!has_identity_) return ChatComposeStatus::MissingField;
return buildOutgoingMessage(identity_, chatIdentityPublicKeyHex(identity_), myReplyZaddr,
peerPublicKeyHex, peerZaddr, conversationId, plaintext, out);
}
ChatComposeStatus ChatService::composeContactRequest(const std::string& myReplyZaddr,
const std::string& peerZaddr,
const std::string& conversationId,
const std::string& requestText,
OutgoingChatMemos& out) const {
if (!has_identity_) return ChatComposeStatus::MissingField;
return buildOutgoingContactRequest(chatIdentityPublicKeyHex(identity_), myReplyZaddr,
peerZaddr, conversationId, requestText, out);
}
bool ChatService::recordOutgoing(const ChatMessage& message) {
if (store_.append(message)) {
if (db_) db_->append(message);
return true;
}
return false;
}
bool ChatService::recordOutgoingPending(const ChatMessage& message) {
// Persist immediately (as Sending) so a send survives an app quit before the broadcast resolves;
// resolveOutgoing() then UPSERTS the row to the final status. A stray persisted Sending (crash mid-
// broadcast) loads as Sent (see ChatDatabase::deserialize).
const bool appended = store_.append(message);
if (appended && db_) db_->upsert(message);
return appended;
}
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery, const std::string& reason) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery, reason);
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
}
} // namespace dragonx::chat