fix(desktop): verify user pubkey via get_public_key after nostrconnect login

The nostrconnect flow was trusting params[0] from the signer's connect
message as the user's pubkey. Some signers (e.g. nsec.app) don't put
the actual user pubkey there, causing all relay subscriptions to query
the wrong identity — contact lists, profiles, and DMs all returned empty.

Now calls remoteSigner.getPublicKey() after the handshake to get the
verified pubkey from the signer. Also stabilizes FeedScreen relay
subscriptions with distinctUntilChanged() and adds relay.primal.net
to defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-09 12:44:58 +02:00
parent 3a1e38a2f2
commit 5475d6c2bc
15 changed files with 2634 additions and 110 deletions
@@ -30,7 +30,12 @@ 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
@@ -135,6 +140,9 @@ class AccountManager internal constructor(
private val _forceLogoutReason = MutableStateFlow<String?>(null)
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
private val _loginProgress = MutableStateFlow<LoginProgress?>(null)
val loginProgress: StateFlow<LoginProgress?> = _loginProgress.asStateFlow()
private var heartbeatJob: Job? = null
// --- Dedicated NIP-46 client (isolated from general relay pool) ---
@@ -174,18 +182,72 @@ class AccountManager internal constructor(
}
}
private fun updateRelayLoginStatus(
relay: NormalizedRelayUrl,
status: RelayLoginStatus,
) {
val current = _loginProgress.value ?: return
val updated = current.relayStatuses + (relay to status)
_loginProgress.value =
when (current) {
is LoginProgress.ConnectingToRelays -> current.copy(relayStatuses = updated)
is LoginProgress.WaitingForSigner -> current.copy(relayStatuses = updated)
is LoginProgress.SendingAck -> current.copy(relayStatuses = updated)
}
}
private fun createLoginRelayListener(): IRelayClientListener =
object : IRelayClientListener {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
updateRelayLoginStatus(relay.url, RelayLoginStatus.CONNECTED)
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
updateRelayLoginStatus(relay.url, RelayLoginStatus.FAILED)
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
if (cmd is EventCmd) {
updateRelayLoginStatus(
relay.url,
if (success) RelayLoginStatus.EVENT_SENT else RelayLoginStatus.SEND_FAILED,
)
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
}
}
// --- Account loading ---
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
try {
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
// Check for bunker account first
val lastNpub = getLastNpub()
val bunkerUri = getBunkerUri()
if (bunkerUri != null) {
loadBunkerAccount(bunkerUri, lastNpub)
} else {
} else if (lastNpub != null) {
loadInternalAccount(lastNpub)
} else {
Result.failure(Exception("No saved account"))
}
} catch (e: Exception) {
Result.failure(e)
@@ -213,7 +275,7 @@ class AccountManager internal constructor(
private suspend fun loadBunkerAccount(
bunkerUri: String,
npub: String,
npub: String?,
): Result<AccountState.LoggedIn> {
val ephemeralPrivKeyHex =
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
@@ -226,13 +288,24 @@ class AccountManager internal constructor(
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
remoteSigner.openSubscription()
val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
val pubKeyHex =
if (npub != null) {
decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
} else {
// npub missing (e.g. last_account.txt deleted) — must wait for relay
// before calling getPublicKey() to recover from signer
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
remoteSigner.getPublicKey()
}
val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub()
if (npub == null) saveLastNpub(resolvedNpub)
val state =
AccountState.LoggedIn(
signer = remoteSigner,
pubKeyHex = pubKeyHex,
npub = npub,
npub = resolvedNpub,
nsec = null,
isReadOnly = false,
signerType = SignerType.Remote(bunkerUri),
@@ -244,18 +317,33 @@ class AccountManager internal constructor(
// --- Bunker login ---
suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn> =
suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn> {
val listener = createLoginRelayListener()
var client: NostrClient? = null
try {
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val nip46Client = getOrCreateNip46Client()
client = nip46Client
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
// Emit connecting with initial relay statuses
_loginProgress.value =
LoginProgress.ConnectingToRelays(
remoteSigner.relays.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 state =
@@ -277,22 +365,28 @@ class AccountManager internal constructor(
npub = state.npub,
)
Result.success(state)
return Result.success(state)
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection."))
return Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection."))
} catch (e: SignerExceptions.TimedOutException) {
Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
return Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
Result.failure(Exception("Connection rejected by remote signer."))
return Result.failure(Exception("Connection rejected by remote signer."))
} catch (e: SignerExceptions.CouldNotPerformException) {
Result.failure(Exception("Remote signer error: ${e.message}"))
return Result.failure(Exception("Remote signer error: ${e.message}"))
} catch (e: Exception) {
Result.failure(Exception("Connection failed: ${e.message}"))
return Result.failure(Exception("Connection failed: ${e.message}"))
} finally {
_loginProgress.value = null
client?.unsubscribe(listener)
}
}
// --- Nostrconnect login ---
suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result<AccountState.LoggedIn> =
suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result<AccountState.LoggedIn> {
val listener = createLoginRelayListener()
var client: NostrClient? = null
try {
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
@@ -302,13 +396,31 @@ class AccountManager internal constructor(
val relays = NIP46_RELAYS
val relayParams = relays.joinToString("&") { "relay=$it" }
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
onUriGenerated(uri)
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 },
)
nip46Client.subscribe(listener)
onUriGenerated(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)
}
@@ -321,13 +433,17 @@ class AccountManager internal constructor(
)
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 state =
AccountState.LoggedIn(
signer = remoteSigner,
pubKeyHex = connectData.userPubkey,
npub = connectData.userPubkey.hexToByteArray().toNpub(),
pubKeyHex = verifiedPubkey,
npub = verifiedPubkey.hexToByteArray().toNpub(),
nsec = null,
isReadOnly = false,
signerType = SignerType.Remote(syntheticBunkerUri),
@@ -341,12 +457,16 @@ class AccountManager internal constructor(
npub = state.npub,
)
Result.success(state)
return Result.success(state)
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code."))
return Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code."))
} catch (e: Exception) {
Result.failure(Exception("Connection failed: ${e.message}"))
return Result.failure(Exception("Connection failed: ${e.message}"))
} finally {
_loginProgress.value = null
client?.unsubscribe(listener)
}
}
private suspend fun waitForConnectRequest(
ephemeralSigner: NostrSignerInternal,
@@ -463,9 +583,9 @@ class AccountManager internal constructor(
ephemeralPrivKeyHex: String,
npub: String,
) {
saveBunkerUri(bunkerUri)
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
saveLastNpub(npub)
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
saveBunkerUri(bunkerUri)
}
fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
@@ -0,0 +1,47 @@
/*
* 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.account
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
enum class RelayLoginStatus {
CONNECTING,
CONNECTED,
EVENT_SENT,
SEND_FAILED,
FAILED,
}
sealed class LoginProgress {
abstract val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus>
data class ConnectingToRelays(
override val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus> = emptyMap(),
) : LoginProgress()
data class WaitingForSigner(
override val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus> = emptyMap(),
) : LoginProgress()
data class SendingAck(
override val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus> = emptyMap(),
) : LoginProgress()
}
@@ -45,5 +45,6 @@ object DefaultRelays {
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.primal.net",
)
}
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.desktop.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.desktop.DebugConfig
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
@@ -75,7 +74,6 @@ fun rememberSubscription(
DisposableEffect(*keys, subscription?.subId) {
subscription?.let { cfg ->
if (cfg.relays.isNotEmpty()) {
DebugConfig.log("SUB OPEN ${cfg.subId} relays=${cfg.relays.size}")
relayManager.subscribe(
subId = cfg.subId,
filters = cfg.filters,
@@ -104,7 +102,6 @@ fun rememberSubscription(
onDispose {
subscription?.let {
DebugConfig.log("SUB CLOSE ${it.subId}")
relayManager.unsubscribe(it.subId)
}
}
@@ -84,6 +84,8 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Note card with action buttons.
@@ -159,6 +161,14 @@ fun FeedScreen(
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
// Configured relay URLs only — stabilized with distinctUntilChanged() to prevent
// subscription churn from relay status changes (pings, connect/disconnect).
// openReqSubscription connects relays on demand; no need to wait for connectedRelays.
val configuredRelays by remember {
relayManager.relayStatuses
.map { it.keys }
.distinctUntilChanged()
}.collectAsState(emptySet())
val scope = rememberCoroutineScope()
val eventState =
remember {
@@ -190,17 +200,15 @@ fun FeedScreen(
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
DebugConfig.log("contactList sub: relays=${connectedRelays.size}, account=${account?.pubKeyHex?.take(8)}, mode=$feedMode")
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) {
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription(
relays = connectedRelays,
relays = configuredRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, relay, _ ->
DebugConfig.log("contactList event: kind=${event.kind}, isContactList=${event is ContactListEvent}, from=$relay")
if (event is ContactListEvent) {
val follows = event.verifiedFollowKeySet()
DebugConfig.log("followedUsers: ${follows.size} users")
DebugConfig.log("contactList: ${follows.size} follows from $relay")
followedUsers = follows
}
},
@@ -211,8 +219,8 @@ fun FeedScreen(
}
// Load user's bookmark list
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
rememberSubscription(configuredRelays, account, relayManager = relayManager) {
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "bookmarks-${account.pubKeyHex.take(8)}",
filters =
@@ -223,7 +231,7 @@ fun FeedScreen(
limit = 1,
),
),
relays = connectedRelays,
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
@@ -251,16 +259,16 @@ fun FeedScreen(
}
// Subscribe to feed based on mode
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
DebugConfig.log("feedSub: mode=$feedMode, relays=${connectedRelays.size}, followedUsers=${followedUsers.size}")
if (connectedRelays.isEmpty()) {
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
DebugConfig.log("feedSub: mode=$feedMode, relays=${configuredRelays.size}, followedUsers=${followedUsers.size}")
if (configuredRelays.isEmpty()) {
return@rememberSubscription null
}
when (feedMode) {
FeedMode.GLOBAL -> {
createGlobalFeedSubscription(
relays = connectedRelays,
relays = configuredRelays,
onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
@@ -277,7 +285,7 @@ fun FeedScreen(
FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) {
createFollowingFeedSubscription(
relays = connectedRelays,
relays = configuredRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
// Store metadata events in cache
@@ -299,13 +307,13 @@ fun FeedScreen(
// Subscribe to zaps for visible events
val eventIds = events.map { it.id }
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = connectedRelays,
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
@@ -329,8 +337,8 @@ fun FeedScreen(
.flatten()
.map { it.senderPubKey }
.distinct()
rememberSubscription(connectedRelays, zapSenderPubkeys, relayManager = relayManager) {
if (connectedRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) {
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
return@rememberSubscription null
}
@@ -348,7 +356,7 @@ fun FeedScreen(
}
createBatchMetadataSubscription(
relays = connectedRelays,
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -359,13 +367,13 @@ fun FeedScreen(
}
// Subscribe to reactions for visible events
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = connectedRelays,
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
@@ -381,13 +389,13 @@ fun FeedScreen(
}
// Subscribe to replies for visible events
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = connectedRelays,
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
// Find the event this is replying to
@@ -408,13 +416,13 @@ fun FeedScreen(
}
// Subscribe to reposts for visible events
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = connectedRelays,
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
@@ -440,13 +448,13 @@ fun FeedScreen(
}
// Fallback subscription if coordinator not available
rememberSubscription(connectedRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator
if (subscriptionsCoordinator != null) {
return@rememberSubscription null
}
if (connectedRelays.isEmpty() || authorPubkeys.isEmpty()) {
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
return@rememberSubscription null
}
@@ -464,7 +472,7 @@ fun FeedScreen(
}
createBatchMetadataSubscription(
relays = connectedRelays,
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -41,6 +41,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -71,6 +72,7 @@ fun LoginScreen(
var showNewKeyDialog by remember { mutableStateOf(false) }
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
val scope = rememberCoroutineScope()
val loginProgress by accountManager.loginProgress.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
@@ -116,6 +118,7 @@ fun LoginScreen(
onLoginSuccess()
}
},
loginProgress = loginProgress,
)
val account = generatedAccount
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.resources.login_button
import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle
import com.vitorpamplona.amethyst.commons.resources.login_card_title
import com.vitorpamplona.amethyst.commons.resources.login_generate_button
import com.vitorpamplona.amethyst.desktop.account.LoginProgress
import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -73,6 +74,7 @@ fun LoginCard(
onGenerateNew: () -> Unit,
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>)? = null,
loginProgress: LoginProgress? = null,
modifier: Modifier = Modifier,
cardWidth: Dp = 400.dp,
title: String = stringResource(Res.string.login_card_title),
@@ -119,8 +121,8 @@ fun LoginCard(
}
when (selectedTab) {
0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle)
1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect)
0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle, loginProgress)
1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect, loginProgress)
}
}
}
@@ -132,6 +134,7 @@ private fun PasteKeyContent(
onGenerateNew: () -> Unit,
onLoginBunker: (suspend (String) -> Result<Unit>)?,
subtitle: String,
loginProgress: LoginProgress? = null,
) {
var keyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -167,21 +170,25 @@ private fun PasteKeyContent(
Spacer(Modifier.height(16.dp))
if (isConnecting) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(12.dp))
Text(
"Connecting to remote signer...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (loginProgress != null) {
LoginProgressSteps(loginProgress)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(12.dp))
Text(
"Connecting to remote signer...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Row(
@@ -234,7 +241,10 @@ private fun PasteKeyContent(
}
@Composable
private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>) {
private fun NostrConnectContent(
onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>,
loginProgress: LoginProgress? = null,
) {
var nostrConnectUri by remember { mutableStateOf<String?>(null) }
var isConnecting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -316,21 +326,25 @@ private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (S
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
"Waiting for signer to connect...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (loginProgress != null) {
LoginProgressSteps(loginProgress)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
"Waiting for signer to connect...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Row(
@@ -0,0 +1,220 @@
/*
* 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.auth
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.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.account.LoginProgress
import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus
private enum class StepState { DONE, ACTIVE, PENDING }
private data class StepInfo(
val label: String,
val state: StepState,
)
@Composable
fun LoginProgressSteps(
progress: LoginProgress,
modifier: Modifier = Modifier,
) {
val steps = buildStepList(progress)
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
steps.forEach { step ->
StepRow(step)
// Show relay rows under the active step
if (step.state == StepState.ACTIVE && progress.relayStatuses.isNotEmpty()) {
progress.relayStatuses.forEach { (relay, status) ->
RelayRow(relay.url, status)
}
}
}
}
}
private fun buildStepList(progress: LoginProgress): List<StepInfo> {
val orderedSteps =
listOf(
"Connecting to relays",
"Waiting for signer",
"Sending acknowledgment",
)
val activeIndex =
when (progress) {
is LoginProgress.ConnectingToRelays -> 0
is LoginProgress.WaitingForSigner -> 1
is LoginProgress.SendingAck -> 2
}
return orderedSteps.mapIndexed { index, label ->
StepInfo(
label = label,
state =
when {
index < activeIndex -> StepState.DONE
index == activeIndex -> StepState.ACTIVE
else -> StepState.PENDING
},
)
}
}
@Composable
private fun StepRow(step: StepInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
when (step.state) {
StepState.DONE -> {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(16.dp),
)
}
StepState.ACTIVE -> {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
}
StepState.PENDING -> {
Spacer(Modifier.size(16.dp))
}
}
Spacer(Modifier.width(8.dp))
Text(
step.label,
style = MaterialTheme.typography.bodySmall,
color =
when (step.state) {
StepState.DONE -> Color(0xFF4CAF50)
StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface
StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
)
}
}
@Composable
private fun RelayRow(
url: String,
status: RelayLoginStatus,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 24.dp),
) {
when (status) {
RelayLoginStatus.EVENT_SENT -> {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color(0xFF2196F3),
modifier = Modifier.size(12.dp),
)
}
RelayLoginStatus.CONNECTED -> {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(12.dp),
)
}
RelayLoginStatus.FAILED,
RelayLoginStatus.SEND_FAILED,
-> {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(12.dp),
)
}
RelayLoginStatus.CONNECTING -> {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
)
}
}
Spacer(Modifier.width(6.dp))
val label = url.removePrefix("wss://").removeSuffix("/")
val statusLabel =
when (status) {
RelayLoginStatus.EVENT_SENT -> "$label (sent)"
RelayLoginStatus.SEND_FAILED -> "$label (send failed)"
RelayLoginStatus.FAILED -> "$label (failed)"
else -> label
}
Text(
statusLabel,
style = MaterialTheme.typography.labelSmall,
color =
when (status) {
RelayLoginStatus.FAILED, RelayLoginStatus.SEND_FAILED -> {
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
}
RelayLoginStatus.EVENT_SENT -> {
Color(0xFF2196F3)
}
else -> {
MaterialTheme.colorScheme.onSurfaceVariant
}
},
)
}
}