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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user