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>
This commit is contained in:
2026-09-01 22:54:51 -05:00
parent a60a2f8e39
commit 0a042df8e0
15 changed files with 611 additions and 45 deletions

View File

@@ -760,6 +760,22 @@ private:
// wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_;
// ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
// Chat nav-badge unread count — recomputed only when the store revision changes or after a short
// interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
mutable int chat_unread_cached_ = 0;
mutable double chat_unread_computed_at_ = 0.0;
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
// size), not every frame. See App::render().
std::int64_t sb_unconf_key_ts_ = -1;
std::size_t sb_unconf_key_n_ = 0;
int sb_unconf_count_ = 0;
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
mutable double daemon_mem_cached_mb_ = 0.0;
mutable double daemon_mem_probe_at_ = 0.0;
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
@@ -835,6 +851,9 @@ public:
int chatUnreadCount() const;
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
// Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
// whose stamped time predates the deleted thread's last message — still badges as unread).
void forgetChatConversationSeen(const std::string& cid);
private:
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the

View File

@@ -1458,18 +1458,26 @@ void App::processWalletSwitchRevert()
int App::chatUnreadCount() const
{
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
int unread = 0;
// This is called every frame from render() to size the Chat nav badge. Recompute only when the store
// actually changed (new/removed messages bump revision()) or after a short interval (to pick up
// mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x
// messages) scan that COPIED and stable_sorted every conversation's messages, every frame.
const auto& store = chat_service_.store();
for (const auto& cid : store.conversationIds()) {
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
std::int64_t seen = 0;
const std::uint64_t rev = store.revision();
const double now = ImGui::GetTime();
if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) {
chat_unread_cached_ = store.countUnread(
[this](const std::string& cid) { // excluded from the badge
return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid));
},
[this](const std::string& cid) -> std::int64_t { // seen watermark
const auto it = chat_seen_watermark_.find(cid);
if (it != chat_seen_watermark_.end()) seen = it->second;
for (const auto& m : store.conversation(cid))
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
return it != chat_seen_watermark_.end() ? it->second : 0;
});
chat_unread_rev_ = rev;
chat_unread_computed_at_ = now;
}
return unread;
return chat_unread_cached_;
}
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
@@ -1477,6 +1485,11 @@ void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
}
void App::forgetChatConversationSeen(const std::string& cid)
{
chat_seen_watermark_.erase(cid);
}
void App::wipePendingTransactionHistoryCachePassphrase()
{
if (!pending_transaction_history_cache_passphrase_.empty()) {
@@ -3041,6 +3054,10 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store
// with prior messages (decrypted at rest under a key only this seed can derive).
chat_service_.setPersistence(&chat_db_);
// A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked.
chat_service_.setBlockedPredicate([this](const std::string& cid) {
return settings_ && settings_->isChatBlocked(cid);
});
if (chat_db_.unlockWithSecret(trimmed)) {
chat_service_.loadFromDatabase();
// Baseline unread: treat everything already in the store at load as read, so only messages

View File

@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
lock();
return false;
}
loadTombstones();
return true;
}
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
wallet_tag_.clear();
tombstones_.clear();
}
bool ChatDatabase::append(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher;
@@ -193,13 +196,79 @@ std::vector<ChatMessage> ChatDatabase::load()
void ChatDatabase::clearWallet()
{
if (wallet_tag_.empty() || !ensureOpen()) return;
for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
"DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
!= SQLITE_OK) {
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
tombstones_.clear();
}
bool ChatDatabase::deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone)
{
if (!key_ready_ || !ensureOpen()) return false;
if (messages.empty()) return true;
if (!exec("BEGIN")) return false;
bool ok = true;
for (const auto& m : messages) {
const std::string dedup = dedupHash(m.txid, m.payload_position);
sqlite3_stmt* del = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
-1, &del, nullptr) == SQLITE_OK) {
sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(del) != SQLITE_DONE) ok = false;
sqlite3_finalize(del);
} else {
ok = false;
}
if (tombstone) {
sqlite3_stmt* ins = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
-1, &ins, nullptr) == SQLITE_OK) {
sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
sqlite3_finalize(ins);
} else {
ok = false;
}
}
}
if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
// Only reflect the tombstones in the in-memory cache once they are durably committed.
if (ok && tombstone)
for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
return ok;
}
bool ChatDatabase::isTombstoned(const ChatMessage& message) const
{
if (!key_ready_ || tombstones_.empty()) return false;
return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
}
void ChatDatabase::loadTombstones()
{
tombstones_.clear();
if (!key_ready_ || !ensureOpen()) return;
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
-1, &stmt, nullptr) != SQLITE_OK) {
return;
}
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
while (sqlite3_step(stmt) == SQLITE_ROW) {
const auto* h = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (h) tombstones_.insert(h);
}
sqlite3_finalize(stmt);
}
@@ -260,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
bool ChatDatabase::createSchema()
{
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
"nonce BLOB NOT NULL, "
"payload BLOB NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))"))
return false;
// Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
// message rows use). A chain re-scan checks this so a deleted message never re-imports.
return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
"wallet_tag TEXT NOT NULL, "
"dedup_hash TEXT NOT NULL, "
"PRIMARY KEY (wallet_tag, dedup_hash))");
}

