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 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-18 13:19:46 +02:00
parent 893a318fbd
commit 5d81da2486
29 changed files with 2917 additions and 80 deletions
@@ -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<LocationState.LocationResult>,
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<PrivateDmEvent>) {
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
if (!isWriteable()) return
val newEvent = signer.sign(eventTemplate)
@@ -1573,7 +1573,7 @@ class Account(
broadcastPrivately(wraps)
}
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
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) &&
@@ -20,9 +20,5 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.commons.model.ListChange
import kotlinx.coroutines.flow.MutableSharedFlow
interface ChangesFlowFilter<T> : IAdditiveFeedFilter<T> {
fun changesFlow(): MutableSharedFlow<ListChange<T>>
}
// Re-export from commons for backwards compatibility
typealias ChangesFlowFilter<T> = com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter<T>
@@ -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<Note>(),
ChangesFlowFilter<Note> {
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<Note> = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val chatroom = chatroom()
return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet()
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
// Re-export from commons for backwards compatibility
typealias ChatroomFeedFilter = com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = ChatroomFeedViewModel(user, account) as T
}
}
@Stable
abstract class ListChangeFeedViewModel(
localFilter: ChangesFlowFilter<Note>,
) : 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)
}
}
}
}
}
@@ -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,
@@ -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<String>
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<PrivateDmEvent>)
/** Send a NIP-17 gift-wrapped direct message */
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>)
}
@@ -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<Color>? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> 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
}
}
}
}
@@ -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<Color>? = null,
onClick: () -> Boolean = { false },
onAuthorClick: () -> Unit = {},
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit = {},
authorLine: @Composable () -> Unit = {},
detailRow: @Composable () -> Unit = {},
messageContent: @Composable (MutableState<Color>) -> 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)
}
}
}
@@ -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<User>,
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,
)
}
}
}
}
}
@@ -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()
}
@@ -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<T> : IAdditiveFeedFilter<T> {
fun changesFlow(): MutableSharedFlow<ListChange<T>>
}
@@ -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<Note>(),
ChangesFlowFilter<Note> {
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<Note> = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val chatroom = chatroom()
return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet()
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<Note> =
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
@@ -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)
@@ -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<TextFieldValue> = _message.asStateFlow()
private val _nip17 = MutableStateFlow(false)
val nip17: StateFlow<Boolean> = _nip17.asStateFlow()
private val _replyTo = MutableStateFlow<Note?>(null)
val replyTo: StateFlow<Note?> = _replyTo.asStateFlow()
private val _subject = MutableStateFlow(TextFieldValue(""))
val subject: StateFlow<TextFieldValue> = _subject.asStateFlow()
private val _room = MutableStateFlow<ChatroomKey?>(null)
val room: StateFlow<ChatroomKey?> = _room.asStateFlow()
/** Whether NIP-17 is required (group chat with >1 recipient) */
private val _requiresNip17 = MutableStateFlow(false)
val requiresNip17: StateFlow<Boolean> = _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<BaseDMGroupEvent>()
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<PrivateDmEvent>(),
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" }
}
}
@@ -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)
@@ -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<Note>,
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)
}
}
}
}
}
+3
View File
@@ -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)
@@ -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 -> {
@@ -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<Set<NormalizedRelayUrl>>(emptySet())
val dmRelayList: StateFlow<Set<NormalizedRelayUrl>> = _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<NormalizedRelayUrl>) {
_dmRelayList.value = relays
}
}
@@ -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<Set<NormalizedRelayUrl>>,
connectedRelays: StateFlow<Set<NormalizedRelayUrl>>,
scope: CoroutineScope,
) {
/**
* Combined DM relay set.
* Prefers DM-specific relays when available, falls back to connected relays.
*/
val flow: StateFlow<Set<NormalizedRelayUrl>> =
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<Set<NormalizedRelayUrl>> = connectedRelays
}
@@ -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<DualCase> = emptyList()
override val hiddenUsersHashCodes: Set<Int> = emptySet()
override val spammersHashCodes: Set<Int> = 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<String> = 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<PrivateDmEvent>) {
// 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<ChatMessageEvent>) {
// TODO: Wire through relay client for actual NIP-17 DM sending
com.vitorpamplona.quartz.utils.Log
.d("DesktopIAccount", "sendNip17PrivateMessage (stub)")
}
}
@@ -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<String>()
/**
* 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<Filter>?,
) {
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()
}
@@ -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).
*
@@ -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<HexKey>,
since: Long? = null,
): List<Filter> {
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<NormalizedRelayUrl>,
userPubKeyHex: HexKey,
since: Long? = null,
limit: Int? = null,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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<NormalizedRelayUrl>,
userPubKeyHex: HexKey,
since: Long? = null,
limit: Int? = null,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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<NormalizedRelayUrl>,
userPubKeyHex: HexKey,
since: Long? = null,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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<NormalizedRelayUrl>,
outboxRelays: Set<NormalizedRelayUrl>,
userPubKeyHex: HexKey,
conversationPubKeys: Set<HexKey>,
since: Long? = null,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> 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,
)
}
@@ -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<Note>,
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<HexKey>
// 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),
)
}
}
}
}
@@ -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<User>,
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<ConversationTab> = _selectedTab.asStateFlow()
private val _selectedRoom = MutableStateFlow<ChatroomKey?>(null)
val selectedRoom: StateFlow<ChatroomKey?> = _selectedRoom.asStateFlow()
private val _knownRooms = MutableStateFlow<List<ConversationItem>>(emptyList())
val knownRooms: StateFlow<List<ConversationItem>> = _knownRooms.asStateFlow()
private val _newRooms = MutableStateFlow<List<ConversationItem>>(emptyList())
val newRooms: StateFlow<List<ConversationItem>> = _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<ConversationItem>()
val new = mutableListOf<ConversationItem>()
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 }
}
}
@@ -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),
)
}
}
}
}
@@ -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,
)
}
}
}