Follow-up to the event class renames: the names around those classes now
match too.
Quartz (published API, every old name kept as a @Deprecated alias/forwarder):
- nip51Lists packages moved: followList -> starterPack, hashtagList -> interestList,
peopleList -> followSet, labeledBookmarkList -> bookmarkSet. The old packages
keep a Deprecated.kt with typealiases and forwarding extension functions
(ReplaceWith points at the new package).
- LnZapPrivateEvent -> PrivateZapEvent, LnZapReceiptValidator -> ZapReceiptValidator,
ChannelListDiff -> PublicChatListDiff, HashtagListDiff -> InterestListDiff.
- Kind 30063 now indexes NIP-82 release notes for search (never NIP-51 content,
which can hold encrypted private items). searchable-events docs updated.
App code (amethyst, commons, desktopApp, cli; no aliases needed):
- model packages nip51Lists.{hashtagLists,peopleList,labeledBookmarkLists,relayFeeds}
-> {interestLists,followSets,bookmarkSets,favoriteRelays}.
- State/cache/UI names derived from the old event names: HashtagListState ->
InterestListState, PeopleListsState -> FollowSetsState, FollowListsState ->
StarterPacksState, LabeledBookmarkList -> BookmarkSet, RelayFeedListState ->
FavoriteRelayListState, ContactCardsState -> UserAssertionsState, NIP90* view
models/renderers -> Dvm*, LnZap* handlers -> Zap*, SealedRumor* -> Seal*, and
Account.peopleLists/followLists/hashtagList -> followSets/starterPacks/interestList.
- String literals were left untouched, so preference keys and @SerialName values
stored on disk are unchanged.
- The generic PeopleList UI model (shared by follow sets and starter packs) and the
EmojiPackSelection route (which shows a kind 30030 pack) keep their names.
Docs: plans, brainstorms, changelogs, skills and READMEs use the new names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
19 KiB
title, type, status, date, origin, module
| title | type | status | date | origin | module |
|---|---|---|---|---|---|
| Desktop Moderation & Safety (mute/block enforcement + NIP-56 report + NIP-36 content-warning) | feat | active | 2026-07-23 | docs/brainstorms/2026-07-23-desktop-moderation-safety-brainstorm.md | 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:
- Enforcement primitive is already shared.
LiveHiddenUsersandNote.isHiddenFor(accountChoices: LiveHiddenUsers)live incommons/(commons/.../model/Note.kt:1279, referenced fromcommons/.../model/IAccount.kt, covered bycommons/.../model/NoteIsHiddenForTest.kt). Desktop does not need to port or promote the hidden-check logic — it only needs to assemble aLiveHiddenUsersvalue 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). - Feeds have a live-invalidation hook. Desktop feeds implement
commons/.../ui/feeds/InvalidatableContentviaFeedContentState(invalidateData()). Wiring the hidden-users StateFlow to callinvalidateData()on emit makes mute/block hide without restart. → Crux 2 resolved: reuseinvalidateData(), don't invent a bus. - Concrete APIs confirmed (see Research Insights under Technical Approach):
MuteListEvent.create/add/remove(MuteTag, isPrivate, signer)whereMuteTag ∈ {UserTag, HashtagTag(word), EventTag(thread)};FollowSetEvent.addUser/ removeUserfor blocks; publish viasigner.sign(template)+relayManager.broadcastToAll(signed)(precedentreactToNote/repostNoteindesktopApp/.../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:
- P2 — Fix the silent mute/block no-op (a live bug: muting does nothing on desktop feeds today) + management surfaces.
- P3 — Report a note/user (NIP-56).
- 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 hardcodedfalse;isAcceptable(note)only checks deletions;DesktopFeedFiltersnever calls either. Desktop also holds no mute/block state at all — the fields (hiddenUsersHashCodes,hiddenWordsCase,spammersHashCodes,showSensitiveContent) are empty/nullstubs. 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.
showSensitiveContentis hardcodednulland 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
LiveHiddenUsersonDesktopIAccount; hydrate via a new account subscription and the account signer (decrypts the private list). - Enforcement: implement
isHidden/isAcceptableby delegating to quartzNote.isHiddenFor(liveHiddenUsers); chain&& account.isAcceptable(note)into everyDesktopFeedFiltersfeed path. The chatroom list already callsisAcceptable, so DMs get filtered for free once the predicate is real. - Actions:
hideUser/showUser/blockUser/hideWord/hideThreadonDesktopIAccountbuild → sign → publish an updatedMuteListEvent/FollowSetEvent. - Report:
report(note|user, type, comment)reuses quartzReportEvent.build(kind 1984) +ReportType, published to the account's public + private outbox (match Android). NewReportNoteDialogported to desktop Compose, wired intoShareMenuandUserProfileScreen. - Content warning: generalize
SpamCheckedNoteRenderto also gateEvent.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
flowchart TD
subgraph relays[Relays]
K10000[kind 10000 MuteListEvent - encrypted]
K10001[kind 10001 FollowSetEvent - 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), FollowSetEvent (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)
- State-holder placement → desktop-local, value-object only. RESOLVED.
LiveHiddenUsers/Note.isHiddenForalready incommons/— no port, no type promotion. Build a small desktop assembler that reads the account's mute list (kind 10000) + blocked people (kind 10001) fromDesktopLocalCache+ signer and emitsStateFlow<LiveHiddenUsers>. Option (a) full commons extraction is deferred — unnecessary for v1. - Reactivity →
FeedContentState.invalidateData(). RESOLVED. Desktop feeds implementcommons/.../ui/feeds/InvalidatableContent. Collect theStateFlow<LiveHiddenUsers>where feeds are built and callinvalidateData()on change so mutes apply live. Verify each feed's content-state gets the call (Following/Global/Thread/Notifications/Profile). - Reveal generalization shape. One generalized
SpamCheckedNoteRender(collapse for spam or CW), two reasons — avoids double nesting. (Decision stands; confirm final shape during Phase 4.) - Media blur fidelity. v1 = opaque scrim + reason overlay + reveal (no new
dependency; Compose Desktop
Modifier.blursupport is uneven across the skiko backend, so avoid depending on it). True blur is a v2 nicety. Documented limitation. - Report publish path →
signer.sign+relayManager.broadcastToAll. RESOLVED. Desktop has nosendMyPublicAndPrivateOutboxhelper; 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)
FollowSetEvent.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 aLiveHiddenUsersStateFlow fromDesktopLocalCache+ 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)viaNote.isHiddenFor(hiddenUsers.value); populateshowSensitiveContent,hiddenUsersHashCodes, etc. from state. - Chain
&& account.isAcceptable(note)into everyDesktopFeedFiltersfeed()/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/hideThreadbuild+sign+ publish updated list events.- Mute/Block action in note context menu (
ShareMenu) +UserProfileScreen. - Port
BlockedUsersScreen/HiddenWordsScreen/MutedThreadsScreento 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 intoShareMenu+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
SpamCheckedNoteRenderto also gateisSensitiveOrNSFW()notes: scrim + reason + reveal, honoring per-noterememberSaveablereveal 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 sheetdesktopApp/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
- Mute user → notes vanish across Following/Global/Thread/Notifications + DM list, live; unmute restores.
- Block via report "Block & Hide" → kind-10000 published + note hidden.
- Hidden word → matching notes collapse (via
isHiddenFor). - Muted thread → replies under that root hidden.
- Report note → kind-1984 published to public+private outbox, correct tags.
- CW note → blurred; reveal shows; "always show" bypasses.
- Read-only account → actions disabled, enforcement of public list still works.
- Bunker account → mute list decrypts asynchronously; enforcement engages on arrival.
Acceptance Criteria
Functional
- 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. - Mute action available from note context menu (
ShareMenu). (Mute-from- profile deferred.) - 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.)
- 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
- Read-only + bunker accounts behave correctly (write actions gated on
isWriteable(); enforcement tolerates late async decrypt via the flow). - Enforcement predicate is hash-set lookups (
Note.isHiddenFor), no feed jank. - Unit tests for the enforcement predicate green (
DesktopMuteEnforcementTest). spotlessApplyclean; 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
iAccountthreaded into those two composables).
Dependencies & Risks
- Reactivity of feed filters to the hidden-users flow — top risk (crux 2).
- State-holder placement /
LiveHiddenUserstype 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 viaNote.isHiddenFor; report via quartzReportEvent; 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.ktquartz/.../nip56Reports/{ReportEvent,ReportType}.ktquartz/.../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.