commit e3efd8a0d875382b5f8b771530dfe1ec0d60cfa7
parent 799cab96994574b09b0cdd10d3444088f51e71eb
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 21:12:41 +0000
application: reduce shell state atomically
- Route observed identity and UI events through one pure shell reducer.
- Apply state transitions atomically with MutableStateFlow update.
- Reject stale snapshot revisions and retain independent dashboard state.
- Prove deterministic interleavings and idempotent observation closure.
Diffstat:
4 files changed, 274 insertions(+), 54 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
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.harvestcircle.design.AppearanceState
import org.harvestcircle.design.MotionPreference
@@ -13,8 +14,6 @@ import org.harvestcircle.design.TextSizePreference
import org.harvestcircle.design.ThemePreference
import org.harvestcircle.navigation.AppRoute
import org.harvestcircle.navigation.NavigationIntent
-import org.harvestcircle.navigation.NavigationReducer
-import org.harvestcircle.navigation.NavigationState
interface IdentityPresentationPort {
val state: StateFlow<HarvestCirclePresenterState>
@@ -79,13 +78,13 @@ class HarvestCircleShellPresenter(
fun dispatch(intent: HarvestCircleShellIntent) {
when (intent) {
is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent)
- HarvestCircleShellIntent.EnterReadOnly -> updateSession(mutableState.value.session.enterReadOnly())
- is HarvestCircleShellIntent.Navigate -> updateNavigation { activateShellDestination(it, intent.destination) }
- is HarvestCircleShellIntent.Navigation -> updateNavigationIntent(intent.intent)
- is HarvestCircleShellIntent.Overlay -> mutate { copy(overlays = OverlayReducer.reduce(overlays, intent.intent)) }
- is HarvestCircleShellIntent.SetTheme -> mutate { copy(appearance = appearance.copy(theme = intent.theme)) }
- is HarvestCircleShellIntent.SetTextSize -> mutate { copy(appearance = appearance.copy(textSize = intent.textSize)) }
- is HarvestCircleShellIntent.SetMotion -> mutate { copy(appearance = appearance.copy(motion = intent.motion)) }
+ HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly)
+ is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.destination))
+ is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent))
+ is HarvestCircleShellIntent.Overlay -> reduce(ShellEvent.Overlay(intent.intent))
+ is HarvestCircleShellIntent.SetTheme -> reduce(ShellEvent.SetTheme(intent.theme))
+ is HarvestCircleShellIntent.SetTextSize -> reduce(ShellEvent.SetTextSize(intent.textSize))
+ is HarvestCircleShellIntent.SetMotion -> reduce(ShellEvent.SetMotion(intent.motion))
}
}
@@ -94,53 +93,11 @@ class HarvestCircleShellPresenter(
}
private fun updateIdentity(identity: HarvestCirclePresenterState) {
- mutate {
- val derived = deriveShellRoot(identity, session)
- val retained =
- when {
- derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard ->
- derived.copy(navigation = root.navigation)
- else -> derived
- }
- copy(
- identity = identity,
- localUsability = deriveLocalUsability(identity.snapshot),
- root = retained,
- )
- }
- }
-
- private fun updateSession(session: ShellSessionState) {
- mutate { copy(session = session, root = deriveShellRoot(identity, session)) }
- }
-
- private fun updateNavigation(block: (NavigationState) -> NavigationState) {
- mutate {
- val dashboard = root as? ShellRoot.Dashboard ?: return@mutate this
- copy(root = dashboard.copy(navigation = block(dashboard.navigation)))
- }
- }
-
- private fun updateNavigationIntent(intent: NavigationIntent) {
- mutate {
- when (val currentRoot = root) {
- is ShellRoot.Dashboard ->
- copy(
- root = currentRoot.copy(navigation = NavigationReducer.reduce(currentRoot.navigation, intent)),
- )
- is ShellRoot.BootstrapCanvas ->
- if (intent is NavigationIntent.SelectBootstrapStep) {
- copy(root = currentRoot.copy(step = intent.step))
- } else {
- this
- }
- is ShellRoot.LifecycleCanvas -> this
- }
- }
+ reduce(ShellEvent.IdentityObserved(identity))
}
- private fun mutate(block: HarvestCircleShellState.() -> HarvestCircleShellState) {
- mutableState.value = mutableState.value.block()
+ private fun reduce(event: ShellEvent) {
+ mutableState.update { current -> ShellReducer.reduce(current, event) }
}
}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt
@@ -47,6 +47,10 @@ sealed interface OverlayIntent {
data object SubmitReference : OverlayIntent
+ data class ApplyReferenceResult(
+ val result: ReferenceResult,
+ ) : OverlayIntent
+
data object Confirm : OverlayIntent
data object Close : OverlayIntent
@@ -64,6 +68,7 @@ object OverlayReducer {
is OverlayIntent.EditReference ->
state.copy(current = (state.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value))
OverlayIntent.SubmitReference -> submitReference(state)
+ is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result)
OverlayIntent.Confirm, OverlayIntent.Close, OverlayIntent.Escape -> state.copy(current = null)
}
@@ -71,6 +76,14 @@ object OverlayReducer {
val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state
return state.copy(current = overlay.copy(result = validateNostrReference(overlay.input)))
}
+
+ private fun applyReferenceResult(
+ state: OverlayState,
+ result: ReferenceResult,
+ ): OverlayState {
+ val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state
+ return state.copy(current = overlay.copy(result = result))
+ }
}
enum class ReferenceResult(
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt
@@ -0,0 +1,114 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.design.MotionPreference
+import org.harvestcircle.design.TextSizePreference
+import org.harvestcircle.design.ThemePreference
+import org.harvestcircle.navigation.NavigationIntent
+import org.harvestcircle.navigation.NavigationReducer
+
+sealed interface ShellEvent {
+ data class IdentityObserved(
+ val identity: HarvestCirclePresenterState,
+ ) : ShellEvent
+
+ data object EnterReadOnly : ShellEvent
+
+ data class Navigate(
+ val destination: ShellDestination,
+ ) : ShellEvent
+
+ data class Navigation(
+ val intent: NavigationIntent,
+ ) : ShellEvent
+
+ data class Overlay(
+ val intent: OverlayIntent,
+ ) : ShellEvent
+
+ data class SetTheme(
+ val theme: ThemePreference,
+ ) : ShellEvent
+
+ data class SetTextSize(
+ val textSize: TextSizePreference,
+ ) : ShellEvent
+
+ data class SetMotion(
+ val motion: MotionPreference,
+ ) : ShellEvent
+}
+
+object ShellReducer {
+ fun reduce(
+ state: HarvestCircleShellState,
+ event: ShellEvent,
+ ): HarvestCircleShellState =
+ when (event) {
+ is ShellEvent.IdentityObserved -> observeIdentity(state, event.identity)
+ ShellEvent.EnterReadOnly -> updateSession(state, state.session.enterReadOnly())
+ is ShellEvent.Navigate -> updateNavigation(state) { activateShellDestination(it, event.destination) }
+ is ShellEvent.Navigation -> updateNavigation(state, event.intent)
+ is ShellEvent.Overlay -> state.copy(overlays = OverlayReducer.reduce(state.overlays, event.intent))
+ is ShellEvent.SetTheme -> state.copy(appearance = state.appearance.copy(theme = event.theme))
+ is ShellEvent.SetTextSize -> state.copy(appearance = state.appearance.copy(textSize = event.textSize))
+ is ShellEvent.SetMotion -> state.copy(appearance = state.appearance.copy(motion = event.motion))
+ }
+
+ private fun observeIdentity(
+ state: HarvestCircleShellState,
+ identity: HarvestCirclePresenterState,
+ ): HarvestCircleShellState {
+ if (identity.snapshot.revision.value < state.identity.snapshot.revision.value) return state
+ val derived = deriveShellRoot(identity, state.session)
+ return state.copy(
+ identity = identity,
+ localUsability = deriveLocalUsability(identity.snapshot),
+ root = retainDashboardNavigation(state.root, derived),
+ )
+ }
+
+ private fun updateSession(
+ state: HarvestCircleShellState,
+ session: ShellSessionState,
+ ): HarvestCircleShellState {
+ val derived = deriveShellRoot(state.identity, session)
+ return state.copy(
+ session = session,
+ root = retainDashboardNavigation(state.root, derived),
+ )
+ }
+
+ private fun updateNavigation(
+ state: HarvestCircleShellState,
+ block: (org.harvestcircle.navigation.NavigationState) -> org.harvestcircle.navigation.NavigationState,
+ ): HarvestCircleShellState {
+ val dashboard = state.root as? ShellRoot.Dashboard ?: return state
+ return state.copy(root = dashboard.copy(navigation = block(dashboard.navigation)))
+ }
+
+ private fun updateNavigation(
+ state: HarvestCircleShellState,
+ intent: NavigationIntent,
+ ): HarvestCircleShellState =
+ when (val root = state.root) {
+ is ShellRoot.Dashboard ->
+ state.copy(root = root.copy(navigation = NavigationReducer.reduce(root.navigation, intent)))
+ is ShellRoot.BootstrapCanvas ->
+ if (intent is NavigationIntent.SelectBootstrapStep) {
+ state.copy(root = root.copy(step = intent.step))
+ } else {
+ state
+ }
+ is ShellRoot.LifecycleCanvas -> state
+ }
+
+ private fun retainDashboardNavigation(
+ current: ShellRoot,
+ derived: ShellRoot,
+ ): ShellRoot =
+ if (current is ShellRoot.Dashboard && derived is ShellRoot.Dashboard) {
+ derived.copy(navigation = current.navigation)
+ } else {
+ derived
+ }
+}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt
@@ -0,0 +1,136 @@
+package org.harvestcircle.application
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.harvestcircle.design.MotionPreference
+import org.harvestcircle.design.TextSizePreference
+import org.harvestcircle.design.ThemePreference
+import org.harvestcircle.navigation.AppRoute
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ShellReducerTest {
+ @Test
+ fun reducerRetainsIndependentStateAcrossDeterministicInterleavings() {
+ val initial = HarvestCircleShellState(activePresenterState(1UL), BuildInfo.unknown())
+ val events =
+ listOf(
+ ShellEvent.IdentityObserved(activePresenterState(3UL)),
+ ShellEvent.Navigate(ShellDestination.Network),
+ ShellEvent.SetTheme(ThemePreference.Dark),
+ ShellEvent.SetTextSize(TextSizePreference.VeryLarge),
+ ShellEvent.SetMotion(MotionPreference.Reduced),
+ ShellEvent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())),
+ )
+
+ val forward = events.fold(initial, ShellReducer::reduce)
+ val reverse = events.reversed().fold(initial, ShellReducer::reduce)
+
+ listOf(forward, reverse).forEach { state ->
+ assertEquals(3UL, state.identity.snapshot.revision.value)
+ assertEquals(AppRoute.Network, state.currentRoute)
+ assertEquals(ThemePreference.Dark, state.appearance.theme)
+ assertEquals(TextSizePreference.VeryLarge, state.appearance.textSize)
+ assertEquals(MotionPreference.Reduced, state.appearance.motion)
+ assertTrue(state.overlays.current is FoundationOverlay.OpenNostrReference)
+ }
+ }
+
+ @Test
+ fun reducerRejectsStaleIdentityObservations() {
+ val current = HarvestCircleShellState(activePresenterState(3UL), BuildInfo.unknown())
+ val reduced = ShellReducer.reduce(current, ShellEvent.IdentityObserved(activePresenterState(2UL)))
+ assertTrue(reduced === current)
+ }
+
+ @Test
+ fun presenterAtomicallyRetainsConcurrentIndependentEvents() =
+ runTest {
+ val identity = ReducerIdentityPresentation(activePresenterState(1UL))
+ val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this)
+ runCurrent()
+
+ listOf(
+ HarvestCircleShellIntent.Navigate(ShellDestination.Network),
+ HarvestCircleShellIntent.SetTheme(ThemePreference.Dark),
+ HarvestCircleShellIntent.SetTextSize(TextSizePreference.VeryLarge),
+ HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced),
+ HarvestCircleShellIntent.EnterReadOnly,
+ HarvestCircleShellIntent.Overlay(
+ OverlayIntent.Open(FoundationOverlay.OpenNostrReference()),
+ ),
+ ).map { intent -> async { presenter.dispatch(intent) } }
+ .awaitAll()
+
+ val state = presenter.state.value
+ assertEquals(AppRoute.Network, state.currentRoute)
+ assertEquals(ThemePreference.Dark, state.appearance.theme)
+ assertEquals(TextSizePreference.VeryLarge, state.appearance.textSize)
+ assertEquals(MotionPreference.Reduced, state.appearance.motion)
+ assertTrue(state.session.readOnly)
+ assertTrue(state.overlays.current is FoundationOverlay.OpenNostrReference)
+ presenter.close()
+ }
+
+ @Test
+ fun observationCloseIsIdempotentAndTerminal() =
+ runTest {
+ val identity = ReducerIdentityPresentation(activePresenterState(1UL))
+ val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this)
+ runCurrent()
+
+ presenter.close()
+ presenter.close()
+ identity.state.value = activePresenterState(2UL)
+ runCurrent()
+
+ assertEquals(1UL, presenter.state.value.identity.snapshot.revision.value)
+ }
+}
+
+private class ReducerIdentityPresentation(
+ initial: HarvestCirclePresenterState,
+) : IdentityPresentationPort {
+ override val state = MutableStateFlow(initial)
+
+ override fun dispatch(intent: HarvestCircleIntent) = Unit
+}
+
+private fun activePresenterState(revision: ULong): HarvestCirclePresenterState {
+ val identity =
+ IdentitySummary(
+ id = IdentityId.fromPublicKeyHex("03".repeat(32)),
+ npub = "npub1reducer",
+ displayLabel = "Reducer identity",
+ signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available),
+ createdAt = UnixSeconds(1),
+ lastUsedAt = null,
+ )
+ return HarvestCirclePresenterState(
+ ApplicationSnapshot(
+ revision = SnapshotRevision(revision),
+ lifecycle = ApplicationLifecycle.Ready,
+ lifecycleProblem = null,
+ configuredRelays = emptyList(),
+ identities = listOf(identity),
+ selectedIdentityId = identity.id,
+ session = SessionLifecycle.Active,
+ sessionSubjectIdentityId = identity.id,
+ sessionProblem = null,
+ activeIdentity =
+ ActiveIdentity(
+ identity = identity,
+ relays = RelaySummary(emptyList(), RelayConnectionState.Disconnected),
+ profileState = ProfileLoadState.Empty,
+ profile = null,
+ ),
+ recoverableProblem = null,
+ ),
+ )
+}