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 ef8a01e648..00c058dbbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -2052,6 +2052,7 @@ fun MainContent( 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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 4919a63ca4..59bffab41a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -204,26 +204,20 @@ fun FeedNoteCard( forceReveal: Boolean = false, ) { val event = note.event ?: return - val moderationAccount = com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount.current - val ctxScope = rememberCoroutineScope() var showReportDialog by remember(note.idHex) { mutableStateOf(false) } - val canModerate = moderationAccount != null && moderationAccount.isWriteable() && event.pubKey != moderationAccount.pubKey + // 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, ) { - // Right-click context menu (Compose Desktop). Mute/Report are only added - // for other authors on a writeable account; Copy is always available. ContextMenuArea(items = { - buildList { - add(ContextMenuItem("Copy text") { copyTextToClipboard(event.content) }) - if (canModerate && moderationAccount != null) { - add(ContextMenuItem("Mute user") { ctxScope.launch { moderationAccount.hideUser(event.pubKey) } }) - add(ContextMenuItem("Report…") { showReportDialog = true }) - } - } + menuActions.map { action -> ContextMenuItem(action.label) { action.onClick() } } }) { FeedNoteCardBody( note = note, @@ -246,29 +240,12 @@ fun FeedNoteCard( } } - if (showReportDialog && moderationAccount != null) { - com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( - onDismiss = { showReportDialog = false }, - onReport = { type, comment -> - ctxScope.launch { moderationAccount.reportEvent(event, type, comment) } - }, - onBlockAndReport = { type, comment -> - ctxScope.launch { - moderationAccount.reportEvent(event, type, comment) - moderationAccount.hideUser(event.pubKey) - } - }, - ) + if (showReportDialog) { + com.vitorpamplona.amethyst.desktop.ui.note + .NoteReportDialog(event) { showReportDialog = false } } } -private fun copyTextToClipboard(text: String) { - java.awt.Toolkit - .getDefaultToolkit() - .systemClipboard - .setContents(java.awt.datatransfer.StringSelection(text), null) -} - @Composable private fun FeedNoteCardBody( note: Note, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.kt new file mode 100644 index 0000000000..94cb6dc7e6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LocalSnackbarHost.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.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 { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 95d7c8eefd..be9ed0ce67 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -186,6 +186,7 @@ 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 } @@ -614,7 +615,13 @@ fun UserProfileScreen( text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, onClick = { scope.launch { - if (isUserMuted) iAccount.showUser(pubKeyHex) else iAccount.hideUser(pubKeyHex) + if (isUserMuted) { + iAccount.showUser(pubKeyHex) + profileSnackbar?.showSnackbar("Unmuted user") + } else { + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Muted user") + } } showProfileModMenu = false }, @@ -1249,12 +1256,24 @@ fun UserProfileScreen( com.vitorpamplona.amethyst.desktop.ui.note.ReportNoteDialog( onDismiss = { showProfileReportDialog = false }, onReport = { type, comment -> - scope.launch { iAccount.report(pubKeyHex, 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 { - iAccount.report(pubKeyHex, type, comment) - iAccount.hideUser(pubKeyHex) + try { + iAccount.report(pubKeyHex, type, comment) + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Reported & muted") + } catch (e: Exception) { + profileSnackbar?.showSnackbar("Report failed: ${e.message}") + } } }, ) 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 9c9e4767b3..f18a2645d2 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 @@ -22,7 +22,6 @@ 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 @@ -32,6 +31,7 @@ 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 @@ -55,100 +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 { + 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, ) { - val account = LocalDesktopIAccount.current - val scope = rememberCoroutineScope() 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() - }, - ) - - // Moderation actions require a writeable account (a local signer). - if (account != null && account.isWriteable()) { - HorizontalDivider() + actions.forEach { action -> DropdownMenuItem( - text = { Text("Mute user") }, + text = { Text(action.label) }, onClick = { - scope.launch { account.hideUser(event.pubKey) } - state.dismiss() - }, - ) - DropdownMenuItem( - text = { Text("Report…") }, - onClick = { - showReportDialog = true + action.onClick() 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 { + 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) {