diff --git a/src/app.h b/src/app.h index 15645f6..610e0b3 100644 --- a/src/app.h +++ b/src/app.h @@ -760,6 +760,22 @@ private: // wallets. In-memory only (resets on app restart). std::map 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 diff --git a/src/app_network.cpp b/src/app_network.cpp index cc2f979..a987c54 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 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; + 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); + 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 diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp index 13d697f..f533819 100644 --- a/src/chat/chat_database.cpp +++ b/src/chat/chat_database.cpp @@ -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 nonce; std::vector cipher; @@ -193,13 +196,79 @@ std::vector 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_, 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& 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_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr) - != SQLITE_OK) { + 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(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, " - "nonce BLOB NOT NULL, " - "payload BLOB NOT NULL, " "PRIMARY KEY (wallet_tag, dedup_hash))"); } diff --git a/src/chat/chat_database.h b/src/chat/chat_database.h index 1156a60..550c0ae 100644 --- a/src/chat/chat_database.h +++ b/src/chat/chat_database.h @@ -16,6 +16,7 @@ #include #include #include +#include #include 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& 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 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 tombstones_; // dedup_hash cache of locally-deleted messages }; } // namespace dragonx::chat diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index df97bff..e817a10 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector& metadata std::int64_t fallbackTimestamp, std::vector* 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& 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& 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 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_); diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index af9d5d9..b570560 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -11,6 +11,7 @@ #include "chat_store.h" #include +#include #include #include #include @@ -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 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 blocked_pred_; // true => cid is blocked (drop its messages) }; } // namespace dragonx::chat diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index 82cf76b..b493f90 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -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& excluded, + const std::function& 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 ChatStore::conversationIds() const { std::vector ids; std::unordered_set seenIds; @@ -53,9 +66,27 @@ std::vector ChatStore::conversationIds() const { return ids; } +std::vector ChatStore::eraseConversation(const std::string& conversationId) { + std::vector removed; + std::vector 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 diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 3c923e8..4ab6bf2 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -5,6 +5,8 @@ #include "chat_message.h" +#include +#include #include #include #include @@ -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 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& excluded, + const std::function& seenFor) const; + private: static std::string dedupKey(const ChatMessage& message); std::vector messages_; std::unordered_set seen_; + std::uint64_t revision_ = 0; }; } // namespace dragonx::chat diff --git a/src/config/settings.cpp b/src/config/settings.cpp index e6c05ad..2274f28 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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()); } + 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(), + 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_; diff --git a/src/config/settings.h b/src/config/settings.h index 4017575..5b2b0a9 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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& 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 muted_chat_cids_; // muted chat conversations by cid (Q10) std::vector hidden_chat_cids_; // hidden chat conversations by cid + std::vector 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) diff --git a/src/data/address_book.cpp b/src/data/address_book.cpp index 9893406..0c36d4d 100644 --- a/src/data/address_book.cpp +++ b/src/data/address_book.cpp @@ -68,8 +68,9 @@ bool AddressBook::load() } DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size()); + ++revision_; return true; - + } catch (const std::exception& e) { DEBUG_LOGF("Error loading address book: %s\n", e.what()); return false; @@ -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; } diff --git a/src/data/address_book.h b/src/data/address_book.h index 1c4ae02..5c2127b 100644 --- a/src/data/address_book.h +++ b/src/data/address_book.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include @@ -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 e) { entries_ = std::move(e); } + void sweepSetEntries(std::vector e) { entries_ = std::move(e); ++revision_; } /** * @brief Check if empty @@ -134,6 +142,7 @@ public: private: std::vector entries_; + std::uint64_t revision_ = 0; std::string file_path_; }; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 2f90cd9..400833c 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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 s_convs; +int s_convsHidden = 0; +std::uint64_t s_convsKey = ~0ull; +std::string s_threadCid; +std::uint64_t s_threadRev = ~0ull; +std::vector s_threadMsgs; + // Centered, muted, wrapped hint for the empty states. void centeredHint(const char* text) { ImVec2 avail = ImGui::GetContentRegionAvail(); @@ -725,32 +739,46 @@ void RenderChatTab(App* app) } // Build conversation summaries (single scan per conversation), sorted by most-recent activity. - std::vector convs; - int hiddenCount = 0; - for (const auto& cid : store.conversationIds()) { - const bool hidden = app->settings() && app->settings()->isChatHidden(cid); - if (hidden) ++hiddenCount; - if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on - const auto messages = store.conversation(cid); - if (messages.empty()) continue; - ConvSummary c; - c.cid = cid; - c.hidden = hidden; - c.count = static_cast(messages.size()); - for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the - if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides - if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD) + // 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(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) ++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; + ConvSummary c; + c.cid = cid; + c.hidden = hidden; + c.count = static_cast(messages.size()); + for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the + if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides + if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD) + } + const auto& last = messages.back(); + c.lastBody = last.body; + c.lastTs = last.timestamp; + 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); + s_convs.push_back(std::move(c)); } - const auto& last = messages.back(); - c.lastBody = last.body; - c.lastTs = last.timestamp; - 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)); + std::sort(s_convs.begin(), s_convs.end(), + [](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; }); + s_convsKey = convsKey; } - std::sort(convs.begin(), convs.end(), - [](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; }); + std::vector& 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::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) diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 352c006..e2657c4 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9c5f775..e909b23 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 convX{ mkMeta("dx1", "conv-x", "x-one"), + mkMeta("dx2", "conv-x", "x-two") }; + std::vector 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 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();