fix(marmot): an admin removal that decrypts before its target still removes it

A kind-4891 only dropped messages already on screen and was not remembered. A catch-up
returns newest first and both sit in one epoch, so the removal often decrypts first,
removes nothing, and the target is then added and stays, on every restart too (the replay
keeps arrival order). Unlike a kind-5 there is no LocalCache deletion index behind it.
MarmotGroupList now keeps the removed ids per group and refuses them on add. Tests cover
removal-before-target, the per-group scope, and removal of a shown message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-28 10:47:47 -04:00
co-authored by Claude Opus 5.5
parent 0e1bd6e810
commit 3615caabcc
3 changed files with 47 additions and 1 deletions
@@ -309,7 +309,7 @@ class AccountMarmotActions(
) {
val manager = account.marmotManager ?: return
manager.adminRemovalTargets(nostrGroupId, innerEvent).forEach { targetId ->
account.cache.getNoteIfExists(targetId)?.let { account.marmotGroupList.removeMessage(nostrGroupId, it) }
account.marmotGroupList.applyAdminRemoval(nostrGroupId, targetId, account.cache.getNoteIfExists(targetId))
}
}
@@ -38,6 +38,12 @@ class MarmotGroupList(
private val noteToGroupIndex = LargeCache<HexKey, HexKey>()
// Message id -> group, for every message a group admin removed (kind 4891). A removal can
// decrypt before its target: a catch-up returns newest first, and both sit in one epoch.
// Unlike a kind-5 there is no LocalCache deletion index behind it, so without this record a
// target arriving second would be added and stay, restarts included.
private val adminRemovedIds = LargeCache<HexKey, HexKey>()
private val _groupListChanges = MutableSharedFlow<HexKey>(0, 20, BufferOverflow.DROP_OLDEST)
val groupListChanges = _groupListChanges
@@ -48,6 +54,7 @@ class MarmotGroupList(
msg: Note,
) {
if (!isDisplayableFeedMessage(msg)) return
if (adminRemovedIds.get(msg.idHex) == nostrGroupId) return
val chatroom = getOrCreateGroup(nostrGroupId)
if (chatroom.addMessageSync(msg)) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
@@ -99,6 +106,19 @@ class MarmotGroupList(
}
}
/**
* Applies an admin removal of [targetId] in [nostrGroupId]: drops [target] if it is already
* shown, and keeps the removal so the message is refused if it arrives later.
*/
fun applyAdminRemoval(
nostrGroupId: HexKey,
targetId: HexKey,
target: Note?,
) {
adminRemovedIds.put(targetId, nostrGroupId)
if (target != null) removeMessage(nostrGroupId, target)
}
/**
* Drop a group from the in-memory list. Also clears the chatroom's own
* message set and the note→group index: LocalCache holds notes weakly, so
@@ -113,4 +113,30 @@ class MarmotGroupFeedVisibilityTest {
// note skipped the kind rules above and rendered "Event is loading or can't be found".
assertEquals(0, visibleCount(list(), Note("d".repeat(64))))
}
@Test
fun `an admin removal that arrives before its target still removes it`() {
// A catch-up returns newest first, so the kind-4891 can decrypt before the message.
val list = list()
val target = note(9, peer, content = "spam")
list.applyAdminRemoval(groupId, target.idHex, null)
assertEquals(0, visibleCount(list, target))
}
@Test
fun `an admin removal in one group does not hide the same id in another`() {
val list = list()
val target = note(9, peer, content = "hello")
list.applyAdminRemoval("e".repeat(64), target.idHex, null)
assertEquals(1, visibleCount(list, target))
}
@Test
fun `an admin removal drops a message already shown`() {
val list = list()
val target = note(9, peer, content = "spam")
assertEquals(1, visibleCount(list, target))
list.applyAdminRemoval(groupId, target.idHex, target)
assertEquals(0, list.getOrCreateGroup(groupId).messages.size)
}
}