mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 4.8
parent
755cfc82fe
commit
49d0518db1
+57
@@ -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
|
||||
}
|
||||
}
|
||||
+73
@@ -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))
|
||||
|
||||
|
||||
+8
-4
@@ -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) {
|
||||
|
||||
+69
-4
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+152
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user