From 9090dfe82c9bc9f4e803e72c726836a6ecdfa9ea Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:01:30 +0300 Subject: [PATCH] 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) + } + } + } } /**