diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt new file mode 100644 index 000000000..7f2b53646 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt @@ -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, + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt new file mode 100644 index 000000000..42a6f3aad --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt @@ -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() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt index a6b6969a4..4bce8f7a1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -47,6 +47,10 @@ abstract class ListChangeFeedViewModel( init { 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) { localFilter.changesFlow().collect { Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}") diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e463e9a27..e97b2f18a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -96,12 +96,14 @@ 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.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch 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) { + // Clean up previous DM subscriptions on any account change (logout, switch) + subscriptionsCoordinator.unsubscribeFromDms() + val currentAccount = accountState if (currentAccount is AccountState.LoggedIn) { + // Wait for at least one relay to connect before subscribing + relayManager.connectedRelays.first { it.isNotEmpty() } + val dmRelayState = DesktopDmRelayState( dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()), @@ -535,9 +543,13 @@ fun MainContent( } DesktopScreen.Messages -> { + val dmSendTracker = + remember(relayManager) { + DmSendTracker(relayManager.client) + } val iAccount = - remember(account, localCache, relayManager) { - DesktopIAccount(account, localCache, relayManager) + remember(account, localCache, relayManager, dmSendTracker) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) } DesktopMessagesScreen( account = iAccount, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e0c1bcc61..cb6051321 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -28,6 +28,7 @@ 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.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.NostrSigner 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.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Desktop implementation of IAccount. @@ -55,6 +58,8 @@ class DesktopIAccount( private val accountState: AccountState.LoggedIn, private val localCache: DesktopLocalCache, private val relayManager: RelayConnectionManager, + val dmSendTracker: DmSendTracker, + private val scope: CoroutineScope, ) : IAccount { 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) { @@ -122,7 +127,7 @@ class DesktopIAccount( 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 -> val recipientKey = wrap.recipientPubKey() val targetRelays = @@ -138,7 +143,7 @@ class DesktopIAccount( relayManager.connectedRelays.value } - relayManager.send(wrap, targetRelays) + scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) } } } @@ -158,7 +163,7 @@ class DesktopIAccount( relayManager.connectedRelays.value } - relayManager.send(wrap, targetRelays) + scope.launch { dmSendTracker.sendAndTrack(wrap, targetRelays) } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index ec6a68a2d..aa8778210 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -79,6 +79,8 @@ 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.DmBroadcastBanner +import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -114,6 +116,7 @@ fun ChatPane( cacheProvider: ICacheProvider, feedViewModel: ChatroomFeedViewModel, messageState: ChatNewMessageState, + dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { @@ -157,6 +160,9 @@ fun ChatPane( HorizontalDivider() + // Broadcast status banner + DmBroadcastBanner(status = dmBroadcastStatus) + // Message list Box(modifier = Modifier.weight(1f).fillMaxWidth()) { when (feedState) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 02234d844..4cb952421 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -54,8 +54,10 @@ 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.ui.chat.DmBroadcastStatus import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -138,12 +140,22 @@ fun DesktopMessagesScreen( ChatNewMessageState(account, cacheProvider, scope) } + val broadcastStatus = + if (account is DesktopIAccount) { + account.dmSendTracker.status + .collectAsState() + .value + } else { + DmBroadcastStatus.Idle + } + ChatPane( roomKey = currentRoom, account = account, cacheProvider = cacheProvider, feedViewModel = feedViewModel, messageState = messageState, + dmBroadcastStatus = broadcastStatus, onNavigateToProfile = onNavigateToProfile, ) } else { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt new file mode 100644 index 000000000..c07e8cd65 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt @@ -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.Idle) + val status: StateFlow = _status.asStateFlow() + + suspend fun sendAndTrack( + event: Event, + relays: Set, + ) { + 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 + } +}