From 156391ec0a3e864f470185cfc3e99c6b68f885b1 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 29 Apr 2026 09:54:08 +0300 Subject: [PATCH] fix(multi-account): persist display names in encrypted storage, fix npub-only fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../commons/model/account/AccountInfo.kt | 1 + .../vitorpamplona/amethyst/desktop/Main.kt | 11 ++++ .../desktop/account/AccountManager.kt | 54 +++++++++++++------ .../desktop/account/DesktopAccountStorage.kt | 3 ++ .../ui/account/AccountSwitcherDropdown.kt | 7 +-- 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/account/AccountInfo.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/account/AccountInfo.kt index 25454c7d9..cecdfe6ca 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/account/AccountInfo.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/account/AccountInfo.kt @@ -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, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index caa084ccd..2265b86b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 17626a82e..eb5e7b2ab 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -247,23 +247,26 @@ class AccountManager internal constructor( } private suspend fun loadInternalAccount(npub: String): Result { - 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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt index 03e5d0a64..51d6c2b84 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt @@ -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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt index 2aace03a2..ee8bb2cff 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt @@ -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 },