commit 647baa5e653a7d7bd4837963396f2ad1d31e0470
parent ae8e238394cc742b9187aee8c6069301ef2e8a53
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:56:59 +0000
ui(accounts): add generate and import controls
- render the signed-out account shell from Rust-backed UI models
- expose explicit generate and existing-key import actions
- mask secret-key entry and disable unsafe concurrent commands
- cover the minimal empty failure and interaction states in Compose
Diffstat:
4 files changed, 214 insertions(+), 3 deletions(-)
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
@@ -0,0 +1,125 @@
+package org.radroots.studio.accounts.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+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.semantics
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+
+private val WindowBackgroundColor = Color(0xFFF5F5F2)
+private val ButtonBackgroundColor = Color(0xFFE7E7E2)
+private val InputBackgroundColor = Color(0xFFFEFDF8)
+
+data class StudioUiActions(
+ val editImportDraft: (String) -> Unit = {},
+ val generateAccount: () -> Unit = {},
+ val importSecretKey: () -> Unit = {},
+)
+
+@Composable
+fun StudioScreen(
+ model: StudioUiModel,
+ actions: StudioUiActions,
+) {
+ InactiveAccountsScreen(model, actions)
+}
+
+@Composable
+private fun InactiveAccountsScreen(
+ model: StudioUiModel,
+ actions: StudioUiActions,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(WindowBackgroundColor)
+ .padding(24.dp)
+ .testTag("accounts-screen"),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ BasicText("radroots")
+ BasicText("Accounts")
+
+ TextAction(
+ text = "Generate new key",
+ testTag = "generate-key",
+ contentDescription = "Generate a new Nostr key",
+ enabled = !model.busy,
+ onClick = actions.generateAccount,
+ )
+
+ BasicTextField(
+ value = model.importDraft,
+ onValueChange = actions.editImportDraft,
+ enabled = !model.busy,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics {
+ contentDescription = "Nostr secret key"
+ password()
+ }
+ .testTag("import-nsec-input")
+ .background(InputBackgroundColor)
+ .padding(8.dp),
+ decorationBox = { innerTextField ->
+ if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex")
+ innerTextField()
+ },
+ )
+ TextAction(
+ text = "Add existing key",
+ testTag = "import-key",
+ contentDescription = "Import an existing Nostr secret key",
+ enabled = !model.busy && model.importDraft.isNotBlank(),
+ onClick = actions.importSecretKey,
+ )
+
+ model.problem?.let {
+ BasicText(it, Modifier.testTag("accounts-problem"))
+ }
+
+ if (model.accounts.isEmpty()) {
+ BasicText("No saved accounts.", Modifier.testTag("accounts-empty"))
+ }
+ }
+}
+
+@Composable
+internal fun TextAction(
+ text: String,
+ testTag: String,
+ contentDescription: String,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+) {
+ BasicText(
+ text = text,
+ modifier = Modifier
+ .semantics {
+ role = Role.Button
+ this.contentDescription = contentDescription
+ if (!enabled) disabled()
+ }
+ .testTag(testTag)
+ .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
+ .background(ButtonBackgroundColor)
+ .padding(8.dp),
+ )
+}
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
@@ -1,11 +1,13 @@
package org.radroots.studio.application
-import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope
+import org.radroots.studio.accounts.ui.StudioScreen
+import org.radroots.studio.accounts.ui.StudioUiActions
+import org.radroots.studio.accounts.ui.toUiModel
import org.radroots.studio.ffi.StudioAppCore
internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore
@@ -21,7 +23,14 @@ fun RadrootsApplication(
onDispose(store::close)
}
- BasicText("radroots")
+ StudioScreen(
+ model = store.state.value.toUiModel(),
+ actions = StudioUiActions(
+ editImportDraft = store::editImportDraft,
+ generateAccount = store::generateAccount,
+ importSecretKey = store::importSecretKey,
+ ),
+ )
}
internal fun createStudioAppStore(scope: CoroutineScope): StudioAppStore {
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
@@ -0,0 +1,77 @@
+package org.radroots.studio.accounts.ui
+
+import androidx.compose.runtime.getValue
+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.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.radroots.studio.ffi.SessionStateDto
+
+@OptIn(ExperimentalTestApi::class)
+class StudioScreenTest {
+ @Test
+ fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = runComposeUiTest {
+ var importDraft by mutableStateOf("")
+ var generateCalls = 0
+ var importCalls = 0
+ setContent {
+ StudioScreen(
+ model = emptyUiModel(importDraft = importDraft),
+ actions = StudioUiActions(
+ editImportDraft = { importDraft = it },
+ generateAccount = { generateCalls += 1 },
+ importSecretKey = { importCalls += 1 },
+ ),
+ )
+ }
+
+ onNodeWithTag("accounts-screen").assertIsDisplayed()
+ onNodeWithText("radroots").assertIsDisplayed()
+ onNodeWithTag("generate-key").performClick()
+ onNodeWithTag("import-nsec-input").performTextInput("nsec1secret")
+ onNodeWithTag("import-key").performClick()
+
+ assertEquals(1, generateCalls)
+ assertEquals(1, importCalls)
+ assertEquals("nsec1secret", importDraft)
+ assertTrue(onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any {
+ it.key.name == "Password" && it.value == Unit
+ })
+ }
+
+ @Test
+ fun inactiveScreenShowsSafeFailureAndNoGenericFields() = runComposeUiTest {
+ setContent {
+ StudioScreen(
+ model = emptyUiModel(problem = "The secret key is invalid."),
+ actions = StudioUiActions(),
+ )
+ }
+
+ onNodeWithText("The secret key is invalid.").assertIsDisplayed()
+ onNodeWithTag("accounts-empty").assertIsDisplayed()
+ }
+}
+
+private fun emptyUiModel(
+ importDraft: String = "",
+ problem: String? = null,
+) = StudioUiModel(
+ accounts = emptyList(),
+ activeAccount = null,
+ configuredRelays = emptyList(),
+ importDraft = importDraft,
+ generatedKeyBackup = null,
+ pendingRemovalPublicKeyHex = null,
+ session = SessionStateDto.SIGNED_OUT,
+ busy = false,
+ problem = problem,
+)
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -534,7 +534,7 @@ handoff commit sequence.
- [x] 48. Add Kotlin StudioAppStore thin adapter.
- [x] 49. Bootstrap AppCore once at application root.
- [x] 50. Create UI model mapping helpers without server fields.
-- [ ] 51. Implement inactive account screen generate/import controls.
+- [x] 51. Implement inactive account screen generate/import controls.
- [ ] 52. Implement generated-key backup panel.
- [ ] 53. Implement saved-account list, activation, and removal controls.
- [ ] 54. Implement active account home screen.