app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 54e67634396aabaa947b8f4cef7d97d726bef44a
parent c72a52d0fb61f72578fa1ca0b6a8d9675d0e30d7
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:01:12 +0000

ui(home): support account switching

- preserve the active session while presenting the account chooser
- return to the active home without mutating Rust session state
- hide the chooser only after successful activation or sign-out
- cover chooser navigation and replacement activation intent

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 2++
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 25++++++++++++++++++++++++-
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 2++
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 21+++++++++++++++++++--
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt | 1+
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 42++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
7 files changed, 91 insertions(+), 4 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -44,6 +44,7 @@ data class StudioUiModel( val importDraft: String, val generatedKeyBackup: GeneratedKeyBackupUiModel?, val pendingRemovalPublicKeyHex: String?, + val accountChooserVisible: Boolean, val session: SessionStateDto, val busy: Boolean, val problem: String?, @@ -61,6 +62,7 @@ fun StudioStoreState.toUiModel(): StudioUiModel { GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.nsec) }, pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, + accountChooserVisible = accountChooserVisible, session = snapshot.session, busy = busy, problem = problem diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -42,6 +42,8 @@ data class StudioUiActions( val confirmAccountRemoval: () -> Unit = {}, val refreshActiveProfile: () -> Unit = {}, val signOut: () -> Unit = {}, + val showAccountChooser: () -> Unit = {}, + val hideAccountChooser: () -> Unit = {}, ) @Composable @@ -49,7 +51,11 @@ fun StudioScreen( model: StudioUiModel, actions: StudioUiActions, ) { - if (model.session == org.radroots.studio.ffi.SessionStateDto.ACTIVE && model.activeAccount != null) { + if ( + model.session == org.radroots.studio.ffi.SessionStateDto.ACTIVE && + model.activeAccount != null && + !model.accountChooserVisible + ) { ActiveAccountHome(model, model.activeAccount, actions) } else { InactiveAccountsScreen(model, actions) @@ -88,6 +94,13 @@ private fun ActiveAccountHome( model.configuredRelays.forEach { relay -> BasicText(relay) } } TextAction( + text = "Switch account", + testTag = "switch-account", + contentDescription = "Choose another saved account", + enabled = !model.busy, + onClick = actions.showAccountChooser, + ) + TextAction( text = "Refresh metadata", testTag = "refresh-profile", contentDescription = "Refresh active Nostr profile metadata", @@ -121,6 +134,16 @@ private fun InactiveAccountsScreen( BasicText("radroots") BasicText("Accounts") + if (model.activeAccount != null) { + BasicText("Choose an account to activate. The current account remains active until replacement succeeds.") + TextAction( + text = "Back to active account", + testTag = "return-home", + contentDescription = "Return to the active account", + onClick = actions.hideAccountChooser, + ) + } + TextAction( text = "Generate new key", testTag = "generate-key", diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -42,6 +42,8 @@ fun RadrootsApplication( confirmAccountRemoval = store::confirmAccountRemoval, refreshActiveProfile = store::refreshActiveProfile, signOut = store::signOut, + showAccountChooser = store::showAccountChooser, + hideAccountChooser = store::hideAccountChooser, ), ) } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -18,6 +18,7 @@ data class StudioStoreState( val importDraft: String = "", val generatedKeyBackup: GeneratedKeyBackup? = null, val pendingRemovalPublicKeyHex: String? = null, + val accountChooserVisible: Boolean = false, val busy: Boolean = false, val problem: String? = null, ) @@ -70,11 +71,27 @@ class StudioAppStore( } fun activateAccount(publicKeyHex: String) { - runCommand { gateway.activateAccount(publicKeyHex) } + runCommand { + gateway.activateAccount(publicKeyHex).also { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + } } fun signOut() { - runCommand { gateway.signOut() } + runCommand { + gateway.signOut().also { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + } + } + + fun showAccountChooser() { + mutableState.value = mutableState.value.copy(accountChooserVisible = true, problem = null) + } + + fun hideAccountChooser() { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) } fun refreshActiveProfile() { diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt @@ -38,6 +38,7 @@ class AccountsUiModelTest { assertEquals("fresh", model.activeAccount?.profileState) assertEquals("alice@example.com", model.activeAccount?.profile?.nip05) assertEquals(listOf("ws://localhost:8080"), model.configuredRelays) + assertFalse(model.accountChooserVisible) assertFalse(model.accounts.single().label.contains("server", ignoreCase = true)) } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -174,6 +174,47 @@ class StudioScreenTest { assertEquals(1, refreshCalls) assertEquals(1, signOutCalls) } + + @Test + fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = runComposeUiTest { + val first = accountUi("44".repeat(32), selected = true) + val second = accountUi("55".repeat(32), selected = false) + val active = ActiveAccountUiModel( + account = first, + heading = first.label, + relayState = "connected", + profileState = "cached", + profile = ProfileUiModel("", "", "", "", ""), + ) + var chooserVisible by mutableStateOf(false) + var activated: String? = null + setContent { + StudioScreen( + model = emptyUiModel().copy( + accounts = listOf(first, second), + activeAccount = active, + session = SessionStateDto.ACTIVE, + accountChooserVisible = chooserVisible, + ), + actions = StudioUiActions( + showAccountChooser = { chooserVisible = true }, + hideAccountChooser = { chooserVisible = false }, + activateAccount = { activated = it }, + ), + ) + } + + onNodeWithTag("switch-account").performClick() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(second.publicKeyHex, activated) + assertEquals(SessionStateDto.ACTIVE, emptyUiModel().copy( + activeAccount = active, + session = SessionStateDto.ACTIVE, + ).session) + onNodeWithTag("return-home").performClick() + onNodeWithTag("home-screen").assertIsDisplayed() + } } private fun emptyUiModel( @@ -186,6 +227,7 @@ private fun emptyUiModel( importDraft = importDraft, generatedKeyBackup = null, pendingRemovalPublicKeyHex = null, + accountChooserVisible = false, session = SessionStateDto.SIGNED_OUT, busy = false, problem = problem, diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -538,7 +538,7 @@ handoff commit sequence. - [x] 52. Implement generated-key backup panel. - [x] 53. Implement saved-account list, activation, and removal controls. - [x] 54. Implement active account home screen. -- [ ] 55. Implement switch account flow in UI. +- [x] 55. Implement switch account flow in UI. - [ ] 56. Remove canonical Kotlin account reducer/store usage. - [ ] 57. Remove generic server-account remnants.