docs(desktop): moderation & safety brainstorm + plan + manual testing sheet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-23 10:44:20 +03:00
co-authored by Claude Opus 4.8
parent f4435bfe42
commit 755cfc82fe
2 changed files with 428 additions and 0 deletions
@@ -0,0 +1,53 @@
# Manual Testing — Desktop Moderation & Safety
Branch: `worktree-feat-desktop-moderation-safety`
Run: `./gradlew :desktopApp:run`
Prereq: log in with a **writeable** account (local nsec or bunker). For mute-list
sync tests, use an account that already has a kind-10000 mute list from mobile.
## Enforcement (the bug fix)
| # | Scenario | Expected |
|---|----------|----------|
| E1 | Account has a mobile-set mute on user X → open Following/Global | X's notes do NOT appear |
| E2 | Right-click a note → **Mute user** | Author's notes vanish from the feed **without restart** (may take a beat while it publishes) |
| E3 | Mute a user you follow | Their notes are hidden in Following too (mute wins over follow) |
| E4 | Open a thread where a reply author is muted | Muted reply hidden; thread root still shows |
| E5 | Open the muted user's profile | (v1) profile still renders — enforcement is feed/notification/DM-scoped, not profile |
| E6 | Notifications tab with a muted user reacting/replying | Their notifications are hidden |
| E7 | DM conversation list with a muted user | Their conversation is filtered out |
| E8 | Hidden word present in mute list (from mobile) → note containing it | Note is filtered |
| E9 | Read-only (npub) account | No crash; public mute portion still enforced; no Mute/Report items in menu |
## Report (NIP-56)
| # | Scenario | Expected |
|---|----------|----------|
| R1 | Right-click a note → **Report…** | Dialog opens with reason radios + comment field |
| R2 | Pick a reason → **Report** | kind-1984 published to relays (verify via `amy` or a relay log); no local hide |
| R3 | **Block & report** | Report published AND author muted (notes vanish) |
| R4 | Read-only account | No Report item in the menu |
## Content warning (NIP-36)
| # | Scenario | Expected |
|---|----------|----------|
| C1 | Feed contains a note tagged `content-warning` / NSFW | Renders collapsed: "Sensitive content" + reason + **Show** |
| C2 | Click **Show** | Note body reveals; stays revealed while scrolling (per-note) |
| C3 | Thread root that is CW-tagged | Auto-revealed (forceReveal) — you navigated into it |
> Note: the "always show sensitive content" account setting defaults to blur.
> A settings toggle to flip it is a deferred follow-up (see plan Non-goals).
## Regression
| # | Scenario | Expected |
|---|----------|----------|
| X1 | Feeds with no mutes | Everything renders as before; no perf regression scrolling |
| X2 | Spam-collapse (hashtag spam) | Still works; CW + spam don't conflict on the same note |
## Automated coverage
`./gradlew :desktopApp:test --tests "com.vitorpamplona.amethyst.desktop.filters.DesktopMuteEnforcementTest"`
covers E1/E3/E8 at the filter level (muted author hidden, hidden even if followed,
hidden-word note dropped, clean notes pass).
@@ -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 ~L176182,
`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`.