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.
* 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() {
val current = currentAccount() ?: return
val info = AccountInfo(npub = current.npub, signerType = current.signerType)
@@ -688,7 +709,7 @@ class AccountManager internal constructor(
when (sType) {
is SignerType.Internal -> loadInternalAccount(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
@@ -59,6 +59,21 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
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"
*/
@@ -102,44 +117,47 @@ fun AccountSwitcherDropdown(
allAccounts.forEach { account ->
val isActive = account.npub == activeNpub
// Resolve display name from cache
val displayName =
remember(account.npub, localCache) {
val pubkeyHex = decodePublicKeyAsHexOrNull(account.npub)
if (pubkeyHex != null && localCache != null) {
val user = localCache.getUserIfExists(pubkeyHex)
user?.toBestDisplayName()?.takeIf { it != user.pubkeyDisplayHex() }
} else {
null
}
// Resolve display name from cache (no remember — cheap lookup, re-evaluates each recomposition)
val displayName = resolveDisplayName(account.npub, localCache)
val shortNpub = npubShortMiddle(account.npub)
val signerLabel =
when (account.signerType) {
is SignerType.Remote -> " · Bunker"
is SignerType.ViewOnly -> " · View only"
is SignerType.Internal -> ""
}
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
// Row 1: Display name (or npub if no name)
// Title: display name or npub
Text(
displayName ?: npubShortMiddle(account.npub),
displayName ?: shortNpub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium,
)
// Row 2: npub (middle-truncated) + signer type badge
val signerLabel =
when (account.signerType) {
is SignerType.Remote -> " · Bunker"
is SignerType.ViewOnly -> " · View only"
is SignerType.Internal -> ""
}
Text(
npubShortMiddle(account.npub) + signerLabel,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Subtitle: only show if different from title
val subtitle = shortNpub + signerLabel
if (displayName != null) {
Text(
subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (signerLabel.isNotEmpty()) {
// No display name but has signer label — show it
Text(
signerLabel.removePrefix(" · "),
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (isActive) {
Icon(
@@ -187,15 +205,7 @@ fun AccountSwitcherDropdown(
// Logout confirmation dialog
val logoutNpub = confirmLogoutNpub
if (logoutNpub != null) {
val logoutDisplayName =
remember(logoutNpub, localCache) {
val hex = decodePublicKeyAsHexOrNull(logoutNpub)
if (hex != null && localCache != null) {
localCache.getUserIfExists(hex)?.toBestDisplayName()
} else {
null
}
}
val logoutDisplayName = resolveDisplayName(logoutNpub, localCache)
AlertDialog(
onDismissRequest = { confirmLogoutNpub = null },