fix(multi-account): npub-only switching + reactive display names

- Add loadReadOnlyAccount() for ViewOnly signer type switching
  (was mapping to loadInternalAccount which requires private key)
- Remove remember() from display name resolution — now re-evaluates
  each recomposition so names appear once metadata loads from relays
- Only show subtitle (npub row) when display name is available;
  if no display name, show npub as title only
- Signer type badge shown as subtitle when no display name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 09:41:51 +03:00
parent 250bb5a1ad
commit 29aa1d6c9c
2 changed files with 67 additions and 36 deletions
@@ -641,6 +641,27 @@ class AccountManager internal constructor(
* Ensures the currently logged-in account is persisted in multi-account storage. * Ensures the currently logged-in account is persisted in multi-account storage.
* Call before switching to a new account to avoid losing the current one. * Call before switching to a new account to avoid losing the current one.
*/ */
private fun loadReadOnlyAccount(npub: String): Result<AccountState.LoggedIn> {
val pubKeyHex =
decodePublicKeyAsHexOrNull(npub)
?: return Result.failure(Exception("Invalid npub: $npub"))
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
val signer = NostrSignerInternal(keyPair)
val state =
AccountState.LoggedIn(
signer = signer,
pubKeyHex = pubKeyHex,
npub = npub,
nsec = null,
isReadOnly = true,
signerType = SignerType.ViewOnly,
)
_accountState.value = state
return Result.success(state)
}
suspend fun ensureCurrentAccountInStorage() { suspend fun ensureCurrentAccountInStorage() {
val current = currentAccount() ?: return val current = currentAccount() ?: return
val info = AccountInfo(npub = current.npub, signerType = current.signerType) val info = AccountInfo(npub = current.npub, signerType = current.signerType)
@@ -688,7 +709,7 @@ class AccountManager internal constructor(
when (sType) { when (sType) {
is SignerType.Internal -> loadInternalAccount(target.npub) is SignerType.Internal -> loadInternalAccount(target.npub)
is SignerType.Remote -> loadBunkerAccount(sType.bunkerUri, target.npub) is SignerType.Remote -> loadBunkerAccount(sType.bunkerUri, target.npub)
is SignerType.ViewOnly -> loadInternalAccount(target.npub) is SignerType.ViewOnly -> loadReadOnlyAccount(target.npub)
} }
if (newState.isFailure) return newState if (newState.isFailure) return newState
@@ -59,6 +59,21 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
/**
* Resolve display name from cache. Returns null if no metadata or name matches hex fallback.
*/
private fun resolveDisplayName(
npub: String,
localCache: DesktopLocalCache?,
): String? {
if (localCache == null) return null
val pubkeyHex = decodePublicKeyAsHexOrNull(npub) ?: return null
val user = localCache.getUserIfExists(pubkeyHex) ?: return null
val name = user.toBestDisplayName()
// Don't return hex fallback as "display name"
return name.takeIf { it != user.pubkeyDisplayHex() }
}
/** /**
* Middle-truncates an npub: "npub1abcdef...wxyz" * Middle-truncates an npub: "npub1abcdef...wxyz"
*/ */
@@ -102,44 +117,47 @@ fun AccountSwitcherDropdown(
allAccounts.forEach { account -> allAccounts.forEach { account ->
val isActive = account.npub == activeNpub val isActive = account.npub == activeNpub
// Resolve display name from cache // Resolve display name from cache (no remember — cheap lookup, re-evaluates each recomposition)
val displayName = val displayName = resolveDisplayName(account.npub, localCache)
remember(account.npub, localCache) { val shortNpub = npubShortMiddle(account.npub)
val pubkeyHex = decodePublicKeyAsHexOrNull(account.npub) val signerLabel =
if (pubkeyHex != null && localCache != null) { when (account.signerType) {
val user = localCache.getUserIfExists(pubkeyHex) is SignerType.Remote -> " · Bunker"
user?.toBestDisplayName()?.takeIf { it != user.pubkeyDisplayHex() } is SignerType.ViewOnly -> " · View only"
} else { is SignerType.Internal -> ""
null
}
} }
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Row 1: Display name (or npub if no name) // Title: display name or npub
Text( Text(
displayName ?: npubShortMiddle(account.npub), displayName ?: shortNpub,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
// Row 2: npub (middle-truncated) + signer type badge // Subtitle: only show if different from title
val signerLabel = val subtitle = shortNpub + signerLabel
when (account.signerType) { if (displayName != null) {
is SignerType.Remote -> " · Bunker" Text(
is SignerType.ViewOnly -> " · View only" subtitle,
is SignerType.Internal -> "" maxLines = 1,
} overflow = TextOverflow.Ellipsis,
Text( style = MaterialTheme.typography.labelSmall,
npubShortMiddle(account.npub) + signerLabel, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, )
overflow = TextOverflow.Ellipsis, } else if (signerLabel.isNotEmpty()) {
style = MaterialTheme.typography.labelSmall, // No display name but has signer label — show it
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
) signerLabel.removePrefix(" · "),
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
} }
if (isActive) { if (isActive) {
Icon( Icon(
@@ -187,15 +205,7 @@ fun AccountSwitcherDropdown(
// Logout confirmation dialog // Logout confirmation dialog
val logoutNpub = confirmLogoutNpub val logoutNpub = confirmLogoutNpub
if (logoutNpub != null) { if (logoutNpub != null) {
val logoutDisplayName = val logoutDisplayName = resolveDisplayName(logoutNpub, localCache)
remember(logoutNpub, localCache) {
val hex = decodePublicKeyAsHexOrNull(logoutNpub)
if (hex != null && localCache != null) {
localCache.getUserIfExists(hex)?.toBestDisplayName()
} else {
null
}
}
AlertDialog( AlertDialog(
onDismissRequest = { confirmLogoutNpub = null }, onDismissRequest = { confirmLogoutNpub = null },