diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index d1b3a20cad..e767ce649d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -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)) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 59059005c9..fcd4f2ed18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -38,6 +38,12 @@ class MarmotGroupList( private val noteToGroupIndex = LargeCache() + // 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() + private val _groupListChanges = MutableSharedFlow(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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt index 57363d7fbc..3ec74e6a8e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt @@ -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) + } }