mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 4.8
parent
bc482cb6eb
commit
0ab1930123
@@ -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()
|
||||
|
||||
+74
@@ -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))
|
||||
}
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
}
|
||||
+10
@@ -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 ->
|
||||
|
||||
+8
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user