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<LiveHiddenUsers>,
  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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-23 10:01:30 +03:00
co-authored by Claude Opus 4.8
parent 99f28ee1ca
commit 9090dfe82c
7 changed files with 227 additions and 22 deletions
@@ -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(),
)
}
}
/**
@@ -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<Filter>?,
) {
if (event is AdvertisedRelayListEvent || event is BlossomServersEvent) {
if (event is AdvertisedRelayListEvent || event is BlossomServersEvent || event is MuteListEvent) {
scope.launch(Dispatchers.IO) {
localCache.justConsumeMyOwnEvent(event)
}
@@ -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<Note>.deduplicateReposts(): List<Note> =
*/
class DesktopGlobalFeedFilter(
private val cache: DesktopLocalCache,
private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY },
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "global"
override fun feed(): List<Note> =
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<Note>): Set<Note> = newItems.filterTo(HashSet()) { isFeedNote(it.event) }
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isFeedNote(it.event) && !it.isHiddenFor(hidden()) }
override fun sort(items: Set<Note>): List<Note> = 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<HexKey>,
) : AdditiveFeedFilter<Note>() {
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<Note>): Set<Note> {
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<Note>() {
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<Note>() {
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<Note>()
// 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<Note>,
) {
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<Note>() {
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<Note>() {
override fun feedKey(): String = "reads"
override fun feed(): List<Note> =
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<Note>): Set<Note> = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent }
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent && !it.isHiddenFor(hidden()) }
override fun sort(items: Set<Note>): List<Note> = 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<Note>() {
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<Note>() {
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())
}
}
@@ -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<Boolean?> = 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<Set<String>>(emptySet())
private val muteEventFlow: StateFlow<NoteState> = muteListNote.flow().metadata.stateFlow
private val blockEventFlow: StateFlow<NoteState> = blockListNote.flow().metadata.stateFlow
private suspend fun assemble(
muteEvent: MuteListEvent?,
blockEvent: PeopleListEvent?,
transient: Set<String>,
showSensitive: Boolean?,
): LiveHiddenUsers {
val hiddenUsers = mutableSetOf<String>()
val hiddenWords = mutableSetOf<String>()
val mutedThreads = mutableSetOf<String>()
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<LiveHiddenUsers> =
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
}
}
@@ -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<Boolean?>(null)
override val hiddenWordsCase: List<DualCase> = 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<Int> = emptySet()
/** Current moderation choices — feeds observe this to re-filter live on mute/block. */
val hiddenUsers: StateFlow<LiveHiddenUsers> get() = hiddenUsersState.flow
override val spammersHashCodes: Set<Int> = emptySet()
override val showSensitiveContent: Boolean? get() = hiddenUsersState.flow.value.showSensitiveContent
override val hiddenWordsCase: List<DualCase> get() = hiddenUsersState.flow.value.hiddenWordsCase
override val hiddenUsersHashCodes: Set<Int> get() = hiddenUsersState.flow.value.hiddenUsersHashCodes
override val spammersHashCodes: Set<Int> 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<String> = 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<PrivateDmEvent>) {
@@ -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
@@ -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<Note>,
cacheProvider: ICacheProvider,
hiddenUsers: StateFlow<LiveHiddenUsers>? = 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)
}
}
}
}
/**