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 80775af4d..e463e9a27 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,6 +81,7 @@ import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator @@ -283,6 +284,32 @@ fun App( } } + // Subscribe to DMs when user logs in + LaunchedEffect(accountState) { + val currentAccount = accountState + if (currentAccount is AccountState.LoggedIn) { + val dmRelayState = + DesktopDmRelayState( + dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()), + connectedRelays = relayManager.connectedRelays, + scope = scope, + ) + subscriptionsCoordinator.subscribeToDms( + userPubKeyHex = currentAccount.pubKeyHex, + dmRelayState = dmRelayState, + onDmEvent = { event, relay -> + // Store event as a note in cache so ChatroomFeedViewModel can display it + val note = localCache.getOrCreateNote(event.id) + val author = localCache.getOrCreateUser(event.pubKey) + if (note.event == null) { + note.loadEvent(event, author, emptyList()) + note.addRelay(relay) + } + }, + ) + } + } + MaterialTheme( colorScheme = darkColorScheme(), ) { @@ -509,8 +536,8 @@ fun MainContent( DesktopScreen.Messages -> { val iAccount = - remember(account, localCache) { - DesktopIAccount(account, localCache) + remember(account, localCache, relayManager) { + DesktopIAccount(account, localCache, relayManager) } DesktopMessagesScreen( account = iAccount, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 450a03577..fe583f8a9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent @@ -46,12 +48,12 @@ import com.vitorpamplona.quartz.utils.DualCase * shared IAccount interface used by commons ViewModels (ChatroomFeedViewModel, * ChatNewMessageState, etc.). * - * For now, DM sending is a no-op stub that logs intent. Full send support - * requires wiring through the relay client, which is a follow-up step. + * Bridges the desktop relay client for DM sending (NIP-04 and NIP-17). */ class DesktopIAccount( private val accountState: AccountState.LoggedIn, private val localCache: DesktopLocalCache, + private val relayManager: RelayConnectionManager, ) : IAccount { override val signer: NostrSigner = accountState.signer @@ -98,14 +100,44 @@ class DesktopIAccount( } override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { - // TODO: Wire through relay client for actual NIP-04 DM sending - com.vitorpamplona.quartz.utils.Log - .d("DesktopIAccount", "sendNip04PrivateMessage (stub)") + if (!isWriteable()) return + + val signedEvent = signer.sign(eventTemplate) + val recipient = signedEvent.verifiedRecipientPubKey() + + // Broadcast to connected relays + recipient's DM inbox relays + val targetRelays = relayManager.connectedRelays.value.toMutableSet() + if (recipient != null) { + localCache.getOrCreateUser(recipient).dmInboxRelays()?.let { + targetRelays.addAll(it) + } + } + + relayManager.send(signedEvent, targetRelays) } override suspend fun sendNip17PrivateMessage(template: EventTemplate) { - // TODO: Wire through relay client for actual NIP-17 DM sending - com.vitorpamplona.quartz.utils.Log - .d("DesktopIAccount", "sendNip17PrivateMessage (stub)") + if (!isWriteable()) return + + val result = NIP17Factory().createMessageNIP17(template, signer) + + // Broadcast each gift wrap to the recipient's inbox relays + result.wraps.forEach { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = + if (recipientKey != null) { + val dmRelays = + localCache + .getOrCreateUser(recipientKey) + .dmInboxRelays() + ?.toSet() + dmRelays?.ifEmpty { null } + ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + + relayManager.send(wrap, targetRelays) + } } } 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 de9771c1e..02234d844 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 @@ -35,8 +35,10 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -80,6 +82,7 @@ fun DesktopMessagesScreen( } val selectedRoom by listState.selectedRoom.collectAsState() val listFocusRequester = remember { FocusRequester() } + var showNewDmDialog by remember { mutableStateOf(false) } Row( modifier = @@ -98,7 +101,7 @@ fun DesktopMessagesScreen( // Cmd+Shift+N / Ctrl+Shift+N -> new DM event.key == Key.N && isModifier && event.isShiftPressed -> { - // TODO: trigger new DM dialog when implemented + showNewDmDialog = true true } @@ -115,6 +118,7 @@ fun DesktopMessagesScreen( onConversationSelected = { roomKey -> listState.selectRoom(roomKey) }, + onNewConversation = { showNewDmDialog = true }, focusRequester = listFocusRequester, ) @@ -148,6 +152,17 @@ fun DesktopMessagesScreen( } } } + + if (showNewDmDialog) { + NewDmDialog( + cacheProvider = cacheProvider, + onUserSelected = { roomKey -> + listState.selectRoom(roomKey) + showNewDmDialog = false + }, + onDismiss = { showNewDmDialog = false }, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt new file mode 100644 index 000000000..75d23d90c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -0,0 +1,175 @@ +/* + * 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 androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.search.SearchResult +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +@Composable +fun NewDmDialog( + cacheProvider: ICacheProvider, + onUserSelected: (ChatroomKey) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val searchState = remember { SearchBarState(cacheProvider, scope) } + val searchText by searchState.searchText.collectAsState() + val bech32Results by searchState.bech32Results.collectAsState() + val cachedUsers by searchState.cachedUserResults.collectAsState() + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "New Message", + style = MaterialTheme.typography.titleLarge, + ) + + OutlinedTextField( + value = searchText, + onValueChange = { searchState.updateSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Search users by name or npub...") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + trailingIcon = { + if (searchText.isNotEmpty()) { + IconButton(onClick = { searchState.clearSearch() }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Bech32 user results + val userResults = + bech32Results.filterIsInstance() + items(userResults) { result -> + val user = + cacheProvider.getUserIfExists(result.pubKeyHex) as? User + if (user != null) { + UserSearchCard( + user = user, + onClick = { + onUserSelected( + ChatroomKey(setOf(user.pubkeyHex)), + ) + }, + ) + } else { + // Minimal card for unloaded users + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Text( + result.displayId, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Cached user results + items(cachedUsers) { user -> + UserSearchCard( + user = user, + onClick = { + onUserSelected( + ChatroomKey(setOf(user.pubkeyHex)), + ) + }, + ) + } + + // No results hint + if (searchText.length >= 2 && + userResults.isEmpty() && + cachedUsers.isEmpty() + ) { + item { + Text( + "No users found", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +}