feat(chat): per-conversation delete (revive + block); memoize chat/badge render

Adds a per-conversation "delete" with two modes, and removes the per-frame
rescans of the chat history that shared these files.

Delete conversation (header trash icon → confirm dialog):
- Delete (revive-on-new-message): clears local history and tombstones the
  messages (new chat_deleted table, keyed dedup hashes) so the every-few-
  seconds memo re-scan can't re-import them; a genuinely NEW message (new
  txid) revives the thread.
- Delete & block: removes history WITHOUT a tombstone and records the cid as
  blocked (settings); ChatService::ingest drops that conversation's messages
  — old and future — until unblocked from the "Blocked" manager, which then
  re-imports the conversation from chain.
- Local-only (messages remain on-chain; the peer keeps their copy).
  deleteConversation() deletes the DB rows FIRST and only then mutates the
  store, so a failed write can't leave the two diverged.
Unit-tested (revive / tombstone-survives-reload / block / unblock) and
adversarially reviewed (store/DB divergence, half-open DB, revive-unread).

Performance (chat + badge hot paths, from the perf audit):
- ChatStore gains revision(); the Chat unread badge is now a single O(N)
  no-alloc pass cached on it (was O(conversations x messages) copy+sort every
  frame). The conversation list and open thread are memoized on revision()
  (+ show-hidden and AddressBook::revision() for peer-name resolution).
- AddressBook gains revision() so an in-place contact rename invalidates the
  chat memo (an edit keeps entries().size() constant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 22:54:51 -05:00
parent a60a2f8e39
commit 0a042df8e0
15 changed files with 611 additions and 45 deletions

View File

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