ShellOverlays.kt (8587B)
1 package org.harvestcircle.application 2 3 sealed interface FoundationOverlay { 4 data class ConfirmAction( 5 val title: String, 6 val explanation: String, 7 val actionLabel: String, 8 val action: ConfirmationAction, 9 val phase: ConfirmationPhase = ConfirmationPhase.Ready, 10 ) : FoundationOverlay { 11 val busy: Boolean 12 get() = phase != ConfirmationPhase.Ready 13 } 14 15 data class Status( 16 val key: StatusOverlayKey, 17 ) : FoundationOverlay 18 19 data class OpenNostrReference( 20 val input: String = "", 21 val result: ReferenceResult? = null, 22 ) : FoundationOverlay 23 } 24 25 sealed interface ConfirmationAction { 26 data class RemoveLocalIdentity( 27 val identityId: IdentityId, 28 val requestId: RemovalRequestId, 29 ) : ConfirmationAction 30 } 31 32 enum class ConfirmationPhase { Ready, Submitting, Dismissing } 33 34 enum class StatusOverlayKey { Signer, Sync } 35 36 data class OverlayState( 37 val current: FoundationOverlay? = null, 38 val returnFocus: ShellFocusTarget? = null, 39 val restoreFocus: ShellFocusTarget? = null, 40 ) 41 42 sealed interface ShellFocusTarget { 43 data object RouteFallback : ShellFocusTarget 44 45 data object BootstrapFallback : ShellFocusTarget 46 47 data object TodayReference : ShellFocusTarget 48 49 data object TopBarReference : ShellFocusTarget 50 51 data object TopBarSync : ShellFocusTarget 52 53 data object TopBarSigner : ShellFocusTarget 54 55 data class IdentityRow( 56 val publicKeyHex: String, 57 ) : ShellFocusTarget 58 } 59 60 sealed interface OverlayIntent { 61 data class Open( 62 val overlay: FoundationOverlay, 63 val returnFocus: ShellFocusTarget = ShellFocusTarget.RouteFallback, 64 ) : OverlayIntent 65 66 data class OpenReference( 67 val returnFocus: ShellFocusTarget = ShellFocusTarget.RouteFallback, 68 ) : OverlayIntent 69 70 class EditReference( 71 val value: String, 72 ) : OverlayIntent { 73 override fun toString(): String = "EditReference(value=[REDACTED])" 74 } 75 76 data object SubmitReference : OverlayIntent 77 78 data class ApplyReferenceResult( 79 val result: ReferenceResult, 80 val clearInput: Boolean = false, 81 ) : OverlayIntent 82 83 data class Confirm( 84 val action: ConfirmationAction, 85 ) : OverlayIntent 86 87 data class DismissConfirmation( 88 val action: ConfirmationAction, 89 ) : OverlayIntent 90 91 data object Close : OverlayIntent 92 93 data class Escape( 94 val confirmation: ConfirmationAction? = null, 95 ) : OverlayIntent 96 } 97 98 sealed interface ShellEffect { 99 data class DispatchIdentity( 100 val intent: HarvestCircleIntent, 101 ) : ShellEffect 102 } 103 104 data class OverlayTransition( 105 val state: HarvestCircleShellState, 106 val effects: List<ShellEffect> = emptyList(), 107 ) 108 109 object OverlayReducer { 110 fun transition( 111 state: HarvestCircleShellState, 112 intent: OverlayIntent, 113 ): OverlayTransition = 114 when (intent) { 115 is OverlayIntent.Open -> 116 if (intent.overlay is FoundationOverlay.OpenNostrReference) { 117 OverlayTransition(state) 118 } else { 119 state.openOverlay(intent.overlay, intent.returnFocus) 120 } 121 is OverlayIntent.OpenReference -> 122 state.openOverlay(FoundationOverlay.OpenNostrReference(), intent.returnFocus) 123 is OverlayIntent.EditReference -> applyReferenceEdit(state, intent.value) 124 OverlayIntent.SubmitReference -> OverlayTransition(state) 125 is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result, intent.clearInput) 126 is OverlayIntent.Confirm -> admitConfirmation(state, intent.action, submitting = true) 127 is OverlayIntent.DismissConfirmation -> admitConfirmation(state, intent.action, submitting = false) 128 OverlayIntent.Close -> 129 if (state.overlays.current is FoundationOverlay.ConfirmAction) { 130 OverlayTransition(state) 131 } else { 132 state.closeOverlay() 133 } 134 is OverlayIntent.Escape -> 135 when (val current = state.overlays.current) { 136 is FoundationOverlay.ConfirmAction -> { 137 val expected = intent.confirmation 138 if (expected == null) OverlayTransition(state) else admitConfirmation(state, expected, submitting = false) 139 } 140 null -> OverlayTransition(state) 141 else -> state.closeOverlay() 142 } 143 } 144 145 private fun applyReferenceEdit( 146 state: HarvestCircleShellState, 147 raw: String, 148 ): OverlayTransition { 149 val overlay = state.overlays.current as? FoundationOverlay.OpenNostrReference ?: return OverlayTransition(state) 150 val updated = 151 when (val admission = ReferenceInputPolicy.admit(raw)) { 152 is ReferenceInputAdmission.Accepted -> overlay.copy(input = admission.value, result = null) 153 ReferenceInputAdmission.PrivateKeyShaped -> overlay.copy(input = "", result = ReferenceResult.PrivateKeyRejected) 154 ReferenceInputAdmission.TooLarge -> overlay.copy(input = "", result = ReferenceResult.Invalid) 155 ReferenceInputAdmission.AmbiguousHex -> overlay.copy(input = "", result = ReferenceResult.AmbiguousHex) 156 } 157 return state.updateOverlay(updated) 158 } 159 160 private fun admitConfirmation( 161 state: HarvestCircleShellState, 162 expected: ConfirmationAction, 163 submitting: Boolean, 164 ): OverlayTransition { 165 val current = state.overlays.current as? FoundationOverlay.ConfirmAction ?: return OverlayTransition(state) 166 val removal = expected as? ConfirmationAction.RemoveLocalIdentity ?: return OverlayTransition(state) 167 val admitted = state.identity.removalConfirmation ?: return OverlayTransition(state) 168 if (current.action != expected || 169 current.phase != ConfirmationPhase.Ready || 170 state.identity.removalStatus != RemovalStatus.AWAITING_CONFIRMATION || 171 admitted.identityId != removal.identityId || 172 admitted.requestId != removal.requestId 173 ) { 174 return OverlayTransition(state) 175 } 176 val phase = if (submitting) ConfirmationPhase.Submitting else ConfirmationPhase.Dismissing 177 val identityIntent = 178 if (submitting) { 179 HarvestCircleIntent.ConfirmIdentityRemoval(removal.identityId, removal.requestId) 180 } else { 181 HarvestCircleIntent.CancelIdentityRemoval(removal.identityId, removal.requestId) 182 } 183 return OverlayTransition( 184 state.copy(overlays = state.overlays.copy(current = current.copy(phase = phase))), 185 listOf(ShellEffect.DispatchIdentity(identityIntent)), 186 ) 187 } 188 189 private fun applyReferenceResult( 190 state: HarvestCircleShellState, 191 result: ReferenceResult, 192 clearInput: Boolean, 193 ): OverlayTransition { 194 val overlay = state.overlays.current as? FoundationOverlay.OpenNostrReference ?: return OverlayTransition(state) 195 return state.updateOverlay(overlay.copy(input = if (clearInput) "" else overlay.input, result = result)) 196 } 197 198 private fun HarvestCircleShellState.openOverlay( 199 overlay: FoundationOverlay, 200 returnFocus: ShellFocusTarget, 201 ): OverlayTransition = OverlayTransition(copy(overlays = overlays.opened(overlay, returnFocus))) 202 203 private fun HarvestCircleShellState.updateOverlay(overlay: FoundationOverlay): OverlayTransition = 204 OverlayTransition(copy(overlays = overlays.copy(current = overlay))) 205 206 private fun HarvestCircleShellState.closeOverlay(): OverlayTransition = OverlayTransition(copy(overlays = overlays.closed())) 207 } 208 209 internal fun OverlayState.opened( 210 overlay: FoundationOverlay, 211 returnFocus: ShellFocusTarget, 212 ): OverlayState = copy(current = overlay, returnFocus = returnFocus, restoreFocus = null) 213 214 internal fun OverlayState.closed(): OverlayState = copy(current = null, returnFocus = null, restoreFocus = returnFocus) 215 216 enum class ReferenceResult( 217 val message: String, 218 ) { 219 Invalid("This reference is not valid."), 220 PrivateKeyRejected("Private-key references cannot be opened."), 221 AmbiguousHex( 222 "Bare hexadecimal references are not accepted because they can also be private keys.\n\n" + 223 "Use a note1 or nevent1 reference.", 224 ), 225 Unsupported("This Nostr reference is not supported by this build."), 226 }