View File

@@ -16,6 +16,7 @@
#include <array>
#include <cstddef>
#include <string>
#include <unordered_set>
#include <vector>
struct sqlite3;
@@ -54,8 +55,20 @@ public:
void clearWallet(); // delete the unlocked wallet's rows
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
bool isTombstoned(const ChatMessage& message) const;
private:
bool ensureOpen();
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
bool exec(const char* sql);
bool createSchema();
std::string dedupHash(const std::string& txid, std::size_t position) const;
@@ -74,6 +87,7 @@ private:
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
bool key_ready_ = false;
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
};
} // namespace dragonx::chat

View File

@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
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_);
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
}
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
@@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
void ChatService::loadFromDatabase() {
if (!db_) return;
for (const auto& message : db_->load()) {
// 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;
store_.append(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_);

View File

@@ -11,6 +11,7 @@
#include "chat_store.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
@@ -55,6 +56,19 @@ public:
// 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;
@@ -90,6 +104,7 @@ private:
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

View File

@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
bool ChatStore::append(const ChatMessage& message) {
if (!seen_.insert(dedupKey(message)).second) return false;
messages_.push_back(message);
++revision_;
return true;
}
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
++revision_;
return &message;
}
}
return nullptr;
}
int ChatStore::countUnread(const std::function<bool(const std::string&)>& excluded,
const std::function<std::int64_t(const std::string&)>& seenFor) const {
int unread = 0;
for (const auto& m : messages_) {
if (m.direction != ChatDirection::Incoming) continue;
if (excluded && excluded(m.conversation_id)) continue;
if (m.timestamp > seenFor(m.conversation_id)) ++unread;
}
return unread;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;
@@ -53,9 +66,27 @@ std::vector<std::string> ChatStore::conversationIds() const {
return ids;
}
std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& conversationId) {
std::vector<ChatMessage> removed;
std::vector<ChatMessage> kept;
kept.reserve(messages_.size());
for (auto& m : messages_) {
if (m.conversation_id == conversationId) {
seen_.erase(dedupKey(m));
removed.push_back(std::move(m));
} else {
kept.push_back(std::move(m));
}
}
messages_.swap(kept);
if (!removed.empty()) ++revision_;
return removed;
}
void ChatStore::clear() {
messages_.clear();
seen_.clear();
++revision_;
}
} // namespace dragonx::chat

View File

@@ -5,6 +5,8 @@
#include "chat_message.h"
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_set>
#include <vector>
@@ -38,15 +40,32 @@ public:
return out;
}
// 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);
std::vector<ChatMessage> messages_;
std::unordered_set<std::string> seen_;
std::uint64_t revision_ = 0;
};
} // namespace dragonx::chat

View File

@@ -157,6 +157,13 @@ bool Settings::load(const std::string& path)
for (const auto& c : j["hidden_chat_cids"])
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
}
if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
blocked_chat_convs_.clear();
for (const auto& c : j["blocked_chat_convs"])
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
c.value("name", std::string())});
}
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
@@ -461,6 +468,13 @@ bool Settings::save(const std::string& path)
j["hidden_chat_cids"] = json::array();
for (const auto& c : hidden_chat_cids_)
j["hidden_chat_cids"].push_back(c);
j["blocked_chat_convs"] = json::array();
for (const auto& b : blocked_chat_convs_) {
json o;
o["cid"] = b.cid;
o["name"] = b.name;
j["blocked_chat_convs"].push_back(o);
}
j["chat_emoji_color"] = chat_emoji_color_;
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
j["chat_bubble_style"] = chat_bubble_style_;

View File

@@ -144,6 +144,26 @@ public:
hidden_chat_cids_.end());
}
// Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
// local history AND suppresses every message for the cid — old and future — until you unblock, at
// which point the conversation re-imports from the chain. The last-known peer name is kept so the
// "Blocked" list can label the entry (its messages are gone from the local store).
struct BlockedChatConv { std::string cid; std::string name; };
bool isChatBlocked(const std::string& cid) const {
for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
return false;
}
void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
const bool already = isChatBlocked(cid);
if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
else if (!blocked && already)
blocked_chat_convs_.erase(
std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
[&](const BlockedChatConv& b) { return b.cid == cid; }),
blocked_chat_convs_.end());
}
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
bool getChatEmojiColor() const { return chat_emoji_color_; }
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
@@ -559,6 +579,7 @@ private:
std::string chat_reply_zaddr_;
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)

View File

@@ -68,6 +68,7 @@ bool AddressBook::load()
}
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
++revision_;
return true;
} catch (const std::exception& e) {
@@ -121,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
}
entries_.push_back(entry);
++revision_;
return save();
}
@@ -136,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
}
entries_[index] = entry;
++revision_;
return save();
}
@@ -146,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
}
entries_.erase(entries_.begin() + index);
++revision_;
return save();
}
@@ -159,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
e.scope = scopeId;
++rescoped;
}
if (rescoped > 0) save();
if (rescoped > 0) { ++revision_; save(); }
return rescoped;
}

View File

@@ -4,6 +4,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
@@ -121,11 +122,18 @@ public:
*/
size_t size() const { return entries_.size(); }
/**
* @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
* (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
* address change that keeps size() constant — still invalidates them.
*/
std::uint64_t revision() const { return revision_; }
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
*/
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
/**
* @brief Check if empty
@@ -134,6 +142,7 @@ public:
private:
std::vector<AddressBookEntry> entries_;
std::uint64_t revision_ = 0;
std::string file_path_;
};

View File

@@ -93,6 +93,10 @@ char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = ""; // conversation-list filter (Q8)
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
std::string s_delete_cid; // conversation targeted by the delete confirm
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
char s_emoji_search[48] = ""; // emoji picker keyword filter
@@ -523,6 +527,16 @@ struct ConvSummary {
bool hidden = false; // shown only while "Show hidden" is on
};
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them
// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild.
std::vector<ConvSummary> s_convs;
int s_convsHidden = 0;
std::uint64_t s_convsKey = ~0ull;
std::string s_threadCid;
std::uint64_t s_threadRev = ~0ull;
std::vector<chat::ChatMessage> s_threadMsgs;
// Centered, muted, wrapped hint for the empty states.
void centeredHint(const char* text) {
ImVec2 avail = ImGui::GetContentRegionAvail();
@@ -725,11 +739,21 @@ void RenderChatTab(App* app)
}
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std::vector<ConvSummary> convs;
int hiddenCount = 0;
// MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
// only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew
// (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a
// rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset
// in ResetChatTab on wallet switch.
const std::uint64_t convsKey =
store.revision() * 1000003ull
+ static_cast<std::uint64_t>(s_show_hidden ? 1 : 0)
+ (book.revision() << 20); // book.revision() catches in-place contact edits (rename) that keep size()
if (convsKey != s_convsKey) {
s_convs.clear();
s_convsHidden = 0;
for (const auto& cid : store.conversationIds()) {
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++hiddenCount;
if (hidden) ++s_convsHidden;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
@@ -747,10 +771,14 @@ void RenderChatTab(App* app)
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
c.peerName = (idx >= 0) ? book.entries()[idx].label
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
convs.push_back(std::move(c));
s_convs.push_back(std::move(c));
}
std::sort(convs.begin(), convs.end(),
std::sort(s_convs.begin(), s_convs.end(),
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
s_convsKey = convsKey;
}
std::vector<ConvSummary>& convs = s_convs;
int hiddenCount = s_convsHidden;
// Keep the selection valid (only when there is something to select).
if (!convs.empty() &&
@@ -845,6 +873,15 @@ void RenderChatTab(App* app)
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
}
// Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no
// stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock.
const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0;
if (blockedCount > 0) {
const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")";
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
ImGui::PopStyleColor();
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
@@ -1018,7 +1055,7 @@ void RenderChatTab(App* app)
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
// shown whenever there's an address to save the contact under; the settings "notch" gear is
// always the rightmost icon.
const int nBtns = 4 + (hasAddr ? 1 : 0);
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
@@ -1069,6 +1106,7 @@ void RenderChatTab(App* app)
} else {
Notifications::instance().error(TR("address_book_exists"));
}
s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild
}
s_rename_cid.clear();
} else if (cancel) {
@@ -1170,6 +1208,21 @@ void RenderChatTab(App* app)
s_selected_cid.clear();
Notifications::instance().info(TR("chat_hidden_toast"));
}
s_convsKey = ~0ull; // hidden-state changed — force the conversation-list memo to rebuild
}
bx += ib + gap;
}
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
{
ImGui::SetCursorScreenPos(ImVec2(bx, by));
material::IconButtonStyle a = base;
a.tooltip = TR("chat_delete");
a.hoverColor = material::Error();
if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) {
s_delete_cid = sel->cid;
s_delete_name = sel->peerName;
s_show_delete_confirm = true;
}
bx += ib + gap;
}
@@ -1278,7 +1331,15 @@ void RenderChatTab(App* app)
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
const auto messages = store.conversation(s_selected_cid);
// MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
// copies+sorts the match every call. Rebuild only when the open thread or the store changes,
// not every frame while the thread is simply being read/scrolled.
if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
s_threadMsgs = store.conversation(s_selected_cid);
s_threadCid = s_selected_cid;
s_threadRev = store.revision();
}
const auto& messages = s_threadMsgs;
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
// one meta header and stack tightly; a date pill is drawn once per calendar day.
const std::int64_t nowTs = static_cast<std::int64_t>(std::time(nullptr));
@@ -1820,6 +1881,122 @@ void RenderChatTab(App* app)
}
}
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
if (s_show_delete_confirm) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_delete_title");
ov.p_open = &s_show_delete_confirm; // X / backdrop closes it (no-op)
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name +
TR("chat_delete_body_suffix")).c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_delete_revive_note"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::TextUnformatted(TR("chat_delete_local_note"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
auto doDelete = [&](bool block) {
// Delete first — if the persisted rows can't be removed, change nothing else (no block,
// no toast) so the store and DB can't diverge.
if (!app->chatService().deleteConversation(s_delete_cid, block)) {
Notifications::instance().error(TR("chat_delete_failed"));
s_show_delete_confirm = false;
s_delete_cid.clear(); s_delete_name.clear();
return;
}
if (app->settings()) {
if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true);
app->settings()->setChatHidden(s_delete_cid, false); // clear any prior hide flag
app->settings()->save();
}
// Revive mode: forget the seen-watermark so a re-imported message badges as unread even if
// its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored
// history doesn't all re-badge.
if (!block) app->forgetChatConversationSeen(s_delete_cid);
if (s_selected_cid == s_delete_cid) s_selected_cid.clear();
Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast"));
s_show_delete_confirm = false;
s_delete_cid.clear(); s_delete_name.clear();
};
auto textW = [&](const char* t) {
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
};
const float gap2 = Layout::spacingSm();
const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm")));
const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block")));
const float wCan = std::max(90.0f * dp, textW(TR("chat_cancel")));
material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0));
ImGui::SameLine(0, gap2);
const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0));
ImGui::PopStyleColor(4);
ImGui::SameLine(0, gap2);
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
if (doDel) doDelete(false);
if (doBlk) doDelete(true);
if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); }
material::EndOverlayDialog();
}
}
// ---- Blocked-conversations manager (unblock) ----
if (s_show_blocked) {
const float dp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("chat_blocked_title");
ov.p_open = &s_show_blocked;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked";
if (material::BeginOverlayDialog(ov)) {
ImGui::PushTextWrapPos(0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_blocked_desc"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (app->settings()) {
// Copy so unblocking (which mutates the settings vector) during iteration is safe.
const auto blocked = app->settings()->blockedChatConversations();
std::string unblockCid;
for (const auto& b : blocked) {
ImGui::PushID(b.cid.c_str());
const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name;
const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x +
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label.c_str());
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid;
ImGui::PopID();
}
if (!unblockCid.empty()) {
app->settings()->setChatBlocked(unblockCid, "", false);
app->settings()->save();
Notifications::instance().info(TR("chat_unblocked_toast")); // re-imports on the next chat scan
if (app->settings()->blockedChatConversations().empty()) s_show_blocked = false;
}
}
material::EndOverlayDialog();
}
}
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
if (s_show_chat_settings) {
material::OverlayDialogSpec ov;
@@ -1883,6 +2060,18 @@ void ResetChatTab()
s_rename_focus = false;
s_show_new_convo = false;
s_show_chat_settings = false;
s_show_delete_confirm = false;
s_delete_cid.clear();
s_delete_name.clear();
s_show_blocked = false;
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
s_convs.clear();
s_convsHidden = 0;
s_convsKey = ~0ull;
s_threadCid.clear();
s_threadRev = ~0ull;
s_threadMsgs.clear();
}
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)

View File

@@ -259,6 +259,23 @@ void I18n::loadBuiltinEnglish()
strings_["chat_emoji_search"] = "Search emoji";
strings_["chat_hide_hidden"] = "Hide hidden";
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
// Delete conversation (local cache) — revive-on-new-message vs delete & block
strings_["chat_delete"] = "Delete conversation";
strings_["chat_delete_title"] = "Delete conversation?";
strings_["chat_delete_body_prefix"] = "Delete your local copy of the conversation with ";
strings_["chat_delete_body_suffix"] = "?";
strings_["chat_delete_revive_note"] = "\"Delete\" clears the history on this device. If they message you again, the conversation comes back.";
strings_["chat_delete_local_note"] = "This only affects this device — the messages stay on the blockchain and the other person keeps their copy.";
strings_["chat_delete_confirm"] = "Delete";
strings_["chat_delete_block"] = "Delete & block";
strings_["chat_deleted_toast"] = "Conversation deleted";
strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed.";
strings_["chat_blocked_toast"] = "Conversation deleted & blocked";
strings_["chat_blocked_manage"] = "Blocked";
strings_["chat_blocked_title"] = "Blocked conversations";
strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain.";
strings_["chat_unblock"] = "Unblock";
strings_["chat_unblocked_toast"] = "Conversation unblocked";
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
strings_["chat_copy_address_tip"] = "Click to copy address";

View File

@@ -7006,6 +7006,97 @@ void testHushChatDatabase()
scrub(dbPath); scrub(dbPath2);
}
// Per-conversation local delete: (1) "delete, revive on new message" tombstones the removed messages so
// a chain re-scan can't re-import them — but a NEW txid in the same conversation flows through; the
// tombstone survives a DB reload. (2) "delete & block" removes the rows WITHOUT a tombstone and relies on
// a blocked-cid predicate to drop messages while blocked; unblocking re-imports the conversation.
void testHushChatDeleteConversation()
{
using namespace dragonx::chat;
namespace fs = std::filesystem;
const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_del_test.sqlite").string();
auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); };
scrub(dbPath);
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("del-alice", alice, true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("del-bob", bob, true);
// Build one incoming (alice->bob) metadata entry. encryptOutgoing produces a fresh ciphertext each
// call, but dedup/tombstone key off (txid, position) only, so re-using the same txids re-scans them.
auto mkMeta = [&](const std::string& txid, const std::string& cid, const std::string& body) {
std::string e, ct;
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, body, e, ct) == ChatCryptoStatus::Ok);
HushChatTransactionMetadata m;
m.txid = txid; m.type = HushChatHeaderType::Message; m.conversation_id = cid;
m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex;
m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1;
return m;
};
std::vector<HushChatTransactionMetadata> convX{ mkMeta("dx1", "conv-x", "x-one"),
mkMeta("dx2", "conv-x", "x-two") };
std::vector<HushChatTransactionMetadata> convY{ mkMeta("dy1", "conv-y", "y-one") };
// (1) Delete with revive-on-new-message.
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret("del-seed"));
ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
EXPECT_EQ(svc.ingest(convX, {}, 100), 2);
EXPECT_EQ(svc.ingest(convY, {}, 100), 1);
EXPECT_EQ((int)svc.store().size(), 3);
EXPECT_TRUE(svc.deleteConversation("conv-x", /*block=*/false));
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0);
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); // untouched
EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // re-scan: tombstoned, not re-imported
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0);
std::vector<HushChatTransactionMetadata> convXnew{ mkMeta("dx3", "conv-x", "x-three") };
EXPECT_EQ(svc.ingest(convXnew, {}, 200), 1); // NEW txid revives the thread
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);
EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three"));
}
// (2) Tombstone survives a DB reload into a fresh service.
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret("del-seed"));
ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
svc.loadFromDatabase();
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); // only the revived dx3 persisted
EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three"));
EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // dx1/dx2 still tombstoned after reload
EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1);
}
// (3) Delete & block, then unblock.
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret("del-seed"));
ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db);
svc.loadFromDatabase();
bool blocked = false;
svc.setBlockedPredicate([&](const std::string& cid) { return blocked && cid == "conv-y"; });
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);
blocked = true;
EXPECT_TRUE(svc.deleteConversation("conv-y", /*block=*/true));
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0);
EXPECT_EQ(svc.ingest(convY, {}, 100), 0); // suppressed by the predicate (no tombstone)
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0);
blocked = false; // unblock
EXPECT_EQ(svc.ingest(convY, {}, 100), 1); // re-imported from chain
EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1);
EXPECT_EQ(svc.store().conversation("conv-y")[0].body, std::string("y-one"));
}
scrub(dbPath);
}
// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
// ChatService compose/recordOutgoing echoes into the store.
void testHushChatOutgoing()
@@ -7456,6 +7547,7 @@ int main()
testHushChatReceivePath();
testHushChatService();
testHushChatDatabase();
testHushChatDeleteConversation();
testHushChatOutgoing();
testHushChatTransport();
testHushChatShuffledReceive();