fix(multi-account): persist display names in encrypted storage, fix npub-only fallback

Three fixes:

1. Display names now stored in AccountInfo/AccountInfoDto and persisted
   in encrypted storage. Populated when metadata arrives from relays via
   metadataVersion LaunchedEffect. Available immediately on next open.

2. loadInternalAccount falls back to loadReadOnlyAccount when no private
   key found in SecureKeyStorage — fixes npub-only accounts that were
   saved as SignerType.Internal from earlier sessions.

3. Dropdown uses persisted displayName first, cache lookup as fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 09:54:08 +03:00
parent 1032db0c05
commit 156391ec0a
5 changed files with 56 additions and 20 deletions
@@ -30,5 +30,6 @@ import androidx.compose.runtime.Immutable
data class AccountInfo(
val npub: String,
val signerType: SignerType,
val displayName: String? = null,
val isTransient: Boolean = false,
)
@@ -673,6 +673,17 @@ fun App(
}
}
// Persist display name when metadata arrives from relays
val metadataVersion by localCache.metadataVersion.collectAsState()
LaunchedEffect(metadataVersion) {
val current = accountManager.currentAccount() ?: return@LaunchedEffect
val user = localCache.getUserIfExists(current.pubKeyHex) ?: return@LaunchedEffect
val name = user.toBestDisplayName()
if (name != user.pubkeyDisplayHex()) {
accountManager.updateDisplayName(current.npub, name)
}
}
// Try to load saved account on startup
DisposableEffect(Unit) {
relayManager.addDefaultRelays()
@@ -247,23 +247,26 @@ class AccountManager internal constructor(
}
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 privKeyHex = secureStorage.getPrivateKey(npub)
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
val signer = NostrSignerInternal(keyPair)
if (privKeyHex != null) {
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)
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)
}
// No private key — fall back to read-only
return loadReadOnlyAccount(npub)
}
private suspend fun loadBunkerAccount(
@@ -663,13 +666,30 @@ class AccountManager internal constructor(
return Result.success(state)
}
suspend fun ensureCurrentAccountInStorage() {
suspend fun ensureCurrentAccountInStorage(displayName: String? = null) {
val current = currentAccount() ?: return
val info = AccountInfo(npub = current.npub, signerType = current.signerType)
// Merge with existing stored info to preserve display name if not provided
val existing = accountStorage.loadAccounts().find { it.npub == current.npub }
val info =
AccountInfo(
npub = current.npub,
signerType = current.signerType,
displayName = displayName ?: existing?.displayName,
)
accountStorage.saveAccount(info)
accountStorage.setCurrentAccount(current.npub)
}
suspend fun updateDisplayName(
npub: String,
displayName: String,
) {
val accounts = accountStorage.loadAccounts()
val existing = accounts.find { it.npub == npub } ?: return
accountStorage.saveAccount(existing.copy(displayName = displayName))
refreshAccountList()
}
suspend fun removeAccountFromStorage(npub: String) {
val current = currentAccount()
accountStorage.deleteAccount(npub)
@@ -198,6 +198,7 @@ internal data class AccountInfoDto(
val npub: String,
val signerKind: String, // "internal", "remote", "viewonly"
val bunkerUri: String? = null,
val displayName: String? = null,
val isTransient: Boolean = false,
) {
fun toAccountInfo(): AccountInfo =
@@ -209,6 +210,7 @@ internal data class AccountInfoDto(
"viewonly" -> SignerType.ViewOnly
else -> SignerType.Internal
},
displayName = displayName,
isTransient = isTransient,
)
@@ -223,6 +225,7 @@ internal data class AccountInfoDto(
is SignerType.ViewOnly -> "viewonly"
},
bunkerUri = (info.signerType as? SignerType.Remote)?.bunkerUri,
displayName = info.displayName,
isTransient = info.isTransient,
)
}
@@ -122,8 +122,8 @@ fun AccountSwitcherDropdown(
allAccounts.forEach { account ->
val isActive = account.npub == activeNpub
// Resolve display name from cache (no remember — cheap lookup, re-evaluates each recomposition)
val displayName = resolveDisplayName(account.npub, localCache)
// Use persisted display name first, fall back to cache lookup
val displayName = account.displayName ?: resolveDisplayName(account.npub, localCache)
val shortNpub = npubShortMiddle(account.npub)
val signerLabel =
when (account.signerType) {
@@ -210,7 +210,8 @@ fun AccountSwitcherDropdown(
// Logout confirmation dialog
val logoutNpub = confirmLogoutNpub
if (logoutNpub != null) {
val logoutDisplayName = resolveDisplayName(logoutNpub, localCache)
val logoutAccount = allAccounts.find { it.npub == logoutNpub }
val logoutDisplayName = logoutAccount?.displayName ?: resolveDisplayName(logoutNpub, localCache)
AlertDialog(
onDismissRequest = { confirmLogoutNpub = null },