Merge upstream/main into feat/desktop-advanced-search
Resolve conflicts: - RelayStatus.kt: keep both relay URLs - SearchScreen.kt: keep advanced search implementation - SinglePaneLayout.kt: add missing Spacer import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,11 +72,14 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
|
||||
@@ -86,12 +89,15 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
@@ -104,21 +110,21 @@ enum class LayoutMode {
|
||||
* Desktop navigation state — used for in-column navigation (drill-down).
|
||||
*/
|
||||
sealed class DesktopScreen {
|
||||
object Feed : DesktopScreen()
|
||||
data object Feed : DesktopScreen()
|
||||
|
||||
object Reads : DesktopScreen()
|
||||
data object Reads : DesktopScreen()
|
||||
|
||||
object Search : DesktopScreen()
|
||||
data object Search : DesktopScreen()
|
||||
|
||||
object Bookmarks : DesktopScreen()
|
||||
data object Bookmarks : DesktopScreen()
|
||||
|
||||
object Messages : DesktopScreen()
|
||||
data object Messages : DesktopScreen()
|
||||
|
||||
object Notifications : DesktopScreen()
|
||||
data object Notifications : DesktopScreen()
|
||||
|
||||
object Chess : DesktopScreen()
|
||||
data object Chess : DesktopScreen()
|
||||
|
||||
object MyProfile : DesktopScreen()
|
||||
data object MyProfile : DesktopScreen()
|
||||
|
||||
data class UserProfile(
|
||||
val pubKeyHex: String,
|
||||
@@ -128,7 +134,7 @@ sealed class DesktopScreen {
|
||||
val noteId: String,
|
||||
) : DesktopScreen()
|
||||
|
||||
object Settings : DesktopScreen()
|
||||
data object Settings : DesktopScreen()
|
||||
}
|
||||
|
||||
fun main() =
|
||||
@@ -143,6 +149,8 @@ fun main() =
|
||||
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
|
||||
val deckScope = rememberCoroutineScope()
|
||||
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
var showAddColumnDialog by remember { mutableStateOf(false) }
|
||||
var layoutMode by remember {
|
||||
mutableStateOf(
|
||||
@@ -189,6 +197,16 @@ fun main() =
|
||||
},
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Logout",
|
||||
onClick = {
|
||||
deckScope.launch {
|
||||
accountManager.logout(deleteKey = true)
|
||||
}
|
||||
},
|
||||
enabled = accountState is AccountState.LoggedIn,
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Quit",
|
||||
shortcut =
|
||||
@@ -348,6 +366,7 @@ fun main() =
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
@@ -370,6 +389,7 @@ fun main() =
|
||||
fun App(
|
||||
layoutMode: LayoutMode,
|
||||
deckState: DeckState,
|
||||
accountManager: AccountManager,
|
||||
showComposeDialog: Boolean,
|
||||
showAddColumnDialog: Boolean,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
@@ -381,37 +401,54 @@ fun App(
|
||||
) {
|
||||
val relayManager = remember { DesktopRelayConnectionManager() }
|
||||
val localCache = remember { DesktopLocalCache() }
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
|
||||
// Subscriptions coordinator for metadata/reactions loading
|
||||
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||
val subscriptionsCoordinator =
|
||||
remember(relayManager, localCache) {
|
||||
DesktopRelaySubscriptionsCoordinator(
|
||||
client = relayManager.client,
|
||||
scope = scope,
|
||||
indexRelays = relayManager.availableRelays.value,
|
||||
indexRelays =
|
||||
DefaultRelays.RELAYS
|
||||
.mapNotNull {
|
||||
RelayUrlNormalizer.normalizeOrNull(it)
|
||||
}.toSet(),
|
||||
localCache = localCache,
|
||||
)
|
||||
}
|
||||
|
||||
// Try to load saved account on startup
|
||||
DisposableEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// Load account on IO dispatcher to avoid blocking UI with password prompt (readLine)
|
||||
accountManager.loadSavedAccount()
|
||||
}
|
||||
|
||||
relayManager.addDefaultRelays()
|
||||
relayManager.connect()
|
||||
|
||||
// Start subscriptions coordinator
|
||||
subscriptionsCoordinator.start()
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountManager.hasBunkerAccount()) {
|
||||
// Show connecting UI while dedicated NIP-46 client connects
|
||||
accountManager.setConnectingRelays()
|
||||
}
|
||||
val result = accountManager.loadSavedAccount()
|
||||
if (result.isSuccess) {
|
||||
val current = accountManager.currentAccount()
|
||||
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
}
|
||||
} else if (accountManager.hasBunkerAccount()) {
|
||||
// Corrupt bunker state — fall back to login screen
|
||||
accountManager.logout(deleteKey = true)
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
accountManager.stopHeartbeat()
|
||||
runBlocking { accountManager.disconnectNip46Client() }
|
||||
subscriptionsCoordinator.clear()
|
||||
relayManager.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +463,21 @@ fun App(
|
||||
is AccountState.LoggedOut -> {
|
||||
LoginScreen(
|
||||
accountManager = accountManager,
|
||||
onLoginSuccess = { },
|
||||
onLoginSuccess = {
|
||||
// Start heartbeat if bunker account
|
||||
val current = accountManager.currentAccount()
|
||||
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is AccountState.ConnectingRelays -> {
|
||||
val relays by relayManager.relayStatuses.collectAsState()
|
||||
ConnectingRelaysScreen(
|
||||
subtitle = "Restoring remote signer session",
|
||||
relayStatuses = relays,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -476,6 +527,15 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force logout dialog overlay
|
||||
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
|
||||
forceLogoutReason?.let { reason ->
|
||||
ForceLogoutDialog(
|
||||
reason = reason,
|
||||
onDismiss = { accountManager.clearForceLogoutReason() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,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 =
|
||||
@@ -618,6 +680,8 @@ fun MainContent(
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -632,6 +696,8 @@ fun MainContent(
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
@@ -686,7 +752,7 @@ fun ProfileScreen(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { scope.launch { accountManager.logout() } },
|
||||
onClick = { scope.launch { accountManager.logout(deleteKey = true) } },
|
||||
colors =
|
||||
androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color.Red,
|
||||
@@ -889,5 +955,18 @@ fun RelaySettingsScreen(
|
||||
Text("Reset to Defaults")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
OutlinedButton(
|
||||
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+509
-63
@@ -21,50 +21,92 @@
|
||||
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.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.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.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
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.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
|
||||
sealed class SignerType {
|
||||
data object Internal : SignerType()
|
||||
|
||||
data class Remote(
|
||||
val bunkerUri: String,
|
||||
) : SignerType()
|
||||
}
|
||||
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
data object ConnectingRelays : AccountState()
|
||||
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean,
|
||||
val signerType: SignerType = SignerType.Internal,
|
||||
) : AccountState()
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountManager private constructor(
|
||||
class AccountManager internal constructor(
|
||||
private val secureStorage: SecureKeyStorage,
|
||||
private val homeDir: File = File(System.getProperty("user.home")),
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Creates an AccountManager instance.
|
||||
*
|
||||
* @param context Platform-specific context (required on Android, ignored on Desktop)
|
||||
* @return AccountManager instance
|
||||
*/
|
||||
fun create(context: Any? = null): AccountManager {
|
||||
val storage = SecureKeyStorage.create(context)
|
||||
return AccountManager(storage)
|
||||
}
|
||||
|
||||
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 NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
|
||||
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
||||
}
|
||||
|
||||
private val amethystDir: File by lazy {
|
||||
File(homeDir, ".amethyst")
|
||||
}
|
||||
|
||||
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
||||
@@ -73,43 +115,334 @@ class AccountManager private constructor(
|
||||
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
|
||||
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
|
||||
|
||||
private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
|
||||
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
|
||||
|
||||
private val _lastPingTimeSec = MutableStateFlow<Long?>(null)
|
||||
val lastPingTimeSec: StateFlow<Long?> = _lastPingTimeSec.asStateFlow()
|
||||
|
||||
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) ---
|
||||
|
||||
private val nip46ClientMutex = Mutex()
|
||||
private var nip46Client: NostrClient? = null
|
||||
|
||||
private suspend fun getOrCreateNip46Client(): NostrClient =
|
||||
nip46ClientMutex.withLock {
|
||||
nip46Client ?: NostrClient(
|
||||
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
|
||||
).also {
|
||||
nip46Client = it
|
||||
it.connect()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnectNip46Client() =
|
||||
nip46ClientMutex.withLock {
|
||||
nip46Client?.disconnect()
|
||||
nip46Client = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last saved account from secure storage.
|
||||
* Call on app startup.
|
||||
* Waits for the NIP-46 client to connect to at least one of the target relays.
|
||||
* openSubscription() triggers async relay connection via sendOrConnectAndSync,
|
||||
* but we must wait for the websocket to be ready before sending requests.
|
||||
*/
|
||||
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> {
|
||||
return try {
|
||||
// For simplicity, we'll store the last logged-in npub in a simple file
|
||||
// and use SecureKeyStorage to retrieve the private key
|
||||
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
|
||||
|
||||
val privKeyHex =
|
||||
secureStorage.getPrivateKey(lastNpub)
|
||||
?: return Result.failure(Exception("Private key not found for $lastNpub"))
|
||||
|
||||
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
_accountState.value = state
|
||||
Result.success(state)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
private suspend fun awaitNip46RelayConnection(
|
||||
client: NostrClient,
|
||||
targetRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) {
|
||||
client.connectedRelaysFlow().first { connected ->
|
||||
targetRelays.any { it in connected }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current account to secure storage.
|
||||
*/
|
||||
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()
|
||||
val bunkerUri = getBunkerUri()
|
||||
|
||||
if (bunkerUri != null) {
|
||||
loadBunkerAccount(bunkerUri, lastNpub)
|
||||
} else if (lastNpub != null) {
|
||||
loadInternalAccount(lastNpub)
|
||||
} else {
|
||||
Result.failure(Exception("No saved account"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
private suspend fun loadInternalAccount(npub: String): Result<AccountState.LoggedIn> {
|
||||
val privKeyHex =
|
||||
secureStorage.getPrivateKey(npub)
|
||||
?: return Result.failure(Exception("Private key not found for $npub"))
|
||||
|
||||
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
_accountState.value = state
|
||||
return Result.success(state)
|
||||
}
|
||||
|
||||
private suspend fun loadBunkerAccount(
|
||||
bunkerUri: String,
|
||||
npub: String?,
|
||||
): Result<AccountState.LoggedIn> {
|
||||
val ephemeralPrivKeyHex =
|
||||
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
?: return Result.failure(Exception("Ephemeral key not found"))
|
||||
|
||||
val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray())
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
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 = resolvedNpub,
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(bunkerUri),
|
||||
)
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
return Result.success(state)
|
||||
}
|
||||
|
||||
// --- Bunker login ---
|
||||
|
||||
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 relaysFromUri = parseBunkerRelays(bunkerUri)
|
||||
_loginProgress.value =
|
||||
LoginProgress.ConnectingToRelays(
|
||||
relaysFromUri.associateWith { RelayLoginStatus.CONNECTING },
|
||||
)
|
||||
nip46Client.subscribe(listener)
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.WaitingForSigner(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
|
||||
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = result.signer,
|
||||
pubKeyHex = result.pubKeyHex,
|
||||
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(bunkerUri),
|
||||
)
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
|
||||
saveBunkerAccount(
|
||||
bunkerUri = stripBunkerSecret(bunkerUri),
|
||||
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||
npub = state.npub,
|
||||
)
|
||||
|
||||
return Result.success(state)
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
return Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection."))
|
||||
} catch (e: SignerExceptions.TimedOutException) {
|
||||
return Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
|
||||
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
|
||||
return Result.failure(Exception("Connection rejected by remote signer."))
|
||||
} catch (e: SignerExceptions.CouldNotPerformException) {
|
||||
return Result.failure(Exception("Remote signer error: ${e.message}"))
|
||||
} catch (e: Exception) {
|
||||
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> {
|
||||
val listener = createLoginRelayListener()
|
||||
var client: NostrClient? = null
|
||||
try {
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop")
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
client = nip46Client
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.ConnectingToRelays(
|
||||
uriData.relays.associateWith { RelayLoginStatus.CONNECTING },
|
||||
)
|
||||
nip46Client.subscribe(listener)
|
||||
|
||||
onUriGenerated(uriData.uri)
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.WaitingForSigner(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
|
||||
val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client)
|
||||
|
||||
val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" }
|
||||
val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams"
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = result.signer,
|
||||
pubKeyHex = result.pubKeyHex,
|
||||
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(syntheticBunkerUri),
|
||||
)
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
|
||||
saveBunkerAccount(
|
||||
bunkerUri = syntheticBunkerUri,
|
||||
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||
npub = state.npub,
|
||||
)
|
||||
|
||||
return Result.success(state)
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
return Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code."))
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(Exception("Connection failed: ${e.message}"))
|
||||
} finally {
|
||||
_loginProgress.value = null
|
||||
client?.unsubscribe(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveBunkerAccount(
|
||||
bunkerUri: String,
|
||||
ephemeralPrivKeyHex: String,
|
||||
npub: String,
|
||||
) {
|
||||
saveLastNpub(npub)
|
||||
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
|
||||
saveBunkerUri(bunkerUri)
|
||||
}
|
||||
|
||||
fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
|
||||
|
||||
fun setConnectingRelays() {
|
||||
_accountState.value = AccountState.ConnectingRelays
|
||||
}
|
||||
|
||||
// --- Save/generate (existing) ---
|
||||
|
||||
suspend fun saveCurrentAccount(): Result<Unit> {
|
||||
val current = currentAccount() ?: return Result.failure(Exception("No account logged in"))
|
||||
|
||||
// Bunker accounts are saved during loginWithBunker
|
||||
if (current.signerType is SignerType.Remote) return Result.success(Unit)
|
||||
|
||||
if (current.isReadOnly || current.nsec == null) {
|
||||
return Result.failure(Exception("Cannot save read-only account"))
|
||||
}
|
||||
@@ -146,7 +479,6 @@ class AccountManager private constructor(
|
||||
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
|
||||
val trimmedInput = keyInput.trim()
|
||||
|
||||
// Try as private key first (nsec or hex)
|
||||
val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput)
|
||||
if (privKeyHex != null) {
|
||||
return try {
|
||||
@@ -168,7 +500,6 @@ class AccountManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Try as public key (npub or hex) - read-only mode
|
||||
val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput)
|
||||
if (pubKeyHex != null) {
|
||||
return try {
|
||||
@@ -190,27 +521,96 @@ class AccountManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format."))
|
||||
return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, hex, or bunker:// URI."))
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
suspend fun logout(deleteKey: Boolean = false) {
|
||||
val current = currentAccount()
|
||||
if (deleteKey && current != null) {
|
||||
try {
|
||||
secureStorage.deletePrivateKey(current.npub)
|
||||
clearLastNpub()
|
||||
} catch (e: SecureStorageException) {
|
||||
// Log error but still logout
|
||||
if (current != null) {
|
||||
// Clean up remote signer if bunker account
|
||||
if (current.signerType is SignerType.Remote) {
|
||||
(current.signer as? NostrSignerRemote)?.closeSubscription()
|
||||
if (deleteKey) {
|
||||
try {
|
||||
secureStorage.deletePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} catch (_: SecureStorageException) {
|
||||
}
|
||||
getBunkerFile().delete()
|
||||
}
|
||||
}
|
||||
if (deleteKey) {
|
||||
try {
|
||||
secureStorage.deletePrivateKey(current.npub)
|
||||
clearLastNpub()
|
||||
} catch (_: SecureStorageException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
disconnectNip46Client()
|
||||
_signerConnectionState.value = SignerConnectionState.NotRemote
|
||||
_lastPingTimeSec.value = null
|
||||
_accountState.value = AccountState.LoggedOut
|
||||
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
suspend fun forceLogoutWithReason(reason: String) {
|
||||
_forceLogoutReason.value = reason
|
||||
logout(deleteKey = false)
|
||||
}
|
||||
|
||||
fun clearForceLogoutReason() {
|
||||
_forceLogoutReason.value = null
|
||||
}
|
||||
|
||||
// --- Heartbeat ---
|
||||
|
||||
fun startHeartbeat(scope: CoroutineScope) {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
scope.launch {
|
||||
var consecutiveFailures = 0
|
||||
while (isActive) {
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
val current = currentAccount() ?: continue
|
||||
val remoteSigner = current.signer as? NostrSignerRemote ?: continue
|
||||
try {
|
||||
remoteSigner.ping()
|
||||
consecutiveFailures = 0
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
_lastPingTimeSec.value = TimeUtils.now()
|
||||
} catch (_: SignerExceptions.ManuallyUnauthorizedException) {
|
||||
forceLogoutWithReason("Remote signer revoked access.")
|
||||
return@launch
|
||||
} catch (_: Exception) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
forceLogoutWithReason(
|
||||
"Lost connection to remote signer after $MAX_CONSECUTIVE_FAILURES failed pings.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
_signerConnectionState.value = SignerConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
|
||||
|
||||
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
|
||||
|
||||
// NWC (Nostr Wallet Connect) methods
|
||||
// --- NWC ---
|
||||
|
||||
fun hasNwcSetup(): Boolean = _nwcConnection.value != null
|
||||
|
||||
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
|
||||
@@ -233,42 +633,88 @@ class AccountManager private constructor(
|
||||
if (!uri.isNullOrEmpty()) {
|
||||
try {
|
||||
_nwcConnection.value = Nip47WalletConnect.parse(uri)
|
||||
} catch (e: Exception) {
|
||||
// Invalid stored URI, clear it
|
||||
} catch (_: Exception) {
|
||||
getNwcFile().delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- File storage helpers ---
|
||||
|
||||
private fun ensureAmethystDir() {
|
||||
if (!amethystDir.exists()) {
|
||||
amethystDir.mkdirs()
|
||||
}
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
amethystDir.toPath(),
|
||||
setOf(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
),
|
||||
)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// Windows — file system ACLs handle this
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveNwcUri(uri: String) {
|
||||
val file = getNwcFile()
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(uri)
|
||||
ensureAmethystDir()
|
||||
getNwcFile().writeText(uri)
|
||||
}
|
||||
|
||||
private fun getNwcFile(): java.io.File {
|
||||
val homeDir = System.getProperty("user.home")
|
||||
return java.io.File(homeDir, ".amethyst/nwc_connection.txt")
|
||||
}
|
||||
private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt")
|
||||
|
||||
// Simple file-based storage for last npub (non-sensitive data)
|
||||
private fun getLastNpub(): String? {
|
||||
val file = getPrefsFile()
|
||||
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
|
||||
}
|
||||
|
||||
private fun saveLastNpub(npub: String) {
|
||||
val file = getPrefsFile()
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(npub)
|
||||
ensureAmethystDir()
|
||||
getPrefsFile().writeText(npub)
|
||||
}
|
||||
|
||||
private fun clearLastNpub() {
|
||||
getPrefsFile().delete()
|
||||
}
|
||||
|
||||
private fun getPrefsFile(): File {
|
||||
val homeDir = System.getProperty("user.home")
|
||||
return File(homeDir, ".amethyst/last_account.txt")
|
||||
private fun getPrefsFile(): File = File(amethystDir, "last_account.txt")
|
||||
|
||||
private fun getBunkerUri(): String? {
|
||||
val file = getBunkerFile()
|
||||
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
|
||||
}
|
||||
|
||||
private fun saveBunkerUri(uri: String) {
|
||||
ensureAmethystDir()
|
||||
getBunkerFile().writeText(uri)
|
||||
}
|
||||
|
||||
private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt")
|
||||
}
|
||||
|
||||
internal fun parseBunkerRelays(uri: String): Set<NormalizedRelayUrl> {
|
||||
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
|
||||
val base = uri.substring(0, idx)
|
||||
val params =
|
||||
uri
|
||||
.substring(idx + 1)
|
||||
.split("&")
|
||||
.filter { !it.startsWith("secret=", ignoreCase = true) }
|
||||
return if (params.isEmpty()) base else "$base?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
fun validateBunkerUri(input: String): String? {
|
||||
val trimmed = input.trim()
|
||||
if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
|
||||
|
||||
val afterScheme = trimmed.substring("bunker://".length)
|
||||
val parts = afterScheme.split("?", limit = 2)
|
||||
val pubkeyPart = parts[0]
|
||||
|
||||
if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) {
|
||||
return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..."
|
||||
}
|
||||
|
||||
if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) {
|
||||
return "Bunker URI must include at least one relay parameter (relay=wss://...)"
|
||||
}
|
||||
|
||||
return null // valid
|
||||
}
|
||||
+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()
|
||||
}
|
||||
Vendored
+17
-4
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -85,15 +86,27 @@ class DesktopLocalCache : ICacheProvider {
|
||||
if (user != null) return listOf(user)
|
||||
}
|
||||
|
||||
val dualCase =
|
||||
listOf(
|
||||
DualCase(prefix.lowercase(), prefix.uppercase()),
|
||||
)
|
||||
|
||||
// Search by name/displayName/nip05/lud16
|
||||
return users.values
|
||||
.filter { user ->
|
||||
user.metadataOrNull()?.anyNameStartsWith(prefix) == true ||
|
||||
user.pubkeyHex.startsWith(prefix, ignoreCase = true) ||
|
||||
user.pubkeyNpub().startsWith(prefix, ignoreCase = true)
|
||||
val metadata = user.metadataOrNull()
|
||||
if (metadata == null) {
|
||||
user.pubkeyHex.startsWith(prefix, true) ||
|
||||
user.pubkeyNpub().startsWith(prefix, true)
|
||||
} else {
|
||||
metadata.anyNameOrAddressContains(dualCase) ||
|
||||
user.pubkeyHex.startsWith(prefix, true) ||
|
||||
user.pubkeyNpub().startsWith(prefix, true)
|
||||
}
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
{ !it.toBestDisplayName().startsWith(prefix, ignoreCase = true) },
|
||||
{ it.metadataOrNull()?.anyNameStartsWith(dualCase) == false },
|
||||
{ it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false },
|
||||
{ it.toBestDisplayName().lowercase() },
|
||||
{ it.pubkeyHex },
|
||||
),
|
||||
|
||||
+4
-5
@@ -99,7 +99,7 @@ fun ChessScreen(
|
||||
remember(account.pubKeyHex) {
|
||||
DesktopChessViewModelNew(account, relayManager, scope)
|
||||
}
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val broadcastStatus by viewModel.broadcastStatus.collectAsState()
|
||||
val activeGames by viewModel.activeGames.collectAsState()
|
||||
// Observe state version to force recomposition when game state changes
|
||||
@@ -147,11 +147,10 @@ fun ChessScreen(
|
||||
|
||||
// Subscribe to user metadata for pubkeys that need it
|
||||
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
|
||||
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||
createMetadataListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeys = pubkeysNeeded.toList(),
|
||||
onEvent = { event, _, _, _ ->
|
||||
viewModel.handleIncomingEvent(event)
|
||||
|
||||
+1
-1
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
// Command send tracking
|
||||
// Command send tracking — no-op for now
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -46,5 +46,6 @@ object DefaultRelays {
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.noswhere.com",
|
||||
"wss://relay.primal.net",
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -110,7 +110,9 @@ fun rememberSubscription(
|
||||
}
|
||||
|
||||
onDispose {
|
||||
subscription?.let { relayManager.unsubscribe(it.subId) }
|
||||
subscription?.let {
|
||||
relayManager.unsubscribe(it.subId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -76,7 +76,7 @@ fun BookmarksScreen(
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Tab state
|
||||
@@ -121,9 +121,8 @@ fun BookmarksScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user's bookmark list (kind 30001)
|
||||
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
@@ -134,7 +133,7 @@ fun BookmarksScreen(
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
@@ -179,9 +178,8 @@ fun BookmarksScreen(
|
||||
}
|
||||
|
||||
// Subscribe to fetch the actual public bookmarked events
|
||||
rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, publicBookmarkIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
|
||||
publicEventState.clear()
|
||||
SubscriptionConfig(
|
||||
subId = "public-bookmarked-events-${System.currentTimeMillis()}",
|
||||
@@ -189,7 +187,7 @@ fun BookmarksScreen(
|
||||
listOf(
|
||||
FilterBuilders.byIds(publicBookmarkIds),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
publicEventState.addItem(event)
|
||||
},
|
||||
@@ -201,9 +199,8 @@ fun BookmarksScreen(
|
||||
}
|
||||
|
||||
// Subscribe to fetch the actual private bookmarked events
|
||||
rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, privateBookmarkIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
|
||||
privateEventState.clear()
|
||||
SubscriptionConfig(
|
||||
subId = "private-bookmarked-events-${System.currentTimeMillis()}",
|
||||
@@ -211,7 +208,7 @@ fun BookmarksScreen(
|
||||
listOf(
|
||||
FilterBuilders.byIds(privateBookmarkIds),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
privateEventState.addItem(event)
|
||||
},
|
||||
|
||||
-3
@@ -177,15 +177,12 @@ private suspend fun publishNote(
|
||||
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Check read-only mode
|
||||
if (account.isReadOnly) {
|
||||
throw IllegalStateException("Cannot post in read-only mode")
|
||||
}
|
||||
|
||||
// Use shared PublishAction from commons
|
||||
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
|
||||
|
||||
// Broadcast to all configured relays
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,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.
|
||||
@@ -158,7 +160,14 @@ fun FeedScreen(
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.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,15 +199,15 @@ fun FeedScreen(
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Load followed users for Following feed mode
|
||||
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) {
|
||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
onEvent = { event, _, relay, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
followedUsers = event.verifiedFollowKeySet()
|
||||
val follows = event.verifiedFollowKeySet()
|
||||
followedUsers = follows
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -208,8 +217,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Load user's bookmark list
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, account, relayManager = relayManager) {
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "bookmarks-${account.pubKeyHex.take(8)}",
|
||||
@@ -249,8 +257,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to feed based on mode
|
||||
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -297,8 +304,7 @@ fun FeedScreen(
|
||||
|
||||
// Subscribe to zaps for visible events
|
||||
val eventIds = events.map { it.id }
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -328,8 +334,7 @@ fun FeedScreen(
|
||||
.flatten()
|
||||
.map { it.senderPubKey }
|
||||
.distinct()
|
||||
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -359,8 +364,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reactions for visible events
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -382,8 +386,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to replies for visible events
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -410,8 +413,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reposts for visible events
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -443,13 +445,12 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Fallback subscription if coordinator not available
|
||||
rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
// Skip if using coordinator
|
||||
if (subscriptionsCoordinator != null) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
+159
-9
@@ -20,15 +20,28 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.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
|
||||
@@ -36,16 +49,19 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_title
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
@@ -56,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),
|
||||
@@ -81,9 +98,8 @@ fun LoginScreen(
|
||||
LoginCard(
|
||||
onLogin = { keyInput ->
|
||||
accountManager.loginWithKey(keyInput).map {
|
||||
// Save account to secure storage (use IO dispatcher to avoid blocking UI)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
@@ -92,18 +108,29 @@ fun LoginScreen(
|
||||
generatedAccount = accountManager.generateNewAccount()
|
||||
showNewKeyDialog = true
|
||||
},
|
||||
onLoginBunker = { bunkerUri ->
|
||||
accountManager.loginWithBunker(bunkerUri).map {
|
||||
onLoginSuccess()
|
||||
}
|
||||
},
|
||||
onLoginNostrConnect = { onUriGenerated ->
|
||||
accountManager.loginWithNostrConnect(onUriGenerated).map {
|
||||
onLoginSuccess()
|
||||
}
|
||||
},
|
||||
loginProgress = loginProgress,
|
||||
)
|
||||
|
||||
if (showNewKeyDialog && generatedAccount != null) {
|
||||
val account = generatedAccount
|
||||
if (showNewKeyDialog && account != null) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
NewKeyWarningCard(
|
||||
npub = generatedAccount!!.npub,
|
||||
nsec = generatedAccount!!.nsec,
|
||||
npub = account.npub,
|
||||
nsec = account.nsec,
|
||||
onContinue = {
|
||||
showNewKeyDialog = false
|
||||
// Save generated account (use IO dispatcher to avoid blocking UI)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
|
||||
onLoginSuccess()
|
||||
}
|
||||
},
|
||||
@@ -111,3 +138,126 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectingRelaysScreen(
|
||||
subtitle: String = "Restoring session",
|
||||
relayStatuses: Map<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, RelayStatus> = emptyMap(),
|
||||
) {
|
||||
val total = relayStatuses.size
|
||||
val connected = relayStatuses.values.count { it.connected }
|
||||
val failed = relayStatuses.values.count { it.error != null }
|
||||
val progress = if (total > 0) connected.toFloat() / total else 0f
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"Amethyst",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
if (total > 0) "Connecting to relays ($connected/$total)" else "Connecting to relays...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
|
||||
// Progress bar
|
||||
if (total > 0) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
animationSpec = tween(300),
|
||||
label = "relay-progress",
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Per-relay status rows
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
relayStatuses.values.forEach { status ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
when {
|
||||
status.connected -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
status.error != null -> {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
status.url.url
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (status.error != null) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (status.connected && status.pingMs != null) {
|
||||
Text(
|
||||
"${status.pingMs}ms",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -113,7 +113,6 @@ fun NotificationsScreen(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val notificationState =
|
||||
remember {
|
||||
@@ -139,11 +138,10 @@ fun NotificationsScreen(
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Subscribe to notifications
|
||||
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createNotificationsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Skip events from the user themselves (except zaps)
|
||||
|
||||
@@ -175,7 +175,6 @@ fun ReadsScreen(
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val eventState =
|
||||
@@ -195,11 +194,11 @@ fun ReadsScreen(
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Load followed users for Following feed mode
|
||||
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
|
||||
val connectedRelays = connectedRelays
|
||||
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
@@ -219,16 +218,16 @@ fun ReadsScreen(
|
||||
}
|
||||
|
||||
// Subscribe to long-form content feed
|
||||
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
val connectedRelays = connectedRelays
|
||||
if (connectedRelays.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> {
|
||||
createLongFormFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LongTextNoteEvent) {
|
||||
eventState.addItem(event)
|
||||
@@ -243,7 +242,7 @@ fun ReadsScreen(
|
||||
FeedMode.FOLLOWING -> {
|
||||
if (followedUsers.isNotEmpty()) {
|
||||
createFollowingLongFormFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
followedUsers = followedUsers.toList(),
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LongTextNoteEvent) {
|
||||
|
||||
+21
-29
@@ -96,7 +96,6 @@ fun ThreadScreen(
|
||||
onReply: (Event) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// State for the root note
|
||||
@@ -149,9 +148,8 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user's bookmark list
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
@@ -162,7 +160,7 @@ fun ThreadScreen(
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
@@ -183,11 +181,10 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to the root note
|
||||
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createNoteSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event.id == noteId) {
|
||||
@@ -205,11 +202,10 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to replies
|
||||
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createThreadRepliesSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { event, _, _, _ ->
|
||||
replyEventState.addItem(event)
|
||||
@@ -225,14 +221,13 @@ fun ThreadScreen(
|
||||
|
||||
// Subscribe to zaps for thread events
|
||||
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createZapsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LnZapEvent) {
|
||||
@@ -251,14 +246,13 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reactions for thread events
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createReactionsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ReactionEvent) {
|
||||
@@ -274,14 +268,13 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to replies for thread events (for counts)
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepliesSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
val replyToId =
|
||||
@@ -301,14 +294,13 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reposts for thread events
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepostsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is RepostEvent) {
|
||||
|
||||
+16
-22
@@ -109,7 +109,6 @@ fun UserProfileScreen(
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
|
||||
// User metadata
|
||||
var displayName by remember { mutableStateOf<String?>(null) }
|
||||
@@ -154,11 +153,10 @@ fun UserProfileScreen(
|
||||
var eoseReceivedCount by remember(account) { mutableStateOf(0) }
|
||||
|
||||
// Load current user's contact list (for follow state)
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && account != null) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
@@ -175,7 +173,7 @@ fun UserProfileScreen(
|
||||
eoseReceivedCount++
|
||||
|
||||
// Wait for EOSE from at least 2 relays or all relays before enabling button
|
||||
val minEoseCount = minOf(2, configuredRelays.size)
|
||||
val minEoseCount = minOf(2, connectedRelays.size)
|
||||
if (eoseReceivedCount >= minEoseCount && !contactListLoaded) {
|
||||
contactListLoaded = true
|
||||
}
|
||||
@@ -194,11 +192,10 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user metadata
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
@@ -229,11 +226,10 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
// Subscribe to profile user's contact list (for following count)
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
@@ -252,9 +248,8 @@ fun UserProfileScreen(
|
||||
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
|
||||
|
||||
// Subscribe to followers (contact lists that tag this user)
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
// Clear previous followers when subscription restarts
|
||||
followerAuthors.clear()
|
||||
followersCount = 0
|
||||
@@ -269,7 +264,7 @@ fun UserProfileScreen(
|
||||
limit = 500,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Count unique authors who follow this user
|
||||
if (followerAuthors.add(event.pubKey)) {
|
||||
@@ -284,13 +279,12 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user posts
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
postsLoading = true
|
||||
postsError = null
|
||||
createUserPostsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
eventState.addItem(event)
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun ForceLogoutDialog(
|
||||
reason: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
"Session Terminated",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
reason,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+269
-41
@@ -28,20 +28,32 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
@@ -49,29 +61,27 @@ 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
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Login card with Nostr key input field and action buttons.
|
||||
*
|
||||
* @param onLogin Callback when login is attempted with the key input
|
||||
* @param onGenerateNew Callback when "Generate New" is clicked
|
||||
* @param modifier Modifier for the card
|
||||
* @param cardWidth Width of the card (default 400.dp)
|
||||
* @param title Card title
|
||||
* @param subtitle Subtitle/hint text
|
||||
*/
|
||||
@Composable
|
||||
fun LoginCard(
|
||||
onLogin: (String) -> Result<Unit>,
|
||||
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),
|
||||
subtitle: String = stringResource(Res.string.login_card_subtitle),
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Paste Key", "Connect")
|
||||
|
||||
Card(
|
||||
modifier = modifier.width(cardWidth),
|
||||
@@ -92,48 +102,266 @@ fun LoginCard(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
KeyInputField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it
|
||||
errorMessage = null
|
||||
},
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
if (onLoginNostrConnect != null) {
|
||||
@Suppress("DEPRECATION")
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when (selectedTab) {
|
||||
0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle, loginProgress)
|
||||
1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect, loginProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@Composable
|
||||
private fun PasteKeyContent(
|
||||
onLogin: (String) -> Result<Unit>,
|
||||
onGenerateNew: () -> Unit,
|
||||
onLoginBunker: (suspend (String) -> Result<Unit>)?,
|
||||
subtitle: String,
|
||||
loginProgress: LoginProgress? = null,
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var isConnecting by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true)
|
||||
|
||||
KeyInputField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it
|
||||
errorMessage = null
|
||||
},
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (isBunker) {
|
||||
Text(
|
||||
"This URI connects to your remote signer. Treat it like a password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (isConnecting) {
|
||||
if (loginProgress != null) {
|
||||
LoginProgressSteps(loginProgress)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isBunker && onLoginBunker != null) {
|
||||
val validationError = validateBunkerUri(keyInput)
|
||||
if (validationError != null) {
|
||||
errorMessage = validationError
|
||||
return@Button
|
||||
}
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val result = onLoginBunker(keyInput.trim())
|
||||
withContext(Dispatchers.Main) {
|
||||
result.fold(
|
||||
onSuccess = { isConnecting = false },
|
||||
onFailure = {
|
||||
errorMessage = it.message
|
||||
isConnecting = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onLogin(keyInput).fold(
|
||||
onSuccess = { /* handled by caller */ },
|
||||
onFailure = { errorMessage = it.message },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = keyInput.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(Res.string.login_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = keyInput.isNotBlank(),
|
||||
) {
|
||||
Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onGenerateNew,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.login_generate_button))
|
||||
OutlinedButton(
|
||||
onClick = onGenerateNew,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.login_generate_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
errorMessage!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = {
|
||||
errorMessage = null
|
||||
nostrConnectUri = null
|
||||
}) {
|
||||
Text("Try Again")
|
||||
}
|
||||
} else if (!isConnecting) {
|
||||
Text(
|
||||
"Show a QR code for your signer app (e.g. Amber) to scan.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val result =
|
||||
onLoginNostrConnect { uri ->
|
||||
scope.launch(Dispatchers.Main) { nostrConnectUri = uri }
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
result.onFailure {
|
||||
errorMessage = it.message
|
||||
isConnecting = false
|
||||
nostrConnectUri = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Start Connection")
|
||||
}
|
||||
} else {
|
||||
val uri = nostrConnectUri
|
||||
if (uri != null) {
|
||||
QrCodeCanvas(
|
||||
data = uri,
|
||||
size = 200.dp,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
uri,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { clipboardManager.setText(AnnotatedString(uri)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Copy URI")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Generating connection...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
@Composable
|
||||
fun QrCodeCanvas(
|
||||
data: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 200.dp,
|
||||
) {
|
||||
val bitmap =
|
||||
remember(data) {
|
||||
createQrBitmap(data)
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.size(size)) {
|
||||
drawImage(
|
||||
image = bitmap,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(bitmap.width, bitmap.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(this.size.width.toInt(), this.size.height.toInt()),
|
||||
filterQuality = FilterQuality.None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createQrBitmap(data: String): ImageBitmap {
|
||||
val matrix =
|
||||
QRCodeWriter().encode(
|
||||
data,
|
||||
BarcodeFormat.QR_CODE,
|
||||
0,
|
||||
0,
|
||||
mapOf(EncodeHintType.MARGIN to 1),
|
||||
)
|
||||
|
||||
val w = matrix.width
|
||||
val h = matrix.height
|
||||
val black = 0xFF000000.toInt()
|
||||
val white = 0xFFFFFFFF.toInt()
|
||||
|
||||
val image = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
|
||||
for (x in 0 until w) {
|
||||
for (y in 0 until h) {
|
||||
image.setRGB(x, y, if (matrix.get(x, y)) black else white)
|
||||
}
|
||||
}
|
||||
|
||||
return image.toComposeImageBitmap()
|
||||
}
|
||||
+7
-9
@@ -80,18 +80,17 @@ fun NewDmDialog(
|
||||
val cachedUsers by searchState.cachedUserResults.collectAsState()
|
||||
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// NIP-50 relay search when local cache has few/no results
|
||||
rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty()) return@rememberSubscription null
|
||||
rememberSubscription(connectedRelays, searchText, cachedUsers.size, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||
|
||||
if (searchState.shouldSearchRelays) {
|
||||
searchState.startRelaySearch()
|
||||
createSearchPeopleSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
searchQuery = searchText,
|
||||
limit = 20,
|
||||
onEvent = { event, _, _, _ ->
|
||||
@@ -113,16 +112,15 @@ fun NewDmDialog(
|
||||
}
|
||||
|
||||
// Bech32 npub metadata loading
|
||||
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || searchText.length < 2) {
|
||||
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
||||
if (pubkeyHex != null) {
|
||||
createMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubkeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+13
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.deck
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -54,6 +55,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
|
||||
@@ -95,6 +98,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>(DeckColumnType.HomeFeed) }
|
||||
@@ -131,6 +136,14 @@ fun SinglePaneLayout(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
+7
-6
@@ -44,7 +44,8 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Urls
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
|
||||
@@ -71,8 +72,7 @@ fun NoteCard(
|
||||
onClick: (() -> Unit)? = null,
|
||||
onAuthorClick: ((String) -> Unit)? = null,
|
||||
) {
|
||||
val richTextParser = remember { RichTextParser() }
|
||||
val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) }
|
||||
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -154,11 +154,11 @@ fun NoteCard(
|
||||
@Composable
|
||||
fun RichTextContent(
|
||||
content: String,
|
||||
urls: Set<String>,
|
||||
urls: Urls,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 10,
|
||||
) {
|
||||
if (urls.isEmpty()) {
|
||||
if (urls.withScheme.isEmpty()) {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -171,7 +171,8 @@ fun RichTextContent(
|
||||
val annotatedText =
|
||||
buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
val sortedUrls = urls.sortedBy { content.indexOf(it) }
|
||||
// TODO: User the other urls.
|
||||
val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) }
|
||||
|
||||
for (url in sortedUrls) {
|
||||
val startIndex = content.indexOf(url, lastIndex)
|
||||
|
||||
Reference in New Issue
Block a user