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:
+143
-23
@@ -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()
|
||||
|
||||
+47
@@ -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()
|
||||
}
|
||||
+1
@@ -45,5 +45,6 @@ object DefaultRelays {
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.primal.net",
|
||||
)
|
||||
}
|
||||
|
||||
-3
@@ -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
|
||||
|
||||
+47
-33
@@ -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(
|
||||
|
||||
+220
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user