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:
19
src/app.h
19
src/app.h
@@ -760,6 +760,22 @@ private:
|
|||||||
// wallets. In-memory only (resets on app restart).
|
// wallets. In-memory only (resets on app restart).
|
||||||
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
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) ────────────────────────────────────────────────────────
|
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
|
||||||
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
|
// 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
|
// 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;
|
int chatUnreadCount() const;
|
||||||
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
|
// 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);
|
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:
|
private:
|
||||||
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
// 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
|
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||||
|
|||||||
@@ -1458,18 +1458,26 @@ void App::processWalletSwitchRevert()
|
|||||||
int App::chatUnreadCount() const
|
int App::chatUnreadCount() const
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
|
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();
|
const auto& store = chat_service_.store();
|
||||||
for (const auto& cid : store.conversationIds()) {
|
const std::uint64_t rev = store.revision();
|
||||||
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
|
const double now = ImGui::GetTime();
|
||||||
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
|
if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) {
|
||||||
std::int64_t seen = 0;
|
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);
|
const auto it = chat_seen_watermark_.find(cid);
|
||||||
if (it != chat_seen_watermark_.end()) seen = it->second;
|
return it != chat_seen_watermark_.end() ? it->second : 0;
|
||||||
for (const auto& m : store.conversation(cid))
|
});
|
||||||
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
|
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)
|
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;
|
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::forgetChatConversationSeen(const std::string& cid)
|
||||||
|
{
|
||||||
|
chat_seen_watermark_.erase(cid);
|
||||||
|
}
|
||||||
|
|
||||||
void App::wipePendingTransactionHistoryCachePassphrase()
|
void App::wipePendingTransactionHistoryCachePassphrase()
|
||||||
{
|
{
|
||||||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
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
|
// 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).
|
// with prior messages (decrypted at rest under a key only this seed can derive).
|
||||||
chat_service_.setPersistence(&chat_db_);
|
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)) {
|
if (chat_db_.unlockWithSecret(trimmed)) {
|
||||||
chat_service_.loadFromDatabase();
|
chat_service_.loadFromDatabase();
|
||||||
// Baseline unread: treat everything already in the store at load as read, so only messages
|
// Baseline unread: treat everything already in the store at load as read, so only messages
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
|
|||||||
lock();
|
lock();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
loadTombstones();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
|
|||||||
sodium_memzero(key_.data(), key_.size());
|
sodium_memzero(key_.data(), key_.size());
|
||||||
key_ready_ = false;
|
key_ready_ = false;
|
||||||
wallet_tag_.clear();
|
wallet_tag_.clear();
|
||||||
|
tombstones_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::append(const ChatMessage& message)
|
bool ChatDatabase::append(const ChatMessage& message)
|
||||||
{
|
{
|
||||||
if (!key_ready_ || !ensureOpen()) return false;
|
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> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
@@ -193,13 +196,79 @@ std::vector<ChatMessage> ChatDatabase::load()
|
|||||||
void ChatDatabase::clearWallet()
|
void ChatDatabase::clearWallet()
|
||||||
{
|
{
|
||||||
if (wallet_tag_.empty() || !ensureOpen()) return;
|
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;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
|
||||||
!= SQLITE_OK) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
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);
|
sqlite3_finalize(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
|
|||||||
|
|
||||||
bool ChatDatabase::createSchema()
|
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, "
|
"wallet_tag TEXT NOT NULL, "
|
||||||
"dedup_hash TEXT NOT NULL, "
|
"dedup_hash TEXT NOT NULL, "
|
||||||
"nonce BLOB NOT NULL, "
|
"nonce BLOB NOT NULL, "
|
||||||
"payload 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))");
|
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct sqlite3;
|
struct sqlite3;
|
||||||
@@ -54,8 +55,20 @@ public:
|
|||||||
|
|
||||||
void clearWallet(); // delete the unlocked wallet's rows
|
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:
|
private:
|
||||||
bool ensureOpen();
|
bool ensureOpen();
|
||||||
|
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
|
||||||
bool exec(const char* sql);
|
bool exec(const char* sql);
|
||||||
bool createSchema();
|
bool createSchema();
|
||||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
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::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||||
bool key_ready_ = false;
|
bool key_ready_ = false;
|
||||||
|
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
std::int64_t fallbackTimestamp,
|
std::int64_t fallbackTimestamp,
|
||||||
std::vector<std::string>* newIncomingCids) {
|
std::vector<std::string>* newIncomingCids) {
|
||||||
if (!has_identity_) return 0;
|
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_);
|
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||||
|
|
||||||
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
}
|
}
|
||||||
message.payload_position = meta.payload_position;
|
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) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
message.kind = ChatMessageKind::ContactRequest;
|
message.kind = ChatMessageKind::ContactRequest;
|
||||||
message.body = meta.payload_memo; // plaintext request text
|
message.body = meta.payload_memo; // plaintext request text
|
||||||
@@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
void ChatService::loadFromDatabase() {
|
void ChatService::loadFromDatabase() {
|
||||||
if (!db_) return;
|
if (!db_) return;
|
||||||
for (const auto& message : db_->load()) {
|
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);
|
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 {
|
std::string ChatService::identityPublicKeyHex() const {
|
||||||
if (!has_identity_) return {};
|
if (!has_identity_) return {};
|
||||||
return chatIdentityPublicKeyHex(identity_);
|
return chatIdentityPublicKeyHex(identity_);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "chat_store.h"
|
#include "chat_store.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -55,6 +56,19 @@ public:
|
|||||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||||
void loadFromDatabase();
|
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) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -90,6 +104,7 @@ private:
|
|||||||
bool has_identity_ = false;
|
bool has_identity_ = false;
|
||||||
ChatStore store_;
|
ChatStore store_;
|
||||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||||
|
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
|
|||||||
bool ChatStore::append(const ChatMessage& message) {
|
bool ChatStore::append(const ChatMessage& message) {
|
||||||
if (!seen_.insert(dedupKey(message)).second) return false;
|
if (!seen_.insert(dedupKey(message)).second) return false;
|
||||||
messages_.push_back(message);
|
messages_.push_back(message);
|
||||||
|
++revision_;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
|
|||||||
for (auto& message : messages_) {
|
for (auto& message : messages_) {
|
||||||
if (message.txid == txid) {
|
if (message.txid == txid) {
|
||||||
message.delivery = delivery;
|
message.delivery = delivery;
|
||||||
|
++revision_;
|
||||||
return &message;
|
return &message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nullptr;
|
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> ChatStore::conversationIds() const {
|
||||||
std::vector<std::string> ids;
|
std::vector<std::string> ids;
|
||||||
std::unordered_set<std::string> seenIds;
|
std::unordered_set<std::string> seenIds;
|
||||||
@@ -53,9 +66,27 @@ std::vector<std::string> ChatStore::conversationIds() const {
|
|||||||
return ids;
|
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() {
|
void ChatStore::clear() {
|
||||||
messages_.clear();
|
messages_.clear();
|
||||||
seen_.clear();
|
seen_.clear();
|
||||||
|
++revision_;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
#include "chat_message.h"
|
#include "chat_message.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -38,15 +40,32 @@ public:
|
|||||||
return out;
|
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(); }
|
std::size_t size() const { return messages_.size(); }
|
||||||
bool empty() const { return messages_.empty(); }
|
bool empty() const { return messages_.empty(); }
|
||||||
void clear();
|
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:
|
private:
|
||||||
static std::string dedupKey(const ChatMessage& message);
|
static std::string dedupKey(const ChatMessage& message);
|
||||||
|
|
||||||
std::vector<ChatMessage> messages_;
|
std::vector<ChatMessage> messages_;
|
||||||
std::unordered_set<std::string> seen_;
|
std::unordered_set<std::string> seen_;
|
||||||
|
std::uint64_t revision_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ bool Settings::load(const std::string& path)
|
|||||||
for (const auto& c : j["hidden_chat_cids"])
|
for (const auto& c : j["hidden_chat_cids"])
|
||||||
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
|
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).
|
// 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_emoji_color", chat_emoji_color_);
|
||||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
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();
|
j["hidden_chat_cids"] = json::array();
|
||||||
for (const auto& c : hidden_chat_cids_)
|
for (const auto& c : hidden_chat_cids_)
|
||||||
j["hidden_chat_cids"].push_back(c);
|
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_emoji_color"] = chat_emoji_color_;
|
||||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||||
j["chat_bubble_style"] = chat_bubble_style_;
|
j["chat_bubble_style"] = chat_bubble_style_;
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ public:
|
|||||||
hidden_chat_cids_.end());
|
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) ──────
|
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||||
@@ -559,6 +579,7 @@ private:
|
|||||||
std::string chat_reply_zaddr_;
|
std::string chat_reply_zaddr_;
|
||||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
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<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).
|
// 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
|
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)
|
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ bool AddressBook::load()
|
|||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
||||||
|
++revision_;
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
@@ -121,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries_.push_back(entry);
|
entries_.push_back(entry);
|
||||||
|
++revision_;
|
||||||
return save();
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries_[index] = entry;
|
entries_[index] = entry;
|
||||||
|
++revision_;
|
||||||
return save();
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries_.erase(entries_.begin() + index);
|
entries_.erase(entries_.begin() + index);
|
||||||
|
++revision_;
|
||||||
return save();
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
|
|||||||
e.scope = scopeId;
|
e.scope = scopeId;
|
||||||
++rescoped;
|
++rescoped;
|
||||||
}
|
}
|
||||||
if (rescoped > 0) save();
|
if (rescoped > 0) { ++revision_; save(); }
|
||||||
return rescoped;
|
return rescoped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -121,11 +122,18 @@ public:
|
|||||||
*/
|
*/
|
||||||
size_t size() const { return entries_.size(); }
|
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
|
* @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.
|
* 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
|
* @brief Check if empty
|
||||||
@@ -134,6 +142,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector<AddressBookEntry> entries_;
|
std::vector<AddressBookEntry> entries_;
|
||||||
|
std::uint64_t revision_ = 0;
|
||||||
std::string file_path_;
|
std::string file_path_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ char s_new_zaddr[128] = "";
|
|||||||
char s_new_msg[256] = "";
|
char s_new_msg[256] = "";
|
||||||
char s_search[80] = ""; // conversation-list filter (Q8)
|
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_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
|
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
|
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
|
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.
|
// Centered, muted, wrapped hint for the empty states.
|
||||||
void centeredHint(const char* text) {
|
void centeredHint(const char* text) {
|
||||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||||
@@ -725,11 +739,21 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
|
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
|
||||||
std::vector<ConvSummary> convs;
|
// MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
|
||||||
int hiddenCount = 0;
|
// 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()) {
|
for (const auto& cid : store.conversationIds()) {
|
||||||
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
|
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
|
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
|
||||||
const auto messages = store.conversation(cid);
|
const auto messages = store.conversation(cid);
|
||||||
if (messages.empty()) continue;
|
if (messages.empty()) continue;
|
||||||
@@ -747,10 +771,14 @@ void RenderChatTab(App* app)
|
|||||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
: 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; });
|
[](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).
|
// Keep the selection valid (only when there is something to select).
|
||||||
if (!convs.empty() &&
|
if (!convs.empty() &&
|
||||||
@@ -845,6 +873,15 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
|
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;
|
const std::string search = s_search;
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (convs.empty()) {
|
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
|
// 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
|
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
||||||
// always the rightmost icon.
|
// 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);
|
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
||||||
|
|
||||||
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
||||||
@@ -1069,6 +1106,7 @@ void RenderChatTab(App* app)
|
|||||||
} else {
|
} else {
|
||||||
Notifications::instance().error(TR("address_book_exists"));
|
Notifications::instance().error(TR("address_book_exists"));
|
||||||
}
|
}
|
||||||
|
s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild
|
||||||
}
|
}
|
||||||
s_rename_cid.clear();
|
s_rename_cid.clear();
|
||||||
} else if (cancel) {
|
} else if (cancel) {
|
||||||
@@ -1170,6 +1208,21 @@ void RenderChatTab(App* app)
|
|||||||
s_selected_cid.clear();
|
s_selected_cid.clear();
|
||||||
Notifications::instance().info(TR("chat_hidden_toast"));
|
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;
|
bx += ib + gap;
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1331,15 @@ void RenderChatTab(App* app)
|
|||||||
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
|
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
|
||||||
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
|
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
|
||||||
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
|
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
|
// 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.
|
// 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));
|
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 ----
|
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
||||||
if (s_show_chat_settings) {
|
if (s_show_chat_settings) {
|
||||||
material::OverlayDialogSpec ov;
|
material::OverlayDialogSpec ov;
|
||||||
@@ -1883,6 +2060,18 @@ void ResetChatTab()
|
|||||||
s_rename_focus = false;
|
s_rename_focus = false;
|
||||||
s_show_new_convo = false;
|
s_show_new_convo = false;
|
||||||
s_show_chat_settings = 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)
|
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
|
||||||
|
|||||||
@@ -259,6 +259,23 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_emoji_search"] = "Search emoji";
|
strings_["chat_emoji_search"] = "Search emoji";
|
||||||
strings_["chat_hide_hidden"] = "Hide hidden";
|
strings_["chat_hide_hidden"] = "Hide hidden";
|
||||||
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
|
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_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
||||||
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
||||||
strings_["chat_copy_address_tip"] = "Click to copy address";
|
strings_["chat_copy_address_tip"] = "Click to copy address";
|
||||||
|
|||||||
@@ -7006,6 +7006,97 @@ void testHushChatDatabase()
|
|||||||
scrub(dbPath); scrub(dbPath2);
|
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
|
// Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and
|
||||||
// ChatService compose/recordOutgoing echoes into the store.
|
// ChatService compose/recordOutgoing echoes into the store.
|
||||||
void testHushChatOutgoing()
|
void testHushChatOutgoing()
|
||||||
@@ -7456,6 +7547,7 @@ int main()
|
|||||||
testHushChatReceivePath();
|
testHushChatReceivePath();
|
||||||
testHushChatService();
|
testHushChatService();
|
||||||
testHushChatDatabase();
|
testHushChatDatabase();
|
||||||
|
testHushChatDeleteConversation();
|
||||||
testHushChatOutgoing();
|
testHushChatOutgoing();
|
||||||
testHushChatTransport();
|
testHushChatTransport();
|
||||||
testHushChatShuffledReceive();
|
testHushChatShuffledReceive();
|
||||||
|
|||||||
Reference in New Issue
Block a user