commit a4fab8f9067c2e46a77386ac0cd82014404f5543
parent d8dc06940444547b8db846a006f6e65ebdabdcfb
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:51:03 +0000
ui: integrate recovery and identity selection
- Route presenter-owned bootstrap modes and one-use recovery into the shell.
- Render recovery copy, acknowledgement, cancellation, and secret removal.
- Expose explicit identity selection, activation, and confirmed local removal.
- Verify shared UI, desktop, Rust workspace, and formatting suites.
Diffstat:
6 files changed, 319 insertions(+), 9 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt
@@ -99,8 +99,6 @@ class HarvestCircleShellPresenter(
when {
derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard ->
derived.copy(navigation = root.navigation)
- derived is ShellRoot.BootstrapCanvas && root is ShellRoot.BootstrapCanvas ->
- derived.copy(step = root.step)
else -> derived
}
copy(identity = identity, root = retained)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt
@@ -60,23 +60,32 @@ sealed interface ShellRoot {
fun deriveShellRoot(
presenterState: HarvestCirclePresenterState,
session: ShellSessionState,
-): ShellRoot =
- when (presenterState.route) {
+): ShellRoot {
+ if (presenterState.generatedKeyBackup != null) {
+ return ShellRoot.BootstrapCanvas(BootstrapStep.GeneratedRecovery)
+ }
+ return when (presenterState.route) {
HarvestCircleRoute.ACTIVE_IDENTITY -> ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
HarvestCircleRoute.IDENTITIES ->
if (session.readOnly) {
ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
} else {
val step =
- if (presenterState.snapshot.identities.isEmpty()) {
- BootstrapStep.Welcome
- } else {
- BootstrapStep.IdentityChooser
+ when (presenterState.identityEntryMode) {
+ IdentityEntryMode.CREATE -> BootstrapStep.CreateIdentity
+ IdentityEntryMode.IMPORT -> BootstrapStep.ImportIdentity
+ IdentityEntryMode.CHOICE ->
+ if (presenterState.snapshot.identities.isEmpty()) {
+ BootstrapStep.Welcome
+ } else {
+ BootstrapStep.IdentityChooser
+ }
}
ShellRoot.BootstrapCanvas(step)
}
else -> ShellRoot.LifecycleCanvas(presenterState.route)
}
+}
fun activateShellDestination(
state: NavigationState,
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt
@@ -0,0 +1,152 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.design.TextSizePreference
+import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
+import org.harvestcircle.identities.ui.HarvestCircleUiActions
+import org.harvestcircle.identities.ui.HarvestCircleUiModel
+import org.harvestcircle.identities.ui.IdentityUiModel
+
+@Composable
+fun GeneratedRecoveryCanvas(
+ model: HarvestCircleUiModel,
+ actions: HarvestCircleUiActions,
+ platformActions: HarvestCirclePlatformActions,
+) {
+ val backup = requireNotNull(model.generatedKeyBackup)
+ CanvasScaffold(
+ textSize = TextSizePreference.Default,
+ header = { BasicText("Save your recovery key") },
+ body = {
+ Column(Modifier.testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ BasicText("This key is shown once.")
+ BasicText("Store it somewhere private before continuing.")
+ BasicText("Recovery key")
+ BasicText(backup.nsec, Modifier.testTag("generated-nsec"))
+ }
+ },
+ actionBar = {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ShellAction("Copy recovery key", "Copy recovery key", "copy-generated-key") {
+ platformActions.copySecret(backup.nsec)
+ }
+ ShellAction("I have saved the recovery key", "I have saved the recovery key", "acknowledge-key-backup") {
+ actions.acknowledgeGeneratedKeyBackup()
+ }
+ ShellAction("Cancel identity creation", "Cancel identity creation", "cancel-generated-key") {
+ actions.cancelGeneratedKeyBackup()
+ }
+ }
+ },
+ )
+}
+
+@Composable
+fun IdentityChooserCanvas(
+ model: HarvestCircleUiModel,
+ actions: HarvestCircleUiActions,
+ onReadOnly: () -> Unit,
+) {
+ CanvasScaffold(
+ textSize = TextSizePreference.Default,
+ header = { BasicText("Choose a Nostr identity") },
+ body = {
+ LazyColumn(Modifier.fillMaxWidth().testTag("saved-identity-list")) {
+ items(model.identities, key = IdentityUiModel::publicKeyHex) { identity ->
+ IdentityRow(identity, model, actions)
+ }
+ }
+ },
+ actionBar = {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ShellAction("Create another identity", "Create another identity", "choose-create-identity") {
+ actions.chooseCreateIdentity()
+ }
+ ShellAction("Import identity", "Import identity", "choose-import-identity") {
+ actions.chooseImportIdentity()
+ }
+ ShellAction("Explore read-only", "Explore read-only", "chooser-read-only", onClick = onReadOnly)
+ }
+ },
+ )
+}
+
+@Composable
+private fun IdentityRow(
+ identity: IdentityUiModel,
+ model: HarvestCircleUiModel,
+ actions: HarvestCircleUiActions,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .semantics { selected = identity.selected }
+ .testTag("identity-row:${identity.publicKeyHex}"),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ BasicText(identity.label)
+ BasicText(identity.shortNpub)
+ BasicText(
+ if (identity.signerAvailability == "available") {
+ "Local credential available"
+ } else {
+ "Local credential unavailable"
+ },
+ )
+ if (identity.selected) BasicText("Selected")
+ ShellAction(
+ label = if (identity.selected) "Selected identity" else "Select identity",
+ description = "Select ${identity.label}",
+ tag = "select-identity:${identity.publicKeyHex}",
+ enabled = !model.busy && !identity.selected,
+ ) {
+ actions.selectIdentity(identity.publicKeyHex)
+ }
+ ShellAction(
+ label = if (identity.active) "Active identity" else "Activate identity",
+ description = "Activate ${identity.label}",
+ tag = "activate-identity:${identity.publicKeyHex}",
+ enabled = !model.busy && !identity.active,
+ ) {
+ actions.activateIdentity(identity.publicKeyHex)
+ }
+ ShellAction(
+ "Remove local identity",
+ "Remove ${identity.label}",
+ "remove-identity:${identity.publicKeyHex}",
+ enabled = !model.busy,
+ ) {
+ actions.requestIdentityRemoval(identity.publicKeyHex)
+ }
+ if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) {
+ BasicText("Remove this saved identity?")
+ model.removalImpact?.takeIf { it.deletesLocalCredential }?.let {
+ BasicText("Its local credential will be deleted from the operating-system keyring.")
+ }
+ model.removalImpact?.takeIf { it.signsOut }?.let {
+ BasicText("The active session will be signed out before removal.")
+ }
+ ShellAction("Keep identity", "Keep identity", "remove-cancel") { actions.cancelIdentityRemoval() }
+ ShellAction(
+ "Remove local identity",
+ "Remove local identity",
+ "remove-confirm",
+ enabled = !model.busy,
+ ) {
+ actions.confirmIdentityRemoval()
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt
@@ -53,7 +53,16 @@ fun HarvestCircleShell(
)
},
)
- else -> HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions)
+ BootstrapStep.GeneratedRecovery ->
+ GeneratedRecoveryCanvas(state.identity.toUiModel(), identityActions, platformActions)
+ BootstrapStep.IdentityChooser,
+ BootstrapStep.ActivationProgress,
+ ->
+ IdentityChooserCanvas(
+ model = state.identity.toUiModel(),
+ actions = identityActions,
+ onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) },
+ )
}
is ShellRoot.Dashboard -> DashboardRoot(state, root, dispatch)
}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt
@@ -30,6 +30,26 @@ class ShellAvailabilityTest {
}
@Test
+ fun bootstrapEntryAndRecoveryFollowPresenterTruth() {
+ val signedOut = presenterState(HarvestCircleRoute.IDENTITIES)
+ assertEquals(
+ ShellRoot.BootstrapCanvas(BootstrapStep.CreateIdentity),
+ deriveShellRoot(signedOut.copy(identityEntryMode = IdentityEntryMode.CREATE), ShellSessionState()),
+ )
+ assertEquals(
+ ShellRoot.BootstrapCanvas(BootstrapStep.ImportIdentity),
+ deriveShellRoot(signedOut.copy(identityEntryMode = IdentityEntryMode.IMPORT), ShellSessionState()),
+ )
+ assertEquals(
+ ShellRoot.BootstrapCanvas(BootstrapStep.GeneratedRecovery),
+ deriveShellRoot(
+ signedOut.copy(generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated")),
+ ShellSessionState(),
+ ),
+ )
+ }
+
+ @Test
fun disabledFeaturesCannotDispatch() {
val state = NavigationState(AppRoute.PersonalToday)
assertSame(state, activateShellDestination(state, ShellDestination.Explore))
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt
@@ -0,0 +1,122 @@
+package org.harvestcircle.ui.shell
+
+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.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.harvestcircle.application.HarvestCircleRoute
+import org.harvestcircle.application.IdentityEntryMode
+import org.harvestcircle.application.RecoveryAction
+import org.harvestcircle.application.RemovalStatus
+import org.harvestcircle.application.SessionLifecycle
+import org.harvestcircle.identities.ui.GeneratedKeyBackupUiModel
+import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
+import org.harvestcircle.identities.ui.HarvestCircleUiActions
+import org.harvestcircle.identities.ui.HarvestCircleUiModel
+import org.harvestcircle.identities.ui.IdentityUiModel
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalTestApi::class)
+class BootstrapRecoveryAndChooserTest {
+ @Test
+ fun oneUseRecoveryOwnsCopyAcknowledgementAndCancellation() =
+ runComposeUiTest {
+ var backup: GeneratedKeyBackupUiModel? by
+ mutableStateOf(GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"))
+ var copied = ""
+ var cancelled = 0
+ setContent {
+ backup?.let {
+ GeneratedRecoveryCanvas(
+ model = model(generatedKeyBackup = it),
+ actions =
+ HarvestCircleUiActions(
+ acknowledgeGeneratedKeyBackup = { backup = null },
+ cancelGeneratedKeyBackup = { cancelled += 1 },
+ ),
+ platformActions = HarvestCirclePlatformActions(copySecret = { copied = it }),
+ )
+ }
+ }
+
+ onAllNodesWithTag("generated-nsec").assertCountEquals(1)
+ onNodeWithTag("copy-generated-key").performClick()
+ assertEquals("nsec1generated", copied)
+ onNodeWithTag("cancel-generated-key").performClick()
+ assertEquals(1, cancelled)
+ onNodeWithTag("acknowledge-key-backup").performClick()
+ onAllNodesWithTag("generated-key-backup").assertCountEquals(0)
+ onAllNodesWithTag("generated-nsec").assertCountEquals(0)
+ }
+
+ @Test
+ fun chooserExposesExplicitSelectionActivationAndRemoval() =
+ runComposeUiTest {
+ val first = identity("first", selected = true)
+ val second = identity("second")
+ var selected = ""
+ var activated = ""
+ var removal = ""
+ setContent {
+ IdentityChooserCanvas(
+ model = model(identities = listOf(first, second)),
+ actions =
+ HarvestCircleUiActions(
+ selectIdentity = { selected = it },
+ activateIdentity = { activated = it },
+ requestIdentityRemoval = { removal = it },
+ ),
+ onReadOnly = {},
+ )
+ }
+
+ onNodeWithTag("select-identity:second").performClick()
+ onNodeWithTag("activate-identity:second").performClick()
+ onNodeWithTag("remove-identity:second").performClick()
+ assertEquals("second", selected)
+ assertEquals("second", activated)
+ assertEquals("second", removal)
+ }
+}
+
+private fun identity(
+ id: String,
+ selected: Boolean = false,
+) = IdentityUiModel(
+ publicKeyHex = id,
+ npub = "npub1$id",
+ shortNpub = "npub1$id",
+ label = id.replaceFirstChar(Char::uppercaseChar),
+ signerAvailability = "available",
+ selected = selected,
+ active = false,
+)
+
+private fun model(
+ identities: List<IdentityUiModel> = emptyList(),
+ generatedKeyBackup: GeneratedKeyBackupUiModel? = null,
+) = HarvestCircleUiModel(
+ route = HarvestCircleRoute.IDENTITIES,
+ identities = identities,
+ activeIdentity = null,
+ configuredRelays = emptyList(),
+ importDraft = "",
+ generatedKeyBackup = generatedKeyBackup,
+ pendingRemovalPublicKeyHex = null,
+ removalImpact = null,
+ removalStatus = RemovalStatus.NONE,
+ lastRemovedPublicKeyHex = null,
+ identityChooserVisible = false,
+ identityEntryMode = IdentityEntryMode.CHOICE,
+ session = SessionLifecycle.SignedOut,
+ busy = false,
+ problem = null,
+ importGuidance = null,
+ recoveryAction = RecoveryAction.None,
+)