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:
@@ -81,6 +81,7 @@ import androidx.compose.ui.window.rememberWindowState
|
|||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
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.model.DesktopIAccount
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
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(
|
MaterialTheme(
|
||||||
colorScheme = darkColorScheme(),
|
colorScheme = darkColorScheme(),
|
||||||
) {
|
) {
|
||||||
@@ -509,8 +536,8 @@ fun MainContent(
|
|||||||
|
|
||||||
DesktopScreen.Messages -> {
|
DesktopScreen.Messages -> {
|
||||||
val iAccount =
|
val iAccount =
|
||||||
remember(account, localCache) {
|
remember(account, localCache, relayManager) {
|
||||||
DesktopIAccount(account, localCache)
|
DesktopIAccount(account, localCache, relayManager)
|
||||||
}
|
}
|
||||||
DesktopMessagesScreen(
|
DesktopMessagesScreen(
|
||||||
account = iAccount,
|
account = iAccount,
|
||||||
|
|||||||
+40
-8
@@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.User
|
|||||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
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.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
|
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||||
@@ -46,12 +48,12 @@ import com.vitorpamplona.quartz.utils.DualCase
|
|||||||
* shared IAccount interface used by commons ViewModels (ChatroomFeedViewModel,
|
* shared IAccount interface used by commons ViewModels (ChatroomFeedViewModel,
|
||||||
* ChatNewMessageState, etc.).
|
* ChatNewMessageState, etc.).
|
||||||
*
|
*
|
||||||
* For now, DM sending is a no-op stub that logs intent. Full send support
|
* Bridges the desktop relay client for DM sending (NIP-04 and NIP-17).
|
||||||
* requires wiring through the relay client, which is a follow-up step.
|
|
||||||
*/
|
*/
|
||||||
class DesktopIAccount(
|
class DesktopIAccount(
|
||||||
private val accountState: AccountState.LoggedIn,
|
private val accountState: AccountState.LoggedIn,
|
||||||
private val localCache: DesktopLocalCache,
|
private val localCache: DesktopLocalCache,
|
||||||
|
private val relayManager: RelayConnectionManager,
|
||||||
) : IAccount {
|
) : IAccount {
|
||||||
override val signer: NostrSigner = accountState.signer
|
override val signer: NostrSigner = accountState.signer
|
||||||
|
|
||||||
@@ -98,14 +100,44 @@ class DesktopIAccount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
|
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
|
||||||
// TODO: Wire through relay client for actual NIP-04 DM sending
|
if (!isWriteable()) return
|
||||||
com.vitorpamplona.quartz.utils.Log
|
|
||||||
.d("DesktopIAccount", "sendNip04PrivateMessage (stub)")
|
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>) {
|
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
|
||||||
// TODO: Wire through relay client for actual NIP-17 DM sending
|
if (!isWriteable()) return
|
||||||
com.vitorpamplona.quartz.utils.Log
|
|
||||||
.d("DesktopIAccount", "sendNip17PrivateMessage (stub)")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -35,8 +35,10 @@ import androidx.compose.material3.VerticalDivider
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -80,6 +82,7 @@ fun DesktopMessagesScreen(
|
|||||||
}
|
}
|
||||||
val selectedRoom by listState.selectedRoom.collectAsState()
|
val selectedRoom by listState.selectedRoom.collectAsState()
|
||||||
val listFocusRequester = remember { FocusRequester() }
|
val listFocusRequester = remember { FocusRequester() }
|
||||||
|
var showNewDmDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -98,7 +101,7 @@ fun DesktopMessagesScreen(
|
|||||||
|
|
||||||
// Cmd+Shift+N / Ctrl+Shift+N -> new DM
|
// Cmd+Shift+N / Ctrl+Shift+N -> new DM
|
||||||
event.key == Key.N && isModifier && event.isShiftPressed -> {
|
event.key == Key.N && isModifier && event.isShiftPressed -> {
|
||||||
// TODO: trigger new DM dialog when implemented
|
showNewDmDialog = true
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +118,7 @@ fun DesktopMessagesScreen(
|
|||||||
onConversationSelected = { roomKey ->
|
onConversationSelected = { roomKey ->
|
||||||
listState.selectRoom(roomKey)
|
listState.selectRoom(roomKey)
|
||||||
},
|
},
|
||||||
|
onNewConversation = { showNewDmDialog = true },
|
||||||
focusRequester = listFocusRequester,
|
focusRequester = listFocusRequester,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,6 +152,17 @@ fun DesktopMessagesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showNewDmDialog) {
|
||||||
|
NewDmDialog(
|
||||||
|
cacheProvider = cacheProvider,
|
||||||
|
onUserSelected = { roomKey ->
|
||||||
|
listState.selectRoom(roomKey)
|
||||||
|
showNewDmDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showNewDmDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+175
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user