Merge pull request #2862 from davotoula/feat/161-mute-thread

Client-side thread mute (NIP-51 kind-10000 e tags)
This commit is contained in:
Vitor Pamplona
2026-05-13 08:14:50 -04:00
committed by GitHub
26 changed files with 1026 additions and 3 deletions
@@ -60,9 +60,12 @@ data class LiveHiddenUsers(
val hiddenUsers: Set<String> = emptySet(),
val spammers: Set<String> = emptySet(),
val hiddenWords: Set<String> = emptySet(),
val mutedThreads: Set<String> = emptySet(),
val maxHashtagLimit: Int = 5,
) {
fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex)
fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex)
}
/**
@@ -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,6 +861,12 @@ open class Note(
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
if (accountChoices.showSensitiveContent == false && thisEvent.isSensitiveOrNSFW()) {
return true
@@ -0,0 +1,93 @@
/*
* 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
class NoteIsHiddenForTest {
private val rootId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val replyId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private val authorPubKey = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
private val noHidden =
LiveHiddenUsers(
showSensitiveContent = null,
hiddenWordsCase = emptyList(),
hiddenUsersHashCodes = emptySet(),
spammersHashCodes = emptySet(),
mutedThreads = emptySet(),
)
private fun textNoteEvent(
id: String,
pubKey: String = authorPubKey,
eTags: Array<Array<String>> = emptyArray(),
) = TextNoteEvent(
id = id,
pubKey = pubKey,
createdAt = 1_700_000_000L,
tags = eTags,
content = "hello",
sig = "sig",
)
private fun rootETag(eventId: String) = arrayOf("e", eventId, "", "root")
@Test
fun reply_inMutedThread_isHidden() {
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() {
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() {
val otherRoot = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
val event = textNoteEvent(id = replyId, eTags = arrayOf(rootETag(otherRoot)))
val note = Note(replyId).also { it.event = event }
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() {
val event = textNoteEvent(id = replyId)
val note = Note(replyId).also { it.event = event }
val choices = noHidden.copy(hiddenUsersHashCodes = setOf(authorPubKey.hashCode()))
assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden")
}
}