feat(desktop): unify note ⋮ + right-click menus and add snackbar feedback

- rememberNoteMenuActions is now the single source for the note menu; both the
  ⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same
  items (copy ×5 / broadcast / mute / report).
- LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a
  confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to
  relays', and failure toasts. Wired for the note menu + profile ⋮ actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-24 12:04:23 +03:00
co-authored by Claude Opus 4.8
parent c555265f0b
commit 55d11acd61
5 changed files with 168 additions and 108 deletions
@@ -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,
@@ -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,
@@ -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 }
@@ -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}")
}
}
},
)
@@ -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<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,
) {
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) {