mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c555265f0b
commit
55d11acd61
@@ -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,
|
||||
|
||||
+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 }
|
||||
+23
-4
@@ -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}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+104
-72
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user