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
+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,
)
}
}
}