feat(desktop): profile header actions — Message, Add-to-list, Share

- Message opens the Messages column + 1:1 room via a DesktopDmRoute bridge
  (observed by both deck and single-pane layouts)
- Add to list: pack picker from LocalFollowPacksState, appends via
  FollowListEvent.add + broadcast
- Share copies a nostr: profile link to the clipboard
- Actions live in the existing writeable/other-profile overflow menu, so
  self/read-only are already excluded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-29 09:39:56 +03:00
co-authored by Claude Opus 4.8
parent bc482cb6eb
commit 0ab1930123
5 changed files with 160 additions and 0 deletions
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
@@ -298,6 +299,21 @@ fun main(args: Array<String>) {
var composeEditScheduledForSec by remember { mutableStateOf<Long?>(null) }
val deckScope = rememberCoroutineScope()
val deckState = remember { DeckState(deckScope).also { it.load() } }
// Open/focus the Messages column when a "message this user" request arrives
// (e.g. from the profile screen's Message action). The Messages screen then
// selects the 1:1 room from the same DesktopDmRoute target.
LaunchedEffect(deckState) {
DesktopDmRoute.pendingTarget.collect { target ->
if (target != null) {
if (deckState.hasColumnOfType(DeckColumnType.Messages)) {
deckState.focusExistingColumn(DeckColumnType.Messages)
} else {
deckState.addColumn(DeckColumnType.Messages)
}
}
}
}
val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } }
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
@@ -96,6 +96,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText
import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
@@ -119,11 +121,14 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Toolkit
@@ -217,6 +222,9 @@ fun UserProfileScreen(
}
var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) }
var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) }
var showAddToListMenu by remember(pubKeyHex) { mutableStateOf(false) }
val followPacksState = LocalFollowPacksState.current
val followPacks by (followPacksState?.allPacks ?: MutableStateFlow(emptyList<FollowListEvent>())).collectAsState()
val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState()
val isUserMuted = profileHidden.isUserHidden(pubKeyHex)
@@ -790,6 +798,33 @@ fun UserProfileScreen(
expanded = showProfileModMenu,
onDismissRequest = { showProfileModMenu = false },
) {
DropdownMenuItem(
text = { Text("Message") },
onClick = {
DesktopDmRoute.request(pubKeyHex)
showProfileModMenu = false
},
)
DropdownMenuItem(
text = { Text("Add to list…") },
onClick = {
showProfileModMenu = false
showAddToListMenu = true
},
)
DropdownMenuItem(
text = { Text("Share (copy link)") },
onClick = {
pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.let { npub ->
Toolkit
.getDefaultToolkit()
.systemClipboard
.setContents(StringSelection("nostr:$npub"), null)
scope.launch { profileSnackbar?.showSnackbar("Profile link copied") }
}
showProfileModMenu = false
},
)
DropdownMenuItem(
text = { Text(if (isUserMuted) "Unmute user" else "Mute user") },
onClick = {
@@ -813,6 +848,45 @@ fun UserProfileScreen(
},
)
}
// Add-to-list pack picker (opened from the menu above).
DropdownMenu(
expanded = showAddToListMenu,
onDismissRequest = { showAddToListMenu = false },
) {
if (followPacks.isEmpty()) {
DropdownMenuItem(
text = { Text("No lists yet") },
enabled = false,
onClick = { showAddToListMenu = false },
)
} else {
followPacks.forEach { pack ->
DropdownMenuItem(
text = { Text(pack.title() ?: "Untitled list") },
onClick = {
val acct = account
if (acct != null) {
scope.launch {
try {
val updated =
FollowListEvent.add(
pack,
UserTag(pubKeyHex, null),
acct.signer,
)
relayManager.broadcastToAll(updated)
profileSnackbar?.showSnackbar("Added to list")
} catch (e: Exception) {
profileSnackbar?.showSnackbar("Failed to add: ${e.message}")
}
}
}
showAddToListMenu = false
},
)
}
}
}
}
Spacer(Modifier.width(4.dp))
}
@@ -0,0 +1,52 @@
/*
* 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.chats
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Single-process bridge for "open a DM with this pubkey" requests coming from
* anywhere in the app (e.g. the profile screen's Message button).
*
* The caller [request]s a pubkey and opens the Messages column; the Messages
* screen observes [pendingTarget], selects the 1:1 room, then [consume]s it.
* A StateFlow replays the latest value to a late subscriber, so the Messages
* screen still picks it up if it composes after the request.
*
* Desktop is single-process, so a shared object (like GlobalMediaPlayer) is the
* idiomatic bridge here — no cross-process/IPC concern.
*/
object DesktopDmRoute {
private val pendingTargetInternal = MutableStateFlow<String?>(null)
val pendingTarget: StateFlow<String?> = pendingTargetInternal.asStateFlow()
/** Ask the Messages screen to open a 1:1 room with [pubKeyHex]. */
fun request(pubKeyHex: String) {
pendingTargetInternal.value = pubKeyHex
}
/** Called by the Messages screen once it has selected the room. */
fun consume() {
pendingTargetInternal.value = null
}
}
@@ -37,6 +37,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -104,6 +105,15 @@ fun DesktopMessagesScreen(
val listFocusRequester = remember { FocusRequester() }
var showNewDmDialog by remember { mutableStateOf(false) }
// Honor "message this user" requests from elsewhere (e.g. the profile screen).
val pendingDmTarget by DesktopDmRoute.pendingTarget.collectAsState()
LaunchedEffect(pendingDmTarget, account) {
pendingDmTarget?.let { target ->
listState.selectRoom(ChatroomKey(setOf(target)))
DesktopDmRoute.consume()
}
}
// Shared keyboard shortcuts
val keyHandler =
Modifier.onPreviewKeyEvent { event ->
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
@@ -93,6 +94,13 @@ fun SinglePaneLayout(
singlePaneState.clearOverlaySignal.collect { navState.clear() }
}
// Navigate to Messages when a "message this user" request arrives (profile screen).
LaunchedEffect(Unit) {
DesktopDmRoute.pendingTarget.collect { target ->
if (target != null) singlePaneState.navigate(DeckColumnType.Messages)
}
}
// Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes).
// SinglePaneLayout only renders the content pane.
Box(modifier = modifier.fillMaxSize()) {