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 9cf0bc5148..c9966e3442 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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) { var composeEditScheduledForSec by remember { mutableStateOf(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() 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 6b292ee8f1..1ad755466d 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 @@ -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())).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)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt new file mode 100644 index 0000000000..0318c6dd40 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt @@ -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(null) + val pendingTarget: StateFlow = 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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index a2668c14bf..b554461d64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -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 -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 8b93969e34..b0572fffd1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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()) {