commit 4ded537be6025767aa9b5a363006e2ec41a9860a parent f42d764f528b99c99906dc8f1dda915e8b83f358 Author: triesap <tyson@radroots.org> Date: Wed, 12 Aug 2026 16:58:39 +0000 identity: bind removal confirmation to its request - Carry immutable identity and request IDs through confirmation actions. - Admit overlay effects only after a successful compare-and-set transition. - Open and close removal dialogs from observed presenter admission state. - Cover stale, duplicate, failed, cancelled, and confirmed request paths. Diffstat:
17 files changed, 525 insertions(+), 156 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -118,8 +118,6 @@ internal fun HarvestCircleApplicationWithDependencies( requestIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(IdentityId.fromPublicKeyHex(it))) }, - cancelIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) }, - confirmIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) }, refreshActiveProfile = { presenter.dispatch(HarvestCircleIntent.RefreshActiveProfile) }, retryLastCommand = { presenter.dispatch(HarvestCircleIntent.RetryLastCommand) }, dismissProblem = { presenter.dispatch(HarvestCircleIntent.DismissProblem) }, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -74,8 +74,8 @@ class HarvestCirclePresenter( HarvestCircleIntent.RefreshActiveProfile -> executeIntent(intent) HarvestCircleIntent.RetryLastCommand -> retryLastCommand() is HarvestCircleIntent.RequestIdentityRemoval -> requestIdentityRemoval(intent) - HarvestCircleIntent.CancelIdentityRemoval -> cancelIdentityRemoval() - HarvestCircleIntent.ConfirmIdentityRemoval -> confirmIdentityRemoval() + is HarvestCircleIntent.CancelIdentityRemoval -> cancelIdentityRemoval(intent) + is HarvestCircleIntent.ConfirmIdentityRemoval -> confirmIdentityRemoval(intent) HarvestCircleIntent.DismissProblem -> updateState { copy(problem = null) } } } @@ -209,49 +209,55 @@ class HarvestCirclePresenter( private fun requestIdentityRemoval(intent: HarvestCircleIntent.RequestIdentityRemoval) { launchOperation { - pendingRemoval?.let { runtime.cancelIdentityRemoval(it.requestId) } + pendingRemoval?.let { previous -> runtime.cancelIdentityRemoval(previous.requestId) } + pendingRemoval = null + updateState { + copy( + removalConfirmation = null, + removalStatus = RemovalStatus.NONE, + ) + } val request = runtime.requestIdentityRemoval(intent.identityId) check(request.expiresAt.value > clock.now().value) { "Identity removal request is already expired" } pendingRemoval = request updateState { copy( - pendingRemovalIdentityId = request.identityId, - removalImpact = - RemovalImpactState( - request.identityId, - request.deletesLocalCredential, - request.signsOut, - request.expiresAt, - ), + removalConfirmation = request.toConfirmation(), removalStatus = RemovalStatus.AWAITING_CONFIRMATION, ) } } } - private fun cancelIdentityRemoval() { - val request = pendingRemoval ?: return rejectUnavailable("Identity removal confirmation is not available.") + private fun cancelIdentityRemoval(intent: HarvestCircleIntent.CancelIdentityRemoval) { + val request = matchingRemoval(intent.identityId, intent.requestId) ?: return launchOperation { runtime.cancelIdentityRemoval(request.requestId) - pendingRemoval = null - updateState { - copy( - pendingRemovalIdentityId = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - ) + if (pendingRemoval == request) { + pendingRemoval = null + updateState { + copy( + removalConfirmation = null, + removalStatus = RemovalStatus.NONE, + ) + } } } } - private fun confirmIdentityRemoval() { - val request = pendingRemoval ?: return rejectUnavailable("Identity removal confirmation is not available.") + private fun confirmIdentityRemoval(intent: HarvestCircleIntent.ConfirmIdentityRemoval) { + val request = matchingRemoval(intent.identityId, intent.requestId) ?: return val operationId = operationIds.next() launchOperation( operationId = operationId, onAccepted = { pendingRemoval = null - updateState { copy(removalStatus = RemovalStatus.CONFIRMING) } + updateState { + copy( + removalConfirmation = null, + removalStatus = RemovalStatus.CONFIRMING, + ) + } }, ) { try { @@ -265,8 +271,7 @@ class HarvestCirclePresenter( acceptResult(result, operationId) updateState { copy( - pendingRemovalIdentityId = null, - removalImpact = null, + removalConfirmation = null, removalStatus = RemovalStatus.COMPLETED, lastRemovedIdentityId = request.identityId, ) @@ -277,8 +282,7 @@ class HarvestCirclePresenter( runtime.cancelIdentityRemoval(request.requestId) updateState { copy( - pendingRemovalIdentityId = null, - removalImpact = null, + removalConfirmation = null, removalStatus = RemovalStatus.FAILED, ) } @@ -287,6 +291,39 @@ class HarvestCirclePresenter( } } + private fun matchingRemoval( + identityId: IdentityId, + requestId: RemovalRequestId, + ): IdentityRemovalRequest? { + val request = pendingRemoval + val confirmation = state.value.removalConfirmation + if (request == null || confirmation == null || state.value.removalStatus != RemovalStatus.AWAITING_CONFIRMATION) { + rejectUnavailable("Identity removal confirmation is not available.") + return null + } + if (request.identityId != identityId || + request.requestId != requestId || + confirmation.identityId != identityId || + confirmation.requestId != requestId + ) { + rejectUnavailable("Identity removal confirmation does not match the current request.") + return null + } + if (request.expiresAt.value <= clock.now().value) { + pendingRemoval = null + updateState { + copy( + removalConfirmation = null, + removalStatus = RemovalStatus.FAILED, + commandStatus = CommandStatus.FAILED_TERMINAL, + problem = "Identity removal confirmation has expired.", + ) + } + return null + } + return request + } + private fun launchOperation( requireReady: Boolean = true, operationId: OperationId? = null, @@ -432,6 +469,15 @@ private data class PendingRetry( val operationId: OperationId, ) +private fun IdentityRemovalRequest.toConfirmation(): IdentityRemovalConfirmation = + IdentityRemovalConfirmation( + identityId = identityId, + requestId = requestId, + deletesLocalCredential = deletesLocalCredential, + signsOut = signsOut, + expiresAt = expiresAt, + ) + private fun HarvestCircleIntent.toApplicationCommand(): ApplicationCommand = when (this) { is HarvestCircleIntent.SelectIdentity -> ApplicationCommand.SelectIdentity(identityId) 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,7 +99,6 @@ class HarvestCircleShellPresenter( } private fun dispatchOverlay(intent: OverlayIntent) { - val confirmation = mutableState.value.overlays.current as? FoundationOverlay.ConfirmAction when (intent) { is OverlayIntent.EditReference -> { if (referenceParser.parse(intent.value).classification == NostrReferenceClassification.PrivateKeyRejected) { @@ -124,28 +123,33 @@ class HarvestCircleShellPresenter( } return } - OverlayIntent.Confirm -> { - if (mutableState.value.identity.busy) return - when (confirmation?.action) { - ConfirmationAction.RemoveLocalIdentity -> - identityPresenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) - null -> Unit - } - } - OverlayIntent.Close, OverlayIntent.Escape -> - if (confirmation?.action == ConfirmationAction.RemoveLocalIdentity) { - identityPresenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) - } else -> Unit } - reduce(ShellEvent.Overlay(intent)) + admitOverlay(intent) + } + + private fun admitOverlay(intent: OverlayIntent) { + while (true) { + val current = mutableState.value + val transition = OverlayReducer.transition(current, intent) + if (transition.state == current && transition.effects.isEmpty()) return + if (!mutableState.compareAndSet(current, transition.state)) continue + transition.effects.forEach(::execute) + return + } + } + + private fun execute(effect: ShellEffect) { + when (effect) { + is ShellEffect.DispatchIdentity -> identityPresenter.dispatch(effect.intent) + } } private fun applyReferenceResult( result: ReferenceResult, clearInput: Boolean = false, ) { - reduce(ShellEvent.Overlay(OverlayIntent.ApplyReferenceResult(result, clearInput))) + admitOverlay(OverlayIntent.ApplyReferenceResult(result, clearInput)) } private fun reduce(event: ShellEvent) { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt @@ -39,8 +39,9 @@ enum class RemovalStatus { FAILED, } -data class RemovalImpactState( +data class IdentityRemovalConfirmation( val identityId: IdentityId, + val requestId: RemovalRequestId, val deletesLocalCredential: Boolean, val signsOut: Boolean, val expiresAt: UnixSeconds, @@ -51,8 +52,7 @@ data class HarvestCirclePresenterState( val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), val importDraft: String = "", val generatedKeyBackup: GeneratedKeyBackup? = null, - val pendingRemovalIdentityId: IdentityId? = null, - val removalImpact: RemovalImpactState? = null, + val removalConfirmation: IdentityRemovalConfirmation? = null, val removalStatus: RemovalStatus = RemovalStatus.NONE, val lastRemovedIdentityId: IdentityId? = null, val identityChooserVisible: Boolean = false, @@ -105,9 +105,15 @@ sealed interface HarvestCircleIntent { val identityId: IdentityId, ) : HarvestCircleIntent - data object CancelIdentityRemoval : HarvestCircleIntent + data class CancelIdentityRemoval( + val identityId: IdentityId, + val requestId: RemovalRequestId, + ) : HarvestCircleIntent - data object ConfirmIdentityRemoval : HarvestCircleIntent + data class ConfirmIdentityRemoval( + val identityId: IdentityId, + val requestId: RemovalRequestId, + ) : HarvestCircleIntent data object DismissProblem : HarvestCircleIntent } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -6,7 +6,11 @@ sealed interface FoundationOverlay { val explanation: String, val actionLabel: String, val action: ConfirmationAction, - ) : FoundationOverlay + val phase: ConfirmationPhase = ConfirmationPhase.Ready, + ) : FoundationOverlay { + val busy: Boolean + get() = phase != ConfirmationPhase.Ready + } data class Status( val key: StatusOverlayKey, @@ -18,7 +22,14 @@ sealed interface FoundationOverlay { ) : FoundationOverlay } -enum class ConfirmationAction { RemoveLocalIdentity } +sealed interface ConfirmationAction { + data class RemoveLocalIdentity( + val identityId: IdentityId, + val requestId: RemovalRequestId, + ) : ConfirmationAction +} + +enum class ConfirmationPhase { Ready, Submitting, Dismissing } enum class StatusOverlayKey { Signer, Sync } @@ -42,35 +53,105 @@ sealed interface OverlayIntent { val clearInput: Boolean = false, ) : OverlayIntent - data object Confirm : OverlayIntent + data class Confirm( + val action: ConfirmationAction, + ) : OverlayIntent + + data class DismissConfirmation( + val action: ConfirmationAction, + ) : OverlayIntent data object Close : OverlayIntent - data object Escape : OverlayIntent + data class Escape( + val confirmation: ConfirmationAction? = null, + ) : OverlayIntent +} + +sealed interface ShellEffect { + data class DispatchIdentity( + val intent: HarvestCircleIntent, + ) : ShellEffect } +data class OverlayTransition( + val state: HarvestCircleShellState, + val effects: List<ShellEffect> = emptyList(), +) + object OverlayReducer { - fun reduce( - state: OverlayState, + fun transition( + state: HarvestCircleShellState, intent: OverlayIntent, - ): OverlayState = + ): OverlayTransition = when (intent) { - is OverlayIntent.Open -> state.copy(current = intent.overlay) + is OverlayIntent.Open -> state.withOverlay(intent.overlay) is OverlayIntent.EditReference -> - state.copy(current = (state.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value)) - OverlayIntent.SubmitReference -> state + state.withOverlay( + (state.overlays.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value), + ) + OverlayIntent.SubmitReference -> OverlayTransition(state) is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result, intent.clearInput) - OverlayIntent.Confirm, OverlayIntent.Close, OverlayIntent.Escape -> state.copy(current = null) + is OverlayIntent.Confirm -> admitConfirmation(state, intent.action, submitting = true) + is OverlayIntent.DismissConfirmation -> admitConfirmation(state, intent.action, submitting = false) + OverlayIntent.Close -> + if (state.overlays.current is FoundationOverlay.ConfirmAction) { + OverlayTransition(state) + } else { + state.withOverlay(null) + } + is OverlayIntent.Escape -> + when (val current = state.overlays.current) { + is FoundationOverlay.ConfirmAction -> { + val expected = intent.confirmation + if (expected == null) OverlayTransition(state) else admitConfirmation(state, expected, submitting = false) + } + null -> OverlayTransition(state) + else -> state.withOverlay(null) + } } + private fun admitConfirmation( + state: HarvestCircleShellState, + expected: ConfirmationAction, + submitting: Boolean, + ): OverlayTransition { + val current = state.overlays.current as? FoundationOverlay.ConfirmAction ?: return OverlayTransition(state) + val removal = expected as? ConfirmationAction.RemoveLocalIdentity ?: return OverlayTransition(state) + val admitted = state.identity.removalConfirmation ?: return OverlayTransition(state) + if (current.action != expected || + current.phase != ConfirmationPhase.Ready || + state.identity.busy || + state.identity.removalStatus != RemovalStatus.AWAITING_CONFIRMATION || + admitted.identityId != removal.identityId || + admitted.requestId != removal.requestId + ) { + return OverlayTransition(state) + } + val phase = if (submitting) ConfirmationPhase.Submitting else ConfirmationPhase.Dismissing + val identityIntent = + if (submitting) { + HarvestCircleIntent.ConfirmIdentityRemoval(removal.identityId, removal.requestId) + } else { + HarvestCircleIntent.CancelIdentityRemoval(removal.identityId, removal.requestId) + } + return OverlayTransition( + state.copy(overlays = state.overlays.copy(current = current.copy(phase = phase))), + listOf(ShellEffect.DispatchIdentity(identityIntent)), + ) + } + private fun applyReferenceResult( - state: OverlayState, + state: HarvestCircleShellState, result: ReferenceResult, clearInput: Boolean, - ): OverlayState { - val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state - return state.copy(current = overlay.copy(input = if (clearInput) "" else overlay.input, result = result)) + ): OverlayTransition { + val overlay = state.overlays.current as? FoundationOverlay.OpenNostrReference ?: return OverlayTransition(state) + return state.withOverlay(overlay.copy(input = if (clearInput) "" else overlay.input, result = result)) } + + private fun HarvestCircleShellState.withOverlay(overlay: FoundationOverlay?): OverlayTransition = + OverlayTransition(copy(overlays = overlays.copy(current = overlay))) } 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 @@ -49,7 +49,7 @@ object ShellReducer { ShellEvent.EnterReadOnly -> updateSession(state, state.session.enterReadOnly()) is ShellEvent.Navigate -> updateNavigation(state) { activateShellScreen(it, event.screenKey) } is ShellEvent.Navigation -> updateNavigation(state, event.intent) - is ShellEvent.Overlay -> state.copy(overlays = OverlayReducer.reduce(state.overlays, event.intent)) + is ShellEvent.Overlay -> OverlayReducer.transition(state, event.intent).state 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)) @@ -65,6 +65,52 @@ object ShellReducer { identity = identity, localUsability = deriveLocalUsability(identity.snapshot), root = retainDashboardNavigation(state.root, derived), + overlays = reconcileRemovalOverlay(state, identity), + ) + } + + private fun reconcileRemovalOverlay( + state: HarvestCircleShellState, + identity: HarvestCirclePresenterState, + ): OverlayState { + val admitted = identity.removalConfirmation + val current = state.overlays.current as? FoundationOverlay.ConfirmAction + if (admitted == null) { + return if (current?.action is ConfirmationAction.RemoveLocalIdentity) { + state.overlays.copy(current = null) + } else { + state.overlays + } + } + val action = ConfirmationAction.RemoveLocalIdentity(admitted.identityId, admitted.requestId) + if (current?.action == action) { + val admissionRejected = + current.busy && + !identity.busy && + identity.removalStatus == RemovalStatus.AWAITING_CONFIRMATION && + identity.commandStatus in REMOVAL_ADMISSION_FAILURES + return if (admissionRejected) { + state.overlays.copy(current = current.copy(phase = ConfirmationPhase.Ready)) + } else { + state.overlays + } + } + val impact = + when { + admitted.deletesLocalCredential && admitted.signsOut -> + "Its local credential will be deleted and the active session will be signed out." + admitted.deletesLocalCredential -> + "Its local credential will be deleted from the operating-system keyring." + admitted.signsOut -> "The active session will be signed out." + else -> "This saved local identity will be removed." + } + return OverlayState( + FoundationOverlay.ConfirmAction( + title = "Remove this saved identity?", + explanation = impact, + actionLabel = "Remove local identity", + action = action, + ), ) } @@ -113,3 +159,11 @@ object ShellReducer { derived } } + +private val REMOVAL_ADMISSION_FAILURES = + setOf( + CommandStatus.REJECTED_BUSY, + CommandStatus.REJECTED_CLOSED, + CommandStatus.FAILED_RETRYABLE, + CommandStatus.FAILED_TERMINAL, + ) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiActions.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiActions.kt @@ -12,8 +12,6 @@ data class HarvestCircleUiActions( val selectIdentity: (String) -> Unit = {}, val activateIdentity: (String) -> Unit = {}, val requestIdentityRemoval: (String) -> Unit = {}, - val cancelIdentityRemoval: () -> Unit = {}, - val confirmIdentityRemoval: () -> Unit = {}, val refreshActiveProfile: () -> Unit = {}, val retryLastCommand: () -> Unit = {}, val dismissProblem: () -> Unit = {}, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt @@ -5,11 +5,11 @@ import org.harvestcircle.application.ApplicationErrorCode import org.harvestcircle.application.HarvestCirclePresenterState import org.harvestcircle.application.HarvestCircleRoute import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.IdentityRemovalConfirmation import org.harvestcircle.application.IdentitySummary import org.harvestcircle.application.ProfileLoadState import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RelayConnectionState -import org.harvestcircle.application.RemovalImpactState import org.harvestcircle.application.RemovalStatus import org.harvestcircle.application.SessionLifecycle import org.harvestcircle.application.SignerAvailability @@ -54,8 +54,7 @@ data class HarvestCircleUiModel( val configuredRelays: List<String>, val importDraft: String, val generatedKeyBackup: GeneratedKeyBackupUiModel?, - val pendingRemovalPublicKeyHex: String?, - val removalImpact: RemovalImpactState?, + val removalConfirmation: IdentityRemovalConfirmation?, val removalStatus: RemovalStatus, val lastRemovedPublicKeyHex: String?, val identityChooserVisible: Boolean, @@ -93,8 +92,7 @@ fun HarvestCirclePresenterState.toUiModel(): HarvestCircleUiModel { GeneratedKeyBackupUiModel(npub = backup.npub, nsec = nsec) } }, - pendingRemovalPublicKeyHex = pendingRemovalIdentityId?.value, - removalImpact = removalImpact, + removalConfirmation = removalConfirmation, removalStatus = removalStatus, lastRemovedPublicKeyHex = lastRemovedIdentityId?.value, identityChooserVisible = identityChooserVisible, 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 @@ -12,10 +12,6 @@ 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.application.ConfirmationAction -import org.harvestcircle.application.FoundationOverlay -import org.harvestcircle.application.HarvestCircleShellIntent -import org.harvestcircle.application.OverlayIntent import org.harvestcircle.design.TextSizePreference import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -61,7 +57,6 @@ fun IdentityChooserCanvas( model: HarvestCircleUiModel, actions: HarvestCircleUiActions, onReadOnly: () -> Unit, - dispatch: (HarvestCircleShellIntent) -> Unit = {}, ) { CanvasScaffold( textSize = TextSizePreference.Default, @@ -69,7 +64,7 @@ fun IdentityChooserCanvas( body = { LazyColumn(Modifier.fillMaxWidth().testTag("saved-identity-list")) { items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> - IdentityRow(identity, model, actions, dispatch) + IdentityRow(identity, model, actions) } } }, @@ -92,7 +87,6 @@ private fun IdentityRow( identity: IdentityUiModel, model: HarvestCircleUiModel, actions: HarvestCircleUiActions, - dispatch: (HarvestCircleShellIntent) -> Unit, ) { Column( Modifier @@ -134,23 +128,6 @@ private fun IdentityRow( enabled = !model.busy, ) { actions.requestIdentityRemoval(identity.publicKeyHex) - dispatch( - HarvestCircleShellIntent.Overlay( - OverlayIntent.Open( - FoundationOverlay.ConfirmAction( - title = "Remove this saved identity?", - explanation = - if (identity.active) { - "Its local credential will be deleted and the active session will be signed out." - } else { - "Its local credential will be deleted from the operating-system keyring." - }, - actionLabel = "Remove local identity", - action = ConfirmationAction.RemoveLocalIdentity, - ), - ), - ), - ) } } } 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 @@ -42,7 +42,18 @@ fun FoundationOverlayHost( } } state.current?.let { overlay -> - Dialog(onDismissRequest = { if (!busy) onIntent(OverlayIntent.Close) }) { + val overlayBusy = busy || (overlay as? FoundationOverlay.ConfirmAction)?.busy == true + Dialog( + onDismissRequest = { + if (!overlayBusy) { + if (overlay is FoundationOverlay.ConfirmAction) { + onIntent(OverlayIntent.DismissConfirmation(overlay.action)) + } else { + onIntent(OverlayIntent.Close) + } + } + }, + ) { ShellSurface( Modifier .focusGroup() @@ -53,7 +64,7 @@ fun FoundationOverlayHost( .padding(24.dp), ) { when (overlay) { - is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, busy, onIntent) + is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, onIntent) is FoundationOverlay.Status -> when (overlay.key) { StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent) @@ -80,12 +91,18 @@ private fun ConfirmOverlay( ShellButton( overlay.actionLabel, overlay.actionLabel, - { onIntent(OverlayIntent.Confirm) }, + { onIntent(OverlayIntent.Confirm(overlay.action)) }, Modifier.focusRequester(requester).testTag("overlay-confirm"), enabled = !busy, kind = ShellButtonKind.Destructive, ) - ShellButton("Cancel", "Cancel", { onIntent(OverlayIntent.Close) }, Modifier.testTag("overlay-cancel"), !busy) + ShellButton( + "Cancel", + "Cancel", + { onIntent(OverlayIntent.DismissConfirmation(overlay.action)) }, + Modifier.testTag("overlay-cancel"), + !busy, + ) } } LaunchedEffect(Unit) { requester.requestFocus() } 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 @@ -75,7 +75,6 @@ private fun HarvestCircleShellContent( model = state.identity.toUiModel(), actions = identityActions, onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, - dispatch = dispatch, ) } is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch) @@ -222,7 +221,7 @@ private fun dispatchShortcut( HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())) ShellShortcut.Today -> HarvestCircleShellIntent.Navigate(ScreenKey.PersonalToday) ShellShortcut.Settings -> HarvestCircleShellIntent.Navigate(ScreenKey.Settings) - ShellShortcut.CloseOverlay -> HarvestCircleShellIntent.Overlay(OverlayIntent.Escape) + ShellShortcut.CloseOverlay -> HarvestCircleShellIntent.Overlay(OverlayIntent.Escape()) } dispatch(intent) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -246,15 +246,82 @@ class HarvestCirclePresenterTest { presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) advanceUntilIdle() - assertEquals(identityId, presenter.state.value.pendingRemovalIdentityId) - presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) + val confirmation = presenter.state.value.removalConfirmation ?: error("removal confirmation") + assertEquals(identityId, confirmation.identityId) + assertEquals(RemovalRequestId.from("removal-1"), confirmation.requestId) + presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval(identityId, confirmation.requestId)) advanceUntilIdle() - assertNull(presenter.state.value.pendingRemovalIdentityId) + assertNull(presenter.state.value.removalConfirmation) assertEquals(1, runtime.removalCancellationCalls) presenter.close() } + @Test + fun staleRemovalTokenCannotCancelTheAdmittedRequest() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) + advanceUntilIdle() + presenter.dispatch( + HarvestCircleIntent.CancelIdentityRemoval( + identityId, + RemovalRequestId.from("stale-removal"), + ), + ) + advanceUntilIdle() + + assertEquals( + RemovalRequestId.from("removal-1"), + presenter.state.value.removalConfirmation + ?.requestId, + ) + assertEquals(0, runtime.removalCancellationCalls) + assertEquals(CommandStatus.FAILED_TERMINAL, presenter.state.value.commandStatus) + presenter.close() + } + + @Test + fun failedRemovalRequestExposesNoConfirmation() = + runTest { + val runtime = FakePresenterRuntime().also { it.removalFailure = problem(retryable = false) } + val presenter = presenter(runtime) + runCurrent() + + presenter.dispatch( + HarvestCircleIntent.RequestIdentityRemoval(IdentityId.fromPublicKeyHex("01".repeat(32))), + ) + advanceUntilIdle() + + assertNull(presenter.state.value.removalConfirmation) + assertEquals(RemovalStatus.NONE, presenter.state.value.removalStatus) + presenter.close() + } + + @Test + fun exactRemovalConfirmationDispatchesTheAdmittedRequest() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) + advanceUntilIdle() + val confirmation = presenter.state.value.removalConfirmation ?: error("removal confirmation") + presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, confirmation.requestId)) + advanceUntilIdle() + + assertEquals(listOf(confirmation.requestId), runtime.confirmedRemovalRequests) + assertEquals(RemovalStatus.COMPLETED, presenter.state.value.removalStatus) + assertNull(presenter.state.value.removalConfirmation) + presenter.close() + } + private fun TestScope.presenter( runtime: FakePresenterRuntime, ids: DeterministicOperationIds = DeterministicOperationIds(), @@ -293,6 +360,8 @@ private class FakePresenterRuntime( var shutdownCalls = 0 var generatedCancellationCalls = 0 var removalCancellationCalls = 0 + var removalFailure: ApplicationProblem? = null + val confirmedRemovalRequests = mutableListOf<RemovalRequestId>() val importedSecrets = mutableListOf<String>() override suspend fun bootstrap(): ApplicationSnapshot { @@ -322,6 +391,7 @@ private class FakePresenterRuntime( when (command) { is ApplicationCommand.ImportLocalIdentity -> importedSecrets += command.secretKey.take() is ApplicationCommand.CancelGeneratedIdentity -> generatedCancellationCalls += 1 + is ApplicationCommand.ConfirmIdentityRemoval -> confirmedRemovalRequests += command.requestId else -> Unit } return ApplicationCommandResult.Updated(current) @@ -335,14 +405,16 @@ private class FakePresenterRuntime( backup = GeneratedKeyBackup("npub1presenter", "nsec1presenter-secret"), ) - override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = - IdentityRemovalRequest( + override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest { + removalFailure?.let { throw ApplicationFailure(it) } + return IdentityRemovalRequest( requestId = RemovalRequestId.from("removal-1"), identityId = identityId, deletesLocalCredential = true, signsOut = false, expiresAt = UnixSeconds(60), ) + } override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean { removalCancellationCalls += 1 diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -51,31 +51,75 @@ class HarvestCircleShellPresenterTest { } @Test - fun confirmationDispatchesTypedIdentityEffectsAndClosesExactlyOnce() = + fun admittedConfirmationDispatchesExactIdentityEffectOnceAndClosesFromIdentityState() = runTest { - val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) - val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) - val confirmation = - FoundationOverlay.ConfirmAction( - "Remove identity?", - "The local credential will be deleted.", - "Remove local identity", - ConfirmationAction.RemoveLocalIdentity, + val identityId = IdentityId.fromPublicKeyHex("03".repeat(32)) + val requestId = RemovalRequestId.from("removal-shell-1") + val identity = + FakeIdentityPresentation( + presenterState(HarvestCircleRoute.IDENTITIES).withRemovalConfirmation(identityId, requestId), ) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Open(confirmation))) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Confirm)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + val confirmation = presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction + val action = ConfirmationAction.RemoveLocalIdentity(identityId, requestId) + assertEquals(action, confirmation.action) - assertTrue(identity.intents.single() === HarvestCircleIntent.ConfirmIdentityRemoval) - assertNull(presenter.state.value.overlays.current) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Confirm(action))) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Confirm(action))) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Open(confirmation))) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Close)) - assertTrue(identity.intents.last() === HarvestCircleIntent.CancelIdentityRemoval) + assertEquals( + listOf<HarvestCircleIntent>(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, requestId)), + identity.intents, + ) + assertEquals( + ConfirmationPhase.Submitting, + (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).phase, + ) + + identity.state.value = + identity.state.value.copy( + removalConfirmation = null, + removalStatus = RemovalStatus.CONFIRMING, + busy = true, + commandStatus = CommandStatus.RUNNING, + ) + runCurrent() assertNull(presenter.state.value.overlays.current) presenter.close() } @Test + fun dismissalIsRequestBoundAndStaleActionsEmitNothing() = + runTest { + val identityId = IdentityId.fromPublicKeyHex("04".repeat(32)) + val requestId = RemovalRequestId.from("removal-shell-2") + val identity = + FakeIdentityPresentation( + presenterState(HarvestCircleRoute.IDENTITIES).withRemovalConfirmation(identityId, requestId), + ) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + val stale = ConfirmationAction.RemoveLocalIdentity(identityId, RemovalRequestId.from("stale-shell")) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.DismissConfirmation(stale))) + assertTrue(identity.intents.isEmpty()) + + val exact = ConfirmationAction.RemoveLocalIdentity(identityId, requestId) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.DismissConfirmation(exact))) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.DismissConfirmation(exact))) + + assertEquals( + listOf<HarvestCircleIntent>(HarvestCircleIntent.CancelIdentityRemoval(identityId, requestId)), + identity.intents, + ) + assertEquals( + ConfirmationPhase.Dismissing, + (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).phase, + ) + presenter.close() + } + + @Test fun usableDegradationPreservesDashboardNavigation() = runTest { val identity = FakeIdentityPresentation(activePresenterState(ApplicationLifecycle.Ready, null, 1UL)) @@ -204,6 +248,22 @@ private fun presenterState(route: HarvestCircleRoute): HarvestCirclePresenterSta route = route, ) +private fun HarvestCirclePresenterState.withRemovalConfirmation( + identityId: IdentityId, + requestId: RemovalRequestId, +): HarvestCirclePresenterState = + copy( + removalConfirmation = + IdentityRemovalConfirmation( + identityId = identityId, + requestId = requestId, + deletesLocalCredential = true, + signsOut = false, + expiresAt = UnixSeconds(60), + ), + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, + ) + private fun activePresenterState( lifecycle: ApplicationLifecycle, lifecycleProblem: ApplicationProblem?, diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -10,37 +10,41 @@ import kotlin.test.assertTrue class ShellOverlaysTest { @Test fun typedReferenceResultsAreAppliedWithoutParsingInTheReducer() { - val open = - OverlayState( - FoundationOverlay.OpenNostrReference(input = "public-reference"), - ) - val unchanged = OverlayReducer.reduce(open, OverlayIntent.SubmitReference) + val open = shellState(FoundationOverlay.OpenNostrReference(input = "public-reference")) + val unchanged = OverlayReducer.transition(open, OverlayIntent.SubmitReference).state assertEquals(open, unchanged) val rejected = - OverlayReducer.reduce( - open, - OverlayIntent.ApplyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true), - ) + OverlayReducer + .transition( + open, + OverlayIntent.ApplyReferenceResult(ReferenceResult.PrivateKeyRejected, clearInput = true), + ).state assertEquals( FoundationOverlay.OpenNostrReference(input = "", result = ReferenceResult.PrivateKeyRejected), - rejected.current, + rejected.overlays.current, ) } @Test fun oneTopOverlayReplacesPriorAndEscapeDoesNotTouchNavigation() { val first = - OverlayReducer.reduce( - OverlayState(), - OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Signer)), - ) + OverlayReducer + .transition( + shellState(), + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Signer)), + ).state val second = - OverlayReducer.reduce( - first, - OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync)), - ) - assertEquals(FoundationOverlay.Status(StatusOverlayKey.Sync), second.current) - assertNull(OverlayReducer.reduce(second, OverlayIntent.Escape).current) + OverlayReducer + .transition( + first, + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync)), + ).state + assertEquals(FoundationOverlay.Status(StatusOverlayKey.Sync), second.overlays.current) + assertNull( + OverlayReducer + .transition(second, OverlayIntent.Escape()) + .state.overlays.current, + ) } @Test @@ -50,3 +54,25 @@ class ShellOverlaysTest { assertTrue(FeatureAvailability.Foundation in HarvestCircleSurfaceRegistry.overlays.map { it.availability }) } } + +private fun shellState(overlay: FoundationOverlay? = null): HarvestCircleShellState = + HarvestCircleShellState( + identity = + HarvestCirclePresenterState( + ApplicationSnapshot( + revision = SnapshotRevision(1UL), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = emptyList(), + selectedIdentityId = null, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + ), + ), + buildInfo = BuildInfo.unknown(), + overlays = OverlayState(overlay), + ) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt @@ -66,8 +66,7 @@ private fun model(importDraft: String = "") = configuredRelays = emptyList(), importDraft = importDraft, generatedKeyBackup = null, - pendingRemovalPublicKeyHex = null, - removalImpact = null, + removalConfirmation = null, removalStatus = RemovalStatus.NONE, lastRemovedPublicKeyHex = null, identityChooserVisible = false, 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 @@ -10,7 +10,6 @@ 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.HarvestCircleShellIntent import org.harvestcircle.application.IdentityEntryMode import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RemovalStatus @@ -64,7 +63,6 @@ class BootstrapRecoveryAndChooserTest { var selected = "" var activated = "" var removal = "" - val shellIntents = mutableListOf<HarvestCircleShellIntent>() setContent { IdentityChooserCanvas( model = model(identities = listOf(first, second)), @@ -75,7 +73,6 @@ class BootstrapRecoveryAndChooserTest { requestIdentityRemoval = { removal = it }, ), onReadOnly = {}, - dispatch = shellIntents::add, ) } @@ -85,7 +82,6 @@ class BootstrapRecoveryAndChooserTest { assertEquals("second", selected) assertEquals("second", activated) assertEquals("second", removal) - kotlin.test.assertTrue(shellIntents.single() is HarvestCircleShellIntent.Overlay) } } @@ -112,8 +108,7 @@ private fun model( configuredRelays = emptyList(), importDraft = "", generatedKeyBackup = generatedKeyBackup, - pendingRemovalPublicKeyHex = null, - removalImpact = null, + removalConfirmation = null, removalStatus = RemovalStatus.NONE, lastRemovedPublicKeyHex = null, identityChooserVisible = false, 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 @@ -44,7 +44,7 @@ class FoundationOverlayHostTest { status(banner = GlobalStatusBanner("Limited connection", "Some services are unavailable.", BannerSeverity.Caution)), ) { state = - OverlayReducer.reduce( + reduceOverlay( state, if (it == OverlayIntent.SubmitReference) { OverlayIntent.ApplyReferenceResult(ReferenceResult.Invalid) @@ -63,7 +63,7 @@ class FoundationOverlayHostTest { onNodeWithTag("nostr-reference-input").assertIsFocused().performTextInput("note1qqqqqq") onNodeWithTag("nostr-reference-submit").performClick() onAllNodesWithTag("nostr-reference-result").assertCountEquals(1) - state = OverlayReducer.reduce(state, OverlayIntent.Escape) + state = reduceOverlay(state, OverlayIntent.Escape()) onAllNodesWithTag("foundation-overlay").assertCountEquals(0) } @@ -71,6 +71,7 @@ class FoundationOverlayHostTest { fun destructiveConfirmationOwnsFocusAndDispatchesTypedSubmission() = runComposeUiTest { val intents = mutableListOf<OverlayIntent>() + val action = removalAction() val state = OverlayState( current = @@ -78,13 +79,13 @@ class FoundationOverlayHostTest { "Remove identity?", "The local credential will be deleted.", "Remove local identity", - ConfirmationAction.RemoveLocalIdentity, + action, ), ) setContent { FoundationOverlayHost(state, status(), onIntent = intents::add) } onNodeWithTag("overlay-confirm").assertIsFocused().performClick() - kotlin.test.assertEquals(listOf<OverlayIntent>(OverlayIntent.Confirm), intents) + kotlin.test.assertEquals(listOf<OverlayIntent>(OverlayIntent.Confirm(action)), intents) } @Test @@ -98,7 +99,7 @@ class FoundationOverlayHostTest { "Remove identity?", "The local credential will be deleted.", "Remove local identity", - ConfirmationAction.RemoveLocalIdentity, + removalAction(), ), ) setContent { FoundationOverlayHost(state, status(), busy = true, onIntent = intents::add) } @@ -123,3 +124,41 @@ class FoundationOverlayHostTest { private fun status(banner: GlobalStatusBanner? = null) = ShellStatusModel(SyncStatusLabel.NotYetObserved, SignerStatusLabel.SignedOut, banner) + +private fun removalAction() = + ConfirmationAction.RemoveLocalIdentity( + org.harvestcircle.application.IdentityId + .fromPublicKeyHex("05".repeat(32)), + org.harvestcircle.application.RemovalRequestId + .from("overlay-removal"), + ) + +private fun reduceOverlay( + overlays: OverlayState, + intent: OverlayIntent, +): OverlayState { + val identity = + org.harvestcircle.application.HarvestCirclePresenterState( + org.harvestcircle.application.ApplicationSnapshot( + revision = org.harvestcircle.application.SnapshotRevision(1UL), + lifecycle = org.harvestcircle.application.ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = emptyList(), + selectedIdentityId = null, + session = org.harvestcircle.application.SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + ), + ) + val shell = + org.harvestcircle.application.HarvestCircleShellState( + identity, + org.harvestcircle.application.BuildInfo + .unknown(), + overlays = overlays, + ) + return OverlayReducer.transition(shell, intent).state.overlays +}