commit c0d2e3970a1e69b1f6dfb4f4a98540a906af5108
parent 141e3ae1fa95978c705fde47ad59444d84d96a78
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:59:08 +0000
ui(accounts): render saved Nostr accounts
- list public identities selection and credential availability
- forward explicit select and activate intents to the Rust store
- require a one-time removal request before destructive confirmation
- cover multi-account activation and cancelable removal interactions
Diffstat:
4 files changed, 125 insertions(+), 1 deletion(-)
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
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
@@ -18,6 +20,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.password
import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -32,6 +35,11 @@ data class StudioUiActions(
val importSecretKey: () -> Unit = {},
val copyText: (String) -> Unit = {},
val acknowledgeGeneratedKeyBackup: () -> Unit = {},
+ val selectAccount: (String) -> Unit = {},
+ val activateAccount: (String) -> Unit = {},
+ val requestAccountRemoval: (String) -> Unit = {},
+ val cancelAccountRemoval: () -> Unit = {},
+ val confirmAccountRemoval: () -> Unit = {},
)
@Composable
@@ -103,6 +111,68 @@ private fun InactiveAccountsScreen(
if (model.accounts.isEmpty()) {
BasicText("No saved accounts.", Modifier.testTag("accounts-empty"))
+ } else {
+ SavedAccountList(model, actions)
+ }
+ }
+}
+
+@Composable
+private fun SavedAccountList(
+ model: StudioUiModel,
+ actions: StudioUiActions,
+) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("saved-account-list"),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(model.accounts, key = AccountUiModel::publicKeyHex) { account ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { selected = account.selected }
+ .testTag("account-row:${account.publicKeyHex}")
+ .clickable { actions.selectAccount(account.publicKeyHex) }
+ .background(InputBackgroundColor)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ BasicText(account.label)
+ BasicText(account.npub)
+ BasicText("Key: ${account.keyAvailability}")
+ if (account.selected) BasicText("Selected")
+ TextAction(
+ text = "Activate",
+ testTag = "activate-account:${account.publicKeyHex}",
+ contentDescription = "Activate ${account.label}",
+ enabled = !model.busy,
+ onClick = { actions.activateAccount(account.publicKeyHex) },
+ )
+ TextAction(
+ text = "Remove",
+ testTag = "remove-account:${account.publicKeyHex}",
+ contentDescription = "Remove ${account.label}",
+ enabled = !model.busy,
+ onClick = { actions.requestAccountRemoval(account.publicKeyHex) },
+ )
+ if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) {
+ BasicText("Remove this saved account and its local credential?")
+ TextAction(
+ text = "Cancel",
+ testTag = "remove-cancel",
+ contentDescription = "Cancel account removal",
+ onClick = actions.cancelAccountRemoval,
+ )
+ TextAction(
+ text = "Confirm removal",
+ testTag = "remove-confirm",
+ contentDescription = "Confirm account removal",
+ onClick = actions.confirmAccountRemoval,
+ )
+ }
+ }
}
}
}
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
@@ -35,6 +35,11 @@ fun RadrootsApplication(
Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(value), null)
},
acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup,
+ selectAccount = store::selectAccount,
+ activateAccount = store::activateAccount,
+ requestAccountRemoval = store::requestAccountRemoval,
+ cancelAccountRemoval = store::cancelAccountRemoval,
+ confirmAccountRemoval = store::confirmAccountRemoval,
),
)
}
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
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
@@ -87,6 +88,45 @@ class StudioScreenTest {
onAllNodesWithTag("generated-key-backup").assertCountEquals(0)
onAllNodesWithTag("generated-nsec").assertCountEquals(0)
}
+
+ @Test
+ fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = runComposeUiTest {
+ val first = accountUi("11".repeat(32), selected = true)
+ val second = accountUi("22".repeat(32), selected = false)
+ var pendingRemoval: String? by mutableStateOf(null)
+ val selected = mutableListOf<String>()
+ val activated = mutableListOf<String>()
+ var confirmations = 0
+ setContent {
+ StudioScreen(
+ model = emptyUiModel().copy(
+ accounts = listOf(first, second),
+ pendingRemovalPublicKeyHex = pendingRemoval,
+ ),
+ actions = StudioUiActions(
+ selectAccount = selected::add,
+ activateAccount = activated::add,
+ requestAccountRemoval = { pendingRemoval = it },
+ cancelAccountRemoval = { pendingRemoval = null },
+ confirmAccountRemoval = { confirmations += 1 },
+ ),
+ )
+ }
+
+ onNodeWithTag("saved-account-list").assertIsDisplayed()
+ onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected()
+ onNodeWithTag("account-row:${second.publicKeyHex}").performClick()
+ onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick()
+ assertEquals(listOf(second.publicKeyHex), selected)
+ assertEquals(listOf(second.publicKeyHex), activated)
+
+ onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick()
+ onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick()
+ assertEquals(null, pendingRemoval)
+ onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick()
+ onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick()
+ assertEquals(1, confirmations)
+ }
}
private fun emptyUiModel(
@@ -103,3 +143,12 @@ private fun emptyUiModel(
busy = false,
problem = problem,
)
+
+private fun accountUi(publicKeyHex: String, selected: Boolean) = AccountUiModel(
+ publicKeyHex = publicKeyHex,
+ npub = "npub1${publicKeyHex.take(12)}",
+ shortNpub = "npub1${publicKeyHex.take(12)}",
+ label = "Account ${publicKeyHex.take(2)}",
+ keyAvailability = "available",
+ selected = selected,
+)
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -536,7 +536,7 @@ handoff commit sequence.
- [x] 50. Create UI model mapping helpers without server fields.
- [x] 51. Implement inactive account screen generate/import controls.
- [x] 52. Implement generated-key backup panel.
-- [ ] 53. Implement saved-account list, activation, and removal controls.
+- [x] 53. Implement saved-account list, activation, and removal controls.
- [ ] 54. Implement active account home screen.
- [ ] 55. Implement switch account flow in UI.
- [ ] 56. Remove canonical Kotlin account reducer/store usage.