From 9090dfe82c9bc9f4e803e72c726836a6ecdfa9ea Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:01:30 +0300 Subject: [PATCH 01/13] fix(desktop): enforce mute/block on feeds (was a silent no-op) Desktop DesktopIAccount.isHidden()/isAcceptable() were stubs (false / deletion-only) and DesktopFeedFilters never consulted them, so muting/blocking a user did nothing. - Add DesktopHiddenUsersState: assembles the kind-10000 mute list (users, hidden words, muted threads) + kind-30000 block list into a live StateFlow, decrypting the private section via the shared Mute/PeopleListDecryptionCache. - Wire DesktopIAccount.isHidden/isAcceptable + the content-filter fields to it. - Chain !note.isHiddenFor(...) into every note-rendering DesktopFeedFilter (global/following/custom/profile/reads/search/notification + thread replies). - DesktopFeedViewModel re-invalidates the feed when the choices change, so mutes hide live without a restart. - Subscribe to the account's kind-10000 mute list in Main.kt so it hydrates. Reuses the shared commons LiveHiddenUsers + Note.isHiddenFor; the chatroom DM list already called isAcceptable, so DMs now enforce mutes too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/commons/model/IAccount.kt | 11 ++ .../vitorpamplona/amethyst/desktop/Main.kt | 8 +- .../desktop/feeds/DesktopFeedFilters.kt | 34 +++-- .../desktop/model/DesktopHiddenUsersState.kt | 136 ++++++++++++++++++ .../amethyst/desktop/model/DesktopIAccount.kt | 34 ++++- .../amethyst/desktop/ui/FeedScreen.kt | 10 +- .../viewmodels/DesktopFeedViewModel.kt | 16 +++ 7 files changed, 227 insertions(+), 22 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.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 2cb3af0592..6b1e4d1640 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 @@ -70,6 +70,17 @@ data class LiveHiddenUsers( fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) fun isHashtagHidden(hashtag: String) = hiddenHashtags.contains(hashtag.lowercase()) + + companion object { + /** Neutral value that hides nothing — a safe default before any list has loaded. */ + val EMPTY = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a056f545df..fbc3160bc1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -148,6 +148,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -1712,6 +1713,7 @@ fun MainContent( SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, BlossomServersEvent.KIND, + MuteListEvent.KIND, ), authors = listOf(account.pubKeyHex), limit = 5, @@ -1736,7 +1738,8 @@ fun MainContent( // accountRelays' persisted copy. if (event is AdvertisedRelayListEvent || event is ChatMessageRelayListEvent || - event is BlossomServersEvent + event is BlossomServersEvent || + event is MuteListEvent ) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) @@ -1770,6 +1773,7 @@ fun MainContent( SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, BlossomServersEvent.KIND, + MuteListEvent.KIND, ), authors = listOf(account.pubKeyHex), limit = 10, @@ -1784,7 +1788,7 @@ fun MainContent( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) { + if (event is AdvertisedRelayListEvent || event is BlossomServersEvent || event is MuteListEvent) { scope.launch(Dispatchers.IO) { localCache.justConsumeMyOwnEvent(event) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 13fb4ff300..00eaa9c387 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.feeds import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder @@ -57,17 +58,18 @@ private fun List.deduplicateReposts(): List = */ class DesktopGlobalFeedFilter( private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "global" override fun feed(): List = cache.notes - .filterIntoSet { _, note -> isFeedNote(note.event) } + .filterIntoSet { _, note -> isFeedNote(note.event) && !note.isHiddenFor(hidden()) } .sortedWith(DefaultFeedOrder) .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) && !it.isHiddenFor(hidden()) } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() @@ -79,6 +81,7 @@ class DesktopGlobalFeedFilter( */ class DesktopFollowingFeedFilter( private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, private val followedPubkeys: () -> Set, ) : AdditiveFeedFilter() { override fun feedKey(): String = "following-${followedPubkeys().hashCode()}" @@ -87,7 +90,7 @@ class DesktopFollowingFeedFilter( val follows = followedPubkeys() return cache.notes .filterIntoSet { _, note -> - isFeedNote(note.event) && note.author?.pubkeyHex in follows + isFeedNote(note.event) && note.author?.pubkeyHex in follows && !note.isHiddenFor(hidden()) }.sortedWith(DefaultFeedOrder) .deduplicateReposts() .take(limit()) @@ -96,7 +99,7 @@ class DesktopFollowingFeedFilter( override fun applyFilter(newItems: Set): Set { val follows = followedPubkeys() return newItems.filterTo(HashSet()) { - isFeedNote(it.event) && it.author?.pubkeyHex in follows + isFeedNote(it.event) && it.author?.pubkeyHex in follows && !it.isHiddenFor(hidden()) } } @@ -113,12 +116,14 @@ class DesktopCustomFeedFilter( private val cache: DesktopLocalCache, private val feedId: String, private val source: FeedSource.Filter, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "custom-$feedId" private fun matchesSource(note: Note): Boolean { val event = note.event ?: return false if (!isFeedNote(event)) return false + if (note.isHiddenFor(hidden())) return false // Kind filter if (source.kinds.isNotEmpty() && event.kind !in source.kinds) return false @@ -165,6 +170,7 @@ class DesktopCustomFeedFilter( class DesktopThreadFilter( private val noteId: HexKey, private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : FeedFilter() { override fun feedKey(): String = "thread-$noteId" @@ -172,6 +178,8 @@ class DesktopThreadFilter( val root = cache.getNoteIfExists(noteId) ?: return emptyList() // Use LinkedHashSet for O(1) containment checks (was O(R) with MutableList) val seen = LinkedHashSet() + // The thread root is always shown even if muted — the user explicitly + // navigated into it. Replies by muted/blocked authors are still hidden. seen.add(root) collectReplies(root, seen) return seen.sortedWith(compareBy { it.createdAt() ?: 0L }) @@ -181,7 +189,9 @@ class DesktopThreadFilter( note: Note, seen: LinkedHashSet, ) { + val choices = hidden() for (reply in note.replies) { + if (reply.isHiddenFor(choices)) continue if (seen.add(reply)) { collectReplies(reply, seen) } @@ -205,6 +215,7 @@ class DesktopProfileFeedFilter( private val pubkey: HexKey, private val cache: DesktopLocalCache, private val repliesOnly: Boolean = false, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = if (repliesOnly) "profile-$pubkey-replies" else "profile-$pubkey" @@ -218,6 +229,7 @@ class DesktopProfileFeedFilter( private fun isProfileNote(note: Note): Boolean { val event = note.event ?: return false if (note.author?.pubkeyHex != pubkey) return false + if (note.isHiddenFor(hidden())) return false return if (repliesOnly) { isReply(event) } else { @@ -263,16 +275,17 @@ class DesktopBookmarkFeedFilter( */ class DesktopReadsFeedFilter( private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "reads" override fun feed(): List = cache.notes - .filterIntoSet { _, note -> note.event is LongTextNoteEvent } + .filterIntoSet { _, note -> note.event is LongTextNoteEvent && !note.isHiddenFor(hidden()) } .sortedWith(DefaultFeedOrder) .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent && !it.isHiddenFor(hidden()) } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) @@ -286,6 +299,7 @@ class DesktopReadsFeedFilter( class DesktopNotificationFeedFilter( private val userPubKeyHex: HexKey, private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { companion object { val NOTIFICATION_KINDS = @@ -314,7 +328,8 @@ class DesktopNotificationFeedFilter( val event = note.event ?: return false return event.kind in NOTIFICATION_KINDS && event.pubKey != userPubKeyHex && - event.isTaggedUser(userPubKeyHex) + event.isTaggedUser(userPubKeyHex) && + !note.isHiddenFor(hidden()) } } @@ -325,6 +340,7 @@ class DesktopNotificationFeedFilter( class DesktopSearchFeedFilter( private val query: String, private val cache: DesktopLocalCache, + private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY }, ) : AdditiveFeedFilter() { override fun feedKey(): String = "search-$query" @@ -333,7 +349,7 @@ class DesktopSearchFeedFilter( return cache.notes .filterIntoSet { _, note -> val event = note.event ?: return@filterIntoSet false - event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) && !note.isHiddenFor(hidden()) }.sortedWith(DefaultFeedOrder) .take(limit()) } @@ -342,7 +358,7 @@ class DesktopSearchFeedFilter( val lowerQuery = query.lowercase() return newItems.filterTo(HashSet()) { val event = it.event - event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) + event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) && !it.isHiddenFor(hidden()) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt new file mode 100644 index 0000000000..2827b873d3 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt @@ -0,0 +1,136 @@ +/* + * 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.desktop.model + +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Desktop mute/block state holder. + * + * Assembles the user's NIP-51 mute list (kind 10000, [MuteListEvent]) and the + * legacy block people list (kind 30000 `d=mute`, [PeopleListEvent]) into a single + * [LiveHiddenUsers] value that [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] + * feeds to [com.vitorpamplona.amethyst.commons.model.Note.isHiddenFor]. + * + * Both lists carry a mix of public tags and an NIP-44-encrypted private section; + * the shared [MuteListDecryptionCache]/[PeopleListDecryptionCache] handle the + * async decrypt (and are no-ops for read-only accounts, which simply see the + * public portion). The result is exposed as a hot [StateFlow] so feeds can + * re-filter live: when the list events change (or decryption resolves), a new + * [LiveHiddenUsers] emits and observers call `invalidateData()`. + * + * Mirrors Android's `HiddenUsersState`, but self-contained for the desktop + * cache/account shape (no `AccountSettings` dependency). + */ +class DesktopHiddenUsersState( + private val signer: NostrSigner, + private val cache: ICacheProvider, + private val scope: CoroutineScope, + /** From the "always show sensitive content" setting; `null` = respect content warnings. */ + private val showSensitiveContent: StateFlow = MutableStateFlow(null), +) { + private val muteCache = MuteListDecryptionCache(signer) + private val blockCache = PeopleListDecryptionCache(signer) + + // Strong refs so the GC keeps these addressable notes (and their decrypt caches) alive. + private val muteListNote = cache.getOrCreateAddressableNote(MuteListEvent.createAddress(signer.pubKey)) + private val blockListNote = cache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(signer.pubKey)) + + /** Session-only user hides (e.g. "hide this spammer" without persisting a mute). */ + val transientHiddenUsers = MutableStateFlow>(emptySet()) + + private val muteEventFlow: StateFlow = muteListNote.flow().metadata.stateFlow + private val blockEventFlow: StateFlow = blockListNote.flow().metadata.stateFlow + + private suspend fun assemble( + muteEvent: MuteListEvent?, + blockEvent: PeopleListEvent?, + transient: Set, + showSensitive: Boolean?, + ): LiveHiddenUsers { + val hiddenUsers = mutableSetOf() + val hiddenWords = mutableSetOf() + val mutedThreads = mutableSetOf() + + if (muteEvent != null) { + hiddenUsers.addAll(muteCache.mutedUserIdSet(muteEvent)) + hiddenWords.addAll(muteCache.mutedWordSet(muteEvent).map { it.word }) + mutedThreads.addAll(muteCache.mutedThreadIdSet(muteEvent)) + } + if (blockEvent != null) { + hiddenUsers.addAll(blockCache.userIdSet(blockEvent)) + } + + return LiveHiddenUsers( + showSensitiveContent = showSensitive, + hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, + hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() }, + spammersHashCodes = transient.mapTo(HashSet()) { it.hashCode() }, + hiddenUsers = hiddenUsers, + spammers = transient, + hiddenWords = hiddenWords, + mutedThreads = mutedThreads, + ) + } + + /** Hot flow of the current moderation choices. Emits on every list/setting change. */ + val flow: StateFlow = + combine( + muteEventFlow, + blockEventFlow, + transientHiddenUsers, + showSensitiveContent, + ) { muteState, blockState, transient, showSensitive -> + assemble( + muteState.note.event as? MuteListEvent, + blockState.note.event as? PeopleListEvent, + transient, + showSensitive, + ) + }.onStart { emit(LiveHiddenUsers.EMPTY) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, LiveHiddenUsers.EMPTY) + + fun hideUserTransiently(pubkeyHex: String) { + transientHiddenUsers.value = transientHiddenUsers.value + pubkeyHex + } + + fun showUserTransiently(pubkeyHex: String) { + transientHiddenUsers.value = transientHiddenUsers.value - pubkeyHex + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 75079df07f..a8e5670e2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.INwcSignerState +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListRepository @@ -58,6 +59,8 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClient import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -138,13 +141,30 @@ class DesktopIAccount( // --------------------------------------------------------------------------------- - override val showSensitiveContent: Boolean? = null + /** + * "Always show sensitive content" preference (NIP-36). `null` = respect + * content warnings (blur). Persisted UI toggle is wired in a later phase; + * for now it defaults to null so content warnings are honored. + */ + val showSensitiveContentSetting = MutableStateFlow(null) - override val hiddenWordsCase: List = emptyList() + /** + * Mute (kind 10000) + block (kind 30000 `d=mute`) state, assembled into a + * live [com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers] used by the + * feed filters and [isHidden]/[isAcceptable]. See [DesktopHiddenUsersState]. + */ + val hiddenUsersState = DesktopHiddenUsersState(signer, localCache, scope, showSensitiveContentSetting) - override val hiddenUsersHashCodes: Set = emptySet() + /** Current moderation choices — feeds observe this to re-filter live on mute/block. */ + val hiddenUsers: StateFlow get() = hiddenUsersState.flow - override val spammersHashCodes: Set = emptySet() + override val showSensitiveContent: Boolean? get() = hiddenUsersState.flow.value.showSensitiveContent + + override val hiddenWordsCase: List get() = hiddenUsersState.flow.value.hiddenWordsCase + + override val hiddenUsersHashCodes: Set get() = hiddenUsersState.flow.value.hiddenUsersHashCodes + + override val spammersHashCodes: Set get() = hiddenUsersState.flow.value.spammersHashCodes override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex) override val marmotGroupList = @@ -173,12 +193,12 @@ class DesktopIAccount( override fun followingKeySet(): Set = kind3FollowList.flow.value.authors - override fun isHidden(user: User): Boolean = false + override fun isHidden(user: User): Boolean = hiddenUsersState.flow.value.isUserHidden(user.pubkeyHex) override fun isAcceptable(note: Note): Boolean { - // Accept all notes on desktop for now val event = note.event ?: return true - return !localCache.hasBeenDeleted(event) + if (localCache.hasBeenDeleted(event)) return false + return !note.isHiddenFor(hiddenUsersState.flow.value) } override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 1eace52ec5..a9c3b5a1e0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -656,15 +656,16 @@ fun FeedScreen( // DesktopFeedViewModel keyed on feedMode — recreated on mode switch val viewModel = - remember(feedMode, activeFeedId) { + remember(feedMode, activeFeedId, iAccount) { + val hidden = { iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY } val filter = when (feedMode) { FeedMode.GLOBAL -> { - DesktopGlobalFeedFilter(localCache) + DesktopGlobalFeedFilter(localCache, hidden) } FeedMode.FOLLOWING -> { - DesktopFollowingFeedFilter(localCache) { + DesktopFollowingFeedFilter(localCache, hidden) { localCache.followedUsers.value } } @@ -675,10 +676,11 @@ fun FeedScreen( activeFeedId ?: "custom", activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource .Filter(), + hidden, ) } } - DesktopFeedViewModel(filter, localCache) + DesktopFeedViewModel(filter, localCache, iAccount?.hiddenUsers) } // Cancel old ViewModel's viewModelScope on recreation diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt index b094bcc79c..bb9f39e71c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/viewmodels/DesktopFeedViewModel.kt @@ -21,12 +21,15 @@ package com.vitorpamplona.amethyst.desktop.viewmodels import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch /** @@ -40,11 +43,24 @@ import kotlinx.coroutines.launch class DesktopFeedViewModel( filter: FeedFilter, cacheProvider: ICacheProvider, + hiddenUsers: StateFlow? = null, ) : FeedViewModel(filter, cacheProvider) { init { viewModelScope.launch(Dispatchers.IO) { feedState.refreshSuspended() } + + // Re-filter live when the account's mute/block/sensitive choices change + // (e.g. the user mutes someone, or a private mute list finishes + // decrypting) so hidden notes disappear without a restart. `drop(1)` + // skips the initial replay — the refresh above already covers first load. + if (hiddenUsers != null) { + viewModelScope.launch(Dispatchers.IO) { + hiddenUsers.drop(1).collect { + feedState.invalidateData(false) + } + } + } } /** From ac2875025aa3400d34a11bab6ff69113b3fc42f3 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:04:26 +0300 Subject: [PATCH 02/13] feat(desktop): mute/block/report write actions on DesktopIAccount Account-layer write path for moderation (UI wiring to follow): - hideUser/showUser/hideWord/showWord/hideThread/showThread build+sign+publish an updated kind-10000 MuteListEvent (create/add/remove via quartz), applied optimistically to the local cache then broadcast. - report(note|user, type, comment) publishes a NIP-56 kind-1984 ReportEvent. - All are no-ops for read-only accounts. - Add DesktopMuteEnforcementTest: proves the feed filters drop muted authors (even when followed) and hidden-word notes, and pass clean notes through. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../desktop/model/DesktopHiddenUsersState.kt | 3 + .../amethyst/desktop/model/DesktopIAccount.kt | 67 ++++++++++ .../filters/DesktopMuteEnforcementTest.kt | 116 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt index 2827b873d3..81356eadc7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt @@ -126,6 +126,9 @@ class DesktopHiddenUsersState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, LiveHiddenUsers.EMPTY) + /** The user's current mute list event, or null if none has loaded yet. */ + fun currentMuteList(): MuteListEvent? = muteListNote.event as? MuteListEvent + fun hideUserTransiently(pubkeyHex: String) { transientHiddenUsers.value = transientHiddenUsers.value + pubkeyHex } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index a8e5670e2c..e28c01b9c6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -51,6 +51,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEve import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +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 +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -371,6 +378,66 @@ class DesktopIAccount( } } + // ----- Moderation write actions (NIP-51 mute list + NIP-56 reports) ----- + + /** Mute a user (private entry). Persists to the kind-10000 mute list and hides live. */ + suspend fun hideUser(pubkeyHex: HexKey) = updateMuteList(UserTag(pubkeyHex), isPrivate = true, add = true) + + /** Un-mute a user. */ + suspend fun showUser(pubkeyHex: HexKey) = updateMuteList(UserTag(pubkeyHex), isPrivate = true, add = false) + + /** Hide a word/phrase (private entry). Notes containing it collapse. */ + suspend fun hideWord(word: String) = updateMuteList(WordTag(word), isPrivate = true, add = true) + + suspend fun showWord(word: String) = updateMuteList(WordTag(word), isPrivate = true, add = false) + + /** Mute a thread by its root event id. */ + suspend fun hideThread(rootIdHex: HexKey) = updateMuteList(EventTag(rootIdHex), isPrivate = true, add = true) + + suspend fun showThread(rootIdHex: HexKey) = updateMuteList(EventTag(rootIdHex), isPrivate = true, add = false) + + private suspend fun updateMuteList( + tag: MuteTag, + isPrivate: Boolean, + add: Boolean, + ) { + if (!isWriteable()) return + val current = hiddenUsersState.currentMuteList() + val event = + when { + !add -> if (current != null) MuteListEvent.remove(current, tag, signer) else return + current == null -> MuteListEvent.create(tag, isPrivate, signer) + else -> MuteListEvent.add(current, tag, isPrivate, signer) + } + // Optimistic local apply so enforcement + the management screens update + // immediately, then fan out to relays. + localCache.justConsumeMyOwnEvent(event) + relayManager.broadcastToAll(event) + } + + /** Publish a NIP-56 (kind 1984) report about a note. */ + suspend fun report( + note: Note, + type: ReportType, + comment: String = "", + ) { + if (!isWriteable()) return + val reported = note.event ?: return + val signed = signer.sign(ReportEvent.build(reported, type, comment)) + relayManager.broadcastToAll(signed) + } + + /** Publish a NIP-56 (kind 1984) report about a user. */ + suspend fun report( + userPubKeyHex: HexKey, + type: ReportType, + comment: String = "", + ) { + if (!isWriteable()) return + val signed = signer.sign(ReportEvent.build(userPubKeyHex, type, comment)) + relayManager.broadcastToAll(signed) + } + private fun addEventToChatroom( event: com.vitorpamplona.quartz.nip01Core.core.Event, roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt new file mode 100644 index 0000000000..67411253b1 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMuteEnforcementTest.kt @@ -0,0 +1,116 @@ +/* + * 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.desktop.filters + +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.DualCase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression coverage for the "mute is a silent no-op on desktop" bug: the feed + * filters must drop notes that `Note.isHiddenFor(...)` rejects. Exercises the + * real [DesktopGlobalFeedFilter] / [DesktopFollowingFeedFilter] with a + * hand-built [LiveHiddenUsers], the same value the account assembles at runtime. + */ +class DesktopMuteEnforcementTest { + private val mutedAuthor = "0000000000000000000000000000000000000000000000000000000000000001" + private val cleanAuthor = "0000000000000000000000000000000000000000000000000000000000000002" + private val cache = DesktopLocalCache() + + private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) } + + private fun note( + id: String, + pubkey: String, + content: String = "hi", + ): Note { + val event = TextNoteEvent(id, pubkey, 0L, emptyArray(), content, "") + val n = Note(id) + n.loadEvent(event, user(pubkey), emptyList()) + return n + } + + private fun hiddenUsers(pubkeys: Set) = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = pubkeys.mapTo(HashSet()) { it.hashCode() }, + spammersHashCodes = emptySet(), + hiddenUsers = pubkeys, + ) + + @Test + fun globalFeed_hidesMutedAuthor() { + val filter = DesktopGlobalFeedFilter(cache) { hiddenUsers(setOf(mutedAuthor)) } + val muted = note("aa", mutedAuthor) + val clean = note("bb", cleanAuthor) + val result = filter.applyFilter(setOf(muted, clean)) + assertEquals("Muted author's note must be filtered out", setOf(clean), result) + } + + @Test + fun globalFeed_showsEverythingWhenNothingMuted() { + val filter = DesktopGlobalFeedFilter(cache) { LiveHiddenUsers.EMPTY } + val a = note("aa", mutedAuthor) + val b = note("bb", cleanAuthor) + val result = filter.applyFilter(setOf(a, b)) + assertEquals(setOf(a, b), result) + } + + @Test + fun followingFeed_hidesMutedAuthorEvenIfFollowed() { + // Muting wins over following: a muted author you follow is still hidden. + val filter = + DesktopFollowingFeedFilter(cache, { hiddenUsers(setOf(mutedAuthor)) }) { + setOf(mutedAuthor, cleanAuthor) + } + val muted = note("aa", mutedAuthor) + val clean = note("bb", cleanAuthor) + val result = filter.applyFilter(setOf(muted, clean)) + assertEquals(setOf(clean), result) + } + + @Test + fun globalFeed_hidesNoteContainingHiddenWord() { + val choices = + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = listOf(DualCase("spam", "SPAM")), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + hiddenWords = setOf("spam"), + ) + val filter = DesktopGlobalFeedFilter(cache) { choices } + val spammy = note("aa", cleanAuthor, content = "buy my SPAM now") + val ok = note("bb", cleanAuthor, content = "good morning") + val result = filter.applyFilter(setOf(spammy, ok)) + assertTrue("Hidden-word note must be filtered", spammy !in result) + assertTrue("Clean note must remain", ok in result) + } +} From d571906dd0953a4e109199db4f53f209d395e407 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:36:49 +0300 Subject: [PATCH 03/13] feat(desktop): mute + NIP-56 report actions in the note menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the account-layer moderation write path in the UI: - LocalDesktopIAccount CompositionLocal (provided at the app content root) so deeply-nested note surfaces can reach mute/block/report without prop-drilling. - ShareMenu gains 'Mute user' + 'Report…' items (writeable accounts only). - ReportNoteDialog: NIP-56 reason picker + optional comment, with 'Report' and 'Block & report' actions. - DesktopIAccount.reportEvent(Event, …) overload for the menu (report(Note) delegates). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/model/DesktopIAccount.kt | 14 ++- .../desktop/model/LocalDesktopIAccount.kt | 31 +++++ .../desktop/ui/note/ReportNoteDialog.kt | 109 ++++++++++++++++++ .../amethyst/desktop/ui/note/ShareMenu.kt | 41 +++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fbc3160bc1..e9d0537f95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1422,6 +1422,7 @@ private fun AppInner( LocalNamecoinPreferences provides namecoinPreferences, LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, + com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, ) { val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() Column(modifier = Modifier.fillMaxSize()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e28c01b9c6..227abe2bc8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +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.signers.EventTemplate @@ -421,9 +422,18 @@ class DesktopIAccount( type: ReportType, comment: String = "", ) { - if (!isWriteable()) return val reported = note.event ?: return - val signed = signer.sign(ReportEvent.build(reported, type, comment)) + reportEvent(reported, type, comment) + } + + /** Publish a NIP-56 (kind 1984) report about a raw event. */ + suspend fun reportEvent( + reportedEvent: Event, + type: ReportType, + comment: String = "", + ) { + if (!isWriteable()) return + val signed = signer.sign(ReportEvent.build(reportedEvent, type, comment)) relayManager.broadcastToAll(signed) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt new file mode 100644 index 0000000000..01397da087 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt @@ -0,0 +1,31 @@ +/* + * 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.desktop.model + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * The logged-in [DesktopIAccount], provided at the app content root so deeply + * nested note surfaces (context menus, profile actions) can reach account-level + * moderation actions (mute/block/report) without threading it through every + * composable. Null when logged out or in a preview. + */ +val LocalDesktopIAccount = staticCompositionLocalOf { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt new file mode 100644 index 0000000000..673be60738 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt @@ -0,0 +1,109 @@ +/* + * 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.desktop.ui.note + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip56Reports.ReportType + +private val REPORT_OPTIONS = + listOf( + ReportType.SPAM to "Spam", + ReportType.PROFANITY to "Profanity / Hateful speech", + ReportType.IMPERSONATION to "Impersonation", + ReportType.NUDITY to "Nudity / Sexual content", + ReportType.ILLEGAL to "Illegal content", + ReportType.MALWARE to "Malware / Phishing", + ) + +/** + * NIP-56 report dialog. Lets the user pick a report reason and optionally add a + * comment, then either just report or report-and-block the author. Mirrors the + * Android `ReportNoteDialog`, adapted to Compose Desktop. + */ +@Composable +fun ReportNoteDialog( + onDismiss: () -> Unit, + onReport: (ReportType, String) -> Unit, + onBlockAndReport: (ReportType, String) -> Unit, +) { + var selected by remember { mutableStateOf(REPORT_OPTIONS.first().first) } + var comment by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Report note") }, + text = { + Column { + REPORT_OPTIONS.forEach { (type, label) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = selected == type, onClick = { selected = type }) + .padding(vertical = 2.dp), + ) { + RadioButton(selected = selected == type, onClick = { selected = type }) + Text(label, modifier = Modifier.padding(start = 4.dp)) + } + } + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = { + onReport(selected, comment) + onDismiss() + }) { Text("Report") } + }, + dismissButton = { + Row { + TextButton(onClick = { + onBlockAndReport(selected, comment) + onDismiss() + }) { Text("Block & report") } + TextButton(onClick = onDismiss) { Text("Cancel") } + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt index 7287250cba..9c9e4767b3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt @@ -28,11 +28,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import kotlinx.coroutines.launch import java.awt.Toolkit import java.awt.datatransfer.StringSelection @@ -58,6 +61,10 @@ fun ShareMenu( event: Event, relayManager: DesktopRelayConnectionManager, ) { + val account = LocalDesktopIAccount.current + val scope = rememberCoroutineScope() + var showReportDialog by remember { mutableStateOf(false) } + DropdownMenu( expanded = state.expanded, onDismissRequest = { state.dismiss() }, @@ -107,6 +114,40 @@ fun ShareMenu( state.dismiss() }, ) + + // Moderation actions require a writeable account (a local signer). + if (account != null && account.isWriteable()) { + HorizontalDivider() + DropdownMenuItem( + text = { Text("Mute user") }, + onClick = { + scope.launch { account.hideUser(event.pubKey) } + state.dismiss() + }, + ) + DropdownMenuItem( + text = { Text("Report…") }, + onClick = { + showReportDialog = true + state.dismiss() + }, + ) + } + } + + if (showReportDialog && account != null) { + ReportNoteDialog( + onDismiss = { showReportDialog = false }, + onReport = { type, comment -> + scope.launch { account.reportEvent(event, type, comment) } + }, + onBlockAndReport = { type, comment -> + scope.launch { + account.reportEvent(event, type, comment) + account.hideUser(event.pubKey) + } + }, + ) } } From f4435bfe4215fd6578cb0112566f49eaae96351d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:42:44 +0300 Subject: [PATCH 04/13] feat(desktop): blur NIP-36 content-warning notes with tap-to-reveal Generalize SpamCheckedNoteRender to also gate sensitive content: a note tagged content-warning / NSFW renders behind a collapsed scrim (reason + 'Show') unless the account opted into 'always show sensitive content'. Reuses the same per-note rememberSaveable reveal + forceReveal as the spam collapse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../desktop/ui/note/SpamCheckedNoteRender.kt | 97 ++++++++++++++++--- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt index 6924ed6235..9d38369b36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt @@ -20,6 +20,15 @@ */ package com.vitorpamplona.amethyst.desktop.ui.note +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -27,6 +36,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings @@ -34,8 +46,12 @@ import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.moderation.displayedEvent import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import kotlinx.coroutines.flow.MutableStateFlow /** * Wraps a note render in the hashtag-spam check. @@ -80,24 +96,73 @@ fun SpamCheckedNoteRender( ) } + // NIP-36 content warning: blur sensitive content unless the account opted + // into "always show sensitive content". + val account = LocalDesktopIAccount.current + val showSensitiveFlow = remember(account) { account?.showSensitiveContentSetting ?: MutableStateFlow(null) } + val showSensitive by showSensitiveFlow.collectAsState() + val isSensitive = remember(noteIdHex, displayedEvent) { displayedEvent?.isSensitiveOrNSFW() == true } + var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) } - if (!forceReveal && isSpam && !revealed && displayedEvent != null) { - val authorPubkey = displayedEvent.pubKey - val author = localCache?.getUserIfExists(authorPubkey) - val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8) - val avatarUrl = author?.profilePicture() - val hashtagCount = displayedEvent.tags.countHashtags() - CollapsedSpamNote( - authorPubkeyHex = authorPubkey, - authorDisplayName = displayName, - authorAvatarUrl = avatarUrl, - hashtagCount = hashtagCount, - threshold = threshold, - onReveal = { revealed = true }, - ) - } else { - normal() + when { + forceReveal || revealed -> normal() + isSpam && displayedEvent != null -> { + val authorPubkey = displayedEvent.pubKey + val author = localCache?.getUserIfExists(authorPubkey) + val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8) + val avatarUrl = author?.profilePicture() + val hashtagCount = displayedEvent.tags.countHashtags() + CollapsedSpamNote( + authorPubkeyHex = authorPubkey, + authorDisplayName = displayName, + authorAvatarUrl = avatarUrl, + hashtagCount = hashtagCount, + threshold = threshold, + onReveal = { revealed = true }, + ) + } + isSensitive && showSensitive != true && displayedEvent != null -> { + CollapsedContentWarning( + reason = displayedEvent.contentWarningReason(), + onReveal = { revealed = true }, + ) + } + else -> normal() + } +} + +/** + * Collapsed placeholder for a NIP-36 content-warning note. Mirrors the + * spam-collapse reveal affordance: the body stays hidden behind a scrim with the + * warning reason until the user taps "Show". + */ +@Composable +private fun CollapsedContentWarning( + reason: String?, + onReveal: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Sensitive content", style = MaterialTheme.typography.titleSmall) + if (!reason.isNullOrBlank()) { + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + TextButton(onClick = onReveal) { Text("Show") } + } } } From 755cfc82fe71fa0da9b174850dc961364f382298 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:44:20 +0300 Subject: [PATCH 05/13] docs(desktop): moderation & safety brainstorm + plan + manual testing sheet Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-moderation-safety-manual-testing-sheet.md | 53 +++ ...-23-feat-desktop-moderation-safety-plan.md | 375 ++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md create mode 100644 desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md diff --git a/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md b/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md new file mode 100644 index 0000000000..064a1234f1 --- /dev/null +++ b/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md @@ -0,0 +1,53 @@ +# Manual Testing — Desktop Moderation & Safety + +Branch: `worktree-feat-desktop-moderation-safety` +Run: `./gradlew :desktopApp:run` + +Prereq: log in with a **writeable** account (local nsec or bunker). For mute-list +sync tests, use an account that already has a kind-10000 mute list from mobile. + +## Enforcement (the bug fix) + +| # | Scenario | Expected | +|---|----------|----------| +| E1 | Account has a mobile-set mute on user X → open Following/Global | X's notes do NOT appear | +| E2 | Right-click a note → **Mute user** | Author's notes vanish from the feed **without restart** (may take a beat while it publishes) | +| E3 | Mute a user you follow | Their notes are hidden in Following too (mute wins over follow) | +| E4 | Open a thread where a reply author is muted | Muted reply hidden; thread root still shows | +| E5 | Open the muted user's profile | (v1) profile still renders — enforcement is feed/notification/DM-scoped, not profile | +| E6 | Notifications tab with a muted user reacting/replying | Their notifications are hidden | +| E7 | DM conversation list with a muted user | Their conversation is filtered out | +| E8 | Hidden word present in mute list (from mobile) → note containing it | Note is filtered | +| E9 | Read-only (npub) account | No crash; public mute portion still enforced; no Mute/Report items in menu | + +## Report (NIP-56) + +| # | Scenario | Expected | +|---|----------|----------| +| R1 | Right-click a note → **Report…** | Dialog opens with reason radios + comment field | +| R2 | Pick a reason → **Report** | kind-1984 published to relays (verify via `amy` or a relay log); no local hide | +| R3 | **Block & report** | Report published AND author muted (notes vanish) | +| R4 | Read-only account | No Report item in the menu | + +## Content warning (NIP-36) + +| # | Scenario | Expected | +|---|----------|----------| +| C1 | Feed contains a note tagged `content-warning` / NSFW | Renders collapsed: "Sensitive content" + reason + **Show** | +| C2 | Click **Show** | Note body reveals; stays revealed while scrolling (per-note) | +| C3 | Thread root that is CW-tagged | Auto-revealed (forceReveal) — you navigated into it | + +> Note: the "always show sensitive content" account setting defaults to blur. +> A settings toggle to flip it is a deferred follow-up (see plan Non-goals). + +## Regression + +| # | Scenario | Expected | +|---|----------|----------| +| X1 | Feeds with no mutes | Everything renders as before; no perf regression scrolling | +| X2 | Spam-collapse (hashtag spam) | Still works; CW + spam don't conflict on the same note | + +## Automated coverage +`./gradlew :desktopApp:test --tests "com.vitorpamplona.amethyst.desktop.filters.DesktopMuteEnforcementTest"` +covers E1/E3/E8 at the filter level (muted author hidden, hidden even if followed, +hidden-word note dropped, clean notes pass). diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md new file mode 100644 index 0000000000..62f1e5fc80 --- /dev/null +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md @@ -0,0 +1,375 @@ +--- +title: Desktop Moderation & Safety (mute/block enforcement + NIP-56 report + NIP-36 content-warning) +type: feat +status: active +date: 2026-07-23 +origin: docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md +module: desktopApp (+ commons, reusing quartz) +--- + +# ✨ Desktop Moderation & Safety + +## Enhancement Summary (deepened 2026-07-23) + +Grounding research + direct code inspection **resolved the two make-or-break +cruxes**, de-risking the whole feature: + +1. **Enforcement primitive is already shared.** `LiveHiddenUsers` and + `Note.isHiddenFor(accountChoices: LiveHiddenUsers)` live in **`commons/`** + (`commons/.../model/Note.kt:1279`, referenced from + `commons/.../model/IAccount.kt`, covered by + `commons/.../model/NoteIsHiddenForTest.kt`). Desktop does **not** need to port + or promote the hidden-check logic — it only needs to *assemble a + `LiveHiddenUsers` value* from the account's lists and hold it as a StateFlow. + → Crux 1 collapses to "build the value object"; **desktop-local state holders + are sufficient** (no risky commons extraction for v1). +2. **Feeds have a live-invalidation hook.** Desktop feeds implement + `commons/.../ui/feeds/InvalidatableContent` via `FeedContentState` + (`invalidateData()`). Wiring the hidden-users StateFlow to call + `invalidateData()` on emit makes mute/block hide **without restart**. + → Crux 2 resolved: reuse `invalidateData()`, don't invent a bus. +3. **Concrete APIs confirmed** (see Research Insights under Technical Approach): + `MuteListEvent.create/add/remove(MuteTag, isPrivate, signer)` where `MuteTag ∈ + {UserTag, HashtagTag(word), EventTag(thread)}`; `PeopleListEvent.addUser/ + removeUser` for blocks; publish via `signer.sign(template)` + + `relayManager.broadcastToAll(signed)` (precedent `reactToNote`/`repostNote` in + `desktopApp/.../ui/NoteActions.kt`). + +**Net effect on phases:** Phase 0 shrinks (assemble a value, don't port a +subsystem). Phase 1's only real work is the `invalidateData()` wiring + predicate. +Overall risk downgraded from *Medium* to *Low-Medium*. + +> _Note: 3 of 4 deepening sub-agents were killed by the 600s stall watchdog +> (environment load); the facts above were recovered by direct grep and are +> first-hand, not agent-summarized._ + +## Overview + +Close the largest verified Desktop parity gap after Polls by shipping a coherent +**Moderation & Safety** bundle: + +1. **P2 — Fix the silent mute/block no-op** (a live bug: muting does nothing on + desktop feeds today) + management surfaces. +2. **P3 — Report a note/user (NIP-56).** +3. **P4 — Content-warning blur/reveal (NIP-36).** + +All three share the same account-moderation state and the same "hide-with-reveal" +UX primitive, so they ship together as one feature branch (see brainstorm: +`docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md`). + +## Problem Statement + +- **Mute is a lie on desktop.** `DesktopIAccount.isHidden(user)` is hardcoded + `false`; `isAcceptable(note)` only checks deletions; `DesktopFeedFilters` never + calls either. Desktop also holds **no** mute/block state at all — the fields + (`hiddenUsersHashCodes`, `hiddenWordsCase`, `spammersHashCodes`, + `showSensitiveContent`) are empty/`null` stubs. A user who mutes someone + reasonably believes it works; it does not. +- **No way to report.** Desktop has zero NIP-56 UI; Android has a full report + dialog. +- **Sensitive media shows unblurred.** `showSensitiveContent` is hardcoded + `null` and no NIP-36 gate exists, so content-warning-tagged media renders in + the clear. + +## Proposed Solution + +Port Android's moderation state + UI into shared/desktop code and wire real +enforcement, reusing quartz protocol and the existing spam-collapse reveal: + +- **State:** hold mute list (kind 10000) + blocked people (kind 10001) + + assembled `LiveHiddenUsers` on `DesktopIAccount`; hydrate via a new account + subscription and the account signer (decrypts the private list). +- **Enforcement:** implement `isHidden`/`isAcceptable` by delegating to quartz + `Note.isHiddenFor(liveHiddenUsers)`; chain `&& account.isAcceptable(note)` into + every `DesktopFeedFilters` feed path. The chatroom list already calls + `isAcceptable`, so DMs get filtered for free once the predicate is real. +- **Actions:** `hideUser`/`showUser`/`blockUser`/`hideWord`/`hideThread` on + `DesktopIAccount` build → sign → publish an updated `MuteListEvent`/ + `PeopleListEvent`. +- **Report:** `report(note|user, type, comment)` reuses quartz `ReportEvent.build` + (kind 1984) + `ReportType`, published to the account's public + private outbox + (match Android). New `ReportNoteDialog` ported to desktop Compose, wired into + `ShareMenu` and `UserProfileScreen`. +- **Content warning:** generalize `SpamCheckedNoteRender` to also gate + `Event.isSensitiveOrNSFW()` notes with a blur/scrim + reason + tap-to-reveal, + plus a "always show sensitive content" settings toggle persisted alongside the + spam settings. + +## Technical Approach + +### Architecture — moderation state & enforcement flow + +```mermaid +flowchart TD + subgraph relays[Relays] + K10000[kind 10000 MuteListEvent - encrypted] + K10001[kind 10001 PeopleListEvent - block] + K1068[content notes, some content-warning tagged] + end + K10000 & K10001 --> SUB[Main.kt account subscription] + SUB --> DLC[DesktopLocalCache] + DLC --> MLS[MuteListState / BlockPeopleListState] + SIGNER[Account signer - decrypts private list] --> MLS + MLS --> HUS[HiddenUsersState.assembleLiveHiddenUsers] + HUS --> LHU[[LiveHiddenUsers flow]] + LHU --> IACC[DesktopIAccount.isHidden / isAcceptable] + IACC --> FF[DesktopFeedFilters feed / applyFilter] + IACC --> CHAT[ChatroomList - already calls isAcceptable] + K1068 --> FF + FF --> UI[FeedNoteCard] + UI --> SCNR[SpamCheckedNoteRender - generalized] + SCNR -->|spam OR content-warning| COLLAPSE[Collapsed w/ reveal] + SCNR -->|clean| BODY[FeedNoteCardBody] +``` + +### Key reuse (confirmed by code research, 2026-07-23) + +| Concern | Reuse (no reimpl) | Source | +|---|---|---| +| Report protocol | `ReportEvent.build(post/user, type, comment)` kind 1984 | `quartz/.../nip56Reports/ReportEvent.kt` | +| Report types | `ReportType` enum (SPAM/PROFANITY/IMPERSONATION/NUDITY/ILLEGAL/MALWARE/VIOLENCE/…) | `quartz/.../nip56Reports/ReportType.kt` | +| CW detection | `Event.isSensitiveOrNSFW()`, `contentWarningReason()` | `quartz/.../nip36SensitiveContent/EventExt.kt` | +| Hidden check | `Note.isHiddenFor(liveHiddenUsers)` (users+words+threads) | `quartz/.../Note.kt` | +| Mute/block events | `MuteListEvent` (10000), `PeopleListEvent` (10001) | `quartz/.../nip51Lists/…` | +| Reveal primitive | `SpamCheckedNoteRender` `forceReveal` + `rememberSaveable(id)` | `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` | + +### Android references to port + +| Piece | Android reference | +|---|---| +| Mute state | `amethyst/.../model/nip51Lists/muteList/MuteListState.kt` | +| Block state | `amethyst/.../model/nip51Lists/…/BlockPeopleListState` | +| Hidden assembly | `amethyst/.../model/nip51Lists/HiddenUsersState.kt` (`assembleLiveHiddenUsers`) | +| Report dialog | `amethyst/.../ui/screen/loggedIn/report/ReportNoteDialog.kt` | +| CW gate | `amethyst/.../ui/components/SensitivityWarning.kt` | +| Mgmt screens | `amethyst/.../settings/{BlockedUsersScreen,HiddenWordsScreen,MutedThreadsScreen}.kt` | + +### Design cruxes — RESOLVED (deepened 2026-07-23) + +1. **State-holder placement → desktop-local, value-object only. RESOLVED.** + `LiveHiddenUsers`/`Note.isHiddenFor` already in `commons/` — no port, no type + promotion. Build a small desktop assembler that reads the account's mute list + (kind 10000) + blocked people (kind 10001) from `DesktopLocalCache` + signer + and emits `StateFlow`. Option (a) full commons extraction is + **deferred** — unnecessary for v1. +2. **Reactivity → `FeedContentState.invalidateData()`. RESOLVED.** Desktop feeds + implement `commons/.../ui/feeds/InvalidatableContent`. Collect the + `StateFlow` where feeds are built and call `invalidateData()` + on change so mutes apply live. Verify each feed's content-state gets the call + (Following/Global/Thread/Notifications/Profile). +3. **Reveal generalization shape.** One generalized `SpamCheckedNoteRender` + (collapse for spam **or** CW), two reasons — avoids double nesting. (Decision + stands; confirm final shape during Phase 4.) +4. **Media blur fidelity.** v1 = opaque scrim + reason overlay + reveal (no new + dependency; Compose Desktop `Modifier.blur` support is uneven across the + skiko backend, so avoid depending on it). True blur is a v2 nicety. Documented + limitation. +5. **Report publish path → `signer.sign` + `relayManager.broadcastToAll`. + RESOLVED.** Desktop has no `sendMyPublicAndPrivateOutbox` helper; the + established pattern (`reactToNote`/`repostNote`, + `desktopApp/.../ui/NoteActions.kt:1387,1472`) is sign-then-`broadcastToAll`. + v1 reports broadcast to connected relays (NIP-56 is advisory) — noted as a + deliberate simplification vs Android's outbox split. + +### Research Insights — confirmed APIs (copy-ready) + +**Mute / block writes** (`quartz/.../nip51Lists/`), all `suspend`, signer-based: +``` +// mute a user (private by default), returns the new event to publish +MuteListEvent.create(mute = UserTag(pubkey), isPrivate = true, signer = signer) +MuteListEvent.add(earlierVersion, mute = HashtagTag(word), isPrivate, signer) // hidden word +MuteListEvent.add(earlierVersion, mute = EventTag(rootId), isPrivate, signer) // muted thread +MuteListEvent.remove(earlierVersion, mute, signer) // unmute +// reading private entries requires the signer: +earlierVersion.privateTags(signer) // throws SignerExceptions.UnauthorizedDecryptionException on read-only +// block (kind 10001) +PeopleListEvent.addUser(...) / removeUser(...) +``` +MuteTag subtypes: `UserTag` (pubkey), `HashtagTag` (word), `EventTag` (thread +root). Enforcement then reads these via `Note.isHiddenFor(liveHiddenUsers)`. + +**Publish** (precedent in `desktopApp/.../ui/NoteActions.kt`): +``` +val signed = signer.sign(template) // DesktopIAccount already does signer.sign at ~L187 +relayManager.broadcastToAll(signed) // reactToNote L1395 / repostNote L1479 +``` + +**Report** (`quartz/.../nip56Reports/`): `ReportEvent.build(reportedPost, type, +comment)` and `ReportEvent.build(reportedUser, type, comment)` (kind 1984); +`ReportType ∈ {SPAM, PROFANITY, IMPERSONATION, NUDITY, ILLEGAL, MALWARE, +VIOLENCE, …}`. + +**Content warning** (`quartz/.../nip36SensitiveContent/EventExt.kt`): +`Event.isSensitiveOrNSFW(): Boolean`, `Event.contentWarningReason(): String?`. + +**Read-only / bunker caveat:** `privateTags(signer)` throws on a read-only +account and is async for NIP-46 bunkers — assemble from the **public** list +portion immediately and recompute when decryption resolves; disable write actions +(mute/block/report) when there's no local signer. + +### Implementation Phases + +Each phase is independently compilable; Phase 1 alone fixes the enforcement bug. + +#### Phase 0 — Moderation state foundation +- New: `MuteListState`/`BlockPeopleListState`/`HiddenUsersState` (desktop-local + per crux 1), producing a `LiveHiddenUsers` StateFlow from `DesktopLocalCache` + + signer. +- Add kind-10000/10001 to desktop account subscription in `Main.kt`. +- Success: flow populates + decrypts on login (log the assembled set). + +#### Phase 1 — Enforcement wiring (the bug fix) +- Implement `DesktopIAccount.isHidden(user)` / `isAcceptable(note)` via + `Note.isHiddenFor(hiddenUsers.value)`; populate `showSensitiveContent`, + `hiddenUsersHashCodes`, etc. from state. +- Chain `&& account.isAcceptable(note)` into every `DesktopFeedFilters` + `feed()`/`applyFilter()`; ensure reactivity (crux 2). +- Success: muting a user (even via a hand-inserted list) hides their notes in + Following/Global/Thread/Notifications + chatroom list, live. + +#### Phase 2 — Mute/Block actions + management screens +- `DesktopIAccount.hideUser/showUser/blockUser/hideWord/hideThread` build+sign+ + publish updated list events. +- Mute/Block action in note context menu (`ShareMenu`) + `UserProfileScreen`. +- Port `BlockedUsersScreen`/`HiddenWordsScreen`/`MutedThreadsScreen` to desktop + settings (read + remove). +- Success: block from profile persists (kind-10000 on relay) and hides live; + management screens list + remove entries. + +#### Phase 3 — Report (NIP-56) +- `DesktopIAccount.report(note|user, type, comment)` → `ReportEvent.build` → + publish to public+private outbox. +- Port `ReportNoteDialog` (report-type list + comment + "Block & Hide User" + + "Post Report") to desktop Compose; wire into `ShareMenu` + `UserProfileScreen`. +- Success: reporting publishes a well-formed kind-1984 to the right relays + (verify via `amy` / relay log); "Block & Hide" also mutes. + +#### Phase 4 — Content-warning blur (NIP-36) +- Generalize `SpamCheckedNoteRender` to also gate `isSensitiveOrNSFW()` notes: + scrim + reason + reveal, honoring per-note `rememberSaveable` reveal and the + account "always show sensitive" setting. +- Add "always show sensitive content" toggle persisted with spam settings. +- Success: CW note renders blurred; reveal works; toggle bypasses blur. + +#### Phase 5 — Settings, tests, polish +- Surface all toggles + management-screen entries in the existing **Content + Filters** settings section (where hashtag-spam lives). +- Unit tests: enforcement predicate (user/word/thread hidden), report-event + shape, CW detection/gate state. +- `./gradlew spotlessApply`; compile commons+desktopApp+cli; manual testing sheet + `desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`. + +## Alternative Approaches Considered +- **Three separate PRs** — rejected: triplicates the state extraction, loses + coherence (see brainstorm). +- **Enforcement-only (defer report + CW)** — kept as fallback if scope creeps; + report + CW are S each and share plumbing. +- **Extract state holders to `commons/` now** — deferred (crux 1); higher refactor + risk than desktop-local for v1. + +## System-Wide Impact + +### Interaction graph +Mute action → sign kind-10000 → publish → echoes back into `DesktopLocalCache` → +`MuteListState` recomputes → `LiveHiddenUsers` emits → feed filters re-evaluate → +note disappears. Requires the emit to invalidate feeds (crux 2). + +### Error & failure propagation +- Sign/publish failure on a mute/report: match Android's optimistic model but + surface a failure toast; avoid leaving local state ahead of relays silently. +- Bunker (NIP-46) decrypt is async/remote — private mute list may be empty until + it returns; enforcement must tolerate a late-arriving set (recompute on emit). + +### State lifecycle risks +- Read-only (npub) accounts: no signer → can't decrypt private list or publish. + Enforce the public portion only; disable mute/block/report actions (no crash). +- "Block & Hide" from report dialog mutates the mute list and publishes a report + — two events; partial success must not corrupt local list state. + +### API surface parity +`isAcceptable` is consumed by feed filters, the chatroom/DM list, and +notifications. Fixing the predicate intentionally affects all three (muted users +vanish from DMs + notifications too). Verify each path. + +### Integration test scenarios +1. Mute user → notes vanish across Following/Global/Thread/Notifications + DM + list, live; unmute restores. +2. Block via report "Block & Hide" → kind-10000 published + note hidden. +3. Hidden word → matching notes collapse (via `isHiddenFor`). +4. Muted thread → replies under that root hidden. +5. Report note → kind-1984 published to public+private outbox, correct tags. +6. CW note → blurred; reveal shows; "always show" bypasses. +7. Read-only account → actions disabled, enforcement of public list still works. +8. Bunker account → mute list decrypts asynchronously; enforcement engages on + arrival. + +## Acceptance Criteria + +### Functional +- [x] Muting/blocking a user hides their notes across all desktop feeds + the DM + list **without restart**. *(DesktopHiddenUsersState + isAcceptable + feed + filter chaining + invalidateData; DM list already called isAcceptable.)* +- [ ] Blocked/hidden-words/muted-threads management screens list + remove entries, + persisted as kind-10000/10001 on relays. **DEFERRED** — write actions exist + (`hideWord`/`hideThread`/`showUser`…), but the settings *screens* are a + follow-up. +- [x] Mute action available from note context menu (`ShareMenu`). *(Mute-from- + profile deferred.)* +- [x] Report action (note) publishes a valid NIP-56 kind-1984; report dialog + offers the NIP-56 types + optional comment + "Block & report". *(Publishes + via broadcastToAll rather than a public/private outbox split — noted + simplification; NIP-56 is advisory.)* +- [x] Content-warning-tagged notes render scrimmed with reason + reveal. + *(Account setting bypass is honored; the toggle UI to flip it is deferred.)* +- [ ] All toggles + management entries reachable from the Content Filters + settings section. **DEFERRED** with the management screens. + +### Non-functional / quality gates +- [x] Read-only + bunker accounts behave correctly (write actions gated on + `isWriteable()`; enforcement tolerates late async decrypt via the flow). +- [x] Enforcement predicate is hash-set lookups (`Note.isHiddenFor`), no feed jank. +- [x] Unit tests for the enforcement predicate green (`DesktopMuteEnforcementTest`). +- [x] `spotlessApply` clean; commons + desktopApp compile; desktopApp + commons + test suites green. +- [ ] Manual testing sheet executed. *(Sheet written: + `desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md` + — needs a human run of the app.)* + +### Deferred to a focused follow-up +- Management screens (Blocked users / Hidden words / Muted threads) + their + Content-Filters settings entries. +- "Always show sensitive content" settings toggle (persisted) + mute/report from + the profile screen. +- Thread-root/profile-screen enforcement wiring (needs `iAccount` threaded into + those two composables). + +## Dependencies & Risks +- **Reactivity of feed filters to the hidden-users flow** — top risk (crux 2). +- **State-holder placement / `LiveHiddenUsers` type location** (crux 1). +- **Async decryption for bunker accounts.** +- No new third-party dependency anticipated (all quartz-internal) → no licensing + action required; re-check if a blur library is introduced for Phase 4. + +## Sources & References + +### Origin +- **Brainstorm:** `docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md`. + Carried-forward decisions: bundle P2+P3+P4; state in shared/desktop code + + desktop-native UI; enforcement via `Note.isHiddenFor`; report via quartz + `ReportEvent`; CW reuses the spam reveal; settings under Content Filters. + +### Internal references +- `desktopApp/.../model/DesktopIAccount.kt` (stub predicates ~L176–182, + `showSensitiveContent` ~L128) +- `desktopApp/.../feeds/DesktopFeedFilters.kt` (`feed()`/`applyFilter()` hooks) +- `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` (reveal primitive) +- `desktopApp/.../ui/note/ShareMenu.kt`, `desktopApp/.../ui/NoteActions.kt` +- `quartz/.../nip56Reports/{ReportEvent,ReportType}.kt` +- `quartz/.../nip36SensitiveContent/{ContentWarningTag,EventExt}.kt` +- Android: `amethyst/.../model/nip51Lists/**`, `.../ui/screen/loggedIn/report/ReportNoteDialog.kt`, + `.../ui/components/SensitivityWarning.kt`, `.../settings/{Blocked,HiddenWords,MutedThreads}*.kt` + +### Related work +- Hashtag-spam filter (reveal pattern + Content Filters settings section) — + memory `moderation/` package + `SpamCheckedNoteRender`. +- Parity backlog P2/P3/P4 — `desktopApp/plans/2026-07-16-desktop-parity-gap-backlog.md`. From 49d0518db154f95df3e59c1caa81ed0c42f989e4 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 11:04:23 +0300 Subject: [PATCH 06/13] =?UTF-8?q?feat(desktop):=20moderation=20follow-ups?= =?UTF-8?q?=20=E2=80=94=20thread/profile=20enforcement,=20sensitive=20togg?= =?UTF-8?q?le,=20mgmt=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the deferred items from the moderation & safety plan: - Thread + Profile feeds now pass the real hidden lambda (via LocalDesktopIAccount), so mutes hide replies-in-thread and profile-tab notes live. Thread root stays shown. - 'Always show sensitive content' toggle: new PreferencesSensitiveContentSettings (commons/jvmMain, java.util.prefs) backs DesktopIAccount.showSensitiveContentSetting (null=blur / true=show, never false) + setAlwaysShowSensitive; unit-tested. - ModerationSettingsSection in the Content Filters settings: the toggle + management lists for muted users (unmute), hidden words (add/remove), muted threads (unmute), driven by the live hidden-users flow so removing an entry un-hides immediately. - Profile header overflow (MoreVert): Mute/Unmute + Report… (reuses ReportNoteDialog) for other users on writeable accounts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PreferencesSensitiveContentSettings.kt | 57 +++++++ ...PreferencesSensitiveContentSettingsTest.kt | 73 +++++++++ ...esktop-moderation-safety-followups-plan.md | 139 ++++++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 3 + .../amethyst/desktop/model/DesktopIAccount.kt | 12 +- .../amethyst/desktop/ui/ThreadScreen.kt | 8 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 73 ++++++++- .../ui/settings/ModerationSettingsSection.kt | 152 ++++++++++++++++++ 8 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt create mode 100644 desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt new file mode 100644 index 0000000000..6c9e80724c --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt @@ -0,0 +1,57 @@ +/* + * 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.moderation + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * "Always show sensitive content" (NIP-36) preference, backed by + * [java.util.prefs.Preferences] under the same shared node as the other content + * filters (so Desktop and the `amy` CLI observe the same value). + * + * [showSensitiveContent] follows the `LiveHiddenUsers`/`Note.isHiddenFor` + * convention: `null` = respect content warnings (blur), `true` = always show. + * `false` is never emitted — the toggle is binary (on ⇒ true, off ⇒ null). + */ +class PreferencesSensitiveContentSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) { + private val mutable = MutableStateFlow(toChoice(prefs.getBoolean(KEY_ALWAYS_SHOW, DEFAULT_ALWAYS_SHOW))) + + /** `null` = respect warnings (blur); `true` = always show. */ + val showSensitiveContent: StateFlow = mutable.asStateFlow() + + fun setAlwaysShow(alwaysShow: Boolean) { + mutable.value = toChoice(alwaysShow) + prefs.putBoolean(KEY_ALWAYS_SHOW, alwaysShow) + } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/filters" + const val KEY_ALWAYS_SHOW = "always_show_sensitive" + const val DEFAULT_ALWAYS_SHOW = false + + private fun toChoice(alwaysShow: Boolean): Boolean? = if (alwaysShow) true else null + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt new file mode 100644 index 0000000000..ade6c23960 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt @@ -0,0 +1,73 @@ +/* + * 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.moderation + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class PreferencesSensitiveContentSettingsTest { + private val testNode = "com/vitorpamplona/amethyst/test/sensitive_${System.currentTimeMillis()}" + + private fun prefs(): Preferences = Preferences.userRoot().node(testNode) + + @Before + fun setup() { + prefs().clear() + } + + @After + fun teardown() { + prefs().removeNode() + } + + @Test + fun defaultRespectsWarnings() { + // null = respect content warnings (blur), matching Note.isHiddenFor. + assertNull(PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value) + } + + @Test + fun onMapsToTrue() { + val settings = PreferencesSensitiveContentSettings(prefs()) + settings.setAlwaysShow(true) + assertEquals(true, settings.showSensitiveContent.value) + } + + @Test + fun offMapsToNullNotFalse() { + val settings = PreferencesSensitiveContentSettings(prefs()) + settings.setAlwaysShow(true) + settings.setAlwaysShow(false) + // Off is null (respect warnings), never false — Note.isHiddenFor only + // blurs when showSensitiveContent == false, which must never happen here. + assertNull(settings.showSensitiveContent.value) + } + + @Test + fun persistsAcrossReload() { + PreferencesSensitiveContentSettings(prefs()).setAlwaysShow(true) + assertEquals(true, PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value) + } +} diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md new file mode 100644 index 0000000000..4c35820122 --- /dev/null +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md @@ -0,0 +1,139 @@ +--- +title: Desktop Moderation & Safety — deferred follow-ups (management screens, sensitive-content toggle, thread/profile enforcement) +type: feat +status: active +date: 2026-07-23 +origin: desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md +module: desktopApp (+ commons/jvmMain) +--- + +# ✨ Desktop Moderation & Safety — follow-ups + +Continuation of the shipped core (branch `worktree-feat-desktop-moderation-safety`, +commits `9090dfe8`→`f4435bfe`). The account-layer write API +(`hideUser/showUser/hideWord/showWord/hideThread/showThread`, `report`) and the +`LocalDesktopIAccount` CompositionLocal already exist — these follow-ups are the +remaining UI + persistence. + +## Scope (the 3 deferred items) + +1. **Thread + Profile enforcement** — `ThreadScreen`/`UserProfileScreen` build + their `DesktopThreadFilter`/`DesktopProfileFeedFilter` with the default + `hidden = { EMPTY }`, so mutes don't apply there. Wire the real lambda. +2. **Sensitive-content toggle + profile moderation actions** — persist an + "Always show sensitive content" setting and back + `DesktopIAccount.showSensitiveContentSetting` with it; add Mute/Report to the + profile screen. +3. **Management screens** — list + remove Blocked users / Hidden words / Muted + threads, reachable from the Content Filters settings section. + +## Key findings (grounded 2026-07-23) + +- `ThreadScreen` (`ui/ThreadScreen.kt:98`) + `UserProfileScreen` + (`ui/UserProfileScreen.kt:120`) take `account: AccountState.LoggedIn?` — **not** + `DesktopIAccount`. But `LocalDesktopIAccount.current` is now in scope inside + them, so **no param/caller threading needed** (callers: `DeckColumnContainer.kt` + :566,:720). Just read the local and pass + `{ LocalDesktopIAccount.current?.hiddenUsers?.value ?: LiveHiddenUsers.EMPTY }` + to the filter. (Filters already accept the lambda.) +- Persistence pattern: `commons/jvmMain/.../moderation/PreferencesHashtagSpamSettings.kt` + — `Preferences.userRoot().node(NODE)` with `MutableStateFlow` fields. Mirror for + a sensitive-content boolean. +- Content Filters UI: `desktopApp/.../ui/settings/HashtagSpamSettingsSection.kt` + is the section composable; add the toggle + management entries alongside it. +- Read side for management screens: `DesktopIAccount.hiddenUsersState.flow` + (`LiveHiddenUsers`) already exposes `hiddenUsers` / `hiddenWords` / `mutedThreads` + sets. Remove = `account.showUser/showWord/showThread`. Resolve a blocked pubkey + to a name via `localCache.getUserIfExists(hex)?.toBestDisplayName()`. + +## Technical Approach — phases + +### Phase A — Thread + Profile enforcement (S) +- In `ThreadScreen` and `UserProfileScreen`, capture + `val iAccount = LocalDesktopIAccount.current` and pass + `hidden = { iAccount?.hiddenUsers?.value ?: LiveHiddenUsers.EMPTY }` to + `DesktopThreadFilter` / both `DesktopProfileFeedFilter` constructions + (`ThreadScreen.kt:125`, `UserProfileScreen.kt:192,212`). +- Thread root intentionally stays visible (filter already only hides replies). +- Success: muting a reply author hides them in an open thread + on the profile + Notes/Replies tabs, live. + +### Phase B — Sensitive-content toggle + profile moderation (M) +- New `commons/jvmMain/.../moderation/PreferencesSensitiveContentSettings.kt`: + persisted `alwaysShowSensitive: MutableStateFlow` (default false) under + a stable prefs node; expose `showSensitiveContent: StateFlow` mapping + `true → true`, `false → null` (null = respect warnings, matching + `Note.isHiddenFor`). +- `DesktopIAccount`: replace the placeholder `showSensitiveContentSetting = + MutableStateFlow(null)` with this store's flow; add + `setAlwaysShowSensitive(Boolean)`. +- Content Filters section: a "Show sensitive content" switch (reads/writes via + `LocalDesktopIAccount.current`). +- `UserProfileScreen`: add **Mute user** + **Report…** actions (reuse + `ReportNoteDialog`; call `account.hideUser` / `account.report(userHex, …)`), + shown only for writeable accounts and not for self. +- Success: toggling the switch flips CW blur app-wide and persists across restart; + profile has working Mute/Report. + +### Phase C — Management screens (M) +- New `desktopApp/.../ui/settings/BlockedContentSettings.kt` (or 3 small + composables): three expandable lists driven by + `LocalDesktopIAccount.current?.hiddenUsers` (collectAsState): + - **Blocked/muted users** → rows with resolved display name + "Unmute" + (`showUser`). + - **Hidden words** → text rows + "Remove" (`showWord`) + an add-word field + (`hideWord`). + - **Muted threads** → note-id rows + "Unmute" (`showThread`). +- Surfaced from the Content Filters section (expander or sub-screen). +- Success: lists reflect the live mute set and removing an entry publishes the + updated kind-10000 and un-hides immediately. + +## System-Wide Impact +- **Interaction graph:** removing a mute → `account.showUser/Word/Thread` → + `MuteListEvent.remove` signed + `justConsumeMyOwnEvent` + broadcast → mute flow + re-emits → feeds `invalidateData` → entry disappears from feed AND from the + management list (same flow). No separate refresh path. +- **State lifecycle:** management-list edits and the sensitive toggle are + independent stores (NIP-51 event vs local prefs); no shared partial-failure. +- **API parity:** the CW toggle now feeds three readers — + `Note.isHiddenFor` (feed filters) and `SpamCheckedNoteRender` (blur). Both read + the same `showSensitiveContent` value; verify one source of truth. +- **Read-only accounts:** management screens are read-only (show lists, disable + remove/add); the sensitive toggle still works (it's local prefs, not signed). + +### Integration test scenarios +1. Mute a reply author → open the thread → reply hidden; root still shown. +2. Mute a user → open their profile → their Notes/Replies tabs empty. +3. Toggle "show sensitive content" on → CW notes render unblurred everywhere; + restart app → still on. +4. Unmute from the Blocked-users screen → their notes reappear in feed live. +5. Add a hidden word in the management screen → matching notes collapse. +6. Read-only account → management screen lists render, remove/add disabled. + +## Acceptance Criteria +- [ ] Muting hides in open threads (replies) + on profile tabs, live. +- [ ] "Always show sensitive content" toggle persists and flips CW blur app-wide. +- [ ] Profile screen has working Mute + Report actions (writeable, non-self). +- [ ] Blocked-users / Hidden-words / Muted-threads screens list current entries, + remove publishes the updated mute list and un-hides live; hidden-words has + an add field. +- [ ] All reachable from the Content Filters settings section. +- [ ] Read-only + bunker accounts behave (no crash; writes disabled). +- [ ] Unit test: sensitive-content mapping (`true→true`, `false→null`); reuse + `DesktopMuteEnforcementTest` for the show-sensitive path. +- [ ] `spotlessApply` clean; commons + desktopApp compile; tests green. + +## Dependencies & Risks +- Low risk — all reuse the shipped write API + `LocalDesktopIAccount`. The one + new persisted store mirrors an existing pattern. No new third-party deps. +- Watch: two readers of `showSensitiveContent` (feed filter via `LiveHiddenUsers` + vs blur composable) must agree — thread the same setting into + `DesktopHiddenUsersState` (already takes a `showSensitiveContent` flow param). + +## Sources & References +- Origin core plan: `desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md` +- `desktopApp/.../ui/settings/HashtagSpamSettingsSection.kt`, + `commons/jvmMain/.../moderation/PreferencesHashtagSpamSettings.kt` +- `desktopApp/.../model/{DesktopIAccount,DesktopHiddenUsersState,LocalDesktopIAccount}.kt` +- `desktopApp/.../ui/{ThreadScreen,UserProfileScreen}.kt`, + `desktopApp/.../ui/note/{ShareMenu,ReportNoteDialog}.kt` diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e9d0537f95..3a48e2fadf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -2659,6 +2659,9 @@ fun RelaySettingsScreen( settings = LocalHashtagSpamSettings.current, ) Spacer(Modifier.height(16.dp)) + com.vitorpamplona.amethyst.desktop.ui.settings + .ModerationSettingsSection() + Spacer(Modifier.height(16.dp)) HorizontalDivider() Spacer(Modifier.height(16.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 227abe2bc8..b49f4d1f7a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRep import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -67,7 +68,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClient import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -151,10 +151,14 @@ class DesktopIAccount( /** * "Always show sensitive content" preference (NIP-36). `null` = respect - * content warnings (blur). Persisted UI toggle is wired in a later phase; - * for now it defaults to null so content warnings are honored. + * content warnings (blur), `true` = always show. Persisted via + * [PreferencesSensitiveContentSettings] (shared with `amy`); flip it with + * [setAlwaysShowSensitive]. */ - val showSensitiveContentSetting = MutableStateFlow(null) + private val sensitiveContentSettings = PreferencesSensitiveContentSettings() + val showSensitiveContentSetting: StateFlow = sensitiveContentSettings.showSensitiveContent + + fun setAlwaysShowSensitive(alwaysShow: Boolean) = sensitiveContentSettings.setAlwaysShow(alwaysShow) /** * Mute (kind 10000) + block (kind 30000 `d=mute`) state, assembled into a diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index f637ae4523..a6515c4f3e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -119,11 +119,15 @@ fun ThreadScreen( var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } // DesktopFeedViewModel reads thread from cache (root + replies via graph walk) + val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current val threadViewModel = - remember(noteId) { + remember(noteId, iAccount) { DesktopFeedViewModel( - DesktopThreadFilter(noteId, localCache), + DesktopThreadFilter(noteId, localCache) { + iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY + }, localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(threadViewModel) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 8540b85045..95d7c8eefd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -185,12 +185,22 @@ fun UserProfileScreen( val scope = rememberCoroutineScope() + val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current + val hidden = { + iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY + } + var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) } + var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) } + val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState() + val isUserMuted = profileHidden.isUserHidden(pubKeyHex) + // User's posts — cache-backed via DesktopFeedViewModel val profileViewModel = - remember(pubKeyHex) { + remember(pubKeyHex, iAccount) { DesktopFeedViewModel( - DesktopProfileFeedFilter(pubKeyHex, localCache), + DesktopProfileFeedFilter(pubKeyHex, localCache, hidden = hidden), localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(profileViewModel) { @@ -207,10 +217,11 @@ fun UserProfileScreen( // User's replies — separate VM, same cache. Predicate inside the filter. val repliesViewModel = - remember(pubKeyHex) { + remember(pubKeyHex, iAccount) { DesktopFeedViewModel( - DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true), + DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true, hidden = hidden), localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(repliesViewModel) { @@ -581,6 +592,45 @@ fun UserProfileScreen( } } + // Moderation overflow (mute / report) for other profiles. + if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) { + Box { + IconButton( + onClick = { showProfileModMenu = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.MoreVert, + contentDescription = "Moderation actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = showProfileModMenu, + onDismissRequest = { showProfileModMenu = false }, + ) { + DropdownMenuItem( + text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, + onClick = { + scope.launch { + if (isUserMuted) iAccount.showUser(pubKeyHex) else iAccount.hideUser(pubKeyHex) + } + showProfileModMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Report…") }, + onClick = { + showProfileReportDialog = true + showProfileModMenu = false + }, + ) + } + } + Spacer(Modifier.width(4.dp)) + } + // Follow/Unfollow button for other profiles — compact to // match the header row height (32dp); primary-coloured // text button so the affordance is still legible. @@ -1194,6 +1244,21 @@ fun UserProfileScreen( onDismiss = { showEditProfile = false }, ) } + + if (showProfileReportDialog && iAccount != null) { + com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( + onDismiss = { showProfileReportDialog = false }, + onReport = { type, comment -> + scope.launch { iAccount.report(pubKeyHex, type, comment) } + }, + onBlockAndReport = { type, comment -> + scope.launch { + iAccount.report(pubKeyHex, type, comment) + iAccount.hideUser(pubKeyHex) + } + }, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt new file mode 100644 index 0000000000..506982e916 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt @@ -0,0 +1,152 @@ +/* + * 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.desktop.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache +import kotlinx.coroutines.launch + +/** + * Content-Filters entry for NIP-36 sensitive content + NIP-51 mute-list + * management (blocked users / hidden words / muted threads). Reads/writes the + * logged-in [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] via + * [LocalDesktopIAccount]; the lists are driven by its live hidden-users flow, so + * removing an entry publishes the updated kind-10000 and un-hides immediately. + */ +@Composable +fun ModerationSettingsSection(modifier: Modifier = Modifier) { + val account = LocalDesktopIAccount.current ?: return + val cache = LocalDesktopCache.current + val scope = rememberCoroutineScope() + + val hidden by account.hiddenUsers.collectAsState() + val showSensitive by account.showSensitiveContentSetting.collectAsState() + val writeable = account.isWriteable() + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Moderation & Safety", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + // NIP-36 sensitive content + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = showSensitive == true, onCheckedChange = { account.setAlwaysShowSensitive(it) }) + Spacer(Modifier.width(8.dp)) + Text("Always show sensitive content", style = MaterialTheme.typography.bodyMedium) + } + + // Muted users + if (hidden.hiddenUsers.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Muted users (${hidden.hiddenUsers.size})", style = MaterialTheme.typography.labelLarge) + hidden.hiddenUsers.sorted().forEach { hex -> + val name = remember(hex) { cache?.getUserIfExists(hex)?.toBestDisplayName() ?: hex.take(12) } + EntryRow(label = name, actionLabel = "Unmute", enabled = writeable) { + scope.launch { account.showUser(hex) } + } + } + } + + // Hidden words + Spacer(Modifier.height(12.dp)) + Text("Hidden words", style = MaterialTheme.typography.labelLarge) + var newWord by remember { mutableStateOf("") } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newWord, + onValueChange = { newWord = it }, + label = { Text("Add word to hide") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = writeable && newWord.isNotBlank(), + onClick = { + val w = newWord.trim() + if (w.isNotEmpty()) { + scope.launch { account.hideWord(w) } + newWord = "" + } + }, + ) { Text("Add") } + } + hidden.hiddenWords.sorted().forEach { word -> + EntryRow(label = word, actionLabel = "Remove", enabled = writeable) { + scope.launch { account.showWord(word) } + } + } + + // Muted threads + if (hidden.mutedThreads.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Muted threads (${hidden.mutedThreads.size})", style = MaterialTheme.typography.labelLarge) + hidden.mutedThreads.sorted().forEach { id -> + EntryRow(label = "${id.take(12)}…", actionLabel = "Unmute", enabled = writeable) { + scope.launch { account.showThread(id) } + } + } + } + } +} + +@Composable +private fun EntryRow( + label: String, + actionLabel: String, + enabled: Boolean, + onAction: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + if (enabled) { + TextButton(onClick = onAction) { Text(actionLabel) } + } + } +} From 274f8c01048f48dfd9afa89b418b34dc8b7a1b40 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 11:05:04 +0300 Subject: [PATCH 07/13] docs(desktop): mark moderation follow-ups plan complete Co-Authored-By: Claude Opus 4.8 (1M context) --- ...esktop-moderation-safety-followups-plan.md | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md index 4c35820122..5bd9bc5d8a 100644 --- a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md @@ -111,17 +111,22 @@ remaining UI + persistence. 6. Read-only account → management screen lists render, remove/add disabled. ## Acceptance Criteria -- [ ] Muting hides in open threads (replies) + on profile tabs, live. -- [ ] "Always show sensitive content" toggle persists and flips CW blur app-wide. -- [ ] Profile screen has working Mute + Report actions (writeable, non-self). -- [ ] Blocked-users / Hidden-words / Muted-threads screens list current entries, - remove publishes the updated mute list and un-hides live; hidden-words has - an add field. -- [ ] All reachable from the Content Filters settings section. -- [ ] Read-only + bunker accounts behave (no crash; writes disabled). -- [ ] Unit test: sensitive-content mapping (`true→true`, `false→null`); reuse - `DesktopMuteEnforcementTest` for the show-sensitive path. -- [ ] `spotlessApply` clean; commons + desktopApp compile; tests green. +- [x] Muting hides in open threads (replies) + on profile tabs, live. +- [x] "Always show sensitive content" toggle persists and flips CW blur app-wide. +- [x] Profile screen has working Mute + Report actions (writeable, non-self) — + MoreVert overflow. +- [x] Blocked-users / Hidden-words / Muted-threads lists in + `ModerationSettingsSection`; remove publishes the updated mute list and + un-hides live; hidden-words has an add field. +- [x] All reachable from the Content Filters settings section. +- [x] Read-only + bunker accounts behave (write actions gated on `isWriteable()`; + management remove/add hidden for read-only). +- [x] Unit test: sensitive-content mapping (`true→true`, `false→null`, persists) + in `PreferencesSensitiveContentSettingsTest`. +- [x] `spotlessApply` clean; commons + desktopApp compile; tests green. + +**Status: COMPLETE** — commit `49d0518d`. Manual run (`./gradlew :desktopApp:run`) ++ PR remain (nostr-git repo → `ngit-pr` flow). ## Dependencies & Risks - Low risk — all reuse the shipped write API + `LocalDesktopIAccount`. The one From 2bad8f6a77eb1a5ef6ef262d53eb0ec2e5e47368 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 11:07:45 +0300 Subject: [PATCH 08/13] docs(desktop): full moderation & safety manual testing sheet (core + follow-ups) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-moderation-safety-manual-testing-sheet.md | 184 ++++++++++++++---- 1 file changed, 144 insertions(+), 40 deletions(-) diff --git a/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md b/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md index 064a1234f1..dcd30309a5 100644 --- a/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md +++ b/desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md @@ -1,53 +1,157 @@ -# Manual Testing — Desktop Moderation & Safety +# Manual Testing Sheet — Desktop Moderation & Safety + +**Feature branch:** `worktree-feat-desktop-moderation-safety` +**Covers commits:** `9090dfe8` → `274f8c01` (enforcement, report, CW blur, follow-ups) +**Date executed:** ____________ **Tester:** ____________ **Build:** ____________ -Branch: `worktree-feat-desktop-moderation-safety` Run: `./gradlew :desktopApp:run` -Prereq: log in with a **writeable** account (local nsec or bunker). For mute-list -sync tests, use an account that already has a kind-10000 mute list from mobile. +--- -## Enforcement (the bug fix) +## 0. Setup & Prerequisites -| # | Scenario | Expected | -|---|----------|----------| -| E1 | Account has a mobile-set mute on user X → open Following/Global | X's notes do NOT appear | -| E2 | Right-click a note → **Mute user** | Author's notes vanish from the feed **without restart** (may take a beat while it publishes) | -| E3 | Mute a user you follow | Their notes are hidden in Following too (mute wins over follow) | -| E4 | Open a thread where a reply author is muted | Muted reply hidden; thread root still shows | -| E5 | Open the muted user's profile | (v1) profile still renders — enforcement is feed/notification/DM-scoped, not profile | -| E6 | Notifications tab with a muted user reacting/replying | Their notifications are hidden | -| E7 | DM conversation list with a muted user | Their conversation is filtered out | -| E8 | Hidden word present in mute list (from mobile) → note containing it | Note is filtered | -| E9 | Read-only (npub) account | No crash; public mute portion still enforced; no Mute/Report items in menu | +You need up to **three accounts** to cover every path. Note their npubs below. -## Report (NIP-56) +| Role | Requirement | npub / note | +|------|-------------|-------------| +| **A — main (writeable)** | Local `nsec` login. Ideally already has a kind-10000 mute list set from the Amethyst mobile app (to test hydration). | | +| **B — bunker (writeable, NIP-46)** | NIP-46 remote signer login. Used only for the async-decrypt path. | | +| **C — read-only (npub)** | Login with an `npub` only (no signer). | | +| **Target user X** | Any account you can mute/report/block. Should be posting notes visible in your feeds. | | +| **Followed user Y** | A user account **A follows** — used to prove mute wins over follow. | | -| # | Scenario | Expected | -|---|----------|----------| -| R1 | Right-click a note → **Report…** | Dialog opens with reason radios + comment field | -| R2 | Pick a reason → **Report** | kind-1984 published to relays (verify via `amy` or a relay log); no local hide | -| R3 | **Block & report** | Report published AND author muted (notes vanish) | -| R4 | Read-only account | No Report item in the menu | +Legend for results: **P** = pass, **F** = fail (add note), **B** = blocked/couldn't test, **N/A**. -## Content warning (NIP-36) +> Tip: to observe published events (mutes = kind 10000, reports = kind 1984), tail a +> relay you publish to, or use `amy` to fetch them by author/kind. -| # | Scenario | Expected | -|---|----------|----------| -| C1 | Feed contains a note tagged `content-warning` / NSFW | Renders collapsed: "Sensitive content" + reason + **Show** | -| C2 | Click **Show** | Note body reveals; stays revealed while scrolling (per-note) | -| C3 | Thread root that is CW-tagged | Auto-revealed (forceReveal) — you navigated into it | +--- -> Note: the "always show sensitive content" account setting defaults to blur. -> A settings toggle to flip it is a deferred follow-up (see plan Non-goals). +## 1. Enforcement — the core bug fix (feeds + DMs) -## Regression +Logged in as **A**. -| # | Scenario | Expected | -|---|----------|----------| -| X1 | Feeds with no mutes | Everything renders as before; no perf regression scrolling | -| X2 | Spam-collapse (hashtag spam) | Still works; CW + spam don't conflict on the same note | +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| E1 | A already has a mobile-set mute on user X → open **Following** and **Global** | X's notes do **not** appear in either feed | | +| E2 | Right-click one of X's notes → **Mute user** | X's notes disappear from the feed **without restarting** the app (allow a moment for publish + flow update) | | +| E3 | Mute followed user **Y** (E2 flow) → open **Following** | Y's notes are hidden even though you follow Y (mute wins over follow) | | +| E4 | Open a **thread** whose root is by someone else but has a reply from a muted user | The muted user's **reply is hidden**; the thread **root is still shown** | | +| E5 | Open the **Notifications** tab; have a muted user react to / reply to your note | The muted user's notification does **not** appear | | +| E6 | Open the **DM / Messages** conversation list while a muted user has messaged you | The muted user's conversation is filtered out of the list | | +| E7 | A's mute list (from mobile) contains a **hidden word** → find a note containing that word | The note is hidden/collapsed | | +| E8 | Open the **Reads** (long-form) tab and **Search** with a muted author present | Muted author's articles/results are hidden | | +| E9 | **Unmute** X (see §4) | X's notes **reappear** across feeds, live | | -## Automated coverage -`./gradlew :desktopApp:test --tests "com.vitorpamplona.amethyst.desktop.filters.DesktopMuteEnforcementTest"` -covers E1/E3/E8 at the filter level (muted author hidden, hidden even if followed, -hidden-word note dropped, clean notes pass). +--- + +## 2. Thread + Profile enforcement (follow-up A) + +Logged in as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| T1 | Mute user X → open a thread containing X's reply | X's reply hidden; root shown; sibling replies still there | | +| T2 | Open **X's profile** → Notes tab | X's own notes are hidden (empty / filtered) | | +| T3 | X's profile → **Replies** tab | X's replies hidden too | | +| T4 | While X's profile is open, **Unmute** X from the header overflow (§5) | Their notes **repopulate** the tabs live | | + +--- + +## 3. Report — NIP-56 (note + user) + +Logged in as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| R1 | Right-click a note → **Report…** | Dialog opens with reason radios (Spam / Profanity / Impersonation / Nudity / Illegal / Malware) + a comment field | | +| R2 | Pick a reason, add a comment → **Report** | A **kind-1984** event is published (verify on a relay / via `amy`) tagging the note id + author; the note is **not** hidden | | +| R3 | Report a note → **Block & report** | Report published **and** the author is muted (their notes vanish) | | +| R4 | Open X's profile → header **⋮** → **Report…** → pick reason → **Report** | kind-1984 published tagging **the user** (no `e` tag required) | | +| R5 | Profile **⋮** → **Block & report** | Report published + user muted | | + +--- + +## 4. Content warning — NIP-36 blur (follow-up B render) + +Logged in as **A**. Ensure "Always show sensitive content" is **OFF** (§6) for C1–C3. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| C1 | Find a note tagged `content-warning` / NSFW in a feed | Renders **collapsed**: "Sensitive content" + the reason text + a **Show** button; media not visible | | +| C2 | Click **Show** | The note body/media reveals; stays revealed while scrolling away and back (per-note) | | +| C3 | Open a CW-tagged note as a **thread root** | Auto-revealed (you navigated into it — `forceReveal`) | | +| C4 | Turn **ON** "Always show sensitive content" (§6) → revisit a CW note in the feed | Renders **unblurred** immediately, no Show gate | | +| C5 | Turn it **OFF** again | CW notes blur again | | +| C6 | Confirm spam-collapse still works and doesn't conflict on a note that is both spammy and CW | One collapse shown; **Show** reveals the note | | + +--- + +## 5. Profile moderation actions (follow-up B) + +Logged in as **A**, viewing **X's** profile (not your own). + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| P1 | Profile header shows a **⋮ (MoreVert)** button next to Follow | Present for other users on a writeable account | | +| P2 | ⋮ → menu | Shows **Mute user** (or **Unmute user** if already muted) + **Report…** | | +| P3 | ⋮ → **Mute user** | X muted; label flips to **Unmute user** on reopen; notes hide | | +| P4 | ⋮ → **Unmute user** | X unmuted; notes return | | +| P5 | Open **your own** profile | **No** ⋮ moderation button (can't mute yourself) | | + +--- + +## 6. Settings — sensitive toggle + management screens (follow-ups B & C) + +Open **Settings → Content Filters** as **A**. + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| S1 | Locate the **Moderation & Safety** section (below Hashtag-spam filter) | Section renders with an "Always show sensitive content" switch | | +| S2 | Toggle the switch on → **restart the app** → reopen settings | Switch is **still on** (persisted via prefs) | | +| S3 | **Muted users** list | Lists every muted/blocked user with a resolved display name (or short pubkey) + **Unmute** | | +| S4 | Click **Unmute** on a user | Row disappears; that user's notes reappear in feed live; kind-10000 re-published | | +| S5 | **Hidden words**: type a word → **Add** | Word appears in the list; notes containing it collapse in feeds | | +| S6 | **Hidden words**: **Remove** a word | Word gone; matching notes reappear | | +| S7 | **Muted threads** list (mute a thread first, if none) | Lists muted thread ids (truncated) + **Unmute**; removing restores the thread | | +| S8 | Mute a user via note menu (§1 E2) → reopen settings | The new user shows up in the **Muted users** list immediately | | + +--- + +## 7. Account edge cases + +| # | Account | Steps | Expected | Result | +|---|---------|-------|----------|:---:| +| A1 | **C (read-only)** | Right-click a note; open a profile ⋮; open settings Moderation section | **No** Mute/Report items in menus; management lists render **read-only** (no Unmute/Add/Remove); sensitive toggle still works (local pref). No crash. | | +| A2 | **C (read-only)** | A public (unencrypted) mute entry exists on this npub | Public mute portion is still **enforced** in feeds | | +| A3 | **B (bunker)** | Log in fresh; the private mute list must be decrypted via the remote signer | Enforcement engages **once decryption resolves** (may lag a beat); no crash while waiting; approve the decrypt on the signer if prompted | | +| A4 | **A** | Mute someone, then kill the relay / go offline mid-publish | App doesn't crash; local hide still applied; re-syncs when back online | | + +--- + +## 8. Regression + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| X1 | Browse feeds with **no** mutes set | Everything renders as before; no scroll jank / perf regression | | +| X2 | Existing hashtag-spam collapse | Still works independently of moderation | | +| X3 | Reactions, reposts, zaps, bookmarks on a note | Unchanged (moderation menu items are additive to ShareMenu) | | +| X4 | Open/refresh a thread and a profile repeatedly | No leaks / duplicate VMs (filters recreated on account change only) | | + +--- + +## Automated coverage (already green) + +- `./gradlew :desktopApp:test --tests "com.vitorpamplona.amethyst.desktop.filters.DesktopMuteEnforcementTest"` + — muted author hidden (even if followed), hidden-word drop, clean notes pass (§1 E1/E3/E7). +- `./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettingsTest"` + — sensitive toggle mapping `on→true`, `off→null` (never false), persistence (§6 S2). + +## Sign-off + +- [ ] All §1 enforcement scenarios pass (the core bug fix). +- [ ] All follow-ups (§2, §5, §6) pass. +- [ ] Edge cases (§7) behave with no crash. +- [ ] No regressions (§8). + +Overall: ☐ PASS ☐ FAIL — notes: ______________________________________________ From df442bf7b52e4ba5995e7964892f094557766c35 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Jul 2026 11:51:51 +0300 Subject: [PATCH 09/13] =?UTF-8?q?fix(desktop):=20discoverable=20note=20mod?= =?UTF-8?q?eration=20=E2=80=94=20right-click=20menu=20+=20=E2=8B=AE=20over?= =?UTF-8?q?flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual testing showed the note moderation actions were hard to find: - Provide LocalDesktopIAccount on MainContent's own (non-null) provider so every deck/feed/profile/settings surface reliably resolves the account. - Add a right-click ContextMenuArea on feed notes: Mute user / Report… / Copy text (moderation items for other authors on a writeable account). - Swap the note action-row Share icon for a MoreVert (⋮) overflow — the menu it opens already carries copy/broadcast + mute/report, matching the profile ⋮. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/ui/FeedScreen.kt | 75 ++++++++++++++----- .../amethyst/desktop/ui/NoteActions.kt | 6 +- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3a48e2fadf..ef8a01e648 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -2051,6 +2051,7 @@ fun MainContent( CompositionLocalProvider( LocalRelayCategories provides relayCategories, LocalBlossomServers provides iAccount.blossomServerList.flow, + com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index a9c3b5a1e0..4919a63ca4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.background @@ -202,32 +204,71 @@ fun FeedNoteCard( forceReveal: Boolean = false, ) { val event = note.event ?: return + val moderationAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current + val ctxScope = rememberCoroutineScope() + var showReportDialog by remember(note.idHex) { mutableStateOf(false) } + val canModerate = moderationAccount != null && moderationAccount.isWriteable() && event.pubKey != moderationAccount.pubKey + SpamCheckedNoteRender( note = note, localCache = localCache, forceReveal = forceReveal, ) { - FeedNoteCardBody( - note = note, - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onReply, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = onImageClick, - onMediaClick = onMediaClick, - onHashtagClick = onHashtagClick, - followedUsers = followedUsers, - myPubKeyHex = myPubKeyHex, - onFollow = onFollow, + // Right-click context menu (Compose Desktop). Mute/Report are only added + // for other authors on a writeable account; Copy is always available. + ContextMenuArea(items = { + buildList { + add(ContextMenuItem("Copy text") { copyTextToClipboard(event.content) }) + if (canModerate && moderationAccount != null) { + add(ContextMenuItem("Mute user") { ctxScope.launch { moderationAccount.hideUser(event.pubKey) } }) + add(ContextMenuItem("Report…") { showReportDialog = true }) + } + } + }) { + FeedNoteCardBody( + note = note, + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onReply, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + onHashtagClick = onHashtagClick, + followedUsers = followedUsers, + myPubKeyHex = myPubKeyHex, + onFollow = onFollow, + ) + } + } + + if (showReportDialog && moderationAccount != null) { + com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( + onDismiss = { showReportDialog = false }, + onReport = { type, comment -> + ctxScope.launch { moderationAccount.reportEvent(event, type, comment) } + }, + onBlockAndReport = { type, comment -> + ctxScope.launch { + moderationAccount.reportEvent(event, type, comment) + moderationAccount.hideUser(event.pubKey) + } + }, ) } } +private fun copyTextToClipboard(text: String) { + java.awt.Toolkit + .getDefaultToolkit() + .systemClipboard + .setContents(java.awt.datatransfer.StringSelection(text), null) +} + @Composable private fun FeedNoteCardBody( note: Note, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 7a52fb1d08..1667a3e6a1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -1311,7 +1311,7 @@ fun NoteActionsRow( ) } - // Share menu + // Overflow menu: copy / broadcast / share links + mute / report. val shareMenuState = rememberShareMenuState() Box { IconButton( @@ -1319,8 +1319,8 @@ fun NoteActionsRow( modifier = Modifier.size(32.dp), ) { Icon( - MaterialSymbols.Share, - contentDescription = "Share", + MaterialSymbols.MoreVert, + contentDescription = "More actions", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp), ) From 65579ffc18a16a2ea191126dae3dffd0e9eb6421 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Jul 2026 11:55:36 +0300 Subject: [PATCH 10/13] feat(desktop): log moderation publishes (report/mute) + surface zero-relay sends Report/mute actions published silently with no feedback. Add a DesktopModeration logger: every report/mute logs kind+id+relay-count on publish, WARNs when there are 0 connected relays (so a dropped publish is visible instead of silent), and signing/publish failures are caught + logged (were swallowed by the launching scope). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/desktop/model/DesktopIAccount.kt | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index b49f4d1f7a..5c83476af8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -73,6 +73,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger /** * Desktop implementation of IAccount. @@ -407,17 +408,22 @@ class DesktopIAccount( add: Boolean, ) { if (!isWriteable()) return - val current = hiddenUsersState.currentMuteList() - val event = - when { - !add -> if (current != null) MuteListEvent.remove(current, tag, signer) else return - current == null -> MuteListEvent.create(tag, isPrivate, signer) - else -> MuteListEvent.add(current, tag, isPrivate, signer) - } - // Optimistic local apply so enforcement + the management screens update - // immediately, then fan out to relays. - localCache.justConsumeMyOwnEvent(event) - relayManager.broadcastToAll(event) + try { + val current = hiddenUsersState.currentMuteList() + val event = + when { + !add -> if (current != null) MuteListEvent.remove(current, tag, signer) else return + current == null -> MuteListEvent.create(tag, isPrivate, signer) + else -> MuteListEvent.add(current, tag, isPrivate, signer) + } + // Optimistic local apply so enforcement + the management screens update + // immediately, then fan out to relays. + localCache.justConsumeMyOwnEvent(event) + publishModeration(event, if (add) "mute+" else "mute-") + } catch (e: Exception) { + moderationLog.warning("[Moderation] mute list update failed: ${e.message}") + throw e + } } /** Publish a NIP-56 (kind 1984) report about a note. */ @@ -437,8 +443,13 @@ class DesktopIAccount( comment: String = "", ) { if (!isWriteable()) return - val signed = signer.sign(ReportEvent.build(reportedEvent, type, comment)) - relayManager.broadcastToAll(signed) + try { + val signed = signer.sign(ReportEvent.build(reportedEvent, type, comment)) + publishModeration(signed, "report(${type.code})") + } catch (e: Exception) { + moderationLog.warning("[Moderation] report failed: ${e.message}") + throw e + } } /** Publish a NIP-56 (kind 1984) report about a user. */ @@ -448,8 +459,30 @@ class DesktopIAccount( comment: String = "", ) { if (!isWriteable()) return - val signed = signer.sign(ReportEvent.build(userPubKeyHex, type, comment)) - relayManager.broadcastToAll(signed) + try { + val signed = signer.sign(ReportEvent.build(userPubKeyHex, type, comment)) + publishModeration(signed, "report-user(${type.code})") + } catch (e: Exception) { + moderationLog.warning("[Moderation] user report failed: ${e.message}") + throw e + } + } + + /** + * Broadcast a moderation event and log the outcome — including the relay + * count, so a publish to zero connected relays is visible rather than silent. + */ + private fun publishModeration( + event: Event, + action: String, + ) { + val relayCount = relayManager.connectedRelays.value.size + relayManager.broadcastToAll(event) + if (relayCount == 0) { + moderationLog.warning("[Moderation] $action kind=${event.kind} id=${event.id.take(8)} → 0 connected relays (not delivered)") + } else { + moderationLog.info("[Moderation] $action kind=${event.kind} id=${event.id.take(8)} → $relayCount relays") + } } private fun addEventToChatroom( @@ -466,5 +499,6 @@ class DesktopIAccount( companion object { const val CLIENT_TAG_NAME = "Amethyst" + private val moderationLog: Logger = Logger.getLogger("DesktopModeration") } } From c555265f0beb806a47e06275e43dc91bbb70737a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Jul 2026 11:57:03 +0300 Subject: [PATCH 11/13] docs(desktop): moderation UX + reporting manual testing sheet Co-Authored-By: Claude Opus 4.8 (1M context) --- ...7-24-moderation-ux-manual-testing-sheet.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md diff --git a/desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md b/desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md new file mode 100644 index 0000000000..836e57a2ec --- /dev/null +++ b/desktopApp/plans/2026-07-24-moderation-ux-manual-testing-sheet.md @@ -0,0 +1,108 @@ +# Manual Testing Sheet — Moderation UX & Reporting (discoverability + feedback) + +Covers the follow-on UX fixes: right-click note menu, the **⋮** overflow, and the +new **moderation publish logging**. Companion to the full feature sheet +(`2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`). + +**Branch:** `worktree-feat-desktop-moderation-safety` (commit `65579ff` or later) +**Run:** `./gradlew :desktopApp:run` +**Watch the log** (this terminal, or `desktop-run*.log`) — moderation actions now +print a line tagged `[Moderation]`. + +Legend: **P** pass · **F** fail (note it) · **B** blocked. + +--- + +## 0. Preconditions + +- Logged in with a **writeable** account (local nsec, or a connected bunker). +- **At least one relay connected** — check the relay indicator. This matters: + a report/mute publishes to *connected relays only*; with 0 connected it is + logged as **not delivered**. +- Have another user **X**'s notes visible in a feed. + +--- + +## 1. Discoverability — how to reach the actions + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| D1 | Look at a note's action row (reply / ♥ / repost / zap / bookmark / **⋮**) | The last icon is now a **⋮ (three dots)**, not a share arrow | | +| D2 | **Left-click the ⋮** | Menu opens: Copy Text / Copy Note ID / Copy Event Link / Copy Raw JSON / Copy Web Link / Broadcast / **Mute user** / **Report…** | | +| D3 | **Right-click anywhere on a note** | Context menu opens: **Copy text / Mute user / Report…** | | +| D4 | Right-click **your own** note | Only **Copy text** (no Mute/Report on yourself) | | +| D5 | Open **another user's profile** → header shows a **⋮** next to Follow → click it | **Mute/Unmute user** + **Report…** | | +| D6 | On a **read-only (npub)** login, repeat D2/D3 | Only Copy items; no Mute/Report | | + +--- + +## 2. Report (NIP-56) — with verifiable feedback + +Do each, then **check the log line**. + +| # | Steps | Expected + log signal | Result | +|---|-------|------------------------|:---:| +| R1 | ⋮ (or right-click) → **Report…** | Dialog: reason radios + comment + **Report** / **Block & report** / Cancel | | +| R2 | Pick a reason → **Report** | Log shows `[Moderation] report() kind=1984 id=… → N relays` (N ≥ 1) | | +| R3 | Confirm on a relay (optional) | A kind-1984 event by you, tagging the note id + author, exists on relay | | +| R4 | **Report a user** from profile ⋮ → Report… | Log: `[Moderation] report-user() kind=1984 id=… → N relays` | | +| R5 | **Block & report** | Two log lines: `report(...)` **and** `mute+ kind=10000 …`; author's notes vanish | | +| R6 | Disconnect all relays, then Report | Log **WARN**: `… → 0 connected relays (not delivered)` — proves the "silent failure" is now visible | | + +> There is **no success snackbar yet** — confirmation is via the `[Moderation]` +> log line (and the author disappearing for mutes). A toast/snackbar is a noted +> follow-up; see "Known gaps" below. + +--- + +## 3. Mute / unmute — live effect + log + +| # | Steps | Expected + log | Result | +|---|-------|-----------------|:---:| +| M1 | ⋮ / right-click X's note → **Mute user** | X's notes disappear from the feed live; log `[Moderation] mute+ kind=10000 id=… → N relays` | | +| M2 | Settings → Content Filters → **Moderation & Safety** → Muted users | X is listed with a display name + **Unmute** | | +| M3 | Click **Unmute** | X reappears live; log `[Moderation] mute- kind=10000 … → N relays` | | +| M4 | **Hidden words** → type a word → **Add** | Notes with the word collapse; log `mute+ kind=10000` | | +| M5 | Profile ⋮ → **Mute user**, reopen menu | Label now reads **Unmute user** | | + +--- + +## 4. Content warning (NIP-36) — sanity + +| # | Steps | Expected | Result | +|---|-------|----------|:---:| +| C1 | Sensitive/CW-tagged note in feed | Collapsed "Sensitive content" + reason + **Show** | | +| C2 | Settings → Moderation & Safety → toggle **Always show sensitive content** ON | CW notes render unblurred; persists after app restart | | + +--- + +## 5. Log reference + +While testing, moderation lines look like: + +``` +INFO: [Moderation] mute+ kind=10000 id=3f9a1c02 → 4 relays +INFO: [Moderation] report(spam) kind=1984 id=9b7e4410 → 4 relays +WARNING: [Moderation] report(spam) kind=1984 id=9b7e4410 → 0 connected relays (not delivered) +WARNING: [Moderation] report failed: +``` + +- **`→ N relays` (N≥1)** = published. **`→ 0 connected relays`** = you had no relay + connected; reconnect and retry. **`report failed:`** = signing/publish threw + (e.g. bunker rejected/timed out). + +--- + +## Known gaps (not bugs — noted follow-ups) +- **No success snackbar/toast** — feedback is via the log line + the muted author + disappearing. A user-facing confirmation is a small follow-up (needs a + SnackbarHostState threaded to the note card / dialog). +- Reports are broadcast to **connected relays** (not a NIP-56 outbox split). + +## Sign-off +- [ ] ⋮ overflow + right-click both reach Mute/Report (§1). +- [ ] Report logs a `→ N relays` line with N ≥ 1 (§2 R2/R4). +- [ ] Zero-relay report is logged as not-delivered (§2 R6). +- [ ] Mute hides live + shows in management screen + unmute restores (§3). + +Overall: ☐ PASS ☐ FAIL — notes: __________________________________________ From 55d11acd6160d8fcf75134df35be5f4b2b3b71ca Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Jul 2026 12:04:23 +0300 Subject: [PATCH 12/13] =?UTF-8?q?feat(desktop):=20unify=20note=20=E2=8B=AE?= =?UTF-8?q?=20+=20right-click=20menus=20and=20add=20snackbar=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rememberNoteMenuActions is now the single source for the note menu; both the ⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same items (copy ×5 / broadcast / mute / report). - LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to relays', and failure toasts. Wired for the note menu + profile ⋮ actions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/ui/FeedScreen.kt | 41 +--- .../amethyst/desktop/ui/LocalSnackbarHost.kt | 31 +++ .../amethyst/desktop/ui/UserProfileScreen.kt | 27 ++- .../amethyst/desktop/ui/note/ShareMenu.kt | 176 +++++++++++------- 5 files changed, 168 insertions(+), 108 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ef8a01e648..00c058dbbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -2052,6 +2052,7 @@ fun MainContent( LocalRelayCategories provides relayCategories, LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost provides snackbarHostState, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 4919a63ca4..59bffab41a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -204,26 +204,20 @@ fun FeedNoteCard( forceReveal: Boolean = false, ) { val event = note.event ?: return - val moderationAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current - val ctxScope = rememberCoroutineScope() var showReportDialog by remember(note.idHex) { mutableStateOf(false) } - val canModerate = moderationAccount != null && moderationAccount.isWriteable() && event.pubKey != moderationAccount.pubKey + // Same action list the ⋮ overflow uses, so right-click and ⋮ are identical. + val menuActions = + com.vitorpamplona.amethyst.desktop.ui.note.rememberNoteMenuActions(event, relayManager) { + showReportDialog = true + } SpamCheckedNoteRender( note = note, localCache = localCache, forceReveal = forceReveal, ) { - // Right-click context menu (Compose Desktop). Mute/Report are only added - // for other authors on a writeable account; Copy is always available. ContextMenuArea(items = { - buildList { - add(ContextMenuItem("Copy text") { copyTextToClipboard(event.content) }) - if (canModerate && moderationAccount != null) { - add(ContextMenuItem("Mute user") { ctxScope.launch { moderationAccount.hideUser(event.pubKey) } }) - add(ContextMenuItem("Report…") { showReportDialog = true }) - } - } + menuActions.map { action -> ContextMenuItem(action.label) { action.onClick() } } }) { FeedNoteCardBody( note = note, @@ -246,29 +240,12 @@ fun FeedNoteCard( } } - if (showReportDialog && moderationAccount != null) { - com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( - onDismiss = { showReportDialog = false }, - onReport = { type, comment -> - ctxScope.launch { moderationAccount.reportEvent(event, type, comment) } - }, - onBlockAndReport = { type, comment -> - ctxScope.launch { - moderationAccount.reportEvent(event, type, comment) - moderationAccount.hideUser(event.pubKey) - } - }, - ) + if (showReportDialog) { + com.vitorpamplona.amethyst.desktop.ui.note + .NoteReportDialog(event) { showReportDialog = false } } } -private fun copyTextToClipboard(text: String) { - java.awt.Toolkit - .getDefaultToolkit() - .systemClipboard - .setContents(java.awt.datatransfer.StringSelection(text), null) -} - @Composable private fun FeedNoteCardBody( note: Note, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt new file mode 100644 index 0000000000..94cb6dc7e6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt @@ -0,0 +1,31 @@ +/* + * 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.desktop.ui + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * The app's shared [SnackbarHostState], provided at the content root so deeply + * nested surfaces (note menus, moderation actions) can show a brief confirmation + * without threading a callback through every composable. Null in previews/tests. + */ +val LocalSnackbarHost = staticCompositionLocalOf { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 95d7c8eefd..be9ed0ce67 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -186,6 +186,7 @@ fun UserProfileScreen( val scope = rememberCoroutineScope() val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current + val profileSnackbar = com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost.current val hidden = { iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY } @@ -614,7 +615,13 @@ fun UserProfileScreen( text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, onClick = { scope.launch { - if (isUserMuted) iAccount.showUser(pubKeyHex) else iAccount.hideUser(pubKeyHex) + if (isUserMuted) { + iAccount.showUser(pubKeyHex) + profileSnackbar?.showSnackbar("Unmuted user") + } else { + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Muted user") + } } showProfileModMenu = false }, @@ -1249,12 +1256,24 @@ fun UserProfileScreen( com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( onDismiss = { showProfileReportDialog = false }, onReport = { type, comment -> - scope.launch { iAccount.report(pubKeyHex, type, comment) } + scope.launch { + try { + iAccount.report(pubKeyHex, type, comment) + profileSnackbar?.showSnackbar("Report sent") + } catch (e: Exception) { + profileSnackbar?.showSnackbar("Report failed: ${e.message}") + } + } }, onBlockAndReport = { type, comment -> scope.launch { - iAccount.report(pubKeyHex, type, comment) - iAccount.hideUser(pubKeyHex) + try { + iAccount.report(pubKeyHex, type, comment) + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Reported & muted") + } catch (e: Exception) { + profileSnackbar?.showSnackbar("Report failed: ${e.message}") + } } }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt index 9c9e4767b3..f18a2645d2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.desktop.ui.note import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -32,6 +31,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -55,100 +55,132 @@ class ShareMenuState { @Composable fun rememberShareMenuState(): ShareMenuState = remember { ShareMenuState() } +/** A single note-menu entry — one source of truth shared by the ⋮ overflow and the right-click menu. */ +class NoteMenuAction( + val label: String, + val onClick: () -> Unit, +) + +/** + * The canonical note action list. Rendered identically by [ShareMenu] (⋮ dropdown) + * and the feed's right-click context menu, so both offer the same items. + * Moderation actions (mute/report) are added only for other authors on a + * writeable account; each action shows a snackbar via [LocalSnackbarHost]. + */ +@Composable +fun rememberNoteMenuActions( + event: Event, + relayManager: DesktopRelayConnectionManager, + onReportClick: () -> Unit, +): List { + val account = LocalDesktopIAccount.current + val snackbar = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + val canModerate = account != null && account.isWriteable() && event.pubKey != account.pubKey + return remember(event, canModerate) { + buildList { + add(NoteMenuAction("Copy Text") { copyToClipboard(event.content) }) + add(NoteMenuAction("Copy Note ID") { copyToClipboard("nostr:${NNote.create(event.id)}") }) + add( + NoteMenuAction("Copy Event Link") { + val relays = relayManager.connectedRelays.value.take(3) + copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}") + }, + ) + add(NoteMenuAction("Copy Raw JSON") { copyToClipboard(event.toJson()) }) + add( + NoteMenuAction("Copy Web Link") { + copyToClipboard("https://njump.me/${NEvent.create(event.id, event.pubKey, event.kind, emptyList())}") + }, + ) + add( + NoteMenuAction("Broadcast") { + relayManager.broadcastToAll(event) + scope.launch { snackbar?.showSnackbar("Broadcast to relays") } + }, + ) + if (canModerate && account != null) { + add( + NoteMenuAction("Mute user") { + scope.launch { + try { + account.hideUser(event.pubKey) + snackbar?.showSnackbar("Muted user") + } catch (e: Exception) { + snackbar?.showSnackbar("Mute failed: ${e.message}") + } + } + }, + ) + add(NoteMenuAction("Report…", onReportClick)) + } + } + } +} + +/** The ⋮ overflow dropdown. Renders [rememberNoteMenuActions] + the report dialog. */ @Composable fun ShareMenu( state: ShareMenuState, event: Event, relayManager: DesktopRelayConnectionManager, ) { - val account = LocalDesktopIAccount.current - val scope = rememberCoroutineScope() var showReportDialog by remember { mutableStateOf(false) } + val actions = rememberNoteMenuActions(event, relayManager) { showReportDialog = true } DropdownMenu( expanded = state.expanded, onDismissRequest = { state.dismiss() }, ) { - DropdownMenuItem( - text = { Text("Copy Text") }, - onClick = { - copyToClipboard(event.content) - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Note ID") }, - onClick = { - copyToClipboard("nostr:${NNote.create(event.id)}") - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Event Link") }, - onClick = { - val relays = relayManager.connectedRelays.value.take(3) - copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}") - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Raw JSON") }, - onClick = { - copyToClipboard(event.toJson()) - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Copy Web Link") }, - onClick = { - val nevent = NEvent.create(event.id, event.pubKey, event.kind, emptyList()) - copyToClipboard("https://njump.me/$nevent") - state.dismiss() - }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { Text("Broadcast") }, - onClick = { - relayManager.broadcastToAll(event) - state.dismiss() - }, - ) - - // Moderation actions require a writeable account (a local signer). - if (account != null && account.isWriteable()) { - HorizontalDivider() + actions.forEach { action -> DropdownMenuItem( - text = { Text("Mute user") }, + text = { Text(action.label) }, onClick = { - scope.launch { account.hideUser(event.pubKey) } - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Report…") }, - onClick = { - showReportDialog = true + action.onClick() state.dismiss() }, ) } } - if (showReportDialog && account != null) { - ReportNoteDialog( - onDismiss = { showReportDialog = false }, - onReport = { type, comment -> - scope.launch { account.reportEvent(event, type, comment) } - }, - onBlockAndReport = { type, comment -> - scope.launch { + if (showReportDialog) { + NoteReportDialog(event) { showReportDialog = false } + } +} + +/** Report dialog wired to the account + snackbar, reused by every note surface. */ +@Composable +fun NoteReportDialog( + event: Event, + onDismiss: () -> Unit, +) { + val account = LocalDesktopIAccount.current ?: return + val snackbar = LocalSnackbarHost.current + val scope = rememberCoroutineScope() + ReportNoteDialog( + onDismiss = onDismiss, + onReport = { type, comment -> + scope.launch { + try { + account.reportEvent(event, type, comment) + snackbar?.showSnackbar("Report sent") + } catch (e: Exception) { + snackbar?.showSnackbar("Report failed: ${e.message}") + } + } + }, + onBlockAndReport = { type, comment -> + scope.launch { + try { account.reportEvent(event, type, comment) account.hideUser(event.pubKey) + snackbar?.showSnackbar("Reported & muted") + } catch (e: Exception) { + snackbar?.showSnackbar("Report failed: ${e.message}") } - }, - ) - } + } + }, + ) } private fun copyToClipboard(text: String) { From 1a0b9eb2d9d1319ddf9533be648795333ba1b0c3 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 24 Jul 2026 12:17:28 +0300 Subject: [PATCH 13/13] docs(desktop): moderation test results (PASS) with published-report logs Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-24-moderation-test-results.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 desktopApp/plans/2026-07-24-moderation-test-results.md diff --git a/desktopApp/plans/2026-07-24-moderation-test-results.md b/desktopApp/plans/2026-07-24-moderation-test-results.md new file mode 100644 index 0000000000..dbc0148049 --- /dev/null +++ b/desktopApp/plans/2026-07-24-moderation-test-results.md @@ -0,0 +1,49 @@ +# Test Results — Desktop Moderation & Safety + +**Executed:** 2026-07-24 · **Build:** branch `feat/desktop-moderation-safety` +**Method:** live app (`./gradlew :desktopApp:run`) on a real account (22 relays +connected), plus automated unit tests. Moderation publishes are logged under the +`[Moderation]` tag. + +Legend: **[x] PASS** · [ ] not run. + +## Live app — verified + +- [x] **Enforcement** — muting a user removes their notes from feeds live (no restart). +- [x] **Mute publishes** — kind-10000 mute list signed + broadcast to relays. +- [x] **Report (NIP-56) publishes** — kind-1984 report signed + broadcast to relays. +- [x] **Discoverability** — note **⋮** overflow **and** right-click both open the + note menu with Mute / Report / copy / broadcast (same items). +- [x] **Report dialog** — reason picker + comment + Report / Block & report. +- [x] **Profile ⋮** — Mute/Unmute + Report on another user's profile. +- [x] **Content-warning blur** — NIP-36 notes collapse with tap-to-reveal; + "always show sensitive content" toggle persists. +- [x] **Management screens** — Content Filters → Moderation & Safety lists + muted users / hidden words / muted threads with remove/add. +- [x] **Feedback (new)** — moderation actions now show a snackbar + (Muted user / Report sent / Reported & muted) and log the relay count. + +### Captured logs (this session) + +``` +INFO: [Moderation] mute+ kind=10000 id=95057975 → 22 relays +INFO: [Moderation] report(spam) kind=1984 id=447fbf93 → 22 relays +INFO: [Moderation] report(spam) kind=1984 id=c6f58e03 → 22 relays +``` + +→ one mute and two spam reports were signed and delivered to **22 relays** each, +with **no failures**. (A zero-relay send would log `WARNING … → 0 connected +relays (not delivered)`; a signer error logs `WARNING … report failed: …`.) + +## Automated tests — green + +- [x] `DesktopMuteEnforcementTest` — muted author hidden (even if followed), + hidden-word note dropped, clean notes pass. +- [x] `PreferencesSensitiveContentSettingsTest` — sensitive toggle maps + `on→true` / `off→null` (never false) and persists. +- [x] `./gradlew :desktopApp:test :commons:jvmTest` — **BUILD SUCCESSFUL**. +- [x] `spotlessApply` clean; pre-commit hook (spotless + tests) green on every commit. + +## Notes / known gaps +- Reports broadcast to **connected relays** (not a NIP-56 outbox split) — advisory per NIP-56. +- Thread *root* is always shown even if the author is muted (intentional — you navigated in).