From 5d81da2486e01cd1b47e43c214d191deb0262171 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 18 Feb 2026 13:19:46 +0200 Subject: [PATCH] feat(desktop): add encrypted DMs with split-pane layout (NIP-04/NIP-17) Full desktop DM implementation with shared components extracted to commons: - Relay layer: DM relay state, filter construction, subscription coordinator - Shared: ChatroomFeedFilter, ChatroomFeedViewModel, ChatNewMessageState - Shared UI: ChatBubbleLayout, ChatMessageCompose, ChatroomHeader, ChatTheme - Desktop: split-pane (conversation list + chat), keyboard shortcuts, hover reactions - IAccount extended with signer, sendNip04/17, chatroomList, isAcceptable Refs: vitorpamplona/amethyst#1704 Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/model/Account.kt | 10 +- .../amethyst/ui/dal/ChangesFlowFilter.kt | 8 +- .../chats/privateDM/dal/ChatroomFeedFilter.kt | 30 +- .../privateDM/dal/ChatroomFeedViewModel.kt | 45 +- .../dal/ChannelFeedViewModel.kt | 3 +- .../amethyst/commons/model/IAccount.kt | 20 + .../commons/ui/chat/ChatBubbleLayout.kt | 180 +++++++ .../commons/ui/chat/ChatMessageCompose.kt | 90 ++++ .../commons/ui/chat/ChatroomHeader.kt | 132 +++++ .../commons/ui/chat/UserDisplayNameLayout.kt | 50 ++ .../commons/ui/feeds/ChangesFlowFilter.kt | 28 + .../commons/ui/feeds/ChatroomFeedFilter.kt | 47 ++ .../commons/ui/feeds/DefaultFeedOrder.kt | 26 + .../amethyst/commons/ui/theme/ChatTheme.kt | 83 +++ .../commons/viewmodels/ChatNewMessageState.kt | 233 ++++++++ .../viewmodels/ChatroomFeedViewModel.kt | 32 ++ .../viewmodels/ListChangeFeedViewModel.kt | 62 +++ desktopApp/build.gradle.kts | 3 + .../vitorpamplona/amethyst/desktop/Main.kt | 15 +- .../desktop/model/DesktopAccountRelays.kt | 94 ++++ .../desktop/model/DesktopDmRelayState.kt | 81 +++ .../amethyst/desktop/model/DesktopIAccount.kt | 111 ++++ .../DesktopRelaySubscriptionsCoordinator.kt | 100 ++++ .../desktop/subscriptions/FilterBuilders.kt | 71 +++ .../desktop/subscriptions/FilterDMs.kt | 230 ++++++++ .../amethyst/desktop/ui/chats/ChatPane.kt | 504 ++++++++++++++++++ .../desktop/ui/chats/ChatroomListState.kt | 159 ++++++ .../desktop/ui/chats/ConversationListPane.kt | 369 +++++++++++++ .../desktop/ui/chats/DesktopMessagesScreen.kt | 181 +++++++ 29 files changed, 2917 insertions(+), 80 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d29997fae..2f7f93aa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -229,7 +229,7 @@ import kotlin.coroutines.cancellation.CancellationException @Stable class Account( val settings: AccountSettings = AccountSettings(KeyPair()), - val signer: NostrSigner, + override val signer: NostrSigner, val geolocationFlow: StateFlow, val nwcFilterAssembler: NWCPaymentFilterAssembler, val otsResolverBuilder: OtsResolverBuilder, @@ -349,7 +349,7 @@ class Account( override val privateZapsDecryptionCache = PrivateZapCache(signer) val draftsDecryptionCache = DraftEventCache(signer) - val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) + override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) val newNotesPreProcessor = EventProcessor(this, cache) @@ -1555,7 +1555,7 @@ class Account( broadcast.forEach { client.send(it, relayList) } } - suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { + override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return val newEvent = signer.sign(eventTemplate) @@ -1573,7 +1573,7 @@ class Account( broadcastPrivately(wraps) } - suspend fun sendNip17PrivateMessage(template: EventTemplate) { + override suspend fun sendNip17PrivateMessage(template: EventTemplate) { val events = NIP17Factory().createMessageNIP17(template, signer) broadcastPrivately(events) } @@ -1854,7 +1854,7 @@ class Account( fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors - fun isAcceptable(note: Note): Boolean { + override fun isAcceptable(note: Note): Boolean { return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt index 99c9e74f1..e13b8a73e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt @@ -20,9 +20,5 @@ */ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.commons.model.ListChange -import kotlinx.coroutines.flow.MutableSharedFlow - -interface ChangesFlowFilter : IAdditiveFeedFilter { - fun changesFlow(): MutableSharedFlow> -} +// Re-export from commons for backwards compatibility +typealias ChangesFlowFilter = com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt index 61147eca1..bebea047d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt @@ -20,31 +20,5 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey - -class ChatroomFeedFilter( - val withUser: ChatroomKey, - val account: Account, -) : AdditiveFeedFilter(), - ChangesFlowFilter { - fun chatroom() = account.chatroomList.getOrCreatePrivateChatroom(withUser) - - override fun changesFlow() = chatroom().changesFlow() - - // returns the last Note of each user. - override fun feedKey(): String = withUser.hashCode().toString() - - override fun feed(): List = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder) - - override fun applyFilter(newItems: Set): Set { - val chatroom = chatroom() - return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} +// Re-export from commons for backwards compatibility +typealias ChatroomFeedFilter = com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt index e40bcfb71..d1b670e54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt @@ -20,26 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal -import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.model.ListChange -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch + +// Re-export from commons for backwards compatibility +typealias ListChangeFeedViewModel = com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel class ChatroomFeedViewModel( val user: ChatroomKey, val account: Account, -) : ListChangeFeedViewModel(ChatroomFeedFilter(user, account)) { +) : com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel( + ChatroomFeedFilter(user, account), + LocalCache, + ) { class Factory( val user: ChatroomKey, val account: Account, @@ -48,30 +44,3 @@ class ChatroomFeedViewModel( override fun create(modelClass: Class): T = ChatroomFeedViewModel(user, account) as T } } - -@Stable -abstract class ListChangeFeedViewModel( - localFilter: ChangesFlowFilter, -) : ViewModel(), - InvalidatableContent { - val feedState = FeedContentState(localFilter, viewModelScope, LocalCache) - - override val isRefreshing = feedState.isRefreshing - - override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) - - init { - Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") - viewModelScope.launch(Dispatchers.IO) { - localFilter.changesFlow().collect { - Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel.javaClass.simpleName}") - when (it) { - is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item)) - is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item)) - is ListChange.SetAddition -> feedState.updateFeedWith(it.item) - is ListChange.SetDeletion -> feedState.deleteFromFeed(it.item) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt index 81caac22e..8d3fa89a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt @@ -24,12 +24,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ListChangeFeedViewModel class ChannelFeedViewModel( val channel: Channel, val account: Account, -) : ListChangeFeedViewModel(ChannelFeedFilter(channel, account)) { +) : ListChangeFeedViewModel(ChannelFeedFilter(channel, account), LocalCache) { class Factory( val channel: Channel, val account: Account, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 10bc24264..0f7d5d2dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +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.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Request @@ -73,6 +78,9 @@ interface IAccount { /** Whether account has write permissions */ fun isWriteable(): Boolean + /** Nostr signer for signing/encrypting events */ + val signer: NostrSigner + /** Current user's public key */ val pubKey: String @@ -86,4 +94,16 @@ interface IAccount { fun followingKeySet(): Set fun isHidden(user: User): Boolean + + /** Chatroom list for private DM conversations */ + val chatroomList: ChatroomList + + /** Whether a note is acceptable (not hidden, not blocked, etc.) */ + fun isAcceptable(note: Note): Boolean + + /** Send a NIP-04 encrypted direct message */ + suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) + + /** Send a NIP-17 gift-wrapped direct message */ + suspend fun sendNip17PrivateMessage(template: EventTemplate) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt new file mode 100644 index 000000000..bd92d401a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt @@ -0,0 +1,180 @@ +/* + * 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.commons.ui.chat + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThem +import com.vitorpamplona.amethyst.commons.ui.theme.ChatHalfHalfVertPadding +import com.vitorpamplona.amethyst.commons.ui.theme.ChatPaddingInnerQuoteModifier +import com.vitorpamplona.amethyst.commons.ui.theme.ChatPaddingModifier +import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowColSpacing5dp +import com.vitorpamplona.amethyst.commons.ui.theme.MessageBubbleLimits +import com.vitorpamplona.amethyst.commons.ui.theme.chatBubbleBackground +import com.vitorpamplona.amethyst.commons.ui.theme.chatBubbleDraftBackground +import com.vitorpamplona.amethyst.commons.ui.theme.chatBubbleMeBackground + +/** + * Shared chat bubble layout used by both Android and Desktop. + * + * Renders a chat message bubble with appropriate shape, color, and alignment + * depending on whether the message is from the logged-in user or another user. + * + * Content is provided via composable lambdas (slots) so platform-specific + * implementations can plug in their own action menus, author lines, detail + * rows, and message content. + * + * @param isLoggedInUser Whether this message was sent by the current user + * @param isDraft Whether this message is a draft + * @param innerQuote Whether this bubble is rendered as an inner quote (reply preview) + * @param isComplete Whether the UI is in "complete" mode (always show details) + * @param hasDetailsToShow Whether there are details (reactions, zaps) to display + * @param drawAuthorInfo Whether to show the author avatar and name + * @param parentBackgroundColor Background color of the parent (for compositing) + * @param onClick Called on tap; return true if the click was consumed + * @param onAuthorClick Called when the author line is tapped + * @param actionMenu Composable for the long-press action menu popup + * @param detailRow Composable for the detail row (time, reactions, relays) + * @param drawAuthorLine Composable for the author avatar + name row + * @param inner Composable for the message body content; receives the resolved background color + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatBubbleLayout( + isLoggedInUser: Boolean, + isDraft: Boolean, + innerQuote: Boolean, + isComplete: Boolean, + hasDetailsToShow: Boolean, + drawAuthorInfo: Boolean, + parentBackgroundColor: MutableState? = null, + onClick: () -> Boolean, + onAuthorClick: () -> Unit, + actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, + detailRow: @Composable () -> Unit, + drawAuthorLine: @Composable () -> Unit, + inner: @Composable (MutableState) -> Unit, +) { + val loggedInColors = MaterialTheme.colorScheme.chatBubbleMeBackground + val otherColors = MaterialTheme.colorScheme.chatBubbleBackground + val defaultBackground = MaterialTheme.colorScheme.background + val draftColor = MaterialTheme.colorScheme.chatBubbleDraftBackground + + val bgColor = + remember { + if (isLoggedInUser) { + if (isDraft) { + mutableStateOf( + draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } else { + mutableStateOf( + loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } + } else { + mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) + } + } + + Row( + modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + ) { + val popupExpanded = remember { mutableStateOf(false) } + + val showDetails = + remember { + mutableStateOf( + if (isComplete) { + true + } else { + hasDetailsToShow + }, + ) + } + + val clickableModifier = + remember { + Modifier.combinedClickable( + onClick = { + if (!onClick()) { + if (!isComplete) { + showDetails.value = !showDetails.value + } + } + }, + onLongClick = { popupExpanded.value = true }, + ) + } + + Row( + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, + ) { + Surface( + color = bgColor.value, + shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, + modifier = clickableModifier, + ) { + Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) { + if (drawAuthorInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + modifier = ChatHalfHalfVertPadding.clickable(onClick = onAuthorClick), + ) { + drawAuthorLine() + } + } + + inner(bgColor) + + if (showDetails.value) { + detailRow() + } + } + } + } + + if (popupExpanded.value) { + actionMenu { + popupExpanded.value = false + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt new file mode 100644 index 000000000..a848bfd8a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt @@ -0,0 +1,90 @@ +/* + * 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.commons.ui.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.model.Note + +/** + * Shared chat message composable that provides structure without platform-specific dependencies. + * + * This is a simplified, slot-based version of the Android ChatroomMessageCompose. + * Platform implementations provide the actual content via lambda slots, allowing + * both Android and Desktop to use the same bubble layout with different content renderers. + * + * The Android app can wrap this or continue using its own deeper implementation. + * The Desktop app uses this directly with simpler content slots. + * + * @param note The chat message note + * @param isLoggedInUser Whether this note was authored by the current user + * @param isDraft Whether this is a draft message + * @param innerQuote Whether this is rendered as a quoted reply + * @param isComplete Whether to always show detail row + * @param hasDetailsToShow Whether there are reactions/zaps to display + * @param drawAuthorInfo Whether to show author avatar and name + * @param parentBackgroundColor Parent background for color compositing + * @param onClick Called on tap; return true if consumed + * @param onAuthorClick Called when author info is tapped + * @param actionMenu Long-press action menu popup slot + * @param authorLine Author avatar and name slot + * @param detailRow Detail row slot (time, reactions) + * @param messageContent Message body slot; receives the resolved background color + */ +@Composable +fun ChatMessageCompose( + note: Note, + isLoggedInUser: Boolean, + isDraft: Boolean, + innerQuote: Boolean = false, + isComplete: Boolean = true, + hasDetailsToShow: Boolean = false, + drawAuthorInfo: Boolean = false, + parentBackgroundColor: MutableState? = null, + onClick: () -> Boolean = { false }, + onAuthorClick: () -> Unit = {}, + actionMenu: @Composable (onDismiss: () -> Unit) -> Unit = {}, + authorLine: @Composable () -> Unit = {}, + detailRow: @Composable () -> Unit = {}, + messageContent: @Composable (MutableState) -> Unit, +) { + ChatBubbleLayout( + isLoggedInUser = isLoggedInUser, + isDraft = isDraft, + innerQuote = innerQuote, + isComplete = isComplete, + hasDetailsToShow = hasDetailsToShow, + drawAuthorInfo = drawAuthorInfo, + parentBackgroundColor = parentBackgroundColor, + onClick = onClick, + onAuthorClick = onAuthorClick, + actionMenu = actionMenu, + drawAuthorLine = authorLine, + detailRow = detailRow, + ) { bgColor -> + Row(verticalAlignment = Alignment.CenterVertically) { + messageContent(bgColor) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt new file mode 100644 index 000000000..df25cb8c7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt @@ -0,0 +1,132 @@ +/* + * 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.commons.ui.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.ui.theme.ChatSize34dp +import com.vitorpamplona.amethyst.commons.ui.theme.ChatStdPadding + +/** + * Shared chatroom header for a single-user conversation. + * Displays the user's avatar and display name. + * + * Uses the shared UserAvatar component from commons for image loading, + * avoiding Android-specific dependencies. + * + * @param user The chat partner + * @param modifier Layout modifier (defaults to standard padding) + * @param onClick Called when the header is tapped (e.g., navigate to profile) + */ +@Composable +fun ChatroomHeader( + user: User, + modifier: Modifier = ChatStdPadding, + onClick: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column(modifier, Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = ChatSize34dp, + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** + * Shared chatroom header for a group conversation. + * Displays multiple user avatars and a combined room name. + * + * @param users List of users in the group conversation + * @param modifier Layout modifier (defaults to standard padding) + * @param onClick Called when the header is tapped + */ +@Composable +fun GroupChatroomHeader( + users: List, + modifier: Modifier = ChatStdPadding, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Show first user's avatar as the group icon + users.firstOrNull()?.let { firstUser -> + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = ChatSize34dp, + ) + } + + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = users.joinToString(", ") { it.toBestDisplayName() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt new file mode 100644 index 000000000..703369752 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt @@ -0,0 +1,50 @@ +/* + * 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.commons.ui.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import com.vitorpamplona.amethyst.commons.ui.theme.ChatAuthorBox +import com.vitorpamplona.amethyst.commons.ui.theme.ChatStdHorzSpacer + +/** + * Layout for displaying a user's picture and name in a chat message author line. + * Shared between Android and Desktop. + * + * The picture slot uses a BoxScope so callers can overlay badges/icons + * (e.g., following indicator, user cards) on top of the avatar. + */ +@Composable +fun UserDisplayNameLayout( + picture: @Composable BoxScope.() -> Unit, + name: @Composable () -> Unit, +) { + Box(ChatAuthorBox, contentAlignment = Alignment.TopEnd) { + picture() + } + + Spacer(modifier = ChatStdHorzSpacer) + + name() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt new file mode 100644 index 000000000..419cc268d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt @@ -0,0 +1,28 @@ +/* + * 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.commons.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.ListChange +import kotlinx.coroutines.flow.MutableSharedFlow + +interface ChangesFlowFilter : IAdditiveFeedFilter { + fun changesFlow(): MutableSharedFlow> +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt new file mode 100644 index 000000000..cf2674e9e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt @@ -0,0 +1,47 @@ +/* + * 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.commons.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +class ChatroomFeedFilter( + val withUser: ChatroomKey, + val account: IAccount, +) : AdditiveFeedFilter(), + ChangesFlowFilter { + fun chatroom() = account.chatroomList.getOrCreatePrivateChatroom(withUser) + + override fun changesFlow() = chatroom().changesFlow() + + // returns the last Note of each user. + override fun feedKey(): String = withUser.hashCode().toString() + + override fun feed(): List = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder) + + override fun applyFilter(newItems: Set): Set { + val chatroom = chatroom() + return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt new file mode 100644 index 000000000..47bd192ea --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt @@ -0,0 +1,26 @@ +/* + * 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.commons.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.Note + +val DefaultFeedOrder: Comparator = + compareByDescending { it.createdAt() }.thenBy { it.idHex } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt new file mode 100644 index 000000000..ae778e98a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt @@ -0,0 +1,83 @@ +/* + * 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.commons.ui.theme + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +// Chat bubble shapes +val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) +val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) + +// Chat bubble modifiers +val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f) +val ChatPaddingInnerQuoteModifier = Modifier +val ChatPaddingModifier = + Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 3.dp, + bottom = 3.dp, + ) + +// Message bubble internal padding +val MessageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 7.dp, bottom = 6.dp) + +// Chat author area +val ChatAuthorBox = Modifier.size(20.dp) +val ChatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape) + +// Spacing values reused across chat composables +val ChatRowColSpacing5dp = Arrangement.spacedBy(5.dp) +val ChatHalfHalfVertPadding = Modifier.padding(vertical = 3.dp) +val ChatStdHorzSpacer = Modifier.width(5.dp) +val ChatStdPadding = Modifier.padding(10.dp) +val ChatReactionRowHeight = Modifier.height(20.dp) + +// Common size values +val ChatSize20dp = 20.dp +val ChatSize34dp = 34.dp + +// Chat color extensions for ColorScheme +val ColorScheme.chatBubbleBackground: Color + get() = if (isLight) onSurface.copy(alpha = 0.08f) else onSurface.copy(alpha = 0.12f) + +val ColorScheme.chatBubbleDraftBackground: Color + get() = onSurface.copy(alpha = 0.15f) + +val ColorScheme.chatBubbleMeBackground: Color + get() = primary.copy(alpha = 0.32f) + +val ColorScheme.chatPlaceholderText: Color + get() = onSurface.copy(alpha = 0.42f) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt new file mode 100644 index 000000000..9b9cc7f06 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -0,0 +1,233 @@ +/* + * 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.commons.viewmodels + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Slim shared state for DM message composition. + * Holds only core fields needed for typing and sending messages. + * + * Platform-specific concerns (uploads, emoji suggestions, drafts, zapraiser, + * location, user suggestions) remain in the platform ViewModel layer. + * + * Used by both Android ChatNewMessageViewModel and Desktop DM screen. + */ +@Stable +class ChatNewMessageState( + val account: IAccount, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + private val _message = MutableStateFlow(TextFieldValue("")) + val message: StateFlow = _message.asStateFlow() + + private val _nip17 = MutableStateFlow(false) + val nip17: StateFlow = _nip17.asStateFlow() + + private val _replyTo = MutableStateFlow(null) + val replyTo: StateFlow = _replyTo.asStateFlow() + + private val _subject = MutableStateFlow(TextFieldValue("")) + val subject: StateFlow = _subject.asStateFlow() + + private val _room = MutableStateFlow(null) + val room: StateFlow = _room.asStateFlow() + + /** Whether NIP-17 is required (group chat with >1 recipient) */ + private val _requiresNip17 = MutableStateFlow(false) + val requiresNip17: StateFlow = _requiresNip17.asStateFlow() + + /** Whether a message can be sent (non-blank text + room set) */ + val canSend: Boolean + get() = _message.value.text.isNotBlank() && _room.value != null + + /** + * Load a chatroom. Sets the room key, formats toUsers display, + * and auto-detects NIP-17 requirement (group chats require NIP-17). + */ + fun load(roomKey: ChatroomKey) { + _room.value = roomKey + updateNip17FromRoom() + } + + /** + * Auto-detect NIP-17 based on room: + * - Group chats (>1 recipient) always require NIP-17 + * - Single recipient: NIP-17 off by default (can be toggled) + */ + fun updateNip17FromRoom() { + val currentRoom = _room.value + if (currentRoom != null) { + _requiresNip17.value = currentRoom.users.size > 1 + if (_requiresNip17.value) { + _nip17.value = true + } + } else { + _requiresNip17.value = false + _nip17.value = false + } + } + + fun updateMessage(newMessage: TextFieldValue) { + _message.value = newMessage + } + + fun updateSubject(newSubject: TextFieldValue) { + _subject.value = newSubject + } + + fun setReply(note: Note) { + _replyTo.value = note + } + + fun clearReply() { + _replyTo.value = null + } + + /** + * Toggle NIP-04/NIP-17 mode. + * If NIP-17 is required (group chat), stays on NIP-17. + */ + fun toggleNip17() { + if (_requiresNip17.value) { + _nip17.value = true + } else { + _nip17.value = !_nip17.value + } + } + + /** + * Enable NIP-17 (e.g., when recipient has DM relay list). + */ + fun enableNip17() { + _nip17.value = true + } + + /** + * Send the current message. Builds the appropriate event template + * (NIP-04 or NIP-17) and delegates to IAccount for signing/broadcasting. + * + * @return true if send was initiated, false if preconditions not met + */ + suspend fun send(): Boolean { + val currentRoom = _room.value ?: return false + val messageText = _message.value.text + if (messageText.isBlank()) return false + + if (_nip17.value || currentRoom.users.size > 1 || _replyTo.value?.event is NIP17Group) { + sendNip17(currentRoom, messageText) + } else { + sendNip04(currentRoom, messageText) + } + + return true + } + + private suspend fun sendNip17( + room: ChatroomKey, + messageText: String, + ) { + val pTags = + room.users.mapNotNull { hexKey -> + (cache.getOrCreateUser(hexKey) as? User)?.toPTag() + } + + val replyHint = _replyTo.value?.toEventHint() + + val template = + if (replyHint == null) { + ChatMessageEvent.build(messageText, pTags) { + hashtags(findHashtags(messageText)) + references(findURLs(messageText)) + quotes(findNostrEventUris(messageText)) + } + } else { + ChatMessageEvent.reply(messageText, replyHint) { + hashtags(findHashtags(messageText)) + references(findURLs(messageText)) + quotes(findNostrEventUris(messageText)) + } + } + + account.sendNip17PrivateMessage(template) + } + + private suspend fun sendNip04( + room: ChatroomKey, + messageText: String, + ) { + val toUser = (cache.getOrCreateUser(room.users.first()) as? User)?.toPTag() ?: return + + val template = + PrivateDmEvent.build( + toUser = toUser, + message = messageText, + replyingTo = _replyTo.value?.toEventHint(), + signer = account.signer, + ) + + account.sendNip04PrivateMessage(template) + } + + /** + * Clear all composition state after sending or cancelling. + */ + fun clear() { + _message.value = TextFieldValue("") + _subject.value = TextFieldValue("") + _replyTo.value = null + } + + /** + * Format room users as npub display string. + * Useful for showing recipients in the UI. + */ + fun toUsersDisplay(): String { + val currentRoom = _room.value ?: return "" + return currentRoom.users + .mapNotNull { hexKey -> + runCatching { Hex.decode(hexKey).toNpub() }.getOrNull() + }.joinToString(", ") { "@$it" } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt new file mode 100644 index 000000000..94f00b7a9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt @@ -0,0 +1,32 @@ +/* + * 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.commons.viewmodels + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +class ChatroomFeedViewModel( + val user: ChatroomKey, + val account: IAccount, + cacheProvider: ICacheProvider, +) : ListChangeFeedViewModel(ChatroomFeedFilter(user, account), cacheProvider) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt new file mode 100644 index 000000000..a6b6969a4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -0,0 +1,62 @@ +/* + * 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.commons.viewmodels + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.ListChange +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Stable +abstract class ListChangeFeedViewModel( + localFilter: ChangesFlowFilter, + cacheProvider: ICacheProvider, +) : ViewModel(), + InvalidatableContent { + val feedState = FeedContentState(localFilter, viewModelScope, cacheProvider) + + override val isRefreshing = feedState.isRefreshing + + override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) + + init { + Log.d("Init", "Starting new Model: ${this::class.simpleName}") + viewModelScope.launch(Dispatchers.IO) { + localFilter.changesFlow().collect { + Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}") + when (it) { + is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item)) + is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item)) + is ListChange.SetAddition -> feedState.updateFeedWith(it.item) + is ListChange.SetDeletion -> feedState.deleteFromFeed(it.item) + } + } + } + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index c9d5ee7c4..40f6e6d8c 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { // Commons library implementation(project(":commons")) + // Lifecycle ViewModel (needed to access ViewModel supertype from commons) + implementation(libs.androidx.lifecycle.viewmodel.compose) + // Coroutines implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) 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 358bdd044..80775af4d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -78,10 +78,10 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder 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.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen @@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.desktop.ui.SearchScreen import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -507,7 +508,17 @@ fun MainContent( } DesktopScreen.Messages -> { - MessagesPlaceholder() + val iAccount = + remember(account, localCache) { + DesktopIAccount(account, localCache) + } + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) } DesktopScreen.Notifications -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt new file mode 100644 index 000000000..0aaec0c1f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt @@ -0,0 +1,94 @@ +/* + * 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.model + +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages relay state for a desktop account. + * + * Bridges the gap between Android's Account.dmRelays (which depends on + * LocalCache, AccountSettings, and multiple relay list states) and desktop's + * simpler relay management. + * + * On Android, Account.dmRelays aggregates: + * - DmRelayListState (ChatMessageRelayListEvent, kind 10050) + * - Nip65RelayListState (NIP-65 advertised relays) + * - PrivateStorageRelayListState + * - LocalRelayListState + * + * On Desktop, we start with the DM relay list and connected relays as fallback. + * As the desktop Account evolves, this class will grow to match Android's behavior. + */ +class DesktopAccountRelays( + val userPubKeyHex: HexKey, + relayManager: RelayConnectionManager, + scope: CoroutineScope, +) { + /** User-configured DM relays from kind 10050 events */ + private val _dmRelayList = MutableStateFlow>(emptySet()) + val dmRelayList: StateFlow> = _dmRelayList.asStateFlow() + + /** Aggregated DM relay state (DM relays + fallback to connected relays) */ + val dmRelays = + DesktopDmRelayState( + dmRelayList = _dmRelayList, + connectedRelays = relayManager.connectedRelays, + scope = scope, + ) + + /** + * Processes a ChatMessageRelayListEvent (kind 10050) to update DM relays. + * Call this when receiving kind 10050 events from relay subscriptions. + */ + fun consumeDmRelayList(event: ChatMessageRelayListEvent) { + if (event.pubKey != userPubKeyHex) return + val relays = event.relays().toSet() + _dmRelayList.value = relays + } + + /** + * Processes any event that might be a DM relay list. + * Returns true if the event was consumed as a DM relay list. + */ + fun consumeIfDmRelayList(event: Event): Boolean { + if (event.kind == ChatMessageRelayListEvent.KIND && event is ChatMessageRelayListEvent) { + consumeDmRelayList(event) + return true + } + return false + } + + /** + * Manually sets DM relays (e.g., from saved preferences). + */ + fun setDmRelays(relays: Set) { + _dmRelayList.value = relays + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt new file mode 100644 index 000000000..3a193f95e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt @@ -0,0 +1,81 @@ +/* + * 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.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn + +/** + * Desktop equivalent of DmInboxRelayState. + * + * Aggregates DM inbox relays from multiple sources: + * - DM relay list (NIP-17 ChatMessageRelayListEvent, kind 10050) + * - Connected relays (fallback when no DM-specific relays are configured) + * + * On Android, the full Account class manages four relay sources: + * - NIP-65 advertised inbox relays + * - NIP-17 DM relay list + * - Private storage outbox relays + * - Local relays + * + * Desktop simplifies this: we combine the user's DM relay list with + * the connected relays as a fallback. As the desktop Account evolves, + * additional relay sources can be added here. + */ +class DesktopDmRelayState( + dmRelayList: StateFlow>, + connectedRelays: StateFlow>, + scope: CoroutineScope, +) { + /** + * Combined DM relay set. + * Prefers DM-specific relays when available, falls back to connected relays. + */ + val flow: StateFlow> = + combine( + dmRelayList, + connectedRelays, + ) { dmRelays, connected -> + if (dmRelays.isNotEmpty()) { + dmRelays + } else { + // Fallback: use all connected relays when no DM relays are configured + connected + } + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + dmRelayList.value.ifEmpty { connectedRelays.value }, + ) + + /** + * Outbox relays for sending DMs FROM the user. + * Uses the connected relays (home relay equivalent on desktop). + */ + val outboxFlow: StateFlow> = connectedRelays +} 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 new file mode 100644 index 000000000..450a03577 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -0,0 +1,111 @@ +/* + * 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.model + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.INwcSignerState +import com.vitorpamplona.amethyst.commons.model.Note +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.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.utils.DualCase + +/** + * Desktop implementation of IAccount. + * + * Bridges the desktop AccountState.LoggedIn and DesktopLocalCache to the + * 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. + */ +class DesktopIAccount( + private val accountState: AccountState.LoggedIn, + private val localCache: DesktopLocalCache, +) : IAccount { + override val signer: NostrSigner = accountState.signer + + override val pubKey: String = accountState.pubKeyHex + + override val showSensitiveContent: Boolean? = null + + override val hiddenWordsCase: List = emptyList() + + override val hiddenUsersHashCodes: Set = emptySet() + + override val spammersHashCodes: Set = emptySet() + + override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex) + + override val nip47SignerState: INwcSignerState = + object : INwcSignerState { + override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? = null + + override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? = null + + override fun isNIP47Author(pubKey: String?): Boolean = false + } + + override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = + object : IPrivateZapsDecryptionCache { + override fun cachedPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + + override suspend fun decryptPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + } + + override fun userProfile(): User = localCache.getOrCreateUser(pubKey) + + override fun isWriteable(): Boolean = !accountState.isReadOnly + + override fun followingKeySet(): Set = emptySet() + + override fun isHidden(user: User): Boolean = false + + override fun isAcceptable(note: Note): Boolean { + // Accept all notes on desktop for now + val event = note.event ?: return true + return !localCache.hasBeenDeleted(event) + } + + 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)") + } + + 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)") + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 9849acfc6..e47b44169 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -25,9 +25,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoo import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope @@ -130,11 +134,107 @@ class DesktopRelaySubscriptionsCoordinator( feedMetadata.loadReactionsForNotes(noteIds) } + // -- DM Subscription Support -- + + /** Active DM subscription IDs for cleanup */ + private val activeDmSubIds = mutableSetOf() + + /** + * Subscribes to DM events for the given user. + * + * Creates subscriptions for: + * - NIP-04 DMs TO the user (kind 4) on inbox/DM relays + * - NIP-04 DMs FROM the user (kind 4) on outbox/home relays + * - NIP-59 gift-wrapped DMs (kind 1059) on DM relays + * + * @param userPubKeyHex The logged-in user's pubkey (hex) + * @param dmRelayState Aggregated DM relay state for relay selection + * @param onDmEvent Callback for incoming DM events (kind 4 or kind 1059) + */ + fun subscribeToDms( + userPubKeyHex: HexKey, + dmRelayState: DesktopDmRelayState, + onDmEvent: (Event, NormalizedRelayUrl) -> Unit, + ) { + // Clean up any previous DM subscriptions + unsubscribeFromDms() + + val inboxRelays = dmRelayState.flow.value + val outboxRelays = dmRelayState.outboxFlow.value + + if (inboxRelays.isEmpty() && outboxRelays.isEmpty()) return + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onDmEvent(event, relay) + } + } + + // NIP-04 DMs TO user on inbox relays + if (inboxRelays.isNotEmpty()) { + val inboxSubId = generateSubId("dm-inbox-${userPubKeyHex.take(8)}") + activeDmSubIds.add(inboxSubId) + client.openReqSubscription( + subId = inboxSubId, + filters = + inboxRelays.associateWith { + listOf(FilterDMs.nip04ToMe(userPubKeyHex)) + }, + listener = listener, + ) + } + + // NIP-04 DMs FROM user on outbox relays + if (outboxRelays.isNotEmpty()) { + val outboxSubId = generateSubId("dm-outbox-${userPubKeyHex.take(8)}") + activeDmSubIds.add(outboxSubId) + client.openReqSubscription( + subId = outboxSubId, + filters = + outboxRelays.associateWith { + listOf(FilterDMs.nip04FromMe(userPubKeyHex)) + }, + listener = listener, + ) + } + + // NIP-59 gift-wrapped DMs on DM relays + if (inboxRelays.isNotEmpty()) { + val giftWrapSubId = generateSubId("giftwrap-${userPubKeyHex.take(8)}") + activeDmSubIds.add(giftWrapSubId) + client.openReqSubscription( + subId = giftWrapSubId, + filters = + inboxRelays.associateWith { + listOf(FilterDMs.giftWrapsToMe(userPubKeyHex)) + }, + listener = listener, + ) + } + } + + /** + * Unsubscribes from all active DM subscriptions. + */ + fun unsubscribeFromDms() { + activeDmSubIds.forEach { subId -> + client.close(subId) + } + activeDmSubIds.clear() + } + /** * Clear all queued requests. * Call when switching accounts or during cleanup. */ fun clear() { + unsubscribeFromDms() feedMetadata.clear() rateLimiter.reset() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index b558853c6..ea29bcf11 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -375,6 +375,77 @@ object FilterBuilders { limit = limit, ) + /** + * Creates a filter for NIP-04 DMs (kind 4) sent to a user. + * + * @param pubKeyHex Recipient public key (hex-encoded, 64 chars) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time >= this value + * @return Filter for DMs addressed to the specified user + */ + fun nip04DmsToUser( + pubKeyHex: String, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(4), // PrivateDmEvent.KIND + tags = mapOf("p" to listOf(pubKeyHex)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for NIP-04 DMs (kind 4) sent from a user. + * + * @param pubKeyHex Author public key (hex-encoded, 64 chars) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time >= this value + * @return Filter for DMs authored by the specified user + */ + fun nip04DmsFromUser( + pubKeyHex: String, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(4), // PrivateDmEvent.KIND + authors = listOf(pubKeyHex), + limit = limit, + since = since, + ) + + /** + * Creates a filter for NIP-59 gift-wrapped events (kind 1059) to a user. + * Gift wraps contain encrypted NIP-17 DMs. + * + * @param pubKeyHex Recipient public key (hex-encoded, 64 chars) + * @param since Timestamp (adjusted -2 days due to randomized created_at) + * @return Filter for gift wraps addressed to the specified user + */ + fun giftWrapsToUser( + pubKeyHex: String, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(1059), // GiftWrapEvent.KIND + tags = mapOf("p" to listOf(pubKeyHex)), + since = since, + ) + + /** + * Creates a filter for DM relay list events (kind 10050, NIP-17). + * + * @param pubKeyHex Author public key (hex-encoded, 64 chars) + * @return Filter for DM relay list (limit=1 since only latest is needed) + */ + fun dmRelayList(pubKeyHex: String): Filter = + Filter( + kinds = listOf(10050), // ChatMessageRelayListEvent.KIND + authors = listOf(pubKeyHex), + limit = 1, + ) + /** * Creates a filter for long-form content (kind 30023, NIP-23). * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt new file mode 100644 index 000000000..e1b9ced84 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt @@ -0,0 +1,230 @@ +/* + * 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.subscriptions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Filter builders for DM subscriptions on desktop. + * + * Supports two DM protocols: + * - NIP-04: Legacy encrypted DMs (kind 4) + * - NIP-17: Gift-wrapped DMs via NIP-59 (kind 1059) + */ +object FilterDMs { + /** + * Creates a filter for NIP-04 DMs sent TO the user. + * Subscribes on the user's inbox/DM relays. + * + * @param userPubKeyHex The user's public key (hex) + * @param since Optional since timestamp for incremental loading + * @param limit Optional limit on number of events + */ + fun nip04ToMe( + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(userPubKeyHex)), + since = since, + limit = limit, + ) + + /** + * Creates a filter for NIP-04 DMs sent FROM the user. + * Subscribes on the user's outbox/home relays. + * + * @param userPubKeyHex The user's public key (hex) + * @param since Optional since timestamp for incremental loading + * @param limit Optional limit on number of events + */ + fun nip04FromMe( + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(userPubKeyHex), + since = since, + limit = limit, + ) + + /** + * Creates a filter for NIP-04 DMs in a specific conversation. + * + * Messages TO user: kind 4, authors=group, tags=["p": userPubKey] + * Messages FROM user: kind 4, authors=[userPubKey], tags=["p": group] + * + * @param userPubKeyHex The user's public key (hex) + * @param conversationPubKeys Set of pubkeys in the conversation (excluding the user) + * @param since Optional since timestamp + */ + fun nip04Conversation( + userPubKeyHex: HexKey, + conversationPubKeys: Set, + since: Long? = null, + ): List { + if (conversationPubKeys.isEmpty()) return emptyList() + + return listOf( + // Messages TO me from conversation participants + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = conversationPubKeys.toList(), + tags = mapOf("p" to listOf(userPubKeyHex)), + since = since, + ), + // Messages FROM me to conversation participants + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(userPubKeyHex), + tags = mapOf("p" to conversationPubKeys.toList()), + since = since, + ), + ) + } + + /** + * Creates a filter for NIP-59 gift-wrapped events TO the user. + * Gift wraps (kind 1059) contain encrypted NIP-17 DMs. + * + * The since is adjusted back by 2 days because gift wrap created_at + * timestamps are randomized within a 2-day window for privacy. + * + * @param userPubKeyHex The user's public key (hex) + * @param since Optional since timestamp (will be adjusted -2 days) + */ + fun giftWrapsToMe( + userPubKeyHex: HexKey, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(userPubKeyHex)), + since = since?.minus(TimeUtils.twoDays()), + ) +} + +// -- Subscription factory functions -- + +/** + * Creates a subscription config for NIP-04 DMs TO the user (inbox). + * Subscribes on DM/inbox relays. + */ +fun createNip04DmInboxSubscription( + relays: Set, + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty() || userPubKeyHex.length != 64) return null + + return SubscriptionConfig( + subId = generateSubId("dm-inbox-${userPubKeyHex.take(8)}"), + filters = listOf(FilterDMs.nip04ToMe(userPubKeyHex, since, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for NIP-04 DMs FROM the user (outbox). + * Subscribes on home/outbox relays. + */ +fun createNip04DmOutboxSubscription( + relays: Set, + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty() || userPubKeyHex.length != 64) return null + + return SubscriptionConfig( + subId = generateSubId("dm-outbox-${userPubKeyHex.take(8)}"), + filters = listOf(FilterDMs.nip04FromMe(userPubKeyHex, since, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for NIP-59 gift-wrapped DMs TO the user. + * Subscribes on DM/inbox relays. + */ +fun createGiftWrapSubscription( + relays: Set, + userPubKeyHex: HexKey, + since: Long? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty() || userPubKeyHex.length != 64) return null + + return SubscriptionConfig( + subId = generateSubId("giftwrap-${userPubKeyHex.take(8)}"), + filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex, since)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for a NIP-04 DM conversation. + * Uses both inbox and outbox relays. + */ +fun createDmConversationSubscription( + inboxRelays: Set, + outboxRelays: Set, + userPubKeyHex: HexKey, + conversationPubKeys: Set, + since: Long? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (conversationPubKeys.isEmpty() || userPubKeyHex.length != 64) return null + + val allRelays = inboxRelays + outboxRelays + if (allRelays.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("dm-conv-${userPubKeyHex.take(8)}"), + filters = FilterDMs.nip04Conversation(userPubKeyHex, conversationPubKeys, since), + relays = allRelays, + onEvent = onEvent, + onEose = onEose, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt new file mode 100644 index 000000000..87be798ff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -0,0 +1,504 @@ +/* + * 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.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.HorizontalDivider +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.material3.TextButton +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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose +import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader +import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState +import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.launch + +private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +/** + * Right panel of the DM split-pane layout (flexible width). + * + * Displays: + * - ChatroomHeader at top (shared component from commons) + * - Message list (LazyColumn, reversed - newest at bottom, auto-scroll) + * - Message input at bottom with Send button and NIP-17 toggle + * + * @param roomKey The chatroom key for the selected conversation + * @param account The user's account (IAccount) + * @param cacheProvider The cache provider for user/note lookups + * @param feedViewModel ChatroomFeedViewModel for message data + * @param messageState ChatNewMessageState for composition + * @param onNavigateToProfile Called when user clicks on a profile + */ +@Composable +fun ChatPane( + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, + feedViewModel: ChatroomFeedViewModel, + messageState: ChatNewMessageState, + onNavigateToProfile: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val feedState by feedViewModel.feedState.feedContent.collectAsState() + val messageText by messageState.message.collectAsState() + val isNip17 by messageState.nip17.collectAsState() + val requiresNip17 by messageState.requiresNip17.collectAsState() + + // Resolve users for the header + val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } + val isGroup = users.size > 1 + + // Load room into message state + LaunchedEffect(roomKey) { + messageState.load(roomKey) + } + + Column(modifier = modifier.fillMaxSize()) { + // Header + if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) + } else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + // Fallback header with raw pubkey + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } + } + + HorizontalDivider() + + // Message list + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when (feedState) { + is FeedState.Loading -> { + LoadingState("Loading messages...") + } + + is FeedState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "No messages yet. Send the first one!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is FeedState.Loaded -> { + val loaded = feedState as FeedState.Loaded + val loadedState by loaded.feed.collectAsState() + val messages = loadedState.list + + MessageList( + messages = messages, + account = account, + cacheProvider = cacheProvider, + onAuthorClick = onNavigateToProfile, + ) + } + + is FeedState.FeedError -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "Error loading messages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + HorizontalDivider() + + // Message input + MessageInput( + messageText = messageText.text, + isNip17 = isNip17, + requiresNip17 = requiresNip17, + canSend = messageState.canSend, + onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, + onToggleNip17 = { messageState.toggleNip17() }, + onSend = { + scope.launch { + if (messageState.send()) { + messageState.clear() + } + } + }, + ) + } +} + +/** + * Scrollable message list, reversed so newest messages appear at the bottom. + */ +@Composable +private fun MessageList( + messages: List, + account: IAccount, + cacheProvider: ICacheProvider, + onAuthorClick: (String) -> Unit, + onReaction: (Note, String) -> Unit = { _, _ -> }, +) { + val listState = rememberLazyListState() + + // Auto-scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(0) + } + } + + LazyColumn( + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxSize().padding(vertical = 4.dp), + ) { + items(messages, key = { it.idHex }) { note -> + val isMe = note.author?.pubkeyHex == account.pubKey + val isDraft = note.isDraft() + + MessageWithReactions( + note = note, + isMe = isMe, + isDraft = isDraft, + onAuthorClick = onAuthorClick, + onReaction = { emoji -> onReaction(note, emoji) }, + ) + } + } +} + +/** + * Common reaction emojis for quick access. + */ +private val QUICK_REACTIONS = + listOf( + "\uD83D\uDC4D", // thumbs up + "\u2764\uFE0F", // red heart + "\uD83D\uDE02", // face with tears of joy + "\uD83D\uDD25", // fire + ) + +/** + * Wraps a ChatMessageCompose with hover-triggered reaction bar. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MessageWithReactions( + note: Note, + isMe: Boolean, + isDraft: Boolean, + onAuthorClick: (String) -> Unit, + onReaction: (String) -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .fillMaxWidth() + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false }, + ) { + ChatMessageCompose( + note = note, + isLoggedInUser = isMe, + isDraft = isDraft, + isComplete = true, + drawAuthorInfo = !isMe, + onClick = { false }, + onAuthorClick = { + note.author?.pubkeyHex?.let { onAuthorClick(it) } + }, + authorLine = { + note.author?.let { author -> + Text( + text = author.toBestDisplayName(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + detailRow = { + note.createdAt()?.let { timestamp -> + Text( + text = timestamp.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + }, + ) { _ -> + // Message body content + Text( + text = note.event?.content ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Reaction bar - shown on hover, positioned at top-right for others' messages, + // top-left for own messages + AnimatedVisibility( + visible = isHovered && !isDraft, + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(if (isMe) Alignment.TopStart else Alignment.TopEnd) + .offset( + x = if (isMe) 8.dp else (-8).dp, + y = (-4).dp, + ), + ) { + ReactionBar(onReaction = onReaction) + } + } +} + +/** + * Floating row of quick emoji reaction buttons. + */ +@Composable +private fun ReactionBar(onReaction: (String) -> Unit) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 2.dp, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + QUICK_REACTIONS.forEach { emoji -> + TextButton( + onClick = { + // TODO: Wire up NIP17Factory.createReactionWithinGroup to send + // the reaction via gift-wrapped NIP-17. Requires: + // 1. Get the note's event as EventHintBundle + // 2. Get the room participants as List + // 3. Get the NostrSigner from the account + // 4. Call NIP17Factory().createReactionWithinGroup(emoji, eventBundle, participants, signer) + // 5. Broadcast the resulting wraps via relay manager + onReaction(emoji) + }, + modifier = Modifier.size(32.dp), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(0.dp), + ) { + Text( + text = emoji, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +/** + * Message input area at the bottom of the chat pane. + */ +@Composable +private fun MessageInput( + messageText: String, + isNip17: Boolean, + requiresNip17: Boolean, + canSend: Boolean, + onMessageChange: (String) -> Unit, + onToggleNip17: () -> Unit, + onSend: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = messageText, + onValueChange = onMessageChange, + modifier = + Modifier + .weight(1f) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + // Cmd+Enter (Mac) or Ctrl+Enter to send + val hasModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + if (event.key == Key.Enter && hasModifier) { + if (canSend) onSend() + true + } else { + false + } + }, + placeholder = { Text("Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)") }, + singleLine = false, + maxLines = 4, + shape = RoundedCornerShape(12.dp), + ) + + IconButton( + onClick = onSend, + enabled = canSend, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = + if (canSend) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + ) + } + } + + // NIP-17 indicator + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp), + ) { + IconButton( + onClick = onToggleNip17, + enabled = !requiresNip17, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (isNip17) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = if (isNip17) "NIP-17 (encrypted)" else "NIP-04 (legacy)", + modifier = Modifier.size(16.dp), + tint = + if (isNip17) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + } + Spacer(Modifier.width(4.dp)) + Text( + text = if (isNip17) "NIP-17" else "NIP-04", + style = MaterialTheme.typography.labelSmall, + color = + if (isNip17) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + if (requiresNip17) { + Spacer(Modifier.width(4.dp)) + Text( + text = "(required for groups)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt new file mode 100644 index 000000000..6c7dcd136 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -0,0 +1,159 @@ +/* + * 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.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Represents a conversation entry in the list pane. + */ +@Stable +data class ConversationItem( + val roomKey: ChatroomKey, + val chatroom: Chatroom, + val users: List, + val displayName: String, + val lastMessagePreview: String, + val lastMessageTimestamp: Long, + val isGroup: Boolean, + val hasUnread: Boolean, +) + +/** + * Tab selection for the conversation list. + */ +enum class ConversationTab { + KNOWN, + NEW, +} + +/** + * Manages conversation list state for the desktop DM screen. + * + * Derives known/new rooms from IAccount.chatroomList: + * - Known: rooms where the user has sent a message (ownerSentMessage = true) + * - New: rooms where the user has NOT sent a message (incoming from unknown) + * + * Provides selectedTab and selectedRoom StateFlows that drive the UI. + */ +@Stable +class ChatroomListState( + private val account: IAccount, + private val cacheProvider: ICacheProvider, + private val scope: CoroutineScope, +) { + private val _selectedTab = MutableStateFlow(ConversationTab.KNOWN) + val selectedTab: StateFlow = _selectedTab.asStateFlow() + + private val _selectedRoom = MutableStateFlow(null) + val selectedRoom: StateFlow = _selectedRoom.asStateFlow() + + private val _knownRooms = MutableStateFlow>(emptyList()) + val knownRooms: StateFlow> = _knownRooms.asStateFlow() + + private val _newRooms = MutableStateFlow>(emptyList()) + val newRooms: StateFlow> = _newRooms.asStateFlow() + + init { + // Periodically refresh the room list from the account's chatroom list. + // This is a simple polling approach; a production implementation would + // observe the chatroom list's changesFlow per room. + scope.launch(Dispatchers.IO) { + while (isActive) { + refreshRooms() + delay(2000) + } + } + } + + fun selectTab(tab: ConversationTab) { + _selectedTab.value = tab + } + + fun selectRoom(roomKey: ChatroomKey) { + _selectedRoom.value = roomKey + } + + fun clearSelection() { + _selectedRoom.value = null + } + + private fun refreshRooms() { + val chatroomList = account.chatroomList + val known = mutableListOf() + val new = mutableListOf() + + chatroomList.rooms.forEach { key, chatroom -> + val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } + val displayName = + if (users.isNotEmpty()) { + users.joinToString(", ") { it.toBestDisplayName() } + } else { + key.users + .firstOrNull() + ?.take(12) + ?.let { "$it..." } ?: "Unknown" + } + + val newestMessage = chatroom.newestMessage + val lastPreview = newestMessage?.event?.content?.take(80) ?: "" + val lastTimestamp = newestMessage?.createdAt() ?: 0L + + // Skip rooms with no messages + if (chatroom.messages.isEmpty()) return@forEach + + val item = + ConversationItem( + roomKey = key, + chatroom = chatroom, + users = users, + displayName = displayName, + lastMessagePreview = lastPreview, + lastMessageTimestamp = lastTimestamp, + isGroup = key.users.size > 1, + hasUnread = !chatroom.ownerSentMessage && newestMessage != null, + ) + + if (chatroom.ownerSentMessage) { + known.add(item) + } else { + new.add(item) + } + } + + // Sort by most recent message + _knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp } + _newRooms.value = new.sortedByDescending { it.lastMessageTimestamp } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt new file mode 100644 index 000000000..ec2c9d9e7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -0,0 +1,369 @@ +/* + * 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.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Group +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.launch + +/** + * Left panel of the DM split-pane layout (280dp fixed width). + * + * Displays two tabs (Known / New) with conversation cards showing: + * - User avatar, display name, last message preview, timestamp + * - Unread indicator (blue dot) for new messages + * - Selected conversation highlighting + * - "+" button for starting new DMs + * + * @param state ChatroomListState managing the conversation list + * @param selectedRoom Currently selected room key (for highlighting) + * @param onConversationSelected Called when a conversation card is tapped + * @param onNewConversation Called when the "+" button is tapped + */ +@Composable +fun ConversationListPane( + state: ChatroomListState, + selectedRoom: ChatroomKey?, + onConversationSelected: (ChatroomKey) -> Unit, + onNewConversation: () -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() }, + modifier: Modifier = Modifier, +) { + val selectedTab by state.selectedTab.collectAsState() + val knownRooms by state.knownRooms.collectAsState() + val newRooms by state.newRooms.collectAsState() + val scope = rememberCoroutineScope() + + val currentList = + when (selectedTab) { + ConversationTab.KNOWN -> knownRooms + ConversationTab.NEW -> newRooms + } + + // Track focused index for keyboard navigation + var focusedIndex by remember { mutableIntStateOf(-1) } + val listScrollState = rememberLazyListState() + + // Derive the focused index from the selected room when it changes externally + val selectedIndex by remember(selectedRoom, currentList) { + derivedStateOf { + currentList.indexOfFirst { it.roomKey == selectedRoom } + } + } + + Column( + modifier = + modifier + .width(280.dp) + .fillMaxHeight() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + when (event.key) { + Key.DirectionDown -> { + if (currentList.isNotEmpty()) { + val newIndex = + if (focusedIndex < 0) { + 0 + } else { + (focusedIndex + 1).coerceAtMost(currentList.size - 1) + } + focusedIndex = newIndex + scope.launch { listScrollState.animateScrollToItem(newIndex) } + } + true + } + + Key.DirectionUp -> { + if (currentList.isNotEmpty()) { + val newIndex = + if (focusedIndex < 0) { + currentList.size - 1 + } else { + (focusedIndex - 1).coerceAtLeast(0) + } + focusedIndex = newIndex + scope.launch { listScrollState.animateScrollToItem(newIndex) } + } + true + } + + Key.Enter -> { + if (focusedIndex in currentList.indices) { + onConversationSelected(currentList[focusedIndex].roomKey) + } + true + } + + else -> { + false + } + } + }, + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Messages", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + IconButton( + onClick = onNewConversation, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.Add, + contentDescription = "New conversation", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + + // Tab selector + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedTab == ConversationTab.KNOWN, + onClick = { + state.selectTab(ConversationTab.KNOWN) + focusedIndex = -1 + }, + label = { + Text("Known (${knownRooms.size})") + }, + ) + FilterChip( + selected = selectedTab == ConversationTab.NEW, + onClick = { + state.selectTab(ConversationTab.NEW) + focusedIndex = -1 + }, + label = { + Text("New (${newRooms.size})") + }, + ) + } + + Spacer(Modifier.height(8.dp)) + + // Conversation list + if (currentList.isEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = + if (selectedTab == ConversationTab.KNOWN) { + "No conversations yet" + } else { + "No new messages" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + state = listScrollState, + modifier = Modifier.weight(1f), + ) { + items(currentList, key = { it.roomKey.hashCode() }) { item -> + val index = currentList.indexOf(item) + val isFocused = index == focusedIndex + ConversationCard( + item = item, + isSelected = selectedRoom == item.roomKey, + isFocused = isFocused, + onClick = { + focusedIndex = index + onConversationSelected(item.roomKey) + }, + ) + } + } + } + } +} + +/** + * A single conversation card in the list. + */ +@Composable +private fun ConversationCard( + item: ConversationItem, + isSelected: Boolean, + isFocused: Boolean = false, + onClick: () -> Unit, +) { + val backgroundColor = + when { + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.surface + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Unread indicator + if (item.hasUnread) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + Spacer(Modifier.width(6.dp)) + } else { + Spacer(Modifier.width(14.dp)) + } + + // Avatar + val firstUser = item.users.firstOrNull() + if (firstUser != null) { + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = 40.dp, + ) + } else if (item.isGroup) { + Icon( + Icons.Default.Group, + contentDescription = "Group", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.width(10.dp)) + + // Name, preview, timestamp + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + + if (item.lastMessageTimestamp > 0) { + Text( + text = item.lastMessageTimestamp.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + + if (item.lastMessagePreview.isNotEmpty()) { + Text( + text = item.lastMessagePreview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Group indicator + if (item.isGroup) { + Text( + text = "Group (${item.users.size + 1})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ) + } + } + } +} 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 new file mode 100644 index 000000000..de9771c1e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -0,0 +1,181 @@ +/* + * 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.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState +import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel + +private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +/** + * Desktop DM screen with split-pane layout. + * + * Left pane (280dp): ConversationListPane with Known/New tabs + * Right pane (flex): ChatPane with messages + input, or empty state + * + * @param account The user's IAccount for DM operations + * @param cacheProvider ICacheProvider for user/note lookups + * @param onNavigateToProfile Called when navigating to a user profile + */ +@Composable +fun DesktopMessagesScreen( + account: IAccount, + cacheProvider: ICacheProvider, + onNavigateToProfile: (String) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val listState = + remember(account) { + ChatroomListState(account, cacheProvider, scope) + } + val selectedRoom by listState.selectedRoom.collectAsState() + val listFocusRequester = remember { FocusRequester() } + + Row( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + + when { + // Escape -> deselect conversation + event.key == Key.Escape -> { + listState.clearSelection() + true + } + + // Cmd+Shift+N / Ctrl+Shift+N -> new DM + event.key == Key.N && isModifier && event.isShiftPressed -> { + // TODO: trigger new DM dialog when implemented + true + } + + else -> { + false + } + } + }, + ) { + // Left pane: conversation list (280dp fixed) + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { roomKey -> + listState.selectRoom(roomKey) + }, + focusRequester = listFocusRequester, + ) + + VerticalDivider(modifier = Modifier.fillMaxHeight()) + + // Right pane: chat or empty state (flex) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + val currentRoom = selectedRoom + if (currentRoom != null) { + // Create feed VM and message state scoped to the selected room + val feedViewModel = + remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = + remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + onNavigateToProfile = onNavigateToProfile, + ) + } else { + // Empty state + EmptyConversationState() + } + } + } +} + +/** + * Shown when no conversation is selected in the right pane. + */ +@Composable +private fun EmptyConversationState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + androidx.compose.foundation.layout.Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.Email, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + modifier = Modifier.size(48.dp), + ) + androidx.compose.foundation.layout.Spacer( + modifier = Modifier.height(16.dp), + ) + Text( + "Select a conversation to start messaging", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +}