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.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,
|
||||
|
||||
+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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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