feat(desktop): wire DM sending, subscriptions, and new DM dialog

- DesktopIAccount: sign + broadcast NIP-04/NIP-17 messages via relay layer
- Main.kt: activate DM subscriptions on login, consume events into cache
- NewDmDialog: user search dialog with SearchBarState + UserSearchCard
- DesktopMessagesScreen: wire Cmd+Shift+N and "+" button to NewDmDialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-19 07:33:04 +02:00
parent 5d81da2486
commit 9a30b935d2
4 changed files with 260 additions and 11 deletions
@@ -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,
@@ -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<PrivateDmEvent>) {
// 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<PrivateDmEvent>(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<ChatMessageEvent>) {
// 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)
}
}
}
@@ -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 },
)
}
}
/**
@@ -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<SearchResult.UserResult>()
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,
)
}
}
}
}
}
}
}