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:
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.