feat(desktop): add DM broadcast banner and fix empty room loading

- Fix ListChangeFeedViewModel stuck on Loading for empty chatrooms by
  adding invalidateData() call in init block
- Add DmBroadcastStatus sealed class and DmBroadcastBanner composable
  in commons for relay send progress display
- Add DmSendTracker using sendAndWaitForResponse for confirmed delivery
- Wire send tracking through DesktopIAccount into ChatPane banner
- Fix audit risks: unsubscribe DMs on account change, wait for relay
  connect before subscribing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-19 12:20:04 +02:00
parent 5d983c5d27
commit 35a15f0462
8 changed files with 289 additions and 7 deletions
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DmBroadcastBanner(
status: DmBroadcastStatus,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = status !is DmBroadcastStatus.Idle,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
modifier = modifier,
) {
val isFailed = status is DmBroadcastStatus.Failed
val containerColor =
if (isFailed) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val contentColor =
if (isFailed) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
color = containerColor,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val icon =
when (status) {
is DmBroadcastStatus.Subscribing,
is DmBroadcastStatus.Sending,
-> Icons.Default.Sync
is DmBroadcastStatus.Sent -> Icons.Default.CheckCircle
is DmBroadcastStatus.Failed -> Icons.Default.Error
is DmBroadcastStatus.Idle -> Icons.Default.Sync
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = contentColor,
)
Spacer(Modifier.width(4.dp))
Text(
text =
when (status) {
is DmBroadcastStatus.Subscribing -> "Connecting to DM relays..."
is DmBroadcastStatus.Sending -> "Sending message... [${status.successCount}/${status.totalRelays}]"
is DmBroadcastStatus.Sent -> "Sent to ${status.relayCount} relays"
is DmBroadcastStatus.Failed -> "Send failed: ${status.error}"
is DmBroadcastStatus.Idle -> ""
},
style = MaterialTheme.typography.labelMedium,
color = contentColor,
)
}
if (status is DmBroadcastStatus.Sending) {
LinearProgressIndicator(
progress = { status.progress },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.chat
import androidx.compose.runtime.Immutable
@Immutable
sealed class DmBroadcastStatus {
data object Idle : DmBroadcastStatus()
data object Subscribing : DmBroadcastStatus()
data class Sending(
val successCount: Int,
val totalRelays: Int,
) : DmBroadcastStatus() {
val progress: Float
get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f
}
data class Sent(
val relayCount: Int,
) : DmBroadcastStatus()
data class Failed(
val error: String,
) : DmBroadcastStatus()
}
@@ -47,6 +47,10 @@ abstract class ListChangeFeedViewModel(
init { init {
Log.d("Init", "Starting new Model: ${this::class.simpleName}") Log.d("Init", "Starting new Model: ${this::class.simpleName}")
// Trigger initial load so empty rooms show Empty instead of Loading
viewModelScope.launch(Dispatchers.IO) {
feedState.invalidateData(ignoreIfDoing = false)
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
localFilter.changesFlow().collect { localFilter.changesFlow().collect {
Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}") Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}")
@@ -96,12 +96,14 @@ import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -284,10 +286,16 @@ fun App(
} }
} }
// Subscribe to DMs when user logs in // Subscribe to DMs when user logs in; unsubscribe on account change
LaunchedEffect(accountState) { LaunchedEffect(accountState) {
// Clean up previous DM subscriptions on any account change (logout, switch)
subscriptionsCoordinator.unsubscribeFromDms()
val currentAccount = accountState val currentAccount = accountState
if (currentAccount is AccountState.LoggedIn) { if (currentAccount is AccountState.LoggedIn) {
// Wait for at least one relay to connect before subscribing
relayManager.connectedRelays.first { it.isNotEmpty() }
val dmRelayState = val dmRelayState =
DesktopDmRelayState( DesktopDmRelayState(
dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()), dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()),
@@ -535,9 +543,13 @@ fun MainContent(
} }
DesktopScreen.Messages -> { DesktopScreen.Messages -> {
val dmSendTracker =
remember(relayManager) {
DmSendTracker(relayManager.client)
}
val iAccount = val iAccount =
remember(account, localCache, relayManager) { remember(account, localCache, relayManager, dmSendTracker) {
DesktopIAccount(account, localCache, relayManager) DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope)
} }
DesktopMessagesScreen( DesktopMessagesScreen(
account = iAccount, account = iAccount,
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
@@ -41,6 +42,8 @@ import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/** /**
* Desktop implementation of IAccount. * Desktop implementation of IAccount.
@@ -55,6 +58,8 @@ class DesktopIAccount(
private val accountState: AccountState.LoggedIn, private val accountState: AccountState.LoggedIn,
private val localCache: DesktopLocalCache, private val localCache: DesktopLocalCache,
private val relayManager: RelayConnectionManager, private val relayManager: RelayConnectionManager,
val dmSendTracker: DmSendTracker,
private val scope: CoroutineScope,
) : IAccount { ) : IAccount {
override val signer: NostrSigner = accountState.signer override val signer: NostrSigner = accountState.signer
@@ -114,7 +119,7 @@ class DesktopIAccount(
} }
} }
relayManager.send(signedEvent, targetRelays) scope.launch { dmSendTracker.sendAndTrack(signedEvent, targetRelays) }
} }
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) { override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
@@ -122,7 +127,7 @@ class DesktopIAccount(
val result = NIP17Factory().createMessageNIP17(template, signer) val result = NIP17Factory().createMessageNIP17(template, signer)
// Broadcast each gift wrap to the recipient's inbox relays // Broadcast each gift wrap to the recipient's inbox relays with tracking
result.wraps.forEach { wrap -> result.wraps.forEach { wrap ->
val recipientKey = wrap.recipientPubKey() val recipientKey = wrap.recipientPubKey()
val targetRelays = val targetRelays =
@@ -138,7 +143,7 @@ class DesktopIAccount(
relayManager.connectedRelays.value relayManager.connectedRelays.value
} }
relayManager.send(wrap, targetRelays) scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) }
} }
} }
@@ -158,7 +163,7 @@ class DesktopIAccount(
relayManager.connectedRelays.value relayManager.connectedRelays.value
} }
relayManager.send(wrap, targetRelays) scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) }
} }
} }
} }
@@ -79,6 +79,8 @@ import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose
import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastBanner
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -114,6 +116,7 @@ fun ChatPane(
cacheProvider: ICacheProvider, cacheProvider: ICacheProvider,
feedViewModel: ChatroomFeedViewModel, feedViewModel: ChatroomFeedViewModel,
messageState: ChatNewMessageState, messageState: ChatNewMessageState,
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -157,6 +160,9 @@ fun ChatPane(
HorizontalDivider() HorizontalDivider()
// Broadcast status banner
DmBroadcastBanner(status = dmBroadcastStatus)
// Message list // Message list
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (feedState) { when (feedState) {
@@ -54,8 +54,10 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -138,12 +140,22 @@ fun DesktopMessagesScreen(
ChatNewMessageState(account, cacheProvider, scope) ChatNewMessageState(account, cacheProvider, scope)
} }
val broadcastStatus =
if (account is DesktopIAccount) {
account.dmSendTracker.status
.collectAsState()
.value
} else {
DmBroadcastStatus.Idle
}
ChatPane( ChatPane(
roomKey = currentRoom, roomKey = currentRoom,
account = account, account = account,
cacheProvider = cacheProvider, cacheProvider = cacheProvider,
feedViewModel = feedViewModel, feedViewModel = feedViewModel,
messageState = messageState, messageState = messageState,
dmBroadcastStatus = broadcastStatus,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
) )
} else { } else {
@@ -0,0 +1,64 @@
/*
* 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 com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DmSendTracker(
private val client: INostrClient,
) {
private val _status = MutableStateFlow<DmBroadcastStatus>(DmBroadcastStatus.Idle)
val status: StateFlow<DmBroadcastStatus> = _status.asStateFlow()
suspend fun sendAndTrack(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty()) {
_status.value = DmBroadcastStatus.Failed("No relays available")
delay(3000)
_status.value = DmBroadcastStatus.Idle
return
}
_status.value = DmBroadcastStatus.Sending(0, relays.size)
val success = client.sendAndWaitForResponse(event, relays, 10)
_status.value =
if (success) {
DmBroadcastStatus.Sent(relays.size)
} else {
DmBroadcastStatus.Failed("No relay accepted the message")
}
delay(3000)
_status.value = DmBroadcastStatus.Idle
}
}