Merge pull request #3687 from nrobi144/feat/desktop-moderation-safety

feat(desktop): Moderation & Safety — mute/block enforcement, NIP-56 report, NIP-36 content warning
This commit is contained in:
Vitor Pamplona
2026-07-24 08:51:16 -04:00
committed by GitHub
24 changed files with 2086 additions and 111 deletions
@@ -70,6 +70,17 @@ data class LiveHiddenUsers(
fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex)
fun isHashtagHidden(hashtag: String) = hiddenHashtags.contains(hashtag.lowercase())
companion object {
/** Neutral value that hides nothing — a safe default before any list has loaded. */
val EMPTY =
LiveHiddenUsers(
showSensitiveContent = null,
hiddenWordsCase = emptyList(),
hiddenUsersHashCodes = emptySet(),
spammersHashCodes = emptySet(),
)
}
}
/**
@@ -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,157 @@
# Manual Testing Sheet — Desktop Moderation & Safety
**Feature branch:** `worktree-feat-desktop-moderation-safety`
**Covers commits:** `9090dfe8` → `274f8c01` (enforcement, report, CW blur, follow-ups)
**Date executed:** ____________ **Tester:** ____________ **Build:** ____________
Run: `./gradlew :desktopApp:run`
---
## 0. Setup & Prerequisites
You need up to **three accounts** to cover every path. Note their npubs below.
| Role | Requirement | npub / note |
|------|-------------|-------------|
| **A — main (writeable)** | Local `nsec` login. Ideally already has a kind-10000 mute list set from the Amethyst mobile app (to test hydration). | |
| **B — bunker (writeable, NIP-46)** | NIP-46 remote signer login. Used only for the async-decrypt path. | |
| **C — read-only (npub)** | Login with an `npub` only (no signer). | |
| **Target user X** | Any account you can mute/report/block. Should be posting notes visible in your feeds. | |
| **Followed user Y** | A user account **A follows** — used to prove mute wins over follow. | |
Legend for results: **P** = pass, **F** = fail (add note), **B** = blocked/couldn't test, **N/A**.
> Tip: to observe published events (mutes = kind 10000, reports = kind 1984), tail a
> relay you publish to, or use `amy` to fetch them by author/kind.
---
## 1. Enforcement — the core bug fix (feeds + DMs)
Logged in as **A**.
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| E1 | A already has a mobile-set mute on user X → open **Following** and **Global** | X's notes do **not** appear in either feed | |
| E2 | Right-click one of X's notes → **Mute user** | X's notes disappear from the feed **without restarting** the app (allow a moment for publish + flow update) | |
| E3 | Mute followed user **Y** (E2 flow) → open **Following** | Y's notes are hidden even though you follow Y (mute wins over follow) | |
| E4 | Open a **thread** whose root is by someone else but has a reply from a muted user | The muted user's **reply is hidden**; the thread **root is still shown** | |
| E5 | Open the **Notifications** tab; have a muted user react to / reply to your note | The muted user's notification does **not** appear | |
| E6 | Open the **DM / Messages** conversation list while a muted user has messaged you | The muted user's conversation is filtered out of the list | |
| E7 | A's mute list (from mobile) contains a **hidden word** → find a note containing that word | The note is hidden/collapsed | |
| E8 | Open the **Reads** (long-form) tab and **Search** with a muted author present | Muted author's articles/results are hidden | |
| E9 | **Unmute** X (see §4) | X's notes **reappear** across feeds, live | |
---
## 2. Thread + Profile enforcement (follow-up A)
Logged in as **A**.
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| T1 | Mute user X → open a thread containing X's reply | X's reply hidden; root shown; sibling replies still there | |
| T2 | Open **X's profile** → Notes tab | X's own notes are hidden (empty / filtered) | |
| T3 | X's profile → **Replies** tab | X's replies hidden too | |
| T4 | While X's profile is open, **Unmute** X from the header overflow (§5) | Their notes **repopulate** the tabs live | |
---
## 3. Report — NIP-56 (note + user)
Logged in as **A**.
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| R1 | Right-click a note → **Report…** | Dialog opens with reason radios (Spam / Profanity / Impersonation / Nudity / Illegal / Malware) + a comment field | |
| R2 | Pick a reason, add a comment → **Report** | A **kind-1984** event is published (verify on a relay / via `amy`) tagging the note id + author; the note is **not** hidden | |
| R3 | Report a note → **Block & report** | Report published **and** the author is muted (their notes vanish) | |
| R4 | Open X's profile → header **⋮** → **Report…** → pick reason → **Report** | kind-1984 published tagging **the user** (no `e` tag required) | |
| R5 | Profile **⋮** → **Block & report** | Report published + user muted | |
---
## 4. Content warning — NIP-36 blur (follow-up B render)
Logged in as **A**. Ensure "Always show sensitive content" is **OFF** (§6) for C1–C3.
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| C1 | Find a note tagged `content-warning` / NSFW in a feed | Renders **collapsed**: "Sensitive content" + the reason text + a **Show** button; media not visible | |
| C2 | Click **Show** | The note body/media reveals; stays revealed while scrolling away and back (per-note) | |
| C3 | Open a CW-tagged note as a **thread root** | Auto-revealed (you navigated into it — `forceReveal`) | |
| C4 | Turn **ON** "Always show sensitive content" (§6) → revisit a CW note in the feed | Renders **unblurred** immediately, no Show gate | |
| C5 | Turn it **OFF** again | CW notes blur again | |
| C6 | Confirm spam-collapse still works and doesn't conflict on a note that is both spammy and CW | One collapse shown; **Show** reveals the note | |
---
## 5. Profile moderation actions (follow-up B)
Logged in as **A**, viewing **X's** profile (not your own).
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| P1 | Profile header shows a **⋮ (MoreVert)** button next to Follow | Present for other users on a writeable account | |
| P2 | ⋮ → menu | Shows **Mute user** (or **Unmute user** if already muted) + **Report…** | |
| P3 | ⋮ → **Mute user** | X muted; label flips to **Unmute user** on reopen; notes hide | |
| P4 | ⋮ → **Unmute user** | X unmuted; notes return | |
| P5 | Open **your own** profile | **No** ⋮ moderation button (can't mute yourself) | |
---
## 6. Settings — sensitive toggle + management screens (follow-ups B & C)
Open **Settings → Content Filters** as **A**.
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| S1 | Locate the **Moderation & Safety** section (below Hashtag-spam filter) | Section renders with an "Always show sensitive content" switch | |
| S2 | Toggle the switch on → **restart the app** → reopen settings | Switch is **still on** (persisted via prefs) | |
| S3 | **Muted users** list | Lists every muted/blocked user with a resolved display name (or short pubkey) + **Unmute** | |
| S4 | Click **Unmute** on a user | Row disappears; that user's notes reappear in feed live; kind-10000 re-published | |
| S5 | **Hidden words**: type a word → **Add** | Word appears in the list; notes containing it collapse in feeds | |
| S6 | **Hidden words**: **Remove** a word | Word gone; matching notes reappear | |
| S7 | **Muted threads** list (mute a thread first, if none) | Lists muted thread ids (truncated) + **Unmute**; removing restores the thread | |
| S8 | Mute a user via note menu (§1 E2) → reopen settings | The new user shows up in the **Muted users** list immediately | |
---
## 7. Account edge cases
| # | Account | Steps | Expected | Result |
|---|---------|-------|----------|:---:|
| A1 | **C (read-only)** | Right-click a note; open a profile ⋮; open settings Moderation section | **No** Mute/Report items in menus; management lists render **read-only** (no Unmute/Add/Remove); sensitive toggle still works (local pref). No crash. | |
| A2 | **C (read-only)** | A public (unencrypted) mute entry exists on this npub | Public mute portion is still **enforced** in feeds | |
| A3 | **B (bunker)** | Log in fresh; the private mute list must be decrypted via the remote signer | Enforcement engages **once decryption resolves** (may lag a beat); no crash while waiting; approve the decrypt on the signer if prompted | |
| A4 | **A** | Mute someone, then kill the relay / go offline mid-publish | App doesn't crash; local hide still applied; re-syncs when back online | |
---
## 8. Regression
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| X1 | Browse feeds with **no** mutes set | Everything renders as before; no scroll jank / perf regression | |
| X2 | Existing hashtag-spam collapse | Still works independently of moderation | |
| X3 | Reactions, reposts, zaps, bookmarks on a note | Unchanged (moderation menu items are additive to ShareMenu) | |
| X4 | Open/refresh a thread and a profile repeatedly | No leaks / duplicate VMs (filters recreated on account change only) | |
---
## Automated coverage (already green)
- `./gradlew :desktopApp:test --tests "com.vitorpamplona.amethyst.desktop.filters.DesktopMuteEnforcementTest"`
— muted author hidden (even if followed), hidden-word drop, clean notes pass (§1 E1/E3/E7).
- `./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettingsTest"`
— sensitive toggle mapping `on→true`, `off→null` (never false), persistence (§6 S2).
## Sign-off
- [ ] All §1 enforcement scenarios pass (the core bug fix).
- [ ] All follow-ups (§2, §5, §6) pass.
- [ ] Edge cases (§7) behave with no crash.
- [ ] No regressions (§8).
Overall: ☐ PASS ☐ FAIL — notes: ______________________________________________
@@ -0,0 +1,144 @@
---
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
- [x] Muting hides in open threads (replies) + on profile tabs, live.
- [x] "Always show sensitive content" toggle persists and flips CW blur app-wide.
- [x] Profile screen has working Mute + Report actions (writeable, non-self) —
MoreVert overflow.
- [x] Blocked-users / Hidden-words / Muted-threads lists in
`ModerationSettingsSection`; remove publishes the updated mute list and
un-hides live; hidden-words has an add field.
- [x] All reachable from the Content Filters settings section.
- [x] Read-only + bunker accounts behave (write actions gated on `isWriteable()`;
management remove/add hidden for read-only).
- [x] Unit test: sensitive-content mapping (`true→true`, `false→null`, persists)
in `PreferencesSensitiveContentSettingsTest`.
- [x] `spotlessApply` clean; commons + desktopApp compile; tests green.
**Status: COMPLETE** — commit `49d0518d`. Manual run (`./gradlew :desktopApp:run`)
+ PR remain (nostr-git repo → `ngit-pr` flow).
## 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`
@@ -0,0 +1,375 @@
---
title: Desktop Moderation & Safety (mute/block enforcement + NIP-56 report + NIP-36 content-warning)
type: feat
status: active
date: 2026-07-23
origin: docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md
module: desktopApp (+ commons, reusing quartz)
---
# ✨ Desktop Moderation & Safety
## Enhancement Summary (deepened 2026-07-23)
Grounding research + direct code inspection **resolved the two make-or-break
cruxes**, de-risking the whole feature:
1. **Enforcement primitive is already shared.** `LiveHiddenUsers` and
`Note.isHiddenFor(accountChoices: LiveHiddenUsers)` live in **`commons/`**
(`commons/.../model/Note.kt:1279`, referenced from
`commons/.../model/IAccount.kt`, covered by
`commons/.../model/NoteIsHiddenForTest.kt`). Desktop does **not** need to port
or promote the hidden-check logic — it only needs to *assemble a
`LiveHiddenUsers` value* from the account's lists and hold it as a StateFlow.
→ Crux 1 collapses to "build the value object"; **desktop-local state holders
are sufficient** (no risky commons extraction for v1).
2. **Feeds have a live-invalidation hook.** Desktop feeds implement
`commons/.../ui/feeds/InvalidatableContent` via `FeedContentState`
(`invalidateData()`). Wiring the hidden-users StateFlow to call
`invalidateData()` on emit makes mute/block hide **without restart**.
→ Crux 2 resolved: reuse `invalidateData()`, don't invent a bus.
3. **Concrete APIs confirmed** (see Research Insights under Technical Approach):
`MuteListEvent.create/add/remove(MuteTag, isPrivate, signer)` where `MuteTag ∈
{UserTag, HashtagTag(word), EventTag(thread)}`; `PeopleListEvent.addUser/
removeUser` for blocks; publish via `signer.sign(template)` +
`relayManager.broadcastToAll(signed)` (precedent `reactToNote`/`repostNote` in
`desktopApp/.../ui/NoteActions.kt`).
**Net effect on phases:** Phase 0 shrinks (assemble a value, don't port a
subsystem). Phase 1's only real work is the `invalidateData()` wiring + predicate.
Overall risk downgraded from *Medium* to *Low-Medium*.
> _Note: 3 of 4 deepening sub-agents were killed by the 600s stall watchdog
> (environment load); the facts above were recovered by direct grep and are
> first-hand, not agent-summarized._
## Overview
Close the largest verified Desktop parity gap after Polls by shipping a coherent
**Moderation & Safety** bundle:
1. **P2 — Fix the silent mute/block no-op** (a live bug: muting does nothing on
desktop feeds today) + management surfaces.
2. **P3 — Report a note/user (NIP-56).**
3. **P4 — Content-warning blur/reveal (NIP-36).**
All three share the same account-moderation state and the same "hide-with-reveal"
UX primitive, so they ship together as one feature branch (see brainstorm:
`docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md`).
## Problem Statement
- **Mute is a lie on desktop.** `DesktopIAccount.isHidden(user)` is hardcoded
`false`; `isAcceptable(note)` only checks deletions; `DesktopFeedFilters` never
calls either. Desktop also holds **no** mute/block state at all — the fields
(`hiddenUsersHashCodes`, `hiddenWordsCase`, `spammersHashCodes`,
`showSensitiveContent`) are empty/`null` stubs. A user who mutes someone
reasonably believes it works; it does not.
- **No way to report.** Desktop has zero NIP-56 UI; Android has a full report
dialog.
- **Sensitive media shows unblurred.** `showSensitiveContent` is hardcoded
`null` and no NIP-36 gate exists, so content-warning-tagged media renders in
the clear.
## Proposed Solution
Port Android's moderation state + UI into shared/desktop code and wire real
enforcement, reusing quartz protocol and the existing spam-collapse reveal:
- **State:** hold mute list (kind 10000) + blocked people (kind 10001) +
assembled `LiveHiddenUsers` on `DesktopIAccount`; hydrate via a new account
subscription and the account signer (decrypts the private list).
- **Enforcement:** implement `isHidden`/`isAcceptable` by delegating to quartz
`Note.isHiddenFor(liveHiddenUsers)`; chain `&& account.isAcceptable(note)` into
every `DesktopFeedFilters` feed path. The chatroom list already calls
`isAcceptable`, so DMs get filtered for free once the predicate is real.
- **Actions:** `hideUser`/`showUser`/`blockUser`/`hideWord`/`hideThread` on
`DesktopIAccount` build → sign → publish an updated `MuteListEvent`/
`PeopleListEvent`.
- **Report:** `report(note|user, type, comment)` reuses quartz `ReportEvent.build`
(kind 1984) + `ReportType`, published to the account's public + private outbox
(match Android). New `ReportNoteDialog` ported to desktop Compose, wired into
`ShareMenu` and `UserProfileScreen`.
- **Content warning:** generalize `SpamCheckedNoteRender` to also gate
`Event.isSensitiveOrNSFW()` notes with a blur/scrim + reason + tap-to-reveal,
plus a "always show sensitive content" settings toggle persisted alongside the
spam settings.
## Technical Approach
### Architecture — moderation state & enforcement flow
```mermaid
flowchart TD
subgraph relays[Relays]
K10000[kind 10000 MuteListEvent - encrypted]
K10001[kind 10001 PeopleListEvent - block]
K1068[content notes, some content-warning tagged]
end
K10000 & K10001 --> SUB[Main.kt account subscription]
SUB --> DLC[DesktopLocalCache]
DLC --> MLS[MuteListState / BlockPeopleListState]
SIGNER[Account signer - decrypts private list] --> MLS
MLS --> HUS[HiddenUsersState.assembleLiveHiddenUsers]
HUS --> LHU[[LiveHiddenUsers flow]]
LHU --> IACC[DesktopIAccount.isHidden / isAcceptable]
IACC --> FF[DesktopFeedFilters feed / applyFilter]
IACC --> CHAT[ChatroomList - already calls isAcceptable]
K1068 --> FF
FF --> UI[FeedNoteCard]
UI --> SCNR[SpamCheckedNoteRender - generalized]
SCNR -->|spam OR content-warning| COLLAPSE[Collapsed w/ reveal]
SCNR -->|clean| BODY[FeedNoteCardBody]
```
### Key reuse (confirmed by code research, 2026-07-23)
| Concern | Reuse (no reimpl) | Source |
|---|---|---|
| Report protocol | `ReportEvent.build(post/user, type, comment)` kind 1984 | `quartz/.../nip56Reports/ReportEvent.kt` |
| Report types | `ReportType` enum (SPAM/PROFANITY/IMPERSONATION/NUDITY/ILLEGAL/MALWARE/VIOLENCE/…) | `quartz/.../nip56Reports/ReportType.kt` |
| CW detection | `Event.isSensitiveOrNSFW()`, `contentWarningReason()` | `quartz/.../nip36SensitiveContent/EventExt.kt` |
| Hidden check | `Note.isHiddenFor(liveHiddenUsers)` (users+words+threads) | `quartz/.../Note.kt` |
| Mute/block events | `MuteListEvent` (10000), `PeopleListEvent` (10001) | `quartz/.../nip51Lists/…` |
| Reveal primitive | `SpamCheckedNoteRender` `forceReveal` + `rememberSaveable(id)` | `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` |
### Android references to port
| Piece | Android reference |
|---|---|
| Mute state | `amethyst/.../model/nip51Lists/muteList/MuteListState.kt` |
| Block state | `amethyst/.../model/nip51Lists/…/BlockPeopleListState` |
| Hidden assembly | `amethyst/.../model/nip51Lists/HiddenUsersState.kt` (`assembleLiveHiddenUsers`) |
| Report dialog | `amethyst/.../ui/screen/loggedIn/report/ReportNoteDialog.kt` |
| CW gate | `amethyst/.../ui/components/SensitivityWarning.kt` |
| Mgmt screens | `amethyst/.../settings/{BlockedUsersScreen,HiddenWordsScreen,MutedThreadsScreen}.kt` |
### Design cruxes — RESOLVED (deepened 2026-07-23)
1. **State-holder placement → desktop-local, value-object only. RESOLVED.**
`LiveHiddenUsers`/`Note.isHiddenFor` already in `commons/` — no port, no type
promotion. Build a small desktop assembler that reads the account's mute list
(kind 10000) + blocked people (kind 10001) from `DesktopLocalCache` + signer
and emits `StateFlow<LiveHiddenUsers>`. Option (a) full commons extraction is
**deferred** — unnecessary for v1.
2. **Reactivity → `FeedContentState.invalidateData()`. RESOLVED.** Desktop feeds
implement `commons/.../ui/feeds/InvalidatableContent`. Collect the
`StateFlow<LiveHiddenUsers>` where feeds are built and call `invalidateData()`
on change so mutes apply live. Verify each feed's content-state gets the call
(Following/Global/Thread/Notifications/Profile).
3. **Reveal generalization shape.** One generalized `SpamCheckedNoteRender`
(collapse for spam **or** CW), two reasons — avoids double nesting. (Decision
stands; confirm final shape during Phase 4.)
4. **Media blur fidelity.** v1 = opaque scrim + reason overlay + reveal (no new
dependency; Compose Desktop `Modifier.blur` support is uneven across the
skiko backend, so avoid depending on it). True blur is a v2 nicety. Documented
limitation.
5. **Report publish path → `signer.sign` + `relayManager.broadcastToAll`.
RESOLVED.** Desktop has no `sendMyPublicAndPrivateOutbox` helper; the
established pattern (`reactToNote`/`repostNote`,
`desktopApp/.../ui/NoteActions.kt:1387,1472`) is sign-then-`broadcastToAll`.
v1 reports broadcast to connected relays (NIP-56 is advisory) — noted as a
deliberate simplification vs Android's outbox split.
### Research Insights — confirmed APIs (copy-ready)
**Mute / block writes** (`quartz/.../nip51Lists/`), all `suspend`, signer-based:
```
// mute a user (private by default), returns the new event to publish
MuteListEvent.create(mute = UserTag(pubkey), isPrivate = true, signer = signer)
MuteListEvent.add(earlierVersion, mute = HashtagTag(word), isPrivate, signer) // hidden word
MuteListEvent.add(earlierVersion, mute = EventTag(rootId), isPrivate, signer) // muted thread
MuteListEvent.remove(earlierVersion, mute, signer) // unmute
// reading private entries requires the signer:
earlierVersion.privateTags(signer) // throws SignerExceptions.UnauthorizedDecryptionException on read-only
// block (kind 10001)
PeopleListEvent.addUser(...) / removeUser(...)
```
MuteTag subtypes: `UserTag` (pubkey), `HashtagTag` (word), `EventTag` (thread
root). Enforcement then reads these via `Note.isHiddenFor(liveHiddenUsers)`.
**Publish** (precedent in `desktopApp/.../ui/NoteActions.kt`):
```
val signed = signer.sign(template) // DesktopIAccount already does signer.sign at ~L187
relayManager.broadcastToAll(signed) // reactToNote L1395 / repostNote L1479
```
**Report** (`quartz/.../nip56Reports/`): `ReportEvent.build(reportedPost, type,
comment)` and `ReportEvent.build(reportedUser, type, comment)` (kind 1984);
`ReportType ∈ {SPAM, PROFANITY, IMPERSONATION, NUDITY, ILLEGAL, MALWARE,
VIOLENCE, …}`.
**Content warning** (`quartz/.../nip36SensitiveContent/EventExt.kt`):
`Event.isSensitiveOrNSFW(): Boolean`, `Event.contentWarningReason(): String?`.
**Read-only / bunker caveat:** `privateTags(signer)` throws on a read-only
account and is async for NIP-46 bunkers — assemble from the **public** list
portion immediately and recompute when decryption resolves; disable write actions
(mute/block/report) when there's no local signer.
### Implementation Phases
Each phase is independently compilable; Phase 1 alone fixes the enforcement bug.
#### Phase 0 — Moderation state foundation
- New: `MuteListState`/`BlockPeopleListState`/`HiddenUsersState` (desktop-local
per crux 1), producing a `LiveHiddenUsers` StateFlow from `DesktopLocalCache` +
signer.
- Add kind-10000/10001 to desktop account subscription in `Main.kt`.
- Success: flow populates + decrypts on login (log the assembled set).
#### Phase 1 — Enforcement wiring (the bug fix)
- Implement `DesktopIAccount.isHidden(user)` / `isAcceptable(note)` via
`Note.isHiddenFor(hiddenUsers.value)`; populate `showSensitiveContent`,
`hiddenUsersHashCodes`, etc. from state.
- Chain `&& account.isAcceptable(note)` into every `DesktopFeedFilters`
`feed()`/`applyFilter()`; ensure reactivity (crux 2).
- Success: muting a user (even via a hand-inserted list) hides their notes in
Following/Global/Thread/Notifications + chatroom list, live.
#### Phase 2 — Mute/Block actions + management screens
- `DesktopIAccount.hideUser/showUser/blockUser/hideWord/hideThread` build+sign+
publish updated list events.
- Mute/Block action in note context menu (`ShareMenu`) + `UserProfileScreen`.
- Port `BlockedUsersScreen`/`HiddenWordsScreen`/`MutedThreadsScreen` to desktop
settings (read + remove).
- Success: block from profile persists (kind-10000 on relay) and hides live;
management screens list + remove entries.
#### Phase 3 — Report (NIP-56)
- `DesktopIAccount.report(note|user, type, comment)` → `ReportEvent.build` →
publish to public+private outbox.
- Port `ReportNoteDialog` (report-type list + comment + "Block & Hide User" +
"Post Report") to desktop Compose; wire into `ShareMenu` + `UserProfileScreen`.
- Success: reporting publishes a well-formed kind-1984 to the right relays
(verify via `amy` / relay log); "Block & Hide" also mutes.
#### Phase 4 — Content-warning blur (NIP-36)
- Generalize `SpamCheckedNoteRender` to also gate `isSensitiveOrNSFW()` notes:
scrim + reason + reveal, honoring per-note `rememberSaveable` reveal and the
account "always show sensitive" setting.
- Add "always show sensitive content" toggle persisted with spam settings.
- Success: CW note renders blurred; reveal works; toggle bypasses blur.
#### Phase 5 — Settings, tests, polish
- Surface all toggles + management-screen entries in the existing **Content
Filters** settings section (where hashtag-spam lives).
- Unit tests: enforcement predicate (user/word/thread hidden), report-event
shape, CW detection/gate state.
- `./gradlew spotlessApply`; compile commons+desktopApp+cli; manual testing sheet
`desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`.
## Alternative Approaches Considered
- **Three separate PRs** — rejected: triplicates the state extraction, loses
coherence (see brainstorm).
- **Enforcement-only (defer report + CW)** — kept as fallback if scope creeps;
report + CW are S each and share plumbing.
- **Extract state holders to `commons/` now** — deferred (crux 1); higher refactor
risk than desktop-local for v1.
## System-Wide Impact
### Interaction graph
Mute action → sign kind-10000 → publish → echoes back into `DesktopLocalCache` →
`MuteListState` recomputes → `LiveHiddenUsers` emits → feed filters re-evaluate →
note disappears. Requires the emit to invalidate feeds (crux 2).
### Error & failure propagation
- Sign/publish failure on a mute/report: match Android's optimistic model but
surface a failure toast; avoid leaving local state ahead of relays silently.
- Bunker (NIP-46) decrypt is async/remote — private mute list may be empty until
it returns; enforcement must tolerate a late-arriving set (recompute on emit).
### State lifecycle risks
- Read-only (npub) accounts: no signer → can't decrypt private list or publish.
Enforce the public portion only; disable mute/block/report actions (no crash).
- "Block & Hide" from report dialog mutates the mute list and publishes a report
— two events; partial success must not corrupt local list state.
### API surface parity
`isAcceptable` is consumed by feed filters, the chatroom/DM list, and
notifications. Fixing the predicate intentionally affects all three (muted users
vanish from DMs + notifications too). Verify each path.
### Integration test scenarios
1. Mute user → notes vanish across Following/Global/Thread/Notifications + DM
list, live; unmute restores.
2. Block via report "Block & Hide" → kind-10000 published + note hidden.
3. Hidden word → matching notes collapse (via `isHiddenFor`).
4. Muted thread → replies under that root hidden.
5. Report note → kind-1984 published to public+private outbox, correct tags.
6. CW note → blurred; reveal shows; "always show" bypasses.
7. Read-only account → actions disabled, enforcement of public list still works.
8. Bunker account → mute list decrypts asynchronously; enforcement engages on
arrival.
## Acceptance Criteria
### Functional
- [x] Muting/blocking a user hides their notes across all desktop feeds + the DM
list **without restart**. *(DesktopHiddenUsersState + isAcceptable + feed
filter chaining + invalidateData; DM list already called isAcceptable.)*
- [ ] Blocked/hidden-words/muted-threads management screens list + remove entries,
persisted as kind-10000/10001 on relays. **DEFERRED** — write actions exist
(`hideWord`/`hideThread`/`showUser`…), but the settings *screens* are a
follow-up.
- [x] Mute action available from note context menu (`ShareMenu`). *(Mute-from-
profile deferred.)*
- [x] Report action (note) publishes a valid NIP-56 kind-1984; report dialog
offers the NIP-56 types + optional comment + "Block & report". *(Publishes
via broadcastToAll rather than a public/private outbox split — noted
simplification; NIP-56 is advisory.)*
- [x] Content-warning-tagged notes render scrimmed with reason + reveal.
*(Account setting bypass is honored; the toggle UI to flip it is deferred.)*
- [ ] All toggles + management entries reachable from the Content Filters
settings section. **DEFERRED** with the management screens.
### Non-functional / quality gates
- [x] Read-only + bunker accounts behave correctly (write actions gated on
`isWriteable()`; enforcement tolerates late async decrypt via the flow).
- [x] Enforcement predicate is hash-set lookups (`Note.isHiddenFor`), no feed jank.
- [x] Unit tests for the enforcement predicate green (`DesktopMuteEnforcementTest`).
- [x] `spotlessApply` clean; commons + desktopApp compile; desktopApp + commons
test suites green.
- [ ] Manual testing sheet executed. *(Sheet written:
`desktopApp/plans/2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`
— needs a human run of the app.)*
### Deferred to a focused follow-up
- Management screens (Blocked users / Hidden words / Muted threads) + their
Content-Filters settings entries.
- "Always show sensitive content" settings toggle (persisted) + mute/report from
the profile screen.
- Thread-root/profile-screen enforcement wiring (needs `iAccount` threaded into
those two composables).
## Dependencies & Risks
- **Reactivity of feed filters to the hidden-users flow** — top risk (crux 2).
- **State-holder placement / `LiveHiddenUsers` type location** (crux 1).
- **Async decryption for bunker accounts.**
- No new third-party dependency anticipated (all quartz-internal) → no licensing
action required; re-check if a blur library is introduced for Phase 4.
## Sources & References
### Origin
- **Brainstorm:** `docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md`.
Carried-forward decisions: bundle P2+P3+P4; state in shared/desktop code +
desktop-native UI; enforcement via `Note.isHiddenFor`; report via quartz
`ReportEvent`; CW reuses the spam reveal; settings under Content Filters.
### Internal references
- `desktopApp/.../model/DesktopIAccount.kt` (stub predicates ~L176–182,
`showSensitiveContent` ~L128)
- `desktopApp/.../feeds/DesktopFeedFilters.kt` (`feed()`/`applyFilter()` hooks)
- `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` (reveal primitive)
- `desktopApp/.../ui/note/ShareMenu.kt`, `desktopApp/.../ui/NoteActions.kt`
- `quartz/.../nip56Reports/{ReportEvent,ReportType}.kt`
- `quartz/.../nip36SensitiveContent/{ContentWarningTag,EventExt}.kt`
- Android: `amethyst/.../model/nip51Lists/**`, `.../ui/screen/loggedIn/report/ReportNoteDialog.kt`,
`.../ui/components/SensitivityWarning.kt`, `.../settings/{Blocked,HiddenWords,MutedThreads}*.kt`
### Related work
- Hashtag-spam filter (reveal pattern + Content Filters settings section) —
memory `moderation/` package + `SpamCheckedNoteRender`.
- Parity backlog P2/P3/P4 — `desktopApp/plans/2026-07-16-desktop-parity-gap-backlog.md`.
@@ -0,0 +1,49 @@
# Test Results — Desktop Moderation & Safety
**Executed:** 2026-07-24 · **Build:** branch `feat/desktop-moderation-safety`
**Method:** live app (`./gradlew :desktopApp:run`) on a real account (22 relays
connected), plus automated unit tests. Moderation publishes are logged under the
`[Moderation]` tag.
Legend: **[x] PASS** · [ ] not run.
## Live app — verified
- [x] **Enforcement** — muting a user removes their notes from feeds live (no restart).
- [x] **Mute publishes** — kind-10000 mute list signed + broadcast to relays.
- [x] **Report (NIP-56) publishes** — kind-1984 report signed + broadcast to relays.
- [x] **Discoverability** — note **⋮** overflow **and** right-click both open the
note menu with Mute / Report / copy / broadcast (same items).
- [x] **Report dialog** — reason picker + comment + Report / Block & report.
- [x] **Profile ⋮** — Mute/Unmute + Report on another user's profile.
- [x] **Content-warning blur** — NIP-36 notes collapse with tap-to-reveal;
"always show sensitive content" toggle persists.
- [x] **Management screens** — Content Filters → Moderation & Safety lists
muted users / hidden words / muted threads with remove/add.
- [x] **Feedback (new)** — moderation actions now show a snackbar
(Muted user / Report sent / Reported & muted) and log the relay count.
### Captured logs (this session)
```
INFO: [Moderation] mute+ kind=10000 id=95057975 → 22 relays
INFO: [Moderation] report(spam) kind=1984 id=447fbf93 → 22 relays
INFO: [Moderation] report(spam) kind=1984 id=c6f58e03 → 22 relays
```
→ one mute and two spam reports were signed and delivered to **22 relays** each,
with **no failures**. (A zero-relay send would log `WARNING … → 0 connected
relays (not delivered)`; a signer error logs `WARNING … report failed: …`.)
## Automated tests — green
- [x] `DesktopMuteEnforcementTest` — muted author hidden (even if followed),
hidden-word note dropped, clean notes pass.
- [x] `PreferencesSensitiveContentSettingsTest` — sensitive toggle maps
`on→true` / `off→null` (never false) and persists.
- [x] `./gradlew :desktopApp:test :commons:jvmTest` — **BUILD SUCCESSFUL**.
- [x] `spotlessApply` clean; pre-commit hook (spotless + tests) green on every commit.
## Notes / known gaps
- Reports broadcast to **connected relays** (not a NIP-56 outbox split) — advisory per NIP-56.
- Thread *root* is always shown even if the author is muted (intentional — you navigated in).
@@ -0,0 +1,108 @@
# Manual Testing Sheet — Moderation UX & Reporting (discoverability + feedback)
Covers the follow-on UX fixes: right-click note menu, the **⋮** overflow, and the
new **moderation publish logging**. Companion to the full feature sheet
(`2026-07-23-desktop-moderation-safety-manual-testing-sheet.md`).
**Branch:** `worktree-feat-desktop-moderation-safety` (commit `65579ff` or later)
**Run:** `./gradlew :desktopApp:run`
**Watch the log** (this terminal, or `desktop-run*.log`) — moderation actions now
print a line tagged `[Moderation]`.
Legend: **P** pass · **F** fail (note it) · **B** blocked.
---
## 0. Preconditions
- Logged in with a **writeable** account (local nsec, or a connected bunker).
- **At least one relay connected** — check the relay indicator. This matters:
a report/mute publishes to *connected relays only*; with 0 connected it is
logged as **not delivered**.
- Have another user **X**'s notes visible in a feed.
---
## 1. Discoverability — how to reach the actions
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| D1 | Look at a note's action row (reply / ♥ / repost / zap / bookmark / **⋮**) | The last icon is now a **⋮ (three dots)**, not a share arrow | |
| D2 | **Left-click the ⋮** | Menu opens: Copy Text / Copy Note ID / Copy Event Link / Copy Raw JSON / Copy Web Link / Broadcast / **Mute user** / **Report…** | |
| D3 | **Right-click anywhere on a note** | Context menu opens: **Copy text / Mute user / Report…** | |
| D4 | Right-click **your own** note | Only **Copy text** (no Mute/Report on yourself) | |
| D5 | Open **another user's profile** → header shows a **⋮** next to Follow → click it | **Mute/Unmute user** + **Report…** | |
| D6 | On a **read-only (npub)** login, repeat D2/D3 | Only Copy items; no Mute/Report | |
---
## 2. Report (NIP-56) — with verifiable feedback
Do each, then **check the log line**.
| # | Steps | Expected + log signal | Result |
|---|-------|------------------------|:---:|
| R1 | ⋮ (or right-click) → **Report…** | Dialog: reason radios + comment + **Report** / **Block & report** / Cancel | |
| R2 | Pick a reason → **Report** | Log shows `[Moderation] report(<reason>) kind=1984 id=… → N relays` (N ≥ 1) | |
| R3 | Confirm on a relay (optional) | A kind-1984 event by you, tagging the note id + author, exists on relay | |
| R4 | **Report a user** from profile ⋮ → Report… | Log: `[Moderation] report-user(<reason>) kind=1984 id=… → N relays` | |
| R5 | **Block & report** | Two log lines: `report(...)` **and** `mute+ kind=10000 …`; author's notes vanish | |
| R6 | Disconnect all relays, then Report | Log **WARN**: `… → 0 connected relays (not delivered)` — proves the "silent failure" is now visible | |
> There is **no success snackbar yet** — confirmation is via the `[Moderation]`
> log line (and the author disappearing for mutes). A toast/snackbar is a noted
> follow-up; see "Known gaps" below.
---
## 3. Mute / unmute — live effect + log
| # | Steps | Expected + log | Result |
|---|-------|-----------------|:---:|
| M1 | ⋮ / right-click X's note → **Mute user** | X's notes disappear from the feed live; log `[Moderation] mute+ kind=10000 id=… → N relays` | |
| M2 | Settings → Content Filters → **Moderation & Safety** → Muted users | X is listed with a display name + **Unmute** | |
| M3 | Click **Unmute** | X reappears live; log `[Moderation] mute- kind=10000 … → N relays` | |
| M4 | **Hidden words** → type a word → **Add** | Notes with the word collapse; log `mute+ kind=10000` | |
| M5 | Profile ⋮ → **Mute user**, reopen menu | Label now reads **Unmute user** | |
---
## 4. Content warning (NIP-36) — sanity
| # | Steps | Expected | Result |
|---|-------|----------|:---:|
| C1 | Sensitive/CW-tagged note in feed | Collapsed "Sensitive content" + reason + **Show** | |
| C2 | Settings → Moderation & Safety → toggle **Always show sensitive content** ON | CW notes render unblurred; persists after app restart | |
---
## 5. Log reference
While testing, moderation lines look like:
```
INFO: [Moderation] mute+ kind=10000 id=3f9a1c02 → 4 relays
INFO: [Moderation] report(spam) kind=1984 id=9b7e4410 → 4 relays
WARNING: [Moderation] report(spam) kind=1984 id=9b7e4410 → 0 connected relays (not delivered)
WARNING: [Moderation] report failed: <signer error>
```
- **`→ N relays` (N≥1)** = published. **`→ 0 connected relays`** = you had no relay
connected; reconnect and retry. **`report failed:`** = signing/publish threw
(e.g. bunker rejected/timed out).
---
## Known gaps (not bugs — noted follow-ups)
- **No success snackbar/toast** — feedback is via the log line + the muted author
disappearing. A user-facing confirmation is a small follow-up (needs a
SnackbarHostState threaded to the note card / dialog).
- Reports are broadcast to **connected relays** (not a NIP-56 outbox split).
## Sign-off
- [ ] ⋮ overflow + right-click both reach Mute/Report (§1).
- [ ] Report logs a `→ N relays` line with N ≥ 1 (§2 R2/R4).
- [ ] Zero-relay report is logged as not-delivered (§2 R6).
- [ ] Mute hides live + shows in management screen + unmute restores (§3).
Overall: ☐ PASS ☐ FAIL — notes: __________________________________________
@@ -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
@@ -1430,6 +1431,7 @@ private fun AppInner(
LocalNamecoinPreferences provides namecoinPreferences,
LocalNamecoinService provides namecoinService,
LocalSpamExemptKeys provides spamExemptKeys,
com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount,
) {
val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
@@ -1721,6 +1723,7 @@ fun MainContent(
SearchRelayListEvent.KIND,
BlockedRelayListEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
),
authors = listOf(account.pubKeyHex),
limit = 5,
@@ -1745,7 +1748,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)
@@ -1779,6 +1783,7 @@ fun MainContent(
SearchRelayListEvent.KIND,
BlockedRelayListEvent.KIND,
BlossomServersEvent.KIND,
MuteListEvent.KIND,
),
authors = listOf(account.pubKeyHex),
limit = 10,
@@ -1793,7 +1798,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)
}
@@ -2055,6 +2060,8 @@ fun MainContent(
CompositionLocalProvider(
LocalRelayCategories provides relayCategories,
LocalBlossomServers provides iAccount.blossomServerList.flow,
com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount,
com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost provides snackbarHostState,
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
@@ -2663,6 +2670,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))
@@ -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
@@ -59,17 +60,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()
@@ -81,6 +83,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()}"
@@ -89,7 +92,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())
@@ -98,7 +101,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())
}
}
@@ -115,12 +118,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
@@ -167,6 +172,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"
@@ -174,6 +180,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 })
@@ -183,7 +191,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)
}
@@ -207,6 +217,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"
@@ -220,6 +231,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 {
@@ -265,16 +277,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)
@@ -288,6 +301,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 =
@@ -316,7 +330,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())
}
}
@@ -327,6 +342,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"
@@ -335,7 +351,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())
}
@@ -344,7 +360,7 @@ class DesktopSearchFeedFilter(
val lowerQuery = query.lowercase()
return newItems.filterTo(HashSet()) {
val event = it.event
event is TextNoteEvent && event.content.lowercase().contains(lowerQuery)
event is TextNoteEvent && event.content.lowercase().contains(lowerQuery) && !it.isHiddenFor(hidden())
}
}
@@ -0,0 +1,139 @@
/*
* 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)
/** The user's current mute list event, or null if none has loaded yet. */
fun currentMuteList(): MuteListEvent? = muteListNote.event as? MuteListEvent
fun hideUserTransiently(pubkeyHex: String) {
transientHiddenUsers.value = transientHiddenUsers.value + pubkeyHex
}
fun showUserTransiently(pubkeyHex: String) {
transientHiddenUsers.value = transientHiddenUsers.value - pubkeyHex
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.INwcSignerState
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.Kind3FollowListRepository
@@ -32,11 +33,13 @@ 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
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
@@ -50,6 +53,13 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEve
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -58,10 +68,12 @@ 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.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Logger
/**
* Desktop implementation of IAccount.
@@ -138,13 +150,34 @@ class DesktopIAccount(
// ---------------------------------------------------------------------------------
override val showSensitiveContent: Boolean? = null
/**
* "Always show sensitive content" preference (NIP-36). `null` = respect
* content warnings (blur), `true` = always show. Persisted via
* [PreferencesSensitiveContentSettings] (shared with `amy`); flip it with
* [setAlwaysShowSensitive].
*/
private val sensitiveContentSettings = PreferencesSensitiveContentSettings()
val showSensitiveContentSetting: StateFlow<Boolean?> = sensitiveContentSettings.showSensitiveContent
override val hiddenWordsCase: List<DualCase> = emptyList()
fun setAlwaysShowSensitive(alwaysShow: Boolean) = sensitiveContentSettings.setAlwaysShow(alwaysShow)
override val hiddenUsersHashCodes: Set<Int> = emptySet()
/**
* 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 spammersHashCodes: 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 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 +206,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>) {
@@ -351,6 +384,107 @@ class DesktopIAccount(
}
}
// ----- Moderation write actions (NIP-51 mute list + NIP-56 reports) -----
/** Mute a user (private entry). Persists to the kind-10000 mute list and hides live. */
suspend fun hideUser(pubkeyHex: HexKey) = updateMuteList(UserTag(pubkeyHex), isPrivate = true, add = true)
/** Un-mute a user. */
suspend fun showUser(pubkeyHex: HexKey) = updateMuteList(UserTag(pubkeyHex), isPrivate = true, add = false)
/** Hide a word/phrase (private entry). Notes containing it collapse. */
suspend fun hideWord(word: String) = updateMuteList(WordTag(word), isPrivate = true, add = true)
suspend fun showWord(word: String) = updateMuteList(WordTag(word), isPrivate = true, add = false)
/** Mute a thread by its root event id. */
suspend fun hideThread(rootIdHex: HexKey) = updateMuteList(EventTag(rootIdHex), isPrivate = true, add = true)
suspend fun showThread(rootIdHex: HexKey) = updateMuteList(EventTag(rootIdHex), isPrivate = true, add = false)
private suspend fun updateMuteList(
tag: MuteTag,
isPrivate: Boolean,
add: Boolean,
) {
if (!isWriteable()) return
try {
val current = hiddenUsersState.currentMuteList()
val event =
when {
!add -> if (current != null) MuteListEvent.remove(current, tag, signer) else return
current == null -> MuteListEvent.create(tag, isPrivate, signer)
else -> MuteListEvent.add(current, tag, isPrivate, signer)
}
// Optimistic local apply so enforcement + the management screens update
// immediately, then fan out to relays.
localCache.justConsumeMyOwnEvent(event)
publishModeration(event, if (add) "mute+" else "mute-")
} catch (e: Exception) {
moderationLog.warning("[Moderation] mute list update failed: ${e.message}")
throw e
}
}
/** Publish a NIP-56 (kind 1984) report about a note. */
suspend fun report(
note: Note,
type: ReportType,
comment: String = "",
) {
val reported = note.event ?: return
reportEvent(reported, type, comment)
}
/** Publish a NIP-56 (kind 1984) report about a raw event. */
suspend fun reportEvent(
reportedEvent: Event,
type: ReportType,
comment: String = "",
) {
if (!isWriteable()) return
try {
val signed = signer.sign(ReportEvent.build(reportedEvent, type, comment))
publishModeration(signed, "report(${type.code})")
} catch (e: Exception) {
moderationLog.warning("[Moderation] report failed: ${e.message}")
throw e
}
}
/** Publish a NIP-56 (kind 1984) report about a user. */
suspend fun report(
userPubKeyHex: HexKey,
type: ReportType,
comment: String = "",
) {
if (!isWriteable()) return
try {
val signed = signer.sign(ReportEvent.build(userPubKeyHex, type, comment))
publishModeration(signed, "report-user(${type.code})")
} catch (e: Exception) {
moderationLog.warning("[Moderation] user report failed: ${e.message}")
throw e
}
}
/**
* Broadcast a moderation event and log the outcome — including the relay
* count, so a publish to zero connected relays is visible rather than silent.
*/
private fun publishModeration(
event: Event,
action: String,
) {
val relayCount = relayManager.connectedRelays.value.size
relayManager.broadcastToAll(event)
if (relayCount == 0) {
moderationLog.warning("[Moderation] $action kind=${event.kind} id=${event.id.take(8)} → 0 connected relays (not delivered)")
} else {
moderationLog.info("[Moderation] $action kind=${event.kind} id=${event.id.take(8)} → $relayCount relays")
}
}
private fun addEventToChatroom(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey,
@@ -365,5 +499,6 @@ class DesktopIAccount(
companion object {
const val CLIENT_TAG_NAME = "Amethyst"
private val moderationLog: Logger = Logger.getLogger("DesktopModeration")
}
}
@@ -0,0 +1,31 @@
/*
* 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 androidx.compose.runtime.staticCompositionLocalOf
/**
* The logged-in [DesktopIAccount], provided at the app content root so deeply
* nested note surfaces (context menus, profile actions) can reach account-level
* moderation actions (mute/block/report) without threading it through every
* composable. Null when logged out or in a preview.
*/
val LocalDesktopIAccount = staticCompositionLocalOf<DesktopIAccount?> { null }
@@ -28,6 +28,8 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
@@ -205,11 +207,21 @@ fun FeedNoteCard(
forceReveal: Boolean = false,
) {
val event = note.event ?: return
var showReportDialog by remember(note.idHex) { mutableStateOf(false) }
// Same action list the ⋮ overflow uses, so right-click and ⋮ are identical.
val menuActions =
com.vitorpamplona.amethyst.desktop.ui.note.rememberNoteMenuActions(event, relayManager) {
showReportDialog = true
}
SpamCheckedNoteRender(
note = note,
localCache = localCache,
forceReveal = forceReveal,
) {
ContextMenuArea(items = {
menuActions.map { action -> ContextMenuItem(action.label) { action.onClick() } }
}) {
FeedNoteCardBody(
note = note,
event = event,
@@ -229,6 +241,12 @@ fun FeedNoteCard(
onFollow = onFollow,
)
}
}
if (showReportDialog) {
com.vitorpamplona.amethyst.desktop.ui.note
.NoteReportDialog(event) { showReportDialog = false }
}
}
@Composable
@@ -690,15 +708,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
}
}
@@ -709,10 +728,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
@@ -0,0 +1,31 @@
/*
* 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
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.staticCompositionLocalOf
/**
* The app's shared [SnackbarHostState], provided at the content root so deeply
* nested surfaces (note menus, moderation actions) can show a brief confirmation
* without threading a callback through every composable. Null in previews/tests.
*/
val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState?> { null }
@@ -1313,7 +1313,7 @@ fun NoteActionsRow(
)
}
// Share menu
// Overflow menu: copy / broadcast / share links + mute / report.
val shareMenuState = rememberShareMenuState()
Box {
IconButton(
@@ -1321,8 +1321,8 @@ fun NoteActionsRow(
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Share,
contentDescription = "Share",
MaterialSymbols.MoreVert,
contentDescription = "More actions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
@@ -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,23 @@ fun UserProfileScreen(
val scope = rememberCoroutineScope()
val iAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current
val profileSnackbar = com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost.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 +218,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 +593,51 @@ 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)
profileSnackbar?.showSnackbar("Unmuted user")
} else {
iAccount.hideUser(pubKeyHex)
profileSnackbar?.showSnackbar("Muted user")
}
}
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.
@@ -1196,6 +1253,33 @@ fun UserProfileScreen(
onDismiss = { showEditProfile = false },
)
}
if (showProfileReportDialog && iAccount != null) {
com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog(
onDismiss = { showProfileReportDialog = false },
onReport = { type, comment ->
scope.launch {
try {
iAccount.report(pubKeyHex, type, comment)
profileSnackbar?.showSnackbar("Report sent")
} catch (e: Exception) {
profileSnackbar?.showSnackbar("Report failed: ${e.message}")
}
}
},
onBlockAndReport = { type, comment ->
scope.launch {
try {
iAccount.report(pubKeyHex, type, comment)
iAccount.hideUser(pubKeyHex)
profileSnackbar?.showSnackbar("Reported & muted")
} catch (e: Exception) {
profileSnackbar?.showSnackbar("Report failed: ${e.message}")
}
}
},
)
}
}
/**
@@ -0,0 +1,109 @@
/*
* 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.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip56Reports.ReportType
private val REPORT_OPTIONS =
listOf(
ReportType.SPAM to "Spam",
ReportType.PROFANITY to "Profanity / Hateful speech",
ReportType.IMPERSONATION to "Impersonation",
ReportType.NUDITY to "Nudity / Sexual content",
ReportType.ILLEGAL to "Illegal content",
ReportType.MALWARE to "Malware / Phishing",
)
/**
* NIP-56 report dialog. Lets the user pick a report reason and optionally add a
* comment, then either just report or report-and-block the author. Mirrors the
* Android `ReportNoteDialog`, adapted to Compose Desktop.
*/
@Composable
fun ReportNoteDialog(
onDismiss: () -> Unit,
onReport: (ReportType, String) -> Unit,
onBlockAndReport: (ReportType, String) -> Unit,
) {
var selected by remember { mutableStateOf(REPORT_OPTIONS.first().first) }
var comment by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Report note") },
text = {
Column {
REPORT_OPTIONS.forEach { (type, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.selectable(selected = selected == type, onClick = { selected = type })
.padding(vertical = 2.dp),
) {
RadioButton(selected = selected == type, onClick = { selected = type })
Text(label, modifier = Modifier.padding(start = 4.dp))
}
}
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text("Comment (optional)") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(onClick = {
onReport(selected, comment)
onDismiss()
}) { Text("Report") }
},
dismissButton = {
Row {
TextButton(onClick = {
onBlockAndReport(selected, comment)
onDismiss()
}) { Text("Block & report") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
@@ -22,17 +22,20 @@ package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import kotlinx.coroutines.launch
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
@@ -52,62 +55,132 @@ class ShareMenuState {
@Composable
fun rememberShareMenuState(): ShareMenuState = remember { ShareMenuState() }
/** A single note-menu entry — one source of truth shared by the ⋮ overflow and the right-click menu. */
class NoteMenuAction(
val label: String,
val onClick: () -> Unit,
)
/**
* The canonical note action list. Rendered identically by [ShareMenu] (⋮ dropdown)
* and the feed's right-click context menu, so both offer the same items.
* Moderation actions (mute/report) are added only for other authors on a
* writeable account; each action shows a snackbar via [LocalSnackbarHost].
*/
@Composable
fun rememberNoteMenuActions(
event: Event,
relayManager: DesktopRelayConnectionManager,
onReportClick: () -> Unit,
): List<NoteMenuAction> {
val account = LocalDesktopIAccount.current
val snackbar = LocalSnackbarHost.current
val scope = rememberCoroutineScope()
val canModerate = account != null && account.isWriteable() && event.pubKey != account.pubKey
return remember(event, canModerate) {
buildList {
add(NoteMenuAction("Copy Text") { copyToClipboard(event.content) })
add(NoteMenuAction("Copy Note ID") { copyToClipboard("nostr:${NNote.create(event.id)}") })
add(
NoteMenuAction("Copy Event Link") {
val relays = relayManager.connectedRelays.value.take(3)
copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}")
},
)
add(NoteMenuAction("Copy Raw JSON") { copyToClipboard(event.toJson()) })
add(
NoteMenuAction("Copy Web Link") {
copyToClipboard("https://njump.me/${NEvent.create(event.id, event.pubKey, event.kind, emptyList())}")
},
)
add(
NoteMenuAction("Broadcast") {
relayManager.broadcastToAll(event)
scope.launch { snackbar?.showSnackbar("Broadcast to relays") }
},
)
if (canModerate && account != null) {
add(
NoteMenuAction("Mute user") {
scope.launch {
try {
account.hideUser(event.pubKey)
snackbar?.showSnackbar("Muted user")
} catch (e: Exception) {
snackbar?.showSnackbar("Mute failed: ${e.message}")
}
}
},
)
add(NoteMenuAction("Report…", onReportClick))
}
}
}
}
/** The ⋮ overflow dropdown. Renders [rememberNoteMenuActions] + the report dialog. */
@Composable
fun ShareMenu(
state: ShareMenuState,
event: Event,
relayManager: DesktopRelayConnectionManager,
) {
var showReportDialog by remember { mutableStateOf(false) }
val actions = rememberNoteMenuActions(event, relayManager) { showReportDialog = true }
DropdownMenu(
expanded = state.expanded,
onDismissRequest = { state.dismiss() },
) {
actions.forEach { action ->
DropdownMenuItem(
text = { Text("Copy Text") },
text = { Text(action.label) },
onClick = {
copyToClipboard(event.content)
state.dismiss()
},
)
DropdownMenuItem(
text = { Text("Copy Note ID") },
onClick = {
copyToClipboard("nostr:${NNote.create(event.id)}")
state.dismiss()
},
)
DropdownMenuItem(
text = { Text("Copy Event Link") },
onClick = {
val relays = relayManager.connectedRelays.value.take(3)
copyToClipboard("nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}")
state.dismiss()
},
)
DropdownMenuItem(
text = { Text("Copy Raw JSON") },
onClick = {
copyToClipboard(event.toJson())
state.dismiss()
},
)
DropdownMenuItem(
text = { Text("Copy Web Link") },
onClick = {
val nevent = NEvent.create(event.id, event.pubKey, event.kind, emptyList())
copyToClipboard("https://njump.me/$nevent")
state.dismiss()
},
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Broadcast") },
onClick = {
relayManager.broadcastToAll(event)
action.onClick()
state.dismiss()
},
)
}
}
if (showReportDialog) {
NoteReportDialog(event) { showReportDialog = false }
}
}
/** Report dialog wired to the account + snackbar, reused by every note surface. */
@Composable
fun NoteReportDialog(
event: Event,
onDismiss: () -> Unit,
) {
val account = LocalDesktopIAccount.current ?: return
val snackbar = LocalSnackbarHost.current
val scope = rememberCoroutineScope()
ReportNoteDialog(
onDismiss = onDismiss,
onReport = { type, comment ->
scope.launch {
try {
account.reportEvent(event, type, comment)
snackbar?.showSnackbar("Report sent")
} catch (e: Exception) {
snackbar?.showSnackbar("Report failed: ${e.message}")
}
}
},
onBlockAndReport = { type, comment ->
scope.launch {
try {
account.reportEvent(event, type, comment)
account.hideUser(event.pubKey)
snackbar?.showSnackbar("Reported & muted")
} catch (e: Exception) {
snackbar?.showSnackbar("Report failed: ${e.message}")
}
}
},
)
}
private fun copyToClipboard(text: String) {
@@ -20,6 +20,15 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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
@@ -27,6 +36,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.commons.model.Note
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
@@ -34,8 +46,12 @@ import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.displayedEvent
import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Wraps a note render in the hashtag-spam check.
@@ -80,9 +96,18 @@ fun SpamCheckedNoteRender(
)
}
// NIP-36 content warning: blur sensitive content unless the account opted
// into "always show sensitive content".
val account = LocalDesktopIAccount.current
val showSensitiveFlow = remember(account) { account?.showSensitiveContentSetting ?: MutableStateFlow(null) }
val showSensitive by showSensitiveFlow.collectAsState()
val isSensitive = remember(noteIdHex, displayedEvent) { displayedEvent?.isSensitiveOrNSFW() == true }
var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) }
if (!forceReveal && isSpam && !revealed && displayedEvent != null) {
when {
forceReveal || revealed -> normal()
isSpam && displayedEvent != null -> {
val authorPubkey = displayedEvent.pubKey
val author = localCache?.getUserIfExists(authorPubkey)
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
@@ -96,8 +121,48 @@ fun SpamCheckedNoteRender(
threshold = threshold,
onReveal = { revealed = true },
)
} else {
normal()
}
isSensitive && showSensitive != true && displayedEvent != null -> {
CollapsedContentWarning(
reason = displayedEvent.contentWarningReason(),
onReveal = { revealed = true },
)
}
else -> normal()
}
}
/**
* Collapsed placeholder for a NIP-36 content-warning note. Mirrors the
* spam-collapse reveal affordance: the body stays hidden behind a scrim with the
* warning reason until the user taps "Show".
*/
@Composable
private fun CollapsedContentWarning(
reason: String?,
onReveal: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Sensitive content", style = MaterialTheme.typography.titleSmall)
if (!reason.isNullOrBlank()) {
Text(
reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onReveal) { Text("Show") }
}
}
}
@@ -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) }
}
}
}
@@ -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)
}
}
}
}
/**
@@ -0,0 +1,116 @@
/*
* 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.filters
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.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.DualCase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regression coverage for the "mute is a silent no-op on desktop" bug: the feed
* filters must drop notes that `Note.isHiddenFor(...)` rejects. Exercises the
* real [DesktopGlobalFeedFilter] / [DesktopFollowingFeedFilter] with a
* hand-built [LiveHiddenUsers], the same value the account assembles at runtime.
*/
class DesktopMuteEnforcementTest {
private val mutedAuthor = "0000000000000000000000000000000000000000000000000000000000000001"
private val cleanAuthor = "0000000000000000000000000000000000000000000000000000000000000002"
private val cache = DesktopLocalCache()
private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) }
private fun note(
id: String,
pubkey: String,
content: String = "hi",
): Note {
val event = TextNoteEvent(id, pubkey, 0L, emptyArray(), content, "")
val n = Note(id)
n.loadEvent(event, user(pubkey), emptyList())
return n
}
private fun hiddenUsers(pubkeys: Set<String>) =
LiveHiddenUsers(
showSensitiveContent = null,
hiddenWordsCase = emptyList(),
hiddenUsersHashCodes = pubkeys.mapTo(HashSet()) { it.hashCode() },
spammersHashCodes = emptySet(),
hiddenUsers = pubkeys,
)
@Test
fun globalFeed_hidesMutedAuthor() {
val filter = DesktopGlobalFeedFilter(cache) { hiddenUsers(setOf(mutedAuthor)) }
val muted = note("aa", mutedAuthor)
val clean = note("bb", cleanAuthor)
val result = filter.applyFilter(setOf(muted, clean))
assertEquals("Muted author's note must be filtered out", setOf(clean), result)
}
@Test
fun globalFeed_showsEverythingWhenNothingMuted() {
val filter = DesktopGlobalFeedFilter(cache) { LiveHiddenUsers.EMPTY }
val a = note("aa", mutedAuthor)
val b = note("bb", cleanAuthor)
val result = filter.applyFilter(setOf(a, b))
assertEquals(setOf(a, b), result)
}
@Test
fun followingFeed_hidesMutedAuthorEvenIfFollowed() {
// Muting wins over following: a muted author you follow is still hidden.
val filter =
DesktopFollowingFeedFilter(cache, { hiddenUsers(setOf(mutedAuthor)) }) {
setOf(mutedAuthor, cleanAuthor)
}
val muted = note("aa", mutedAuthor)
val clean = note("bb", cleanAuthor)
val result = filter.applyFilter(setOf(muted, clean))
assertEquals(setOf(clean), result)
}
@Test
fun globalFeed_hidesNoteContainingHiddenWord() {
val choices =
LiveHiddenUsers(
showSensitiveContent = null,
hiddenWordsCase = listOf(DualCase("spam", "SPAM")),
hiddenUsersHashCodes = emptySet(),
spammersHashCodes = emptySet(),
hiddenWords = setOf("spam"),
)
val filter = DesktopGlobalFeedFilter(cache) { choices }
val spammy = note("aa", cleanAuthor, content = "buy my SPAM now")
val ok = note("bb", cleanAuthor, content = "good morning")
val result = filter.applyFilter(setOf(spammy, ok))
assertTrue("Hidden-word note must be filtered", spammy !in result)
assertTrue("Clean note must remain", ok in result)
}
}