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:
@@ -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 -> {
|
||||
|
||||
+94
@@ -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
|
||||
}
|
||||
}
|
||||
+81
@@ -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
|
||||
}
|
||||
+111
@@ -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)")
|
||||
}
|
||||
}
|
||||
+100
@@ -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()
|
||||
}
|
||||
|
||||
+71
@@ -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).
|
||||
*
|
||||
|
||||
+230
@@ -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,
|
||||
)
|
||||
}
|
||||
+504
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -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 }
|
||||
}
|
||||
}
|
||||
+369
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user