feat(desktop): mute + NIP-56 report actions in the note menu

Surface the account-layer moderation write path in the UI:
- LocalDesktopIAccount CompositionLocal (provided at the app content root) so
  deeply-nested note surfaces can reach mute/block/report without prop-drilling.
- ShareMenu gains 'Mute user' + 'Report…' items (writeable accounts only).
- ReportNoteDialog: NIP-56 reason picker + optional comment, with
  'Report' and 'Block & report' actions.
- DesktopIAccount.reportEvent(Event, …) overload for the menu (report(Note) delegates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-23 10:36:49 +03:00
co-authored by Claude Opus 4.8
parent ac2875025a
commit d571906dd0
5 changed files with 194 additions and 2 deletions
@@ -1422,6 +1422,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()) {
@@ -38,6 +38,7 @@ 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
@@ -421,9 +422,18 @@ class DesktopIAccount(
type: ReportType,
comment: String = "",
) {
if (!isWriteable()) return
val reported = note.event ?: return
val signed = signer.sign(ReportEvent.build(reported, type, comment))
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
val signed = signer.sign(ReportEvent.build(reportedEvent, type, comment))
relayManager.broadcastToAll(signed)
}
@@ -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 }
@@ -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") }
}
},
)
}
@@ -28,11 +28,14 @@ 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.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
@@ -58,6 +61,10 @@ fun ShareMenu(
event: Event,
relayManager: DesktopRelayConnectionManager,
) {
val account = LocalDesktopIAccount.current
val scope = rememberCoroutineScope()
var showReportDialog by remember { mutableStateOf(false) }
DropdownMenu(
expanded = state.expanded,
onDismissRequest = { state.dismiss() },
@@ -107,6 +114,40 @@ fun ShareMenu(
state.dismiss()
},
)
// Moderation actions require a writeable account (a local signer).
if (account != null && account.isWriteable()) {
HorizontalDivider()
DropdownMenuItem(
text = { Text("Mute user") },
onClick = {
scope.launch { account.hideUser(event.pubKey) }
state.dismiss()
},
)
DropdownMenuItem(
text = { Text("Report…") },
onClick = {
showReportDialog = true
state.dismiss()
},
)
}
}
if (showReportDialog && account != null) {
ReportNoteDialog(
onDismiss = { showReportDialog = false },
onReport = { type, comment ->
scope.launch { account.reportEvent(event, type, comment) }
},
onBlockAndReport = { type, comment ->
scope.launch {
account.reportEvent(event, type, comment)
account.hideUser(event.pubKey)
}
},
)
}
}