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())
+ }
+}