feat(desktop): moderation follow-ups — thread/profile enforcement, sensitive toggle, mgmt screens

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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-23 11:04:23 +03:00
co-authored by Claude Opus 4.8
parent 755cfc82fe
commit 49d0518db1
8 changed files with 507 additions and 10 deletions
@@ -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<Boolean?> = 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
}
}
@@ -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)
}
}
@@ -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<Boolean>` (default false) under
a stable prefs node; expose `showSensitiveContent: StateFlow<Boolean?>` 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`
@@ -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))
@@ -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<Boolean?>(null)
private val sensitiveContentSettings = PreferencesSensitiveContentSettings()
val showSensitiveContentSetting: StateFlow<Boolean?> = sensitiveContentSettings.showSensitiveContent
fun setAlwaysShowSensitive(alwaysShow: Boolean) = sensitiveContentSettings.setAlwaysShow(alwaysShow)
/**
* Mute (kind 10000) + block (kind 30000 `d=mute`) state, assembled into a
@@ -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) {
@@ -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)
}
},
)
}
}
/**
@@ -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) }
}
}
}