commit f2af587e7281a6f70f496f4bb3fdeee6bd1ebe65
parent 40b496c38043747244173568a247c61c41f6a802
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 19:05:28 +0000
ui: let read-only sessions manage identities
- expose identity entry from the global signer-status overlay
- leave read-only mode and close its overlay in one reducer transition
- preserve accessible focus containment across both status actions
- prove read-only through native generation activation and removal
Diffstat:
8 files changed, 111 insertions(+), 15 deletions(-)
diff --git a/app/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt b/app/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt
@@ -70,6 +70,12 @@ class IdentityLifecycleHostUiTest {
}
waitForTag("bootstrap-welcome")
+ onNodeWithTag("bootstrap-read-only").performClick()
+ waitForTag("foundation-today")
+ onNodeWithTag("top-bar-signer").performClick()
+ waitForTag("signer-add-or-activate-identity")
+ onNodeWithTag("signer-add-or-activate-identity").performClick()
+ waitForTag("bootstrap-welcome")
onNodeWithTag("bootstrap-create").performClick()
onNodeWithTag("generate-key").performClick()
waitForTag("generated-key-backup")
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt
@@ -39,6 +39,8 @@ sealed interface HarvestCircleShellIntent {
data object EnterReadOnly : HarvestCircleShellIntent
+ data object ManageIdentity : HarvestCircleShellIntent
+
data class Navigate(
val screenKey: ScreenKey,
) : HarvestCircleShellIntent
@@ -81,6 +83,7 @@ class HarvestCircleShellPresenter(
when (intent) {
is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent)
HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly)
+ HarvestCircleShellIntent.ManageIdentity -> reduce(ShellEvent.ManageIdentity)
is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.screenKey))
is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent))
is HarvestCircleShellIntent.Overlay -> dispatchOverlay(intent.intent)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt
@@ -14,6 +14,8 @@ data class ShellSessionState(
val readOnly: Boolean = false,
) {
fun enterReadOnly(): ShellSessionState = copy(readOnly = true)
+
+ fun leaveReadOnly(): ShellSessionState = copy(readOnly = false)
}
enum class DegradationReason {
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt
@@ -14,6 +14,8 @@ sealed interface ShellEvent {
data object EnterReadOnly : ShellEvent
+ data object ManageIdentity : ShellEvent
+
data class Navigate(
val screenKey: ScreenKey,
) : ShellEvent
@@ -47,6 +49,7 @@ object ShellReducer {
when (event) {
is ShellEvent.IdentityObserved -> observeIdentity(state, event.identity)
ShellEvent.EnterReadOnly -> updateSession(state, state.session.enterReadOnly())
+ ShellEvent.ManageIdentity -> manageIdentity(state)
is ShellEvent.Navigate -> updateNavigation(state) { activateShellScreen(it, event.screenKey) }
is ShellEvent.Navigation -> updateNavigation(state, event.intent)
is ShellEvent.Overlay -> OverlayReducer.transition(state, event.intent).state
@@ -114,6 +117,11 @@ object ShellReducer {
)
}
+ private fun manageIdentity(state: HarvestCircleShellState): HarvestCircleShellState {
+ if (!state.session.readOnly) return state
+ return updateSession(state, state.session.leaveReadOnly()).copy(overlays = OverlayState())
+ }
+
private fun updateSession(
state: HarvestCircleShellState,
session: ShellSessionState,
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt
@@ -29,6 +29,7 @@ import org.harvestcircle.application.FoundationOverlay
import org.harvestcircle.application.OverlayIntent
import org.harvestcircle.application.OverlayState
import org.harvestcircle.application.ShellStatusModel
+import org.harvestcircle.application.SignerStatusLabel
import org.harvestcircle.application.StatusOverlayKey
import org.harvestcircle.designsystem.shell.HarvestCircleShellBanner
import org.harvestcircle.designsystem.shell.HarvestCircleShellBannerTone
@@ -43,6 +44,7 @@ fun FoundationOverlayHost(
state: OverlayState,
status: ShellStatusModel,
showBanner: Boolean = true,
+ onAddOrActivateIdentity: () -> Unit = {},
onIntent: (OverlayIntent) -> Unit,
) {
if (showBanner) {
@@ -74,8 +76,19 @@ fun FoundationOverlayHost(
is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, rootRequester, onIntent)
is FoundationOverlay.Status ->
when (overlay.key) {
- StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent)
- StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent)
+ StatusOverlayKey.Signer ->
+ StatusOverlay(
+ title = "Signer status",
+ status = status.signer.text,
+ primaryAction =
+ if (status.signer == SignerStatusLabel.ReadOnly) {
+ StatusOverlayAction("Add or activate identity", onAddOrActivateIdentity)
+ } else {
+ null
+ },
+ onIntent = onIntent,
+ )
+ StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent = onIntent)
}
is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent)
}
@@ -151,27 +164,54 @@ private fun ConfirmOverlay(
private fun StatusOverlay(
title: String,
status: String,
+ primaryAction: StatusOverlayAction? = null,
onIntent: (OverlayIntent) -> Unit,
) {
- val requester = remember { FocusRequester() }
+ val primaryRequester = remember { FocusRequester() }
+ val closeRequester = remember { FocusRequester() }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
HarvestCircleShellText(title, Modifier.semantics { heading() }, HarvestCircleShellTextRole.SectionTitle)
HarvestCircleShellText(status, Modifier.testTag("overlay-status"))
- HarvestCircleShellButton(
- "Close",
- { onIntent(OverlayIntent.Close) },
- Modifier
- .focusRequester(requester)
- .focusProperties {
- next = requester
- previous = requester
- }.modalFocusCycle(requester, requester)
- .testTag("overlay-close"),
- )
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ primaryAction?.let { action ->
+ HarvestCircleShellButton(
+ action.label,
+ action.onClick,
+ Modifier
+ .focusRequester(primaryRequester)
+ .focusProperties {
+ next = closeRequester
+ previous = closeRequester
+ }.modalFocusCycle(closeRequester, closeRequester)
+ .testTag("signer-add-or-activate-identity"),
+ primary = true,
+ )
+ }
+ HarvestCircleShellButton(
+ "Close",
+ { onIntent(OverlayIntent.Close) },
+ Modifier
+ .focusRequester(closeRequester)
+ .focusProperties {
+ next = if (primaryAction == null) closeRequester else primaryRequester
+ previous = if (primaryAction == null) closeRequester else primaryRequester
+ }.modalFocusCycle(
+ if (primaryAction == null) closeRequester else primaryRequester,
+ if (primaryAction == null) closeRequester else primaryRequester,
+ ).testTag("overlay-close"),
+ )
+ }
+ }
+ LaunchedEffect(primaryAction != null) {
+ if (primaryAction == null) closeRequester.requestFocus() else primaryRequester.requestFocus()
}
- LaunchedEffect(Unit) { requester.requestFocus() }
}
+private data class StatusOverlayAction(
+ val label: String,
+ val onClick: () -> Unit,
+)
+
@Composable
private fun ReferenceOverlay(
overlay: FoundationOverlay.OpenNostrReference,
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
@@ -125,6 +125,7 @@ private fun HarvestCircleShellContent(
state = state.overlays,
status = deriveShellStatus(state),
showBanner = state.root !is ShellRoot.Dashboard,
+ onAddOrActivateIdentity = { dispatch(HarvestCircleShellIntent.ManageIdentity) },
) {
dispatch(HarvestCircleShellIntent.Overlay(it))
}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt
@@ -51,6 +51,22 @@ class ShellReducerTest {
}
@Test
+ fun manageIdentityLeavesReadOnlyAndReturnsToSavedIdentityChooserAtomically() {
+ val entered =
+ ShellReducer.reduce(
+ HarvestCircleShellState(signedOutPresenterState(1UL), BuildInfo.unknown()),
+ ShellEvent.EnterReadOnly,
+ )
+ val readOnly = entered.copy(overlays = OverlayState(FoundationOverlay.Status(StatusOverlayKey.Signer)))
+
+ val managed = ShellReducer.reduce(readOnly, ShellEvent.ManageIdentity)
+
+ assertEquals(false, managed.session.readOnly)
+ assertEquals(ShellRoot.BootstrapCanvas(org.harvestcircle.navigation.BootstrapStep.IdentityChooser), managed.root)
+ assertEquals(OverlayState(), managed.overlays)
+ }
+
+ @Test
fun presenterAtomicallyRetainsConcurrentIndependentEvents() =
runTest {
val identity = ReducerIdentityPresentation(activePresenterState(1UL))
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt
@@ -186,6 +186,26 @@ class FoundationOverlayHostTest {
}
@Test
+ fun readOnlySignerStatusOffersTheIdentityEntryActionAndContainsFocus() =
+ runComposeUiTest {
+ var actionCount = 0
+ val state = OverlayState(FoundationOverlay.Status(org.harvestcircle.application.StatusOverlayKey.Signer))
+ setHarvestCircleContent {
+ FoundationOverlayHost(
+ state = state,
+ status = ShellStatusModel(SyncStatusLabel.NotYetObserved, SignerStatusLabel.ReadOnly, null),
+ onAddOrActivateIdentity = { actionCount += 1 },
+ onIntent = {},
+ )
+ }
+
+ onNodeWithTag("signer-add-or-activate-identity").assertIsFocused().pressTab()
+ onNodeWithTag("overlay-close").assertIsFocused().pressTab()
+ onNodeWithTag("signer-add-or-activate-identity").assertIsFocused().performClick()
+ kotlin.test.assertEquals(1, actionCount)
+ }
+
+ @Test
fun openStatusDialogRendersTheLatestStatusModel() =
runComposeUiTest {
var status by mutableStateOf(status())