feat(multi-account): display names, middle-truncated npub, npub-only account fix

Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 09:35:33 +03:00
parent 66310ca336
commit 250bb5a1ad
24 changed files with 7404 additions and 17 deletions
@@ -729,8 +729,9 @@ fun App(
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
// Refresh account list for switcher
// Ensure account is in multi-account storage + refresh list
scope.launch(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
accountManager.refreshAccountList()
}
},
@@ -1050,6 +1051,7 @@ fun MainContent(
DeckSidebar(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
scope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub)
@@ -55,12 +55,23 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.model.account.SignerType
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.collections.immutable.ImmutableList
/**
* Middle-truncates an npub: "npub1abcdef...wxyz"
*/
private fun npubShortMiddle(npub: String): String {
if (npub.length <= 20) return npub
return "${npub.take(10)}...${npub.takeLast(6)}"
}
@Composable
fun AccountSwitcherDropdown(
activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit,
@@ -90,29 +101,45 @@ 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
}
}
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
// Row 1: Display name (or npub if no name)
Text(
account.npub.take(16) + "...",
displayName ?: npubShortMiddle(account.npub),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium,
)
val label =
// 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 -> null
is SignerType.Remote -> " · Bunker"
is SignerType.ViewOnly -> " · View only"
is SignerType.Internal -> ""
}
if (label != null) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
npubShortMiddle(account.npub) + signerLabel,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isActive) {
Icon(
@@ -160,11 +187,24 @@ 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
}
}
AlertDialog(
onDismissRequest = { confirmLogoutNpub = null },
title = { Text("Remove Account") },
text = {
Text("Remove ${logoutNpub.take(16)}...? This will delete the account from this device.")
Text(
"Remove ${logoutDisplayName ?: npubShortMiddle(logoutNpub)}? " +
"This will delete the account from this device.",
)
},
confirmButton = {
TextButton(
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import kotlinx.collections.immutable.ImmutableList
@@ -50,6 +51,7 @@ import kotlinx.collections.immutable.ImmutableList
fun DeckSidebar(
activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit,
@@ -73,6 +75,7 @@ fun DeckSidebar(
AccountSwitcherDropdown(
activeNpub = activeNpub,
allAccounts = allAccounts,
localCache = localCache,
onSwitchAccount = onSwitchAccount,
onAddAccount = onAddAccount,
onRemoveAccount = onRemoveAccount,
@@ -183,6 +183,7 @@ fun SinglePaneLayout(
AccountSwitcherDropdown(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub)