From f5a6f747312928d3cc6d3554d4c4789ecc99a30a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:38:18 +0200 Subject: [PATCH] feat(ui): what a read-only identity is shown, and what it is not Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a product. One question, asked in one way. LocalCanSign is a composition local for the snackbar host's reason: screens do not receive the identity, MetadataEventDetail -- where follow and send message live -- is several composables below anything that could be handed more, and a parameter threaded through twenty-six lists is forgotten in the twenty-seventh. ProvideSigningCapability sits once above the navigation suite. The default is true rather than an error, the one way this differs from the snackbar host: the provider cannot be forgotten per screen, so the only things composed outside it are previews and tests. And it is a capability, not a kind -- "can this identity sign?", not "is this an npub?" -- so that a remote signer is not a fourth value in every when. The inventory, hidden rather than disabled because the empty state beside each says why: HomeScreen's New chat in both layouts, its sheet and its npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit profile and send message (the "follows you" state still shows -- a fact, not an action); ActiveProfileScreen's key package management and key recovery; ShareProfileScreen's re-broadcast, which signs nothing but queues a copy for the finder relays, and a read-only identity puts nothing on a relay; SocialPreconditionScreen's invite and view invites, pending today and writes when they exist; and the not-found screen's set-up form, since a kind 0 has to be signed. Everything else that writes is behind a chat room, which a read-only identity can never open. The Messages tab, for a read-only identity, is an EmptyState where the rooms would be -- one column at every width, since a two-pane layout is a list beside a detail and there is no list -- whose message says which absence it is and whose action is the upgrade: Sign in with the nsec, which opens the same screen as landing's, hits the credentials file's upgrade rule, and comes back through startup with rooms in it. ChatRoomListViewModel is not composed, so the inbox sync and the MLS negentropy it would queue are not queued. Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens under each value of LocalCanSign and looks for the controls by text, on the unmerged tree so that "does not exist" is not vacuous. The M3 audit's budgets hold. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@e31033e857878c6d62830c5fd34918dfb26abfe5 --- .../composeResources/values/strings.xml | 2 + .../ui/composable/ActiveProfileScreen.kt | 11 +- .../compose/ui/composable/HomeScreen.kt | 60 +++++- .../ui/composable/ShareProfileScreen.kt | 7 +- .../ui/composable/SocialPreconditionScreen.kt | 57 +++--- .../ui/composable/UnsyncedProfileScreen.kt | 6 + .../ui/composable/navigation/MantraNavHost.kt | 5 + .../composable/widgets/SigningCapability.kt | 47 +++++ .../widgets/detail/MetadataEventDetail.kt | 14 +- .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 178 ++++++++++++++++++ 10 files changed, 353 insertions(+), 34 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1f3907b6..593c6b04 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -265,6 +265,8 @@ This will be the display name for this chat room. This will be the display name for your profile and also important for search. Read only + Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent. + Sign in with the nsec This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 6f4b486f..cf1920e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -50,6 +50,7 @@ import press.mantra.compose.ui.view.model.ActiveProfileViewModel import press.mantra.compose.ui.view.state.ActiveProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -84,6 +85,9 @@ fun ActiveProfileScreen( nostrRepository: NostrRepository, ) { + // Whether this identity can sign, which decides which rows below exist. + val canSign = LocalCanSign.current + val activeProfileViewModel: ActiveProfileViewModel = viewModel( factory = ActiveProfileViewModel.factory( nostrEventId = nostrEventId, @@ -226,7 +230,10 @@ fun ActiveProfileScreen( } } - item { + // Key packages are signed, and there is nothing to recover for + // an identity the device holds no secret for: neither row for a + // read-only identity. + if (canSign) item { TextButton( onClick = { onNavigateToRoute.invoke( @@ -279,7 +286,7 @@ fun ActiveProfileScreen( } - item { + if (canSign) item { TextButton( onClick = { onNavigateToRoute.invoke( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 2b91c937..7b2ba46a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -24,6 +25,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -45,6 +47,7 @@ import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SignInRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog @@ -74,6 +77,9 @@ import press.mantra.compose.ui.theme.breakpoint import press.mantra.compose.ui.theme.listPaneWidthFor import press.mantra.compose.ui.theme.readableContent import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here +import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_profile +import mantra.composeapp.generated.resources.sign_in_with_the_nsec +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -120,6 +126,9 @@ fun HomeScreen( } } + // Whether this identity can sign: the one thing the read-only branch below turns on. + val canSign = LocalCanSign.current + val homeScreenViewModel: HomeViewModel = viewModel( factory = HomeViewModel.factory( activeUserPublicKey = activeUserPublicKey, @@ -178,7 +187,11 @@ fun HomeScreen( // the bottom-right of the *window*, which is on top of the // transcript's send button; M3 puts a list-detail layout's primary // action in the list pane, and so does the branch below. - if (listPaneWidth == null) NewChatButton { showBottomSheet = true } + // And only for an identity that can sign: a new chat is a key + // package, a welcome and a gift wrap, none of which a read-only + // identity can produce. Hidden, not disabled -- the empty state + // beside it says why. + if (listPaneWidth == null && canSign) NewChatButton { showBottomSheet = true } } ) { innerPadding -> @Composable @@ -242,7 +255,21 @@ fun HomeScreen( } } - if (listPaneWidth == null) { + if (!canSign) { + // One column at every width. The two-pane layout is a list beside + // a detail, and there is no list: every room is MLS or a gift wrap, + // encrypted to the key this identity does not hold. The empty state + // is the only thing on the screen, which is the one case + // readableContent()'s centring is for -- and its action is where + // the upgrade lives. ChatRoomListViewModel is not composed, so the + // inbox sync and the MLS negentropy it would queue are not queued. + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, + ) { + ReadOnlyInbox(onSignInWithTheNsec = { onNavigateToRoute(SignInRoute) }) + } + } else if (listPaneWidth == null) { // Compact and medium: the list is the screen, and a room is a route. // Byte for byte the layout this screen has always had. ChatRoomListPane( @@ -311,7 +338,10 @@ fun HomeScreen( } } - if (showBottomSheet) { + // Neither can open without the button above, and the button is hidden for + // an identity that cannot sign; the guard here is so that the two cannot + // drift apart. + if (showBottomSheet && canSign) { NewChatBottomSheetDialog( scope = scope, sheetState = sheetState, @@ -327,7 +357,7 @@ fun HomeScreen( } when { - openNpubDialog.value -> { + openNpubDialog.value && canSign -> { StartDirectMessageToNpubOrNip05Dialog( activeUserPublicKey = activeUserPublicKey, scope = scope, @@ -394,6 +424,28 @@ It has survived not only five centuries, but also the leap into electronic types } } +/** + * What a read-only identity sees where its rooms would be, and the one thing that fills + * it. + * + * `EmptyState`'s message is required for exactly this reason -- an absence has to say + * which absence it is -- and its action slot is where the upgrade lives: the nsec pasted + * on the sign-in screen hits the credentials file's upgrade rule, and startup takes the + * user back here with rooms in it. + */ +@Composable +private fun ReadOnlyInbox(onSignInWithTheNsec: () -> Unit) { + EmptyState( + message = stringResource(Res.string.messages_need_the_secret_key_this_profile), + icon = Icons.Default.Lock, + action = { + TextButton(onClick = onSignInWithTheNsec) { + Text(stringResource(Res.string.sign_in_with_the_nsec)) + } + }, + ) +} + /** * The one action the chat list offers, in whichever slot the layout has for it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index fc53f059..c559cac0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -41,6 +41,7 @@ import press.mantra.compose.ui.view.model.ShareProfileViewModel import press.mantra.compose.ui.view.state.ShareProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -176,7 +177,11 @@ fun ShareProfileScreen( } } - Row( + // Nothing is signed by a re-broadcast, but a copy of the kind 0 is + // queued for the finder relays, and a read-only identity puts nothing + // on a relay -- not even a copy. The broadcast pump is not running for + // it to carry the request anyway. + if (LocalCanSign.current) Row( modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalArrangement = Arrangement.Center ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt index ab33152d..936fb60d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -88,36 +89,40 @@ fun SocialPreconditionScreen( modifier = Modifier.weight(1f) ) - Text( - stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), - textAlign = TextAlign.Center - ) - - Button( - onClick = { - onNavigateToInviteFriend.invoke() - } - ) { + // Both pending today, and both writes when they exist: an invite is sent, + // an acceptance signed. A read-only identity is offered only the way past. + if (LocalCanSign.current) { Text( - stringResource(Res.string.invite_a_friend) + stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), + textAlign = TextAlign.Center ) - } - Spacer( - modifier = Modifier.weight(1f) - ) - - Text( - stringResource(Res.string.view_and_accept_invites_you_may_have), - textAlign = TextAlign.Center - ) - - Button( - onClick = { - onNavigateToViewInvites.invoke() + Button( + onClick = { + onNavigateToInviteFriend.invoke() + } + ) { + Text( + stringResource(Res.string.invite_a_friend) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + stringResource(Res.string.view_and_accept_invites_you_may_have), + textAlign = TextAlign.Center + ) + + Button( + onClick = { + onNavigateToViewInvites.invoke() + } + ) { + Text(stringResource(Res.string.view_invites)) } - ) { - Text(stringResource(Res.string.view_invites)) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 7a86f4be..6223c329 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @@ -181,6 +182,11 @@ private fun NotFound( style = MaterialTheme.typography.bodyMedium, ) + // Setting a profile up is signing a kind 0, which a read-only identity cannot do. + // The form is not offered to it; the way out for that kind is Phase 6 of + // docs/npub-sign-in.md. + if (!LocalCanSign.current) return@Column + Text( text = stringResource(Res.string.set_up_a_profile), style = MaterialTheme.typography.titleMedium, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 45da90f4..7ce0f604 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -124,6 +124,7 @@ import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NostrEventDetailUIState import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.NavigationMotion +import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability import press.mantra.compose.ui.theme.breakpoint import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal @@ -430,6 +431,9 @@ fun MantraNavHost( } } + // Once, above every screen: what a read-only identity may not be offered is decided + // where the control is drawn, and this is how the control finds out. + ProvideSigningCapability(sovereignWalletViewModel.activeIdentity) { MantraNavigationSuite( navController = navController, breakpoint = MaterialTheme.breakpoint, @@ -1655,5 +1659,6 @@ fun MantraNavHost( } } } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt new file mode 100644 index 00000000..96127bc5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity + +/** + * Whether the identity the app is running as can sign -- the one question every write + * entrance asks before it is drawn. + * + * A composition local rather than a parameter for the snackbar host's reason: screens + * do not receive the identity, `MetadataEventDetail` -- where *follow* and *send message* + * live -- is several composables below anything that could be handed more, and a + * parameter threaded through twenty-six lists is forgotten in the twenty-seventh. + * [ProvideSigningCapability] sits once, above every screen, in the nav host. + * + * The default is `true` rather than an error, and that is the one way this differs from + * [LocalSnackbarHostState]: the provider cannot be forgotten per screen, so the only + * things composed outside it are previews and tests, and those should render as they + * always have. + * + * A capability, not a kind. A screen asks "can this identity sign?", not "is this an + * npub?", because the answer is what it needs -- and because a remote signer, which can + * sign and holds no local key, would otherwise be a fourth value in every `when`. See + * docs/npub-sign-in.md, Phase 5. + */ +val LocalCanSign: ProvidableCompositionLocal = staticCompositionLocalOf { true } + +/** Provides [LocalCanSign] from the active identity for everything inside [content]. */ +@Composable +fun ProvideSigningCapability( + activeIdentity: StateFlow, + content: @Composable () -> Unit, +) { + val identity by activeIdentity.collectAsState() + // No identity yet -- startup, landing, sign-in -- is not read-only; it is nobody. Those + // screens have no write entrances to hide, and `true` keeps them drawn as they were. + val canSign = identity?.canSign ?: true + CompositionLocalProvider(LocalCanSign provides canSign) { + content() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index d8c0c463..424efa41 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -54,6 +54,7 @@ import press.mantra.compose.ui.view.model.MetadataEventDetailViewModel import press.mantra.compose.ui.view.state.MetadataEventDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -83,6 +84,9 @@ fun MetadataEventDetail( ) { val scope = rememberCoroutineScope() + // Whether this identity can sign, which decides which of the controls below exist. + val canSign = LocalCanSign.current + val profile = localNostrEvent.profile val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -178,6 +182,12 @@ fun MetadataEventDetail( if (metadataEventDetailViewModel.isActionPending.value) { LoadingIndicator() + } else if (!canSign) { + // Edit profile is a kind 0, follow and unfollow a + // kind 3: none of them can be signed by a read-only + // identity, so none is offered. The "follows you" + // state below still shows; it is a fact, not an + // action. } else { if (metadataEventDetailUIState.isActiveUser) { Button( @@ -280,7 +290,9 @@ fun MetadataEventDetail( } } - item { + // A message is a gift wrap, sealed with the sender's key. Not for a + // read-only identity. + if (canSign) item { Button( onClick = { onNavigateToChatRoom.invoke( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt new file mode 100644 index 00000000..a6e95041 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -0,0 +1,178 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ActiveProfileUIState +import press.mantra.compose.ui.view.state.HomeScreenUIState +import kotlin.test.Test + +/** + * The inventory in Phase 5 of docs/npub-sign-in.md, as assertions. + * + * Every write entrance one tap from the three tabs has a row there, and a row without an + * assertion is a row that will regress: the compiler cannot say whether the inventory is + * complete, since `LocalCanSign` is read where a control is drawn and nothing forces a + * control to read it. So each screen is composed twice, once as an identity that can + * sign and once as one that cannot, and the controls are looked for by their text. + * + * The repositories are the no-op ones with the reads each screen makes delegated to a + * fixed answer, as `ChatPaneLayoutJvmTest` does. + */ +@OptIn(ExperimentalTestApi::class) +class ReadOnlyEntrancesJvmTest { + + private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" + private val eventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899" + + // --- Home: the room list, or the empty state with the upgrade --- + + @Test + fun `an identity that can sign is offered a new chat, and no upgrade`() = runDesktopComposeUiTest(400, 900) { + setContent { AsIdentity(canSign = true) { Home() } } + + onNodeWithText("New chat", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a read-only identity is offered the upgrade, and no new chat`() = runDesktopComposeUiTest(400, 900) { + setContent { AsIdentity(canSign = false) { Home() } } + + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("New chat", useUnmergedTree = true).assertDoesNotExist() + } + + /** One column at every width: there is no list to put a detail beside. */ + @Test + fun `a read-only identity in a large window still has one column`() = runDesktopComposeUiTest(1400, 900) { + setContent { AsIdentity(canSign = false) { Home() } } + + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("New chat", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Pick a conversation to read it here.", useUnmergedTree = true).assertDoesNotExist() + } + + // --- Profile: key packages and key recovery, or neither --- + + @Test + fun `an identity that can sign is offered key recovery and key packages`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { ActiveProfile() } } + + onNodeWithText("Key recovery", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Key package management", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a read-only identity is offered neither, and can still sign out`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ActiveProfile() } } + + onNodeWithText("Key recovery", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Key package management", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + } + + // --- harness --- + + @Composable + private fun AsIdentity(canSign: Boolean, content: @Composable () -> Unit) { + MantraTheme { + ProvideSnackbarHost { + CompositionLocalProvider(LocalCanSign provides canSign) { + content() + } + } + } + } + + @Composable + private fun Home() { + HomeScreen( + activeUserPublicKey = publicKey, + initialHomeScreenUIState = HomeScreenUIState.Loaded(profileWithFollowing = profile), + onNavigateToRoute = {}, + onNavigateToDirectMessageDetail = {}, + onNavigateToChatRoomCreation = {}, + nostrRepository = FixedProfile, + chatRepository = NoRooms, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + ) + } + + @Composable + private fun ActiveProfile() { + ActiveProfileScreen( + initialActiveProfileUIState = ActiveProfileUIState.Loaded( + localNostrEvent = LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile), + ), + activeUserPublicKey = publicKey, + nostrEventId = eventId, + onNavigateBack = {}, + onNavigateToRoute = {}, + nostrRepository = FixedProfile, + ) + } + + private val metadataEvent = NostrEvent( + id = eventId, + pubKey = publicKey, + content = """{"name":"Reader"}""", + tags = emptyArray(), + sig = "", + kind = MetadataEvent.KIND, + ) + + private val profile = LocalProfileWithFollowing( + nostrEvent = NostrEvent( + id = eventId, + pubKey = publicKey, + content = "", + tags = emptyArray(), + sig = "", + kind = TextNoteEvent.KIND, + ), + profile = Profile( + publicKey = publicKey, + displayName = "Reader", + nostrEventId = eventId, + ), + following = emptyList(), + ) + + private val FixedProfile = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithFollowing( + publicKey: String, + ): Flow = flowOf(profile) + + override suspend fun observeLocalNostrEventById( + nostrEventId: String, + ): Flow = flowOf(LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile)) + } + + private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeChatRoomListByPublicKey( + publicKey: String, + ): Flow> = flowOf(emptyList()) + } +}