From bbb4e3946596fe8273cca7c63b6c2f3e93bf3157 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 12 May 2026 08:21:51 +0200 Subject: [PATCH 1/5] Quartz: - add mutedThreads/mutedThreadIdSet accessors - MuteTag sealed interface recognizes EventTag - add EventTag for NIP-51 mute-list e tags - MuteListEvent round-trip and legacy-tag migration coverage --- .../quartz/nip51Lists/muteList/TagArrayExt.kt | 7 + .../nip51Lists/muteList/tags/EventTag.kt | 69 ++++++ .../nip51Lists/muteList/tags/MuteTag.kt | 4 +- .../nip51Lists/muteList/MuteListEventTest.kt | 226 ++++++++++++++++++ .../muteList/TagArrayExtMutedThreadsTest.kt | 60 +++++ .../nip51Lists/muteList/tags/EventTagTest.kt | 101 ++++++++ 6 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt index 46ebe49dee..b94b093067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -36,3 +37,9 @@ fun TagArray.mutedUserIdSet() = mapNotNullTo(mutableSetOf(), UserTag::parseKey) fun TagArray.mutedWords() = mapNotNull(WordTag::parse) fun TagArray.mutedWordSet() = mapNotNullTo(mutableSetOf(), WordTag::parse) + +fun TagArray.mutedThreads() = mapNotNull(EventTag::parse) + +fun TagArray.mutedThreadIds() = mapNotNull(EventTag::parseId) + +fun TagArray.mutedThreadIdSet() = mapNotNullTo(mutableSetOf(), EventTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt new file mode 100644 index 0000000000..765e0f0a5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTag.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +class EventTag( + val eventId: HexKey, + val relayHint: NormalizedRelayUrl? = null, + val pubKeyHint: HexKey? = null, +) : MuteTag { + override fun toTagArray() = assemble(eventId, relayHint, pubKeyHint) + + override fun toTagIdOnly() = assemble(eventId, null, null) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array): Boolean = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Tag): EventTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + val hint = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val pubKey = tag.getOrNull(3)?.takeIf { it.length == 64 } + return EventTag(tag[1], hint, pubKey) + } + + fun parseId(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun assemble( + eventId: HexKey, + relayHint: NormalizedRelayUrl?, + pubKeyHint: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relayHint?.url, pubKeyHint) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt index 90b6779792..7d95b71067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -28,8 +28,8 @@ sealed interface MuteTag { fun toTagIdOnly(): Tag companion object { - fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) - fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt new file mode 100644 index 0000000000..8dfd37e726 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEventTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MuteListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + // 64-char hex IDs + private val rootA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" + private val rootB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2b" + private val pubA = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc3c" + + @Test + fun create_withEventTag_privateContent() = + runTest { + val event = + MuteListEvent.create( + mute = EventTag(rootA), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_000, + ) + + // No public "e" tag should be present + val publicETags = event.tags.filter { it.size >= 2 && it[0] == "e" && it[1] == rootA } + assertTrue(publicETags.isEmpty(), "Private mute must not appear as a public e-tag") + + // Content must be non-empty (it is encrypted) + assertTrue(event.content.isNotEmpty(), "Content must be non-empty when mute is private") + + // Decrypting must yield exactly one EventTag with eventId == rootA + val privateMutes = assertNotNull(event.privateMutes(signer), "privateMutes must not return null") + assertEquals(1, privateMutes.size, "Expected exactly one private mute") + val tag = assertNotNull(privateMutes.firstOrNull() as? EventTag, "Mute must be an EventTag") + assertEquals(rootA, tag.eventId) + } + + @Test + fun add_eventTagToExistingMute_combinesPrivateSet() = + runTest { + val firstEvent = + MuteListEvent.create( + mute = EventTag(rootA), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_001, + ) + + val secondEvent = + MuteListEvent.add( + earlierVersion = firstEvent, + mute = EventTag(rootB), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_002, + ) + + val privateMutes = assertNotNull(secondEvent.privateMutes(signer), "privateMutes must not return null") + val ids = privateMutes.filterIsInstance().map { it.eventId }.toSet() + assertTrue(ids.contains(rootA), "rootA must be present after add") + assertTrue(ids.contains(rootB), "rootB must be present after add") + } + + @Test + fun add_eventTagPreservesPriorUserAndWordTags() = + runTest { + // Build a kind-10000 with one UserTag and one WordTag (both private) + val base = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(UserTag(pubA), WordTag("spam")), + signer = signer, + createdAt = 1_700_000_003, + ) + + val updated = + MuteListEvent.add( + earlierVersion = base, + mute = EventTag(rootA), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_004, + ) + + val privateMutes = assertNotNull(updated.privateMutes(signer), "privateMutes must not return null") + assertEquals(3, privateMutes.size, "Expected three private mutes (UserTag + WordTag + EventTag)") + + val userTags = privateMutes.filterIsInstance() + val wordTags = privateMutes.filterIsInstance() + val eventTags = privateMutes.filterIsInstance() + + assertEquals(1, userTags.size, "Must have exactly one UserTag") + assertEquals(pubA, userTags.first().pubKey) + + assertEquals(1, wordTags.size, "Must have exactly one WordTag") + assertEquals("spam", wordTags.first().word) + + assertEquals(1, eventTags.size, "Must have exactly one EventTag") + assertEquals(rootA, eventTags.first().eventId) + } + + @Test + fun remove_eventTagLeavesOthers() = + runTest { + // Build event with both rootA and rootB muted privately + val base = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(EventTag(rootA), EventTag(rootB)), + signer = signer, + createdAt = 1_700_000_005, + ) + + val updated = + MuteListEvent.remove( + earlierVersion = base, + mute = EventTag(rootA), + signer = signer, + createdAt = 1_700_000_006, + ) + + val privateMutes = assertNotNull(updated.privateMutes(signer), "privateMutes must not return null") + val ids = privateMutes.filterIsInstance().map { it.eventId }.toSet() + assertTrue(!ids.contains(rootA), "rootA must have been removed") + assertTrue(ids.contains(rootB), "rootB must still be present") + } + + @Test + fun removeAll_mixedTagsRemovesUserAndEvent_keepsWord() = + runTest { + val base = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(UserTag(pubA), WordTag("spam"), EventTag(rootA)), + signer = signer, + createdAt = 1_700_000_007, + ) + + val updated = + MuteListEvent.removeAll( + earlierVersion = base, + mutes = listOf(UserTag(pubA), EventTag(rootA)), + signer = signer, + createdAt = 1_700_000_008, + ) + + val privateMutes = assertNotNull(updated.privateMutes(signer), "privateMutes must not return null") + + val userTags = privateMutes.filterIsInstance() + val wordTags = privateMutes.filterIsInstance() + val eventTags = privateMutes.filterIsInstance() + + assertTrue(userTags.none { it.pubKey == pubA }, "UserTag(pubA) must have been removed") + assertTrue(eventTags.none { it.eventId == rootA }, "EventTag(rootA) must have been removed") + assertEquals(1, wordTags.size, "WordTag must still be present") + assertEquals("spam", wordTags.first().word) + } + + @Test + fun legacyMuteListWithoutEventTags_decodesToEmptyThreadSet() = + runTest { + // Build a kind-10000 with only p + word tags (public), no e tags + val legacyEvent = + MuteListEvent.create( + publicMutes = listOf(UserTag(pubA), WordTag("spam")), + privateMutes = emptyList(), + signer = signer, + createdAt = 1_700_000_009, + ) + + // Calling mutedThreadIdSet() on a tag array with no e-tags must not crash and return empty + val publicThreadIds = legacyEvent.tags.mutedThreadIdSet() + assertTrue(publicThreadIds.isEmpty(), "Legacy event with only p+word tags must have empty thread id set") + + // privateMutes returns empty list (content is blank/empty for no private mutes) + val privateMutes = legacyEvent.privateMutes(signer) + val privateEventTags = (privateMutes ?: emptyList()).filterIsInstance() + assertTrue(privateEventTags.isEmpty(), "No private EventTags in a legacy event") + } + + @Test + fun roundTrip_eventTagsViaEncryption_preservesIds() = + runTest { + val event = + MuteListEvent.create( + publicMutes = emptyList(), + privateMutes = listOf(EventTag(rootA), EventTag(rootB)), + signer = signer, + createdAt = 1_700_000_010, + ) + + val decrypted = assertNotNull(event.privateMutes(signer), "privateMutes must not return null") + val ids = decrypted.filterIsInstance().map { it.eventId }.toSet() + + assertEquals(setOf(rootA, rootB), ids, "Round-trip must preserve all muted thread IDs") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt new file mode 100644 index 0000000000..b73ea24aef --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExtMutedThreadsTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TagArrayExtMutedThreadsTest { + private val id1 = "3ae34f70016c33d36f9e6ad395591ea36fee7ac488d1dad383ae64ae3d988f50" + private val id2 = "00000000016c33d36f9e6ad395591ea36fee7ac488d1dad383ae64ae3d988f50" + + @Test fun mutedThreads_returnsEventTagsOnly() { + val tags = + arrayOf( + arrayOf("e", id1), + arrayOf("p", id2), + arrayOf("word", "spam"), + arrayOf("e", id2, "wss://relay.damus.io"), + ) + val parsed = tags.mutedThreads() + assertEquals(2, parsed.size) + assertEquals(id1, parsed[0].eventId) + assertEquals(id2, parsed[1].eventId) + } + + @Test fun mutedThreadIdSet_extractsIdsAcrossMixedTags() { + val tags = + arrayOf( + arrayOf("e", id1), + arrayOf("p", id2), + arrayOf("e", id2), + ) + val ids = tags.mutedThreadIdSet() + assertEquals(setOf(id1, id2), ids) + } + + @Test fun mutedThreadIdSet_emptyOnNoEventTags() { + val tags = arrayOf(arrayOf("p", id1), arrayOf("word", "spam")) + assertTrue(tags.mutedThreadIdSet().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt new file mode 100644 index 0000000000..00d2051981 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/EventTagTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.muteList.tags + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EventTagTest { + private val rootHex = "3ae34f70016c33d36f9e6ad395591ea36fee7ac488d1dad383ae64ae3d988f50" + private val authorHex = "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c" + private val relayHint = "wss://relay.damus.io" + + @Test fun isTagged_eventTagWithIdOnly() { + assertTrue(EventTag.isTagged(arrayOf("e", rootHex))) + } + + @Test fun isTagged_eventTagWithRelayHint() { + assertTrue(EventTag.isTagged(arrayOf("e", rootHex, relayHint))) + } + + @Test fun isTagged_rejectsShortId() { + assertFalse(EventTag.isTagged(arrayOf("e", "tooShort"))) + } + + @Test fun isTagged_rejectsWrongPrefix() { + assertFalse(EventTag.isTagged(arrayOf("p", rootHex))) + } + + @Test fun parse_idOnly() { + val tag = assertNotNull(EventTag.parse(arrayOf("e", rootHex))) + assertEquals(rootHex, tag.eventId) + assertNull(tag.relayHint) + assertNull(tag.pubKeyHint) + } + + @Test fun parse_withRelayHint() { + val tag = assertNotNull(EventTag.parse(arrayOf("e", rootHex, relayHint))) + assertEquals(rootHex, tag.eventId) + assertEquals("wss://relay.damus.io/", tag.relayHint?.url) + } + + @Test fun parse_withRelayAndPubkeyHint() { + val tag = assertNotNull(EventTag.parse(arrayOf("e", rootHex, relayHint, authorHex))) + assertEquals(authorHex, tag.pubKeyHint) + } + + @Test fun parse_rejectsShortId() { + assertNull(EventTag.parse(arrayOf("e", "tooShort"))) + } + + @Test fun parseId_extractsIdOnly() { + assertEquals(rootHex, EventTag.parseId(arrayOf("e", rootHex, relayHint))) + } + + @Test fun toTagArray_roundTripsWithHints() { + val original = EventTag(rootHex, RelayUrlNormalizer.normalizeOrNull(relayHint), authorHex) + val parsed = assertNotNull(EventTag.parse(original.toTagArray())) + assertEquals(rootHex, parsed.eventId) + assertEquals(authorHex, parsed.pubKeyHint) + } + + @Test fun toTagIdOnly_stripsHints() { + val tag = EventTag(rootHex, RelayUrlNormalizer.normalizeOrNull(relayHint), authorHex) + val stripped = tag.toTagIdOnly() + assertEquals(2, stripped.size) + assertEquals("e", stripped[0]) + assertEquals(rootHex, stripped[1]) + } + + @Test fun muteTagCompanion_parsesEventTag() { + val parsed = assertNotNull(MuteTag.parse(arrayOf("e", rootHex))) + assertTrue(parsed is EventTag) + } + + @Test fun muteTagCompanion_isTaggedRecognizesEventTag() { + assertTrue(MuteTag.isTagged(arrayOf("e", rootHex))) + } +} From bac3710deb860b4014c2d296807a2b1fa35f0723 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 12 May 2026 08:49:52 +0200 Subject: [PATCH 2/5] Commons: - Note.isHiddenFor drops notes in muted threads - LiveHiddenUsers carries mutedThreads + isThreadMuted predicate --- .../amethyst/commons/model/IAccount.kt | 3 + .../amethyst/commons/model/Note.kt | 6 + .../commons/model/NoteIsHiddenForTest.kt | 138 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 2fe0507b53..714d26fec9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -60,9 +60,12 @@ data class LiveHiddenUsers( val hiddenUsers: Set = emptySet(), val spammers: Set = emptySet(), val hiddenWords: Set = emptySet(), + val mutedThreads: Set = emptySet(), val maxHashtagLimit: Int = 5, ) { fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) + + fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 4edd15882f..6f1ef34c57 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -860,6 +860,12 @@ open class Note( return true } + // if this note belongs to a muted thread (by NIP-10 root id) + if (accountChoices.mutedThreads.isNotEmpty()) { + val rootId = (thisEvent as? BaseThreadedEvent)?.root()?.eventId ?: idHex + if (accountChoices.mutedThreads.contains(rootId)) return true + } + // if the post is sensitive and the user doesn't want to see sensitive content if (accountChoices.showSensitiveContent == false && thisEvent.isSensitiveOrNSFW()) { return true diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt new file mode 100644 index 0000000000..5f54c096ae --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Unit tests for Note.isHiddenFor() focusing on the mutedThreads check. + * + * Fixture approach: + * - TextNoteEvent is constructed directly via its primary constructor (no signing needed — + * the sig field is not validated in isHiddenFor). + * - A Note is created with the event's id, then note.event is set. + * - LiveHiddenUsers is a plain data class — no Android plumbing required. + */ +class NoteIsHiddenForTest { + // 64-char hex constants + private val rootId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val replyId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + private val authorPubKey = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + + // Minimal LiveHiddenUsers with all filters off (used as a baseline) + private val noHidden = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + mutedThreads = emptySet(), + ) + + /** + * Build a TextNoteEvent. + * + * @param id The event id (64 hex chars). + * @param eTags Raw `e` tag arrays included in the event's tags. + */ + private fun textNoteEvent( + id: String, + pubKey: String = authorPubKey, + eTags: Array> = emptyArray(), + ) = TextNoteEvent( + id = id, + pubKey = pubKey, + createdAt = 1_700_000_000L, + tags = eTags, + content = "hello", + sig = "sig", + ) + + /** A marked `e` tag array with marker="root" at index 3. */ + private fun rootETag(eventId: String) = arrayOf("e", eventId, "", "root") + + // ------------------------------------------------------------------------- + + @Test + fun reply_inMutedThread_isHidden() { + // A reply whose NIP-10 root points to `rootId`, which is muted. + val event = + textNoteEvent( + id = replyId, + eTags = arrayOf(rootETag(rootId)), + ) + val note = Note(replyId).also { it.event = event } + + val choices = noHidden.copy(mutedThreads = setOf(rootId)) + + assertTrue(note.isHiddenFor(choices), "Reply inside a muted thread must be hidden") + } + + @Test + fun topLevelNote_ownIdMuted_isHidden() { + // A top-level note (no e-tags) whose own id is in mutedThreads. + val event = + textNoteEvent( + id = rootId, + eTags = emptyArray(), + ) + val note = Note(rootId).also { it.event = event } + + val choices = noHidden.copy(mutedThreads = setOf(rootId)) + + assertTrue(note.isHiddenFor(choices), "Top-level note whose id is muted must be hidden") + } + + @Test + fun note_inUnmutedThread_isNotHidden() { + // A reply note whose root is NOT in mutedThreads, author not hidden. + val otherRoot = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + val event = + textNoteEvent( + id = replyId, + eTags = arrayOf(rootETag(otherRoot)), + ) + val note = Note(replyId).also { it.event = event } + + // rootId is muted, but this note's root is otherRoot + val choices = noHidden.copy(mutedThreads = setOf(rootId)) + + assertFalse(note.isHiddenFor(choices), "Reply in an un-muted thread must not be hidden") + } + + @Test + fun authorHidden_isHidden_regression() { + // Regression guard: author-hidden notes must still return true. + val event = textNoteEvent(id = replyId) + val note = Note(replyId).also { it.event = event } + + // Put the author's pubKey hashCode into hiddenUsersHashCodes + val choices = + noHidden.copy( + hiddenUsersHashCodes = setOf(authorPubKey.hashCode()), + ) + + assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden") + } +} From a99488779ad25e930379f65c48ec5e6d1b33dfb7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 12 May 2026 08:58:34 +0200 Subject: [PATCH 3/5] Amethyst: - drop muted-thread reactions/zaps from notifications feed - SecurityFiltersScreen lists muted threads with unmute action - MutedThreadsFeedFilter for settings screen - add Mute thread entry to long-press dropdown - add Mute thread entry to quick-action sheet - add mute-thread string resources - AccountViewModel.muteThread/unmuteThread/isThreadMutedFor - drop muted-thread events before Android push dispatch - FilterByListParams drops muted-thread notes - Account.isAcceptable drops muted-thread notes - Account.muteThread/unmuteThread + resolveThreadRoot + isThreadMuted - MuteListDecryptionCache exposes mutedThreadIdSet helper - MuteListState supports hideThread/showThread - HiddenUsersState exposes muted-thread root ids --- .../vitorpamplona/amethyst/model/Account.kt | 17 +++ .../model/nip51Lists/HiddenUsersState.kt | 3 + .../muteList/MuteListDecryptionCache.kt | 5 + .../nip51Lists/muteList/MuteListState.kt | 33 +++++ .../EventNotificationConsumer.kt | 12 ++ .../amethyst/ui/dal/FilterByListParams.kt | 8 + .../amethyst/ui/note/NoteQuickActionMenu.kt | 21 +++ .../amethyst/ui/note/elements/DropDownMenu.kt | 12 ++ .../loggedIn/AccountFeedContentStates.kt | 1 + .../ui/screen/loggedIn/AccountViewModel.kt | 20 +++ .../dal/NotificationFeedFilter.kt | 14 ++ .../settings/SecurityFiltersScreen.kt | 139 +++++++++++++++++- .../settings/dal/MutedThreadsFeedFilter.kt | 39 +++++ .../settings/dal/MutedThreadsFeedViewModel.kt | 39 +++++ amethyst/src/main/res/values/strings.xml | 6 + 15 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 30ca6da762..d3b1cfa56e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -167,6 +167,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris @@ -2836,6 +2837,21 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.showWords(words)) } + suspend fun muteThread(rootHex: HexKey) { + sendMyPublicAndPrivateOutbox(muteList.hideThread(rootHex)) + } + + suspend fun unmuteThread(rootHex: HexKey) { + muteList.showThread(rootHex)?.let { sendMyPublicAndPrivateOutbox(it) } + } + + fun resolveThreadRoot(note: Note): HexKey { + val ev = note.event + return (ev as? BaseThreadedEvent)?.root()?.eventId ?: note.idHex + } + + fun isThreadMuted(rootHex: HexKey): Boolean = hiddenUsers.flow.value.isThreadMuted(rootHex) + suspend fun requestDVMContentDiscovery( dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set) -> Unit, @@ -2970,6 +2986,7 @@ class Account( } override fun isAcceptable(note: Note): Boolean { + if (isThreadMuted(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index ef18b8b4c9..d23ccb0d78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -56,6 +57,7 @@ class HiddenUsersState( ): LiveHiddenUsers { val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } + val mutedThreads = muteList.mapNotNullTo(mutableSetOf()) { if (it is EventTag) it.eventId else null } return LiveHiddenUsers( showSensitiveContent = showSensitiveContent, @@ -66,6 +68,7 @@ class HiddenUsersState( spammers = transientHiddenUsers, hiddenWords = hiddenWords, maxHashtagLimit = maxHashtagLimit, + mutedThreads = mutedThreads, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt index 274aebf359..947c93ec8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListDecryptionCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.mutedThreadIdSet import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords @@ -44,6 +45,8 @@ class MuteListDecryptionCache( fun cachedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet() + fun cachedThreadIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedThreadIdSet() + suspend fun mutedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords() suspend fun mutedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers() @@ -53,4 +56,6 @@ class MuteListDecryptionCache( suspend fun mutedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() suspend fun mutedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() + + suspend fun mutedThreadIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedThreadIdSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index 2bcf1ce923..670645211a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -24,8 +24,10 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -139,6 +141,37 @@ class MuteListState( } } + suspend fun hideThread(rootHex: HexKey): MuteListEvent { + val muteList = getMuteList() + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = EventTag(rootHex), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = EventTag(rootHex), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showThread(rootHex: HexKey): MuteListEvent? { + val muteList = getMuteList() + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = EventTag(rootHex), + signer = signer, + ) + } else { + null + } + } + suspend fun showUsers(pubkeys: List): MuteListEvent? { if (pubkeys.isEmpty()) return null val muteList = getMuteList() ?: return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 2d78756a15..4f235a6431 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -604,6 +604,9 @@ class EventNotificationConsumer( Log.d(TAG) { "Notify ZapRequest $noteZapRequest zapped $noteZapped" } + // Drop zaps on muted threads, hidden authors, etc. + if (!account.isAcceptable(noteZapped)) return + if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return Log.d(TAG, "Notify Amount Bigger than 10") @@ -720,6 +723,9 @@ class EventNotificationConsumer( val reactedPostId = event.originalPost().firstOrNull() ?: return val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) + // Drop reactions on muted threads, hidden authors, etc. + if (reactedNote != null && !account.isAcceptable(reactedNote)) return + val author = LocalCache.getOrCreateUser(event.pubKey) val user = author.toBestDisplayName() val userPicture = author.profilePicture() @@ -832,6 +838,9 @@ class EventNotificationConsumer( ) { val replyNote = LocalCache.getNoteIfExists(event.id) ?: return + // Drop events from muted threads, hidden authors, etc. + if (!account.isAcceptable(replyNote)) return + val author = LocalCache.getOrCreateUser(event.pubKey) val user = author.toBestDisplayName() val userPicture = author.profilePicture() @@ -890,6 +899,9 @@ class EventNotificationConsumer( // Age + self-author gates run centrally in dispatchForAccount. val note = LocalCache.getNoteIfExists(event.id) ?: return + // Drop events from muted threads, hidden authors, etc. + if (!account.isAcceptable(note)) return + val author = LocalCache.getOrCreateUser(event.pubKey) val user = author.toBestDisplayName() val userPicture = author.profilePicture() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 2b459aa793..bb37bd3a23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,6 +44,12 @@ class FilterByListParams( ) { fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex)) + fun isNotInMutedThread(noteEvent: Event): Boolean { + if (hiddenLists.mutedThreads.isEmpty()) return true + val rootId = (noteEvent as? BaseThreadedEvent)?.root()?.eventId ?: noteEvent.id + return !hiddenLists.mutedThreads.contains(rootId) + } + fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit @@ -84,6 +91,7 @@ class FilterByListParams( comingFrom: List, ) = (applyTopFilter(comingFrom, noteEvent)) && (isHiddenList || isNotHidden(noteEvent.pubKey)) && + isNotInMutedThread(noteEvent) && isNotInTheFuture(noteEvent) && !hasExcessiveHashtags(noteEvent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 4dfbb1555e..40819190bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -326,6 +326,27 @@ fun CardBody( showBlockAlertDialog.value = true } } + + VerticalDivider(color = primaryLight) + + val isMuted = accountViewModel.isThreadMutedFor(note) + NoteQuickActionItem( + MaterialSymbols.AutoMirrored.VolumeOff, + stringRes( + if (isMuted) { + R.string.quick_action_unmute_thread + } else { + R.string.quick_action_mute_thread + }, + ), + ) { + if (isMuted) { + accountViewModel.unmuteThread(note) + } else { + accountViewModel.muteThread(note) + } + onDismiss() + } } } HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 180aff43f2..c5b03619e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -335,6 +335,18 @@ fun NoteDropDownMenu( // Moderation section M3ActionSection { + val isThreadMuted = accountViewModel.isThreadMutedFor(note) + M3ActionRow( + icon = MaterialSymbols.AutoMirrored.VolumeOff, + text = stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread), + ) { + if (isThreadMuted) { + accountViewModel.unmuteThread(note) + } else { + accountViewModel.muteThread(note) + } + onDismiss() + } if (state.isLoggedUser) { M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 857b6c6fb2..e63e09fd9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -138,6 +138,7 @@ class AccountFeedContentStates( account.hiddenUsers.flow.collect { dmKnown.invalidateData() dmNew.invalidateData() + notifications.invalidateData() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 774e625776..9fd8490257 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1238,6 +1238,26 @@ class AccountViewModel( fun showWords(words: List) = launchSigner { account.showWords(words) } + fun muteThread(note: Note) { + launchSigner { + account.muteThread(account.resolveThreadRoot(note)) + } + } + + fun unmuteThread(note: Note) { + launchSigner { + account.unmuteThread(account.resolveThreadRoot(note)) + } + } + + fun unmuteThread(rootHex: HexKey) { + launchSigner { + account.unmuteThread(rootHex) + } + } + + fun isThreadMutedFor(note: Note): Boolean = account.isThreadMuted(account.resolveThreadRoot(note)) + fun createStatus(newStatus: String) = launchSigner { account.createStatus(newStatus) } fun updateStatus( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 3e96624a0c..3240887479 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -225,6 +225,20 @@ class NotificationFeedFilter( } } + // Drop reactions/zaps/reposts whose target post is in a muted thread. + // The inner-note renderer would otherwise show a misleading + // "This post was hidden because it mentions your hidden users or words" + // placeholder for muted-thread targets (regression from Task 6's + // Note.isHiddenFor extension). + if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || + noteEvent is RepostEvent || noteEvent is GenericRepostEvent + ) { + val target = it.replyTo?.lastOrNull() + if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) { + return false + } + } + // Chess events bypass the follow filter — opponents may not be followed val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 9556dc6834..39d7729553 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -78,6 +78,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord @@ -104,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.MutedThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -141,16 +144,23 @@ fun SecurityFiltersScreen( factory = SpammerAccountsFeedViewModel.Factory(accountViewModel.account), ) + val mutedThreadsFeedViewModel: MutedThreadsFeedViewModel = + viewModel( + factory = MutedThreadsFeedViewModel.Factory(accountViewModel.account), + ) + WatchAccountAndBlockList(accountViewModel = accountViewModel) { hiddenFeedViewModel.invalidateData() spammerFeedViewModel.invalidateData() hiddenWordsFeedViewModel.invalidateData() + mutedThreadsFeedViewModel.invalidateData() } SecurityFiltersScreen( hiddenFeedViewModel, hiddenWordsFeedViewModel, spammerFeedViewModel, + mutedThreadsFeedViewModel, accountViewModel, nav, ) @@ -162,6 +172,7 @@ fun SecurityFiltersScreen( hiddenFeedViewModel: HiddenAccountsFeedViewModel, hiddenWordsViewModel: HiddenWordsFeedViewModel, spammerFeedViewModel: SpammerAccountsFeedViewModel, + mutedThreadsFeedViewModel: MutedThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -175,6 +186,7 @@ fun SecurityFiltersScreen( hiddenWordsViewModel.invalidateData() hiddenFeedViewModel.invalidateData() spammerFeedViewModel.invalidateData() + mutedThreadsFeedViewModel.invalidateData() } } @@ -182,7 +194,7 @@ fun SecurityFiltersScreen( onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } } - val pagerState = rememberPagerState { 3 } + val pagerState = rememberPagerState { 4 } val coroutineScope = rememberCoroutineScope() var selectedUsers by remember { mutableStateOf(setOf()) } @@ -258,6 +270,11 @@ fun SecurityFiltersScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, text = { Text(text = stringRes(R.string.hidden_words)) }, ) + Tab( + selected = pagerState.currentPage == 3, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } }, + text = { Text(text = stringRes(R.string.settings_muted_threads_title)) }, + ) } HorizontalPager(state = pagerState) { page -> when (page) { @@ -289,6 +306,13 @@ fun SecurityFiltersScreen( }, ) } + + 3 -> { + MutedThreadsFeed( + viewModel = mutedThreadsFeedViewModel, + accountViewModel = accountViewModel, + ) + } } } } @@ -801,3 +825,116 @@ private fun SelectableHiddenUsersList( } } } + +@Composable +private fun MutedThreadsFeed( + viewModel: MutedThreadsFeedViewModel, + accountViewModel: AccountViewModel, +) { + RefresheableBox(viewModel, false) { + val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + Column( + Modifier + .fillMaxSize() + .padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(text = stringRes(R.string.settings_muted_threads_empty)) + } + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + + is FeedState.Loading -> { + LoadingFeed() + } + + is FeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, note -> + MutedThreadRow(note = note, accountViewModel = accountViewModel) + HorizontalDivider(thickness = DividerThickness) + } + } + } + } + } + } +} + +@Composable +private fun MutedThreadRow( + note: Note, + accountViewModel: AccountViewModel, +) { + val event = note.event + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + if (event == null) { + Text( + text = stringRes(R.string.settings_muted_threads_unknown, note.idHex.take(12) + "…"), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + val authorName = note.author?.metadataOrNull()?.bestName() + if (authorName != null) { + Text( + text = authorName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = + event.content + .lines() + .firstOrNull() + ?.trim() ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = { accountViewModel.unmuteThread(note) }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.action_unmute), color = Color.White) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt new file mode 100644 index 0000000000..594ce96c63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class MutedThreadsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + + override fun showHiddenKey(): Boolean = true + + override fun feed(): List = + account.hiddenUsers.flow.value.mutedThreads + .mapNotNull { LocalCache.getNoteIfExists(it) ?: LocalCache.getOrCreateNote(it) } + .sortedByDescending { it.createdAt() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt new file mode 100644 index 0000000000..fc9a365a88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class MutedThreadsFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(MutedThreadsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MutedThreadsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b5bb37be9f..4ec6447448 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -401,6 +401,8 @@ Block Delete Block + Mute thread + Unmute thread Report Delete Don\'t show again @@ -1449,6 +1451,7 @@ Muted. Click to unmute Sound on. Click to mute + Unmute Skip back %d seconds Skip forward %d seconds Picture-in-Picture @@ -1606,6 +1609,9 @@ Hidden Words Hide new word or sentence + Muted threads + No muted threads + Unknown thread · %1$s Profile Picture Show Profile pictures From ff780bcb543950d6bfba4dc727035cbdb53b389d Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 12 May 2026 18:30:52 +0200 Subject: [PATCH 4/5] Code review: - consolidate thread-root resolution - consolidate mute-state writes --- .../vitorpamplona/amethyst/model/Account.kt | 12 ++--- .../amethyst/ui/dal/FilterByListParams.kt | 5 +- .../dal/NotificationFeedFilter.kt | 7 +-- .../settings/dal/MutedThreadsFeedFilter.kt | 2 +- .../amethyst/commons/model/Note.kt | 9 ++-- .../commons/model/NoteIsHiddenForTest.kt | 53 ++----------------- .../quartz/nip10Notes/BaseThreadedEvent.kt | 3 ++ 7 files changed, 23 insertions(+), 68 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d3b1cfa56e..2f7f2877a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -167,11 +167,11 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -2838,17 +2838,16 @@ class Account( } suspend fun muteThread(rootHex: HexKey) { + if (isThreadMuted(rootHex)) return sendMyPublicAndPrivateOutbox(muteList.hideThread(rootHex)) } suspend fun unmuteThread(rootHex: HexKey) { + if (!isThreadMuted(rootHex)) return muteList.showThread(rootHex)?.let { sendMyPublicAndPrivateOutbox(it) } } - fun resolveThreadRoot(note: Note): HexKey { - val ev = note.event - return (ev as? BaseThreadedEvent)?.root()?.eventId ?: note.idHex - } + fun resolveThreadRoot(note: Note): HexKey = note.event?.threadRootIdOrSelf() ?: note.idHex fun isThreadMuted(rootHex: HexKey): Boolean = hiddenUsers.flow.value.isThreadMuted(rootHex) @@ -2986,7 +2985,8 @@ class Account( } override fun isAcceptable(note: Note): Boolean { - if (isThreadMuted(resolveThreadRoot(note))) return false + val mutedThreads = hiddenUsers.flow.value.mutedThreads + if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index bb37bd3a23..c1ef9f4c42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -46,8 +46,7 @@ class FilterByListParams( fun isNotInMutedThread(noteEvent: Event): Boolean { if (hiddenLists.mutedThreads.isEmpty()) return true - val rootId = (noteEvent as? BaseThreadedEvent)?.root()?.eventId ?: noteEvent.id - return !hiddenLists.mutedThreads.contains(rootId) + return !hiddenLists.mutedThreads.contains(noteEvent.threadRootIdOrSelf()) } fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 3240887479..c24217b769 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -225,11 +225,8 @@ class NotificationFeedFilter( } } - // Drop reactions/zaps/reposts whose target post is in a muted thread. - // The inner-note renderer would otherwise show a misleading - // "This post was hidden because it mentions your hidden users or words" - // placeholder for muted-thread targets (regression from Task 6's - // Note.isHiddenFor extension). + // Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags, + // so isNotInMutedThread on the wrapper event misses them. if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt index 594ce96c63..616b91b5fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/MutedThreadsFeedFilter.kt @@ -34,6 +34,6 @@ class MutedThreadsFeedFilter( override fun feed(): List = account.hiddenUsers.flow.value.mutedThreads - .mapNotNull { LocalCache.getNoteIfExists(it) ?: LocalCache.getOrCreateNote(it) } + .map { LocalCache.getOrCreateNote(it) } .sortedByDescending { it.createdAt() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 6f1ef34c57..1a2790d087 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -860,10 +861,10 @@ open class Note( return true } - // if this note belongs to a muted thread (by NIP-10 root id) - if (accountChoices.mutedThreads.isNotEmpty()) { - val rootId = (thisEvent as? BaseThreadedEvent)?.root()?.eventId ?: idHex - if (accountChoices.mutedThreads.contains(rootId)) return true + if (accountChoices.mutedThreads.isNotEmpty() && + accountChoices.mutedThreads.contains(thisEvent.threadRootIdOrSelf()) + ) { + return true } // if the post is sensitive and the user doesn't want to see sensitive content diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt index 5f54c096ae..b8c1378006 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt @@ -25,22 +25,11 @@ import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -/** - * Unit tests for Note.isHiddenFor() focusing on the mutedThreads check. - * - * Fixture approach: - * - TextNoteEvent is constructed directly via its primary constructor (no signing needed — - * the sig field is not validated in isHiddenFor). - * - A Note is created with the event's id, then note.event is set. - * - LiveHiddenUsers is a plain data class — no Android plumbing required. - */ class NoteIsHiddenForTest { - // 64-char hex constants private val rootId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" private val replyId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" private val authorPubKey = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - // Minimal LiveHiddenUsers with all filters off (used as a baseline) private val noHidden = LiveHiddenUsers( showSensitiveContent = null, @@ -50,12 +39,6 @@ class NoteIsHiddenForTest { mutedThreads = emptySet(), ) - /** - * Build a TextNoteEvent. - * - * @param id The event id (64 hex chars). - * @param eTags Raw `e` tag arrays included in the event's tags. - */ private fun textNoteEvent( id: String, pubKey: String = authorPubKey, @@ -69,21 +52,12 @@ class NoteIsHiddenForTest { sig = "sig", ) - /** A marked `e` tag array with marker="root" at index 3. */ private fun rootETag(eventId: String) = arrayOf("e", eventId, "", "root") - // ------------------------------------------------------------------------- - @Test fun reply_inMutedThread_isHidden() { - // A reply whose NIP-10 root points to `rootId`, which is muted. - val event = - textNoteEvent( - id = replyId, - eTags = arrayOf(rootETag(rootId)), - ) + val event = textNoteEvent(id = replyId, eTags = arrayOf(rootETag(rootId))) val note = Note(replyId).also { it.event = event } - val choices = noHidden.copy(mutedThreads = setOf(rootId)) assertTrue(note.isHiddenFor(choices), "Reply inside a muted thread must be hidden") @@ -91,14 +65,8 @@ class NoteIsHiddenForTest { @Test fun topLevelNote_ownIdMuted_isHidden() { - // A top-level note (no e-tags) whose own id is in mutedThreads. - val event = - textNoteEvent( - id = rootId, - eTags = emptyArray(), - ) + val event = textNoteEvent(id = rootId, eTags = emptyArray()) val note = Note(rootId).also { it.event = event } - val choices = noHidden.copy(mutedThreads = setOf(rootId)) assertTrue(note.isHiddenFor(choices), "Top-level note whose id is muted must be hidden") @@ -106,16 +74,9 @@ class NoteIsHiddenForTest { @Test fun note_inUnmutedThread_isNotHidden() { - // A reply note whose root is NOT in mutedThreads, author not hidden. val otherRoot = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" - val event = - textNoteEvent( - id = replyId, - eTags = arrayOf(rootETag(otherRoot)), - ) + val event = textNoteEvent(id = replyId, eTags = arrayOf(rootETag(otherRoot))) val note = Note(replyId).also { it.event = event } - - // rootId is muted, but this note's root is otherRoot val choices = noHidden.copy(mutedThreads = setOf(rootId)) assertFalse(note.isHiddenFor(choices), "Reply in an un-muted thread must not be hidden") @@ -123,15 +84,9 @@ class NoteIsHiddenForTest { @Test fun authorHidden_isHidden_regression() { - // Regression guard: author-hidden notes must still return true. val event = textNoteEvent(id = replyId) val note = Note(replyId).also { it.event = event } - - // Put the author's pubKey hashCode into hiddenUsersHashCodes - val choices = - noHidden.copy( - hiddenUsersHashCodes = setOf(authorPubKey.hashCode()), - ) + val choices = noHidden.copy(hiddenUsersHashCodes = setOf(authorPubKey.hashCode())) assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index 56e92115cb..bec989c899 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @@ -30,6 +31,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull +fun Event.threadRootIdOrSelf(): HexKey = (this as? BaseThreadedEvent)?.root()?.eventId ?: id + @Immutable open class BaseThreadedEvent( id: HexKey, From e21794e42a4ee9ea98eb9f062b0f2dca80b10f31 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 12 May 2026 21:58:03 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Manual=20testing=20fixes:=20-=20quartz:=20E?= =?UTF-8?q?vent.threadRootIdOrSelf()=20now=20falls=20back=20to=20markedRep?= =?UTF-8?q?ly()=3F.eventId=20when=20both=20markedRoot()=20and=20unmarkedRo?= =?UTF-8?q?ot()=20are=20absent=20-=20amethyst:=20After=20upstream=20PR=20#?= =?UTF-8?q?2855=20split=20the=20notifications=20feed=20into=20notification?= =?UTF-8?q?sFollowing=20and=20notificationsEveryone,=20the=20hiddenUsers.f?= =?UTF-8?q?low=20collector=20still=20only=20invalidated=20the=20original?= =?UTF-8?q?=20notifications=20feed.=20Extends=20it=20to=20invalidate=20all?= =?UTF-8?q?=20three.=20-=20amethyst:=20CardFeedContentState.refreshSuspend?= =?UTF-8?q?ed()=20takes=20an=20additive-only=20path=20when=20lastNotes=20i?= =?UTF-8?q?s=20populated=20=E2=80=94=20it=20only=20adds=20cards=20for=20ne?= =?UTF-8?q?w=20admissions,=20never=20removing=20cards=20for=20notes=20that?= =?UTF-8?q?=20no=20longer=20pass=20the=20filter.=20Re-muting=20mid-session?= =?UTF-8?q?=20correctly=20rejected=20muted-thread=20reactions=20in=20the?= =?UTF-8?q?=20filter,=20but=20the=20existing=20cards=20stayed=20in=20the?= =?UTF-8?q?=20UI=20as=20"Show=20Anyway"=20placeholders.=20Calls=20clear()?= =?UTF-8?q?=20before=20invalidateData()=20on=20each=20notification=20feed?= =?UTF-8?q?=20so=20the=20refresh=20hits=20the=20full-rebuild=20branch=20an?= =?UTF-8?q?d=20removals=20propagate.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loggedIn/AccountFeedContentStates.kt | 9 +++ .../quartz/nip10Notes/BaseThreadedEvent.kt | 9 ++- .../nip10Notes/ThreadRootIdOrSelfTest.kt | 75 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index e63e09fd9a..c180e87feb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -138,7 +138,16 @@ class AccountFeedContentStates( account.hiddenUsers.flow.collect { dmKnown.invalidateData() dmNew.invalidateData() + // Re-mute removes cards, not just adds them. CardFeedContentState's + // refreshSuspended() takes an additive-only path when lastNotes is + // populated, which keeps stale cards for notes that no longer pass + // the filter. Clear first so the refresh hits the full-rebuild branch. + notifications.clear() notifications.invalidateData() + notificationsFollowing.clear() + notificationsFollowing.invalidateData() + notificationsEveryone.clear() + notificationsEveryone.invalidateData() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index bec989c899..b8379fb628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -31,7 +31,14 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull -fun Event.threadRootIdOrSelf(): HexKey = (this as? BaseThreadedEvent)?.root()?.eventId ?: id +fun Event.threadRootIdOrSelf(): HexKey { + val threaded = this as? BaseThreadedEvent ?: return id + threaded.root()?.eventId?.let { return it } + // NIP-10 legacy single-level form: when only a "reply"-marked e-tag is + // present (no "root" marker), the reply target IS the conversation root. + threaded.markedReply()?.eventId?.let { return it } + return id +} @Immutable open class BaseThreadedEvent( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt new file mode 100644 index 0000000000..2ae2407d19 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/ThreadRootIdOrSelfTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip10Notes + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ThreadRootIdOrSelfTest { + private val selfId = "3b1ef90115dc6f383e9588d29c86fe3ecda6ecc98415af556cd1399731a15e09" + private val rootId = "41ed6fdfbe827e7e87f3bfd852270b036c199b721777593d257029d742adcb46" + private val parentId = "54851588857363fa077fc756c1a64528f511d39476ca37e6a781bcb7aabb9e7d" + private val pubKey = "b62b357c1b939ae051a361d5befd409ddda06264a7588f1a986c24b1d13cc53f" + private val relay = "wss://relay.damus.io" + private val sig = "0".repeat(128) + + private fun textNote(tags: Array>): TextNoteEvent = TextNoteEvent(selfId, pubKey, 1778593701L, tags, "", sig) + + @Test fun topLevelNote_noETags_resolvesToOwnId() { + // A note with no e-tags is the root of its own thread. + val note = textNote(emptyArray()) + assertEquals(selfId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithRootMarker_resolvesToRoot() { + // Modern NIP-10: explicit "root" marker. + val note = textNote(arrayOf(arrayOf("e", rootId, relay, "root"))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithBothRootAndReplyMarkers_resolvesToRoot() { + // Multi-level reply: "root" marker points to thread root, + // "reply" marker points to immediate parent. + val note = + textNote( + arrayOf( + arrayOf("e", rootId, relay, "root"), + arrayOf("e", parentId, relay, "reply"), + ), + ) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithOnlyReplyMarker_resolvesToReplyTarget() { + // NIP-10 legacy single-level form: a one-level reply marked only + // "reply" with no "root" marker. The "reply" target IS the + // conversation root. Regression case from issue #161 device QA. + val note = textNote(arrayOf(arrayOf("e", rootId, relay, "reply"))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } + + @Test fun replyWithUnmarkedETag_resolvesToTaggedEvent() { + // Positional NIP-10: e-tag with no marker. Treated as root by + // BaseThreadedEvent.unmarkedRoot(). + val note = textNote(arrayOf(arrayOf("e", rootId, relay))) + assertEquals(rootId, note.threadRootIdOrSelf()) + } +}