commit bf43951acb2c9490dc29f3933f3c58ed46bb393c
parent d34469d6d67efe0d69f33f8ac5337c2f228087aa
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:34:21 +0000
ui: implement the foundation overlay host
- keep one typed top overlay independent of route history
- implement confirm signer sync and global-status surfaces
- validate bounded Nostr reference syntax without resolution
- register deferred overlays without exposing constructors
Diffstat:
4 files changed, 271 insertions(+), 0 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt
@@ -0,0 +1,97 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.ui.shell.SignerStatusLabel
+import org.harvestcircle.ui.shell.SyncStatusLabel
+
+sealed interface FoundationOverlay {
+ data class ConfirmAction(
+ val title: String,
+ val explanation: String,
+ val actionLabel: String,
+ ) : FoundationOverlay
+
+ data class SignerStatus(
+ val status: SignerStatusLabel,
+ ) : FoundationOverlay
+
+ data class SyncStatus(
+ val status: SyncStatusLabel,
+ ) : FoundationOverlay
+
+ data class OpenNostrReference(
+ val input: String = "",
+ val result: ReferenceResult? = null,
+ ) : FoundationOverlay
+}
+
+enum class BannerSeverity { Information, Caution, Critical }
+
+data class GlobalStatusBanner(
+ val message: String,
+ val severity: BannerSeverity,
+)
+
+data class OverlayState(
+ val current: FoundationOverlay? = null,
+ val banner: GlobalStatusBanner? = null,
+)
+
+sealed interface OverlayIntent {
+ data class Open(
+ val overlay: FoundationOverlay,
+ ) : OverlayIntent
+
+ data class EditReference(
+ val value: String,
+ ) : OverlayIntent
+
+ data object SubmitReference : OverlayIntent
+
+ data object Confirm : OverlayIntent
+
+ data object Close : OverlayIntent
+
+ data object Escape : OverlayIntent
+}
+
+object OverlayReducer {
+ fun reduce(
+ state: OverlayState,
+ intent: OverlayIntent,
+ ): OverlayState =
+ when (intent) {
+ is OverlayIntent.Open -> state.copy(current = intent.overlay)
+ is OverlayIntent.EditReference ->
+ state.copy(current = (state.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value))
+ OverlayIntent.SubmitReference -> submitReference(state)
+ OverlayIntent.Confirm, OverlayIntent.Close, OverlayIntent.Escape -> state.copy(current = null)
+ }
+
+ private fun submitReference(state: OverlayState): OverlayState {
+ val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state
+ return state.copy(current = overlay.copy(result = validateNostrReference(overlay.input)))
+ }
+}
+
+enum class ReferenceResult(
+ val message: String,
+) {
+ Invalid("This reference is not valid."),
+ Unsupported("This Nostr reference is not supported by this build."),
+}
+
+fun validateNostrReference(raw: String): ReferenceResult {
+ if (raw.isBlank() || raw.length > MAX_REFERENCE_CHARS || raw.any(Char::isISOControl)) return ReferenceResult.Invalid
+ val value = raw.trim()
+ val payload = value.removePrefix("nostr:")
+ val accepted =
+ payload.matches(Regex("[0-9a-fA-F]{64}")) ||
+ payload.matches(Regex("(?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]{6,}"))
+ return if (accepted && (value == payload || value.startsWith("nostr:"))) {
+ ReferenceResult.Unsupported
+ } else {
+ ReferenceResult.Invalid
+ }
+}
+
+private const val MAX_REFERENCE_CHARS = 2048
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
@@ -0,0 +1,91 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import org.harvestcircle.application.FoundationOverlay
+import org.harvestcircle.application.OverlayIntent
+import org.harvestcircle.application.OverlayState
+
+@Composable
+fun FoundationOverlayHost(
+ state: OverlayState,
+ onIntent: (OverlayIntent) -> Unit,
+) {
+ state.banner?.let { banner ->
+ BasicText(
+ banner.message,
+ Modifier.semantics { contentDescription = "Status: ${banner.message}" }.testTag("global-status-banner"),
+ )
+ }
+ state.current?.let { overlay ->
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ Modifier
+ .focusGroup()
+ .semantics { contentDescription = "Dialog" }
+ .testTag("foundation-overlay"),
+ ) {
+ when (overlay) {
+ is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, onIntent)
+ is FoundationOverlay.SignerStatus -> StatusOverlay("Signer status", overlay.status.text, onIntent)
+ is FoundationOverlay.SyncStatus -> StatusOverlay("Sync status", overlay.status.text, onIntent)
+ is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConfirmOverlay(
+ overlay: FoundationOverlay.ConfirmAction,
+ onIntent: (OverlayIntent) -> Unit,
+) {
+ BasicText(overlay.title)
+ BasicText(overlay.explanation)
+ ShellAction(overlay.actionLabel, overlay.actionLabel, "overlay-confirm") { onIntent(OverlayIntent.Confirm) }
+ ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) }
+}
+
+@Composable
+private fun StatusOverlay(
+ title: String,
+ status: String,
+ onIntent: (OverlayIntent) -> Unit,
+) {
+ BasicText(title)
+ BasicText(status, Modifier.testTag("overlay-status"))
+ ShellAction("Close", "Close", "overlay-close") { onIntent(OverlayIntent.Close) }
+}
+
+@Composable
+private fun ReferenceOverlay(
+ overlay: FoundationOverlay.OpenNostrReference,
+ onIntent: (OverlayIntent) -> Unit,
+) {
+ val requester = androidx.compose.runtime.remember { FocusRequester() }
+ BasicText("Open a Nostr reference")
+ BasicTextField(
+ value = overlay.input,
+ onValueChange = { onIntent(OverlayIntent.EditReference(it)) },
+ modifier = Modifier.focusRequester(requester).testTag("nostr-reference-input"),
+ )
+ androidx.compose.runtime.LaunchedEffect(Unit) { requester.requestFocus() }
+ overlay.result?.let { BasicText(it.message, Modifier.testTag("nostr-reference-result")) }
+ ShellAction("Open reference", "Open Nostr reference", "nostr-reference-submit") {
+ onIntent(OverlayIntent.SubmitReference)
+ }
+ ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) }
+}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt
@@ -0,0 +1,41 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.product.FeatureAvailability
+import org.harvestcircle.product.HarvestCircleSurfaceRegistry
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ShellOverlaysTest {
+ @Test
+ fun acceptedReferencesRemainSyntaxOnlyAndMalformedInputFails() {
+ assertEquals(ReferenceResult.Unsupported, validateNostrReference("ab".repeat(32)))
+ assertEquals(ReferenceResult.Unsupported, validateNostrReference("nostr:note1qqqqqq"))
+ assertEquals(ReferenceResult.Invalid, validateNostrReference("nostr:bad reference"))
+ assertEquals(ReferenceResult.Invalid, validateNostrReference("note1abc\u0000"))
+ }
+
+ @Test
+ fun oneTopOverlayReplacesPriorAndEscapeDoesNotTouchNavigation() {
+ val first =
+ OverlayReducer.reduce(
+ OverlayState(),
+ OverlayIntent.Open(FoundationOverlay.SignerStatus(org.harvestcircle.ui.shell.SignerStatusLabel.Available)),
+ )
+ val second =
+ OverlayReducer.reduce(
+ first,
+ OverlayIntent.Open(FoundationOverlay.SyncStatus(org.harvestcircle.ui.shell.SyncStatusLabel.Degraded)),
+ )
+ assertTrue(second.current is FoundationOverlay.SyncStatus)
+ assertNull(OverlayReducer.reduce(second, OverlayIntent.Escape).current)
+ }
+
+ @Test
+ fun deferredOverlaysHaveNoFoundationStateConstructor() {
+ val deferred = HarvestCircleSurfaceRegistry.overlays.filter { it.availability.name.startsWith("Deferred") }
+ assertEquals(5, deferred.size)
+ assertTrue(FeatureAvailability.Foundation in HarvestCircleSurfaceRegistry.overlays.map { it.availability })
+ }
+}
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
@@ -0,0 +1,42 @@
+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.assertIsFocused
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.harvestcircle.application.BannerSeverity
+import org.harvestcircle.application.FoundationOverlay
+import org.harvestcircle.application.GlobalStatusBanner
+import org.harvestcircle.application.OverlayIntent
+import org.harvestcircle.application.OverlayReducer
+import org.harvestcircle.application.OverlayState
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class FoundationOverlayHostTest {
+ @Test
+ fun referenceDialogOwnsFocusAndReportsUnsupportedSyntax() =
+ runComposeUiTest {
+ var state by mutableStateOf(
+ OverlayState(
+ current = FoundationOverlay.OpenNostrReference(),
+ banner = GlobalStatusBanner("Limited connection", BannerSeverity.Caution),
+ ),
+ )
+ setContent { FoundationOverlayHost(state) { state = OverlayReducer.reduce(state, it) } }
+ onAllNodesWithTag("foundation-overlay").assertCountEquals(1)
+ onAllNodesWithTag("global-status-banner").assertCountEquals(1)
+ onNodeWithTag("nostr-reference-input").assertIsFocused().performTextInput("note1qqqqqq")
+ onNodeWithTag("nostr-reference-submit").performClick()
+ onAllNodesWithTag("nostr-reference-result").assertCountEquals(1)
+ state = OverlayReducer.reduce(state, OverlayIntent.Escape)
+ onAllNodesWithTag("foundation-overlay").assertCountEquals(0)
+ }
+}