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