From 4d1d92db39f8b7070b9cbb6a16b49c9b64c15173 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 09:33:10 +0200 Subject: [PATCH] feat(desktop): add bunker heartbeat indicator to sidebar navigation Pulsating green heart icon shows NIP-46 signer connection status in both DeckSidebar and SinglePaneLayout NavigationRail. Tooltip displays last ping time. SignerConnectionState moved to commons for KMP sharing. Co-Authored-By: Claude Opus 4.6 --- .../domain/nip46/SignerConnectionState.kt | 29 +++ .../ui/components/BunkerHeartbeatIndicator.kt | 117 ++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 6 + .../desktop/account/AccountManager.kt | 216 +++--------------- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 11 + .../desktop/ui/deck/SinglePaneLayout.kt | 12 + .../account/AccountManagerLogoutTest.kt | 1 + .../AccountManagerNip46IsolationTest.kt | 1 + 8 files changed, 212 insertions(+), 181 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt new file mode 100644 index 000000000..321df54a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt @@ -0,0 +1,29 @@ +/* + * 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.domain.nip46 + +sealed class SignerConnectionState { + data object NotRemote : SignerConnectionState() + + data object Connected : SignerConnectionState() + + data object Disconnected : SignerConnectionState() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt new file mode 100644 index 000000000..7c295ef91 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt @@ -0,0 +1,117 @@ +/* + * 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.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.quartz.utils.TimeUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BunkerHeartbeatIndicator( + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, + modifier: Modifier = Modifier, +) { + if (signerConnectionState is SignerConnectionState.NotRemote) return + + val tooltipState = rememberTooltipState() + val tooltipText = + when (signerConnectionState) { + is SignerConnectionState.Connected -> { + if (lastPingTimeSec != null) { + val agoSeconds = TimeUtils.now() - lastPingTimeSec + "Bunker connected \u2014 last pinged ${agoSeconds}s ago" + } else { + "Bunker connected" + } + } + + is SignerConnectionState.Disconnected -> { + "Bunker disconnected" + } + + else -> { + "" + } + } + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(tooltipText) } }, + state = tooltipState, + modifier = modifier, + ) { + when (signerConnectionState) { + is SignerConnectionState.Connected -> { + val infiniteTransition = rememberInfiniteTransition(label = "heartbeat") + val scale by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = + infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "heartbeatScale", + ) + Icon( + Icons.Default.Favorite, + contentDescription = "Bunker connected", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(20.dp).scale(scale), + ) + } + + is SignerConnectionState.Disconnected -> { + Icon( + Icons.Default.FavoriteBorder, + contentDescription = "Bunker disconnected", + tint = Color(0xFFF44336), + modifier = Modifier.size(20.dp), + ) + } + + else -> {} + } + } +} 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 d353393af..30470359f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -557,6 +557,8 @@ fun MainContent( ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val signerConnectionState by accountManager.signerConnectionState.collectAsState() + val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState() // DM infrastructure — hoisted here so it survives screen navigation val dmSendTracker = @@ -678,6 +680,8 @@ fun MainContent( onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, modifier = Modifier.weight(1f), ) } @@ -692,6 +696,8 @@ fun MainContent( deckState.addColumn(DeckColumnType.Settings) } }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, ) VerticalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 5af6d87eb..7fab08fbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -21,22 +21,21 @@ package com.vitorpamplona.amethyst.desktop.account import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.domain.nip46.BunkerLoginUseCase +import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -46,17 +45,9 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck -import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -72,7 +63,6 @@ import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission -import java.security.SecureRandom sealed class SignerType { data object Internal : SignerType() @@ -82,14 +72,6 @@ sealed class SignerType { ) : SignerType() } -sealed class SignerConnectionState { - data object NotRemote : SignerConnectionState() - - data object Connected : SignerConnectionState() - - data object Disconnected : SignerConnectionState() -} - sealed class AccountState { data object LoggedOut : AccountState() @@ -119,7 +101,6 @@ class AccountManager internal constructor( internal const val HEARTBEAT_INTERVAL_MS = 60_000L internal const val MAX_CONSECUTIVE_FAILURES = 3 internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" - internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L internal val NIP46_RELAYS = listOf("wss://relay.nsec.app") } @@ -137,6 +118,9 @@ class AccountManager internal constructor( private val _signerConnectionState = MutableStateFlow(SignerConnectionState.NotRemote) val signerConnectionState: StateFlow = _signerConnectionState.asStateFlow() + private val _lastPingTimeSec = MutableStateFlow(null) + val lastPingTimeSec: StateFlow = _lastPingTimeSec.asStateFlow() + private val _forceLogoutReason = MutableStateFlow(null) val forceLogoutReason: StateFlow = _forceLogoutReason.asStateFlow() @@ -326,31 +310,26 @@ class AccountManager internal constructor( val nip46Client = getOrCreateNip46Client() client = nip46Client - val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client) - // Emit connecting with initial relay statuses + val relaysFromUri = parseBunkerRelays(bunkerUri) _loginProgress.value = LoginProgress.ConnectingToRelays( - remoteSigner.relays.associateWith { RelayLoginStatus.CONNECTING }, + relaysFromUri.associateWith { RelayLoginStatus.CONNECTING }, ) nip46Client.subscribe(listener) - remoteSigner.openSubscription() - - // Wait for websocket to be ready before sending connect request - awaitNip46RelayConnection(nip46Client, remoteSigner.relays) - _loginProgress.value = LoginProgress.WaitingForSigner( relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), ) - val remotePubkey = remoteSigner.connect() + + val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client) val state = AccountState.LoggedIn( - signer = remoteSigner, - pubKeyHex = remotePubkey, - npub = remotePubkey.hexToByteArray().toNpub(), + signer = result.signer, + pubKeyHex = result.pubKeyHex, + npub = result.pubKeyHex.hexToByteArray().toNpub(), nsec = null, isReadOnly = false, signerType = SignerType.Remote(bunkerUri), @@ -358,7 +337,6 @@ class AccountManager internal constructor( _accountState.value = state _signerConnectionState.value = SignerConnectionState.Connected - // Save bunker account — strip secret param (no longer needed after connect) saveBunkerAccount( bunkerUri = stripBunkerSecret(bunkerUri), ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), @@ -389,61 +367,34 @@ class AccountManager internal constructor( var client: NostrClient? = null try { val ephemeralKeyPair = KeyPair() - val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) - val secret = generateNostrConnectSecret() - val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() - - val relays = NIP46_RELAYS - val relayParams = relays.joinToString("&") { "relay=$it" } - val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop" + val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop") val nip46Client = getOrCreateNip46Client() client = nip46Client - val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() - // Emit connecting with initial relay statuses _loginProgress.value = LoginProgress.ConnectingToRelays( - normalizedRelays.associateWith { RelayLoginStatus.CONNECTING }, + uriData.relays.associateWith { RelayLoginStatus.CONNECTING }, ) nip46Client.subscribe(listener) - onUriGenerated(uri) + onUriGenerated(uriData.uri) _loginProgress.value = LoginProgress.WaitingForSigner( relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), ) - val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client) - if (connectData.requestId != null) { - _loginProgress.value = - LoginProgress.SendingAck( - relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), - ) - sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client) - } + val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client) - val remoteSigner = - NostrSignerRemote( - signer = ephemeralSigner, - remotePubkey = connectData.signerPubkey, - relays = normalizedRelays, - client = nip46Client, - ) - remoteSigner.openSubscription() - - // Verify user pubkey via get_public_key — connect params may contain - // the wrong pubkey (e.g. ephemeral key echoed back by some signers) - val verifiedPubkey = remoteSigner.getPublicKey() - - val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams" + val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" } + val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams" val state = AccountState.LoggedIn( - signer = remoteSigner, - pubKeyHex = verifiedPubkey, - npub = verifiedPubkey.hexToByteArray().toNpub(), + signer = result.signer, + pubKeyHex = result.pubKeyHex, + npub = result.pubKeyHex.hexToByteArray().toNpub(), nsec = null, isReadOnly = false, signerType = SignerType.Remote(syntheticBunkerUri), @@ -468,116 +419,6 @@ class AccountManager internal constructor( } } - private suspend fun waitForConnectRequest( - ephemeralSigner: NostrSignerInternal, - ephemeralPubKey: HexKey, - relays: Set, - expectedSecret: String, - client: NostrClient, - ): ConnectRequestData { - val deferred = CompletableDeferred() - - val subscription = - client.req( - relays = relays.toList(), - filter = - Filter( - kinds = listOf(NostrConnectEvent.KIND), - tags = mapOf("p" to listOf(ephemeralPubKey)), - since = TimeUtils.now() - 60, - ), - ) { event -> - if (event is NostrConnectEvent && !deferred.isCompleted) { - deferred.complete(event) - } - } - - try { - val event = - withTimeout(NOSTRCONNECT_TIMEOUT_MS) { - deferred.await() - } - - val signerPubkey = event.talkingWith(ephemeralSigner.pubKey) - val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey) - val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) - - return when (message) { - is BunkerRequest -> { - if (message.method != BunkerRequestConnect.METHOD_NAME) { - throw Exception("Expected 'connect' method, got '${message.method}'") - } - - val userPubkey = - message.params.getOrNull(0) - ?: throw Exception("Missing user pubkey in connect request") - val receivedSecret = message.params.getOrNull(1) - - if (receivedSecret != expectedSecret) { - throw Exception("Secret mismatch in connect request") - } - - ConnectRequestData( - requestId = message.id, - signerPubkey = signerPubkey, - userPubkey = userPubkey, - ) - } - - is BunkerResponse -> { - if (message.error != null) { - throw Exception("Signer rejected connection: ${message.error}") - } - - val userPubkey = - message.result - ?.takeIf { it.length == 64 && Hex.isHex(it) } - ?: signerPubkey - - ConnectRequestData( - requestId = null, - signerPubkey = signerPubkey, - userPubkey = userPubkey, - ) - } - - else -> { - throw Exception("Unexpected NIP-46 message format") - } - } - } finally { - subscription.close() - } - } - - private suspend fun sendAckResponse( - ephemeralSigner: NostrSignerInternal, - connectData: ConnectRequestData, - relays: Set, - client: NostrClient, - ) { - val ackResponse = BunkerResponseAck(id = connectData.requestId!!) - val ackEvent = - NostrConnectEvent.create( - message = ackResponse, - remoteKey = connectData.signerPubkey, - signer = ephemeralSigner, - ) - client.send(ackEvent, relays) - } - - private fun generateNostrConnectSecret(): String { - val bytes = ByteArray(32) - SecureRandom().nextBytes(bytes) - return bytes.joinToString("") { "%02x".format(it) } - } - - private data class ConnectRequestData( - val requestId: String?, - val signerPubkey: HexKey, - val userPubkey: HexKey, - ) - private suspend fun saveBunkerAccount( bunkerUri: String, ephemeralPrivKeyHex: String, @@ -709,6 +550,7 @@ class AccountManager internal constructor( } disconnectNip46Client() _signerConnectionState.value = SignerConnectionState.NotRemote + _lastPingTimeSec.value = null _accountState.value = AccountState.LoggedOut // Cancel heartbeat LAST — may be called from within the heartbeat coroutine stopHeartbeat() @@ -738,6 +580,7 @@ class AccountManager internal constructor( remoteSigner.ping() consecutiveFailures = 0 _signerConnectionState.value = SignerConnectionState.Connected + _lastPingTimeSec.value = TimeUtils.now() } catch (_: SignerExceptions.ManuallyUnauthorizedException) { forceLogoutWithReason("Remote signer revoked access.") return@launch @@ -853,6 +696,17 @@ class AccountManager internal constructor( private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt") } +internal fun parseBunkerRelays(uri: String): Set { + val idx = uri.indexOf('?') + if (idx < 0) return emptySet() + return uri + .substring(idx + 1) + .split("&") + .filter { it.startsWith("relay=", ignoreCase = true) } + .map { NormalizedRelayUrl(it.removePrefix("relay=")) } + .toSet() +} + internal fun stripBunkerSecret(uri: String): String { val idx = uri.indexOf('?') if (idx < 0) return uri diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 912042a1b..5ddc99b21 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -41,11 +41,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator @Composable fun DeckSidebar( onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, modifier: Modifier = Modifier, ) { Column( @@ -78,6 +82,13 @@ fun DeckSidebar( Spacer(Modifier.weight(1f)) + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) + + Spacer(Modifier.size(8.dp)) + IconButton(onClick = onOpenSettings) { Icon( Icons.Default.Settings, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index dd8276699..e58734588 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -62,6 +62,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -103,6 +105,8 @@ fun SinglePaneLayout( onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, modifier: Modifier = Modifier, ) { var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } @@ -139,6 +143,14 @@ fun SinglePaneLayout( }, ) } + + Spacer(Modifier.weight(1f)) + + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.padding(bottom = 12.dp), + ) } VerticalDivider() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt index fde057bcf..4956cab81 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.account +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip19Bech32.toNsec diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt index ef5bce652..33fe20c01 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.account +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair