mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
99f28ee1ca
commit
9090dfe82c
@@ -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)
|
||||
}
|
||||
|
||||
+25
-9
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+136
@@ -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
|
||||
}
|
||||
}
|
||||
+27
-7
@@ -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
|
||||
|
||||
+16
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user