From d571906dd0953a4e109199db4f53f209d395e407 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 23 Jul 2026 10:36:49 +0300 Subject: [PATCH] feat(desktop): mute + NIP-56 report actions in the note menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/model/DesktopIAccount.kt | 14 ++- .../desktop/model/LocalDesktopIAccount.kt | 31 +++++ .../desktop/ui/note/ReportNoteDialog.kt | 109 ++++++++++++++++++ .../amethyst/desktop/ui/note/ShareMenu.kt | 41 +++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fbc3160bc1..e9d0537f95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e28c01b9c6..227abe2bc8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -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) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt new file mode 100644 index 0000000000..01397da087 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/LocalDesktopIAccount.kt @@ -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 { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt new file mode 100644 index 0000000000..673be60738 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ReportNoteDialog.kt @@ -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") } + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt index 7287250cba..9c9e4767b3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/ShareMenu.kt @@ -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) + } + }, + ) } }