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:
nrobi144
2026-03-12 13:22:15 +02:00
363 changed files with 17702 additions and 2051 deletions
@@ -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")
}
}
}
@@ -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("&")}"
}
@@ -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
}
@@ -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()
}
@@ -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 },
),
@@ -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)
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
cmd: Command,
success: Boolean,
) {
// Command send tracking
// Command send tracking — no-op for now
}
}
@@ -46,5 +46,6 @@ object DefaultRelays {
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
)
}
@@ -110,7 +110,9 @@ fun rememberSubscription(
}
onDispose {
subscription?.let { relayManager.unsubscribe(it.subId) }
subscription?.let {
relayManager.unsubscribe(it.subId)
}
}
}
@@ -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)
},
@@ -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
}
@@ -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),
)
}
}
}
}
}
}
}
@@ -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) {
@@ -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) {
@@ -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)
@@ -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")
}
},
)
}
@@ -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,
)
}
}
}
@@ -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
}
},
)
}
}
@@ -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()
}
@@ -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) {
@@ -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,
@@ -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()
@@ -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)