diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt new file mode 100644 index 0000000000..6c9e80724c --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettings.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * "Always show sensitive content" (NIP-36) preference, backed by + * [java.util.prefs.Preferences] under the same shared node as the other content + * filters (so Desktop and the `amy` CLI observe the same value). + * + * [showSensitiveContent] follows the `LiveHiddenUsers`/`Note.isHiddenFor` + * convention: `null` = respect content warnings (blur), `true` = always show. + * `false` is never emitted — the toggle is binary (on ⇒ true, off ⇒ null). + */ +class PreferencesSensitiveContentSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) { + private val mutable = MutableStateFlow(toChoice(prefs.getBoolean(KEY_ALWAYS_SHOW, DEFAULT_ALWAYS_SHOW))) + + /** `null` = respect warnings (blur); `true` = always show. */ + val showSensitiveContent: StateFlow = mutable.asStateFlow() + + fun setAlwaysShow(alwaysShow: Boolean) { + mutable.value = toChoice(alwaysShow) + prefs.putBoolean(KEY_ALWAYS_SHOW, alwaysShow) + } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/filters" + const val KEY_ALWAYS_SHOW = "always_show_sensitive" + const val DEFAULT_ALWAYS_SHOW = false + + private fun toChoice(alwaysShow: Boolean): Boolean? = if (alwaysShow) true else null + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt new file mode 100644 index 0000000000..ade6c23960 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesSensitiveContentSettingsTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class PreferencesSensitiveContentSettingsTest { + private val testNode = "com/vitorpamplona/amethyst/test/sensitive_${System.currentTimeMillis()}" + + private fun prefs(): Preferences = Preferences.userRoot().node(testNode) + + @Before + fun setup() { + prefs().clear() + } + + @After + fun teardown() { + prefs().removeNode() + } + + @Test + fun defaultRespectsWarnings() { + // null = respect content warnings (blur), matching Note.isHiddenFor. + assertNull(PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value) + } + + @Test + fun onMapsToTrue() { + val settings = PreferencesSensitiveContentSettings(prefs()) + settings.setAlwaysShow(true) + assertEquals(true, settings.showSensitiveContent.value) + } + + @Test + fun offMapsToNullNotFalse() { + val settings = PreferencesSensitiveContentSettings(prefs()) + settings.setAlwaysShow(true) + settings.setAlwaysShow(false) + // Off is null (respect warnings), never false — Note.isHiddenFor only + // blurs when showSensitiveContent == false, which must never happen here. + assertNull(settings.showSensitiveContent.value) + } + + @Test + fun persistsAcrossReload() { + PreferencesSensitiveContentSettings(prefs()).setAlwaysShow(true) + assertEquals(true, PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value) + } +} diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md new file mode 100644 index 0000000000..4c35820122 --- /dev/null +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-followups-plan.md @@ -0,0 +1,139 @@ +--- +title: Desktop Moderation & Safety — deferred follow-ups (management screens, sensitive-content toggle, thread/profile enforcement) +type: feat +status: active +date: 2026-07-23 +origin: desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md +module: desktopApp (+ commons/jvmMain) +--- + +# ✨ Desktop Moderation & Safety — follow-ups + +Continuation of the shipped core (branch `worktree-feat-desktop-moderation-safety`, +commits `9090dfe8`→`f4435bfe`). The account-layer write API +(`hideUser/showUser/hideWord/showWord/hideThread/showThread`, `report`) and the +`LocalDesktopIAccount` CompositionLocal already exist — these follow-ups are the +remaining UI + persistence. + +## Scope (the 3 deferred items) + +1. **Thread + Profile enforcement** — `ThreadScreen`/`UserProfileScreen` build + their `DesktopThreadFilter`/`DesktopProfileFeedFilter` with the default + `hidden = { EMPTY }`, so mutes don't apply there. Wire the real lambda. +2. **Sensitive-content toggle + profile moderation actions** — persist an + "Always show sensitive content" setting and back + `DesktopIAccount.showSensitiveContentSetting` with it; add Mute/Report to the + profile screen. +3. **Management screens** — list + remove Blocked users / Hidden words / Muted + threads, reachable from the Content Filters settings section. + +## Key findings (grounded 2026-07-23) + +- `ThreadScreen` (`ui/ThreadScreen.kt:98`) + `UserProfileScreen` + (`ui/UserProfileScreen.kt:120`) take `account: AccountState.LoggedIn?` — **not** + `DesktopIAccount`. But `LocalDesktopIAccount.current` is now in scope inside + them, so **no param/caller threading needed** (callers: `DeckColumnContainer.kt` + :566,:720). Just read the local and pass + `{ LocalDesktopIAccount.current?.hiddenUsers?.value ?: LiveHiddenUsers.EMPTY }` + to the filter. (Filters already accept the lambda.) +- Persistence pattern: `commons/jvmMain/.../moderation/PreferencesHashtagSpamSettings.kt` + — `Preferences.userRoot().node(NODE)` with `MutableStateFlow` fields. Mirror for + a sensitive-content boolean. +- Content Filters UI: `desktopApp/.../ui/settings/HashtagSpamSettingsSection.kt` + is the section composable; add the toggle + management entries alongside it. +- Read side for management screens: `DesktopIAccount.hiddenUsersState.flow` + (`LiveHiddenUsers`) already exposes `hiddenUsers` / `hiddenWords` / `mutedThreads` + sets. Remove = `account.showUser/showWord/showThread`. Resolve a blocked pubkey + to a name via `localCache.getUserIfExists(hex)?.toBestDisplayName()`. + +## Technical Approach — phases + +### Phase A — Thread + Profile enforcement (S) +- In `ThreadScreen` and `UserProfileScreen`, capture + `val iAccount = LocalDesktopIAccount.current` and pass + `hidden = { iAccount?.hiddenUsers?.value ?: LiveHiddenUsers.EMPTY }` to + `DesktopThreadFilter` / both `DesktopProfileFeedFilter` constructions + (`ThreadScreen.kt:125`, `UserProfileScreen.kt:192,212`). +- Thread root intentionally stays visible (filter already only hides replies). +- Success: muting a reply author hides them in an open thread + on the profile + Notes/Replies tabs, live. + +### Phase B — Sensitive-content toggle + profile moderation (M) +- New `commons/jvmMain/.../moderation/PreferencesSensitiveContentSettings.kt`: + persisted `alwaysShowSensitive: MutableStateFlow` (default false) under + a stable prefs node; expose `showSensitiveContent: StateFlow` mapping + `true → true`, `false → null` (null = respect warnings, matching + `Note.isHiddenFor`). +- `DesktopIAccount`: replace the placeholder `showSensitiveContentSetting = + MutableStateFlow(null)` with this store's flow; add + `setAlwaysShowSensitive(Boolean)`. +- Content Filters section: a "Show sensitive content" switch (reads/writes via + `LocalDesktopIAccount.current`). +- `UserProfileScreen`: add **Mute user** + **Report…** actions (reuse + `ReportNoteDialog`; call `account.hideUser` / `account.report(userHex, …)`), + shown only for writeable accounts and not for self. +- Success: toggling the switch flips CW blur app-wide and persists across restart; + profile has working Mute/Report. + +### Phase C — Management screens (M) +- New `desktopApp/.../ui/settings/BlockedContentSettings.kt` (or 3 small + composables): three expandable lists driven by + `LocalDesktopIAccount.current?.hiddenUsers` (collectAsState): + - **Blocked/muted users** → rows with resolved display name + "Unmute" + (`showUser`). + - **Hidden words** → text rows + "Remove" (`showWord`) + an add-word field + (`hideWord`). + - **Muted threads** → note-id rows + "Unmute" (`showThread`). +- Surfaced from the Content Filters section (expander or sub-screen). +- Success: lists reflect the live mute set and removing an entry publishes the + updated kind-10000 and un-hides immediately. + +## System-Wide Impact +- **Interaction graph:** removing a mute → `account.showUser/Word/Thread` → + `MuteListEvent.remove` signed + `justConsumeMyOwnEvent` + broadcast → mute flow + re-emits → feeds `invalidateData` → entry disappears from feed AND from the + management list (same flow). No separate refresh path. +- **State lifecycle:** management-list edits and the sensitive toggle are + independent stores (NIP-51 event vs local prefs); no shared partial-failure. +- **API parity:** the CW toggle now feeds three readers — + `Note.isHiddenFor` (feed filters) and `SpamCheckedNoteRender` (blur). Both read + the same `showSensitiveContent` value; verify one source of truth. +- **Read-only accounts:** management screens are read-only (show lists, disable + remove/add); the sensitive toggle still works (it's local prefs, not signed). + +### Integration test scenarios +1. Mute a reply author → open the thread → reply hidden; root still shown. +2. Mute a user → open their profile → their Notes/Replies tabs empty. +3. Toggle "show sensitive content" on → CW notes render unblurred everywhere; + restart app → still on. +4. Unmute from the Blocked-users screen → their notes reappear in feed live. +5. Add a hidden word in the management screen → matching notes collapse. +6. Read-only account → management screen lists render, remove/add disabled. + +## Acceptance Criteria +- [ ] Muting hides in open threads (replies) + on profile tabs, live. +- [ ] "Always show sensitive content" toggle persists and flips CW blur app-wide. +- [ ] Profile screen has working Mute + Report actions (writeable, non-self). +- [ ] Blocked-users / Hidden-words / Muted-threads screens list current entries, + remove publishes the updated mute list and un-hides live; hidden-words has + an add field. +- [ ] All reachable from the Content Filters settings section. +- [ ] Read-only + bunker accounts behave (no crash; writes disabled). +- [ ] Unit test: sensitive-content mapping (`true→true`, `false→null`); reuse + `DesktopMuteEnforcementTest` for the show-sensitive path. +- [ ] `spotlessApply` clean; commons + desktopApp compile; tests green. + +## Dependencies & Risks +- Low risk — all reuse the shipped write API + `LocalDesktopIAccount`. The one + new persisted store mirrors an existing pattern. No new third-party deps. +- Watch: two readers of `showSensitiveContent` (feed filter via `LiveHiddenUsers` + vs blur composable) must agree — thread the same setting into + `DesktopHiddenUsersState` (already takes a `showSensitiveContent` flow param). + +## Sources & References +- Origin core plan: `desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md` +- `desktopApp/.../ui/settings/HashtagSpamSettingsSection.kt`, + `commons/jvmMain/.../moderation/PreferencesHashtagSpamSettings.kt` +- `desktopApp/.../model/{DesktopIAccount,DesktopHiddenUsersState,LocalDesktopIAccount}.kt` +- `desktopApp/.../ui/{ThreadScreen,UserProfileScreen}.kt`, + `desktopApp/.../ui/note/{ShareMenu,ReportNoteDialog}.kt` diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e9d0537f95..3a48e2fadf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -2659,6 +2659,9 @@ fun RelaySettingsScreen( settings = LocalHashtagSpamSettings.current, ) Spacer(Modifier.height(16.dp)) + com.vitorpamplona.amethyst.desktop.ui.settings + .ModerationSettingsSection() + Spacer(Modifier.height(16.dp)) HorizontalDivider() Spacer(Modifier.height(16.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 227abe2bc8..b49f4d1f7a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRep import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -67,7 +68,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClient import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -151,10 +151,14 @@ class DesktopIAccount( /** * "Always show sensitive content" preference (NIP-36). `null` = respect - * content warnings (blur). Persisted UI toggle is wired in a later phase; - * for now it defaults to null so content warnings are honored. + * content warnings (blur), `true` = always show. Persisted via + * [PreferencesSensitiveContentSettings] (shared with `amy`); flip it with + * [setAlwaysShowSensitive]. */ - val showSensitiveContentSetting = MutableStateFlow(null) + private val sensitiveContentSettings = PreferencesSensitiveContentSettings() + val showSensitiveContentSetting: StateFlow = sensitiveContentSettings.showSensitiveContent + + fun setAlwaysShowSensitive(alwaysShow: Boolean) = sensitiveContentSettings.setAlwaysShow(alwaysShow) /** * Mute (kind 10000) + block (kind 30000 `d=mute`) state, assembled into a diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index f637ae4523..a6515c4f3e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -119,11 +119,15 @@ fun ThreadScreen( var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) } // DesktopFeedViewModel reads thread from cache (root + replies via graph walk) + val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current val threadViewModel = - remember(noteId) { + remember(noteId, iAccount) { DesktopFeedViewModel( - DesktopThreadFilter(noteId, localCache), + DesktopThreadFilter(noteId, localCache) { + iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY + }, localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(threadViewModel) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 8540b85045..95d7c8eefd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -185,12 +185,22 @@ fun UserProfileScreen( val scope = rememberCoroutineScope() + val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current + val hidden = { + iAccount?.hiddenUsers?.value ?: com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY + } + var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) } + var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) } + val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState() + val isUserMuted = profileHidden.isUserHidden(pubKeyHex) + // User's posts — cache-backed via DesktopFeedViewModel val profileViewModel = - remember(pubKeyHex) { + remember(pubKeyHex, iAccount) { DesktopFeedViewModel( - DesktopProfileFeedFilter(pubKeyHex, localCache), + DesktopProfileFeedFilter(pubKeyHex, localCache, hidden = hidden), localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(profileViewModel) { @@ -207,10 +217,11 @@ fun UserProfileScreen( // User's replies — separate VM, same cache. Predicate inside the filter. val repliesViewModel = - remember(pubKeyHex) { + remember(pubKeyHex, iAccount) { DesktopFeedViewModel( - DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true), + DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true, hidden = hidden), localCache, + iAccount?.hiddenUsers, ) } DisposableEffect(repliesViewModel) { @@ -581,6 +592,45 @@ fun UserProfileScreen( } } + // Moderation overflow (mute / report) for other profiles. + if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) { + Box { + IconButton( + onClick = { showProfileModMenu = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.MoreVert, + contentDescription = "Moderation actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = showProfileModMenu, + onDismissRequest = { showProfileModMenu = false }, + ) { + DropdownMenuItem( + text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, + onClick = { + scope.launch { + if (isUserMuted) iAccount.showUser(pubKeyHex) else iAccount.hideUser(pubKeyHex) + } + showProfileModMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Report…") }, + onClick = { + showProfileReportDialog = true + showProfileModMenu = false + }, + ) + } + } + Spacer(Modifier.width(4.dp)) + } + // Follow/Unfollow button for other profiles — compact to // match the header row height (32dp); primary-coloured // text button so the affordance is still legible. @@ -1194,6 +1244,21 @@ fun UserProfileScreen( onDismiss = { showEditProfile = false }, ) } + + if (showProfileReportDialog && iAccount != null) { + com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( + onDismiss = { showProfileReportDialog = false }, + onReport = { type, comment -> + scope.launch { iAccount.report(pubKeyHex, type, comment) } + }, + onBlockAndReport = { type, comment -> + scope.launch { + iAccount.report(pubKeyHex, type, comment) + iAccount.hideUser(pubKeyHex) + } + }, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt new file mode 100644 index 0000000000..506982e916 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/ModerationSettingsSection.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache +import kotlinx.coroutines.launch + +/** + * Content-Filters entry for NIP-36 sensitive content + NIP-51 mute-list + * management (blocked users / hidden words / muted threads). Reads/writes the + * logged-in [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] via + * [LocalDesktopIAccount]; the lists are driven by its live hidden-users flow, so + * removing an entry publishes the updated kind-10000 and un-hides immediately. + */ +@Composable +fun ModerationSettingsSection(modifier: Modifier = Modifier) { + val account = LocalDesktopIAccount.current ?: return + val cache = LocalDesktopCache.current + val scope = rememberCoroutineScope() + + val hidden by account.hiddenUsers.collectAsState() + val showSensitive by account.showSensitiveContentSetting.collectAsState() + val writeable = account.isWriteable() + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Moderation & Safety", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + // NIP-36 sensitive content + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = showSensitive == true, onCheckedChange = { account.setAlwaysShowSensitive(it) }) + Spacer(Modifier.width(8.dp)) + Text("Always show sensitive content", style = MaterialTheme.typography.bodyMedium) + } + + // Muted users + if (hidden.hiddenUsers.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Muted users (${hidden.hiddenUsers.size})", style = MaterialTheme.typography.labelLarge) + hidden.hiddenUsers.sorted().forEach { hex -> + val name = remember(hex) { cache?.getUserIfExists(hex)?.toBestDisplayName() ?: hex.take(12) } + EntryRow(label = name, actionLabel = "Unmute", enabled = writeable) { + scope.launch { account.showUser(hex) } + } + } + } + + // Hidden words + Spacer(Modifier.height(12.dp)) + Text("Hidden words", style = MaterialTheme.typography.labelLarge) + var newWord by remember { mutableStateOf("") } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newWord, + onValueChange = { newWord = it }, + label = { Text("Add word to hide") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = writeable && newWord.isNotBlank(), + onClick = { + val w = newWord.trim() + if (w.isNotEmpty()) { + scope.launch { account.hideWord(w) } + newWord = "" + } + }, + ) { Text("Add") } + } + hidden.hiddenWords.sorted().forEach { word -> + EntryRow(label = word, actionLabel = "Remove", enabled = writeable) { + scope.launch { account.showWord(word) } + } + } + + // Muted threads + if (hidden.mutedThreads.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("Muted threads (${hidden.mutedThreads.size})", style = MaterialTheme.typography.labelLarge) + hidden.mutedThreads.sorted().forEach { id -> + EntryRow(label = "${id.take(12)}…", actionLabel = "Unmute", enabled = writeable) { + scope.launch { account.showThread(id) } + } + } + } + } +} + +@Composable +private fun EntryRow( + label: String, + actionLabel: String, + enabled: Boolean, + onAction: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + if (enabled) { + TextButton(onClick = onAction) { Text(actionLabel) } + } + } +}