mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* "Always show sensitive content" (NIP-36) preference, backed by
|
||||
* [java.util.prefs.Preferences] under the same shared node as the other content
|
||||
* filters (so Desktop and the `amy` CLI observe the same value).
|
||||
*
|
||||
* [showSensitiveContent] follows the `LiveHiddenUsers`/`Note.isHiddenFor`
|
||||
* convention: `null` = respect content warnings (blur), `true` = always show.
|
||||
* `false` is never emitted — the toggle is binary (on ⇒ true, off ⇒ null).
|
||||
*/
|
||||
class PreferencesSensitiveContentSettings(
|
||||
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
|
||||
) {
|
||||
private val mutable = MutableStateFlow(toChoice(prefs.getBoolean(KEY_ALWAYS_SHOW, DEFAULT_ALWAYS_SHOW)))
|
||||
|
||||
/** `null` = respect warnings (blur); `true` = always show. */
|
||||
val showSensitiveContent: StateFlow<Boolean?> = mutable.asStateFlow()
|
||||
|
||||
fun setAlwaysShow(alwaysShow: Boolean) {
|
||||
mutable.value = toChoice(alwaysShow)
|
||||
prefs.putBoolean(KEY_ALWAYS_SHOW, alwaysShow)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NODE_NAME = "com/vitorpamplona/amethyst/filters"
|
||||
const val KEY_ALWAYS_SHOW = "always_show_sensitive"
|
||||
const val DEFAULT_ALWAYS_SHOW = false
|
||||
|
||||
private fun toChoice(alwaysShow: Boolean): Boolean? = if (alwaysShow) true else null
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
class PreferencesSensitiveContentSettingsTest {
|
||||
private val testNode = "com/vitorpamplona/amethyst/test/sensitive_${System.currentTimeMillis()}"
|
||||
|
||||
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
prefs().clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
prefs().removeNode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultRespectsWarnings() {
|
||||
// null = respect content warnings (blur), matching Note.isHiddenFor.
|
||||
assertNull(PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onMapsToTrue() {
|
||||
val settings = PreferencesSensitiveContentSettings(prefs())
|
||||
settings.setAlwaysShow(true)
|
||||
assertEquals(true, settings.showSensitiveContent.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offMapsToNullNotFalse() {
|
||||
val settings = PreferencesSensitiveContentSettings(prefs())
|
||||
settings.setAlwaysShow(true)
|
||||
settings.setAlwaysShow(false)
|
||||
// Off is null (respect warnings), never false — Note.isHiddenFor only
|
||||
// blurs when showSensitiveContent == false, which must never happen here.
|
||||
assertNull(settings.showSensitiveContent.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsAcrossReload() {
|
||||
PreferencesSensitiveContentSettings(prefs()).setAlwaysShow(true)
|
||||
assertEquals(true, PreferencesSensitiveContentSettings(prefs()).showSensitiveContent.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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))
|
||||
|
||||
|
||||
+25
-9
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+139
@@ -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
|
||||
}
|
||||
}
|
||||
+142
-7
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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,29 +207,45 @@ 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,
|
||||
) {
|
||||
FeedNoteCardBody(
|
||||
note = note,
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
onHashtagClick = onHashtagClick,
|
||||
followedUsers = followedUsers,
|
||||
myPubKeyHex = myPubKeyHex,
|
||||
onFollow = onFollow,
|
||||
)
|
||||
ContextMenuArea(items = {
|
||||
menuActions.map { action -> ContextMenuItem(action.label) { action.onClick() } }
|
||||
}) {
|
||||
FeedNoteCardBody(
|
||||
note = note,
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
onHashtagClick = onHashtagClick,
|
||||
followedUsers = followedUsers,
|
||||
myPubKeyHex = myPubKeyHex,
|
||||
onFollow = onFollow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showReportDialog) {
|
||||
com.vitorpamplona.amethyst.desktop.ui.note
|
||||
.NoteReportDialog(event) { showReportDialog = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
@@ -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) {
|
||||
|
||||
+88
-4
@@ -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}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+109
@@ -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") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+119
-46
@@ -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() },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy Text") },
|
||||
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)
|
||||
state.dismiss()
|
||||
},
|
||||
)
|
||||
actions.forEach { action ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(action.label) },
|
||||
onClick = {
|
||||
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) {
|
||||
|
||||
+81
-16
@@ -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,24 +96,73 @@ 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) {
|
||||
val authorPubkey = displayedEvent.pubKey
|
||||
val author = localCache?.getUserIfExists(authorPubkey)
|
||||
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
|
||||
val avatarUrl = author?.profilePicture()
|
||||
val hashtagCount = displayedEvent.tags.countHashtags()
|
||||
CollapsedSpamNote(
|
||||
authorPubkeyHex = authorPubkey,
|
||||
authorDisplayName = displayName,
|
||||
authorAvatarUrl = avatarUrl,
|
||||
hashtagCount = hashtagCount,
|
||||
threshold = threshold,
|
||||
onReveal = { revealed = true },
|
||||
)
|
||||
} else {
|
||||
normal()
|
||||
when {
|
||||
forceReveal || revealed -> normal()
|
||||
isSpam && displayedEvent != null -> {
|
||||
val authorPubkey = displayedEvent.pubKey
|
||||
val author = localCache?.getUserIfExists(authorPubkey)
|
||||
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
|
||||
val avatarUrl = author?.profilePicture()
|
||||
val hashtagCount = displayedEvent.tags.countHashtags()
|
||||
CollapsedSpamNote(
|
||||
authorPubkeyHex = authorPubkey,
|
||||
authorDisplayName = displayName,
|
||||
authorAvatarUrl = avatarUrl,
|
||||
hashtagCount = hashtagCount,
|
||||
threshold = threshold,
|
||||
onReveal = { revealed = true },
|
||||
)
|
||||
}
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Content-Filters entry for NIP-36 sensitive content + NIP-51 mute-list
|
||||
* management (blocked users / hidden words / muted threads). Reads/writes the
|
||||
* logged-in [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] via
|
||||
* [LocalDesktopIAccount]; the lists are driven by its live hidden-users flow, so
|
||||
* removing an entry publishes the updated kind-10000 and un-hides immediately.
|
||||
*/
|
||||
@Composable
|
||||
fun ModerationSettingsSection(modifier: Modifier = Modifier) {
|
||||
val account = LocalDesktopIAccount.current ?: return
|
||||
val cache = LocalDesktopCache.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val hidden by account.hiddenUsers.collectAsState()
|
||||
val showSensitive by account.showSensitiveContentSetting.collectAsState()
|
||||
val writeable = account.isWriteable()
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Moderation & Safety",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// NIP-36 sensitive content
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(checked = showSensitive == true, onCheckedChange = { account.setAlwaysShowSensitive(it) })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Always show sensitive content", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
// Muted users
|
||||
if (hidden.hiddenUsers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Muted users (${hidden.hiddenUsers.size})", style = MaterialTheme.typography.labelLarge)
|
||||
hidden.hiddenUsers.sorted().forEach { hex ->
|
||||
val name = remember(hex) { cache?.getUserIfExists(hex)?.toBestDisplayName() ?: hex.take(12) }
|
||||
EntryRow(label = name, actionLabel = "Unmute", enabled = writeable) {
|
||||
scope.launch { account.showUser(hex) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden words
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Hidden words", style = MaterialTheme.typography.labelLarge)
|
||||
var newWord by remember { mutableStateOf("") }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = newWord,
|
||||
onValueChange = { newWord = it },
|
||||
label = { Text("Add word to hide") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
enabled = writeable && newWord.isNotBlank(),
|
||||
onClick = {
|
||||
val w = newWord.trim()
|
||||
if (w.isNotEmpty()) {
|
||||
scope.launch { account.hideWord(w) }
|
||||
newWord = ""
|
||||
}
|
||||
},
|
||||
) { Text("Add") }
|
||||
}
|
||||
hidden.hiddenWords.sorted().forEach { word ->
|
||||
EntryRow(label = word, actionLabel = "Remove", enabled = writeable) {
|
||||
scope.launch { account.showWord(word) }
|
||||
}
|
||||
}
|
||||
|
||||
// Muted threads
|
||||
if (hidden.mutedThreads.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Muted threads (${hidden.mutedThreads.size})", style = MaterialTheme.typography.labelLarge)
|
||||
hidden.mutedThreads.sorted().forEach { id ->
|
||||
EntryRow(label = "${id.take(12)}…", actionLabel = "Unmute", enabled = writeable) {
|
||||
scope.launch { account.showThread(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(
|
||||
label: String,
|
||||
actionLabel: String,
|
||||
enabled: Boolean,
|
||||
onAction: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
if (enabled) {
|
||||
TextButton(onClick = onAction) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+116
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user