diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b74dcb34..c337db92 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -485,4 +485,7 @@ Sign in with a key Switch to it The profile you are in stays on this device. + + Looking for this profile on the relays. + Start new chat diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt new file mode 100644 index 00000000..85ea825c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.looking_for_this_profile_on_the_relays +import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.start_new_chat +import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.Profile +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.ProfilePreviewViewModel +import press.mantra.compose.ui.view.state.ProfilePreviewUIState + +/** + * The person behind a pasted npub, and the one thing to do about them. + * + * Reached from the new-chat dialog by public key, before anything is created: the + * profile block is the confirmation the flow never had, and "Start new chat" hands + * over to `ChatRoomMessagingRoute` exactly as the dialog used to -- the room is made + * where it has always been made, and this screen is popped under it, as the profile + * detail is under a chat started from it. See docs/npub-profile-preview.md. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilePreviewScreen( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + onStartChat: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val profilePreviewViewModel: ProfilePreviewViewModel = viewModel( + factory = ProfilePreviewViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = initialProfilePreviewUIState, + nostrRepository = nostrRepository, + ), + ) + + // Sixty-three characters of bech32, remembered once: they are what the user + // pasted, and the block is not a confirmation without them. + val npub = remember(profilePublicKey) { profilePublicKey.hexToNpubHrp() } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.profile)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> + // The bar is constant, so the transition wraps the content alone. None of the + // branches needs `ColumnScope`, which is the one thing that costs. + ScreenStateTransition(profilePreviewViewModel.profilePreviewUIState) { uiState -> + when (val state = uiState) { + ProfilePreviewUIState.Loading -> { + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Npub(npub) + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.looking_for_this_profile_on_the_relays), + ) + } + } + + is ProfilePreviewUIState.Loaded -> { + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + ProfileBlock(profile = state.profile, publicKey = profilePublicKey, npub = npub) + + // A message is a gift wrap, sealed with the sender's key. Not for a + // read-only identity, which is shown the person and nothing to do. + if (LocalCanSign.current) { + StartChatButton( + onClick = { + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ) + ) + }, + ) + } + } + } + + ProfilePreviewUIState.NotFound -> { + // Nothing went wrong: the search came back empty, and that is the one + // absence this screen has to name. Asking again is the action. + EmptyState( + modifier = Modifier.padding(innerPadding).readableContent(), + message = stringResource(Res.string.we_could_not_find_a_profile_for_this_key), + icon = Icons.Default.PersonSearch, + action = { + TextButton(onClick = { profilePreviewViewModel.retry() }) { + Text(stringResource(Res.string.try_again)) + } + }, + ) + } + + ProfilePreviewUIState.Error -> { + // The queue refused, so nothing was asked; asking again is the retry. + ErrorState( + modifier = Modifier.padding(innerPadding).readableContent(), + onRetry = { profilePreviewViewModel.retry() }, + ) + } + } + } + } + + LaunchedEffect(true) { + if (initialProfilePreviewUIState == ProfilePreviewUIState.Loading) { + profilePreviewViewModel.initiate() + } + } +} + +/** + * Who this is: leading-aligned, because rows with an avatar align to a leading edge + * and centring is for a block that is the only thing on the screen, which this is + * not once the button is under it. + */ +@Composable +private fun ProfileBlock( + profile: Profile, + publicKey: HexKey, + npub: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + ) { + // 75dp is the avatar's size, a dimension and not a spacing, and is what the + // profile detail draws. + ProfileAvatar( + size = 75.dp, + publicKey = publicKey, + profile = profile, + ) + + Text( + text = profile.humanReadableNameOrPubkey(), + style = MaterialTheme.typography.titleLarge, + ) + + // The nip05 or the lightning address: the thing a user compares against what + // they were told. + profile.staticIdentifier()?.let { staticIdentifier -> + Text( + text = staticIdentifier, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // The whole of it. This is the one screen whose job is to let the user read it. + profile.about?.takeIf { it.isNotBlank() }?.let { about -> + Text( + text = about, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Npub(npub) + } +} + +@Composable +private fun Npub(npub: String) { + Text( + text = npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** + * A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite + * confirmation uses: Phase 4 of the plan disables this with a reason beside it, and a + * FAB has no disabled state in M3 or in the API. + */ +@Composable +private fun StartChatButton(onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + ) { + Icon( + Icons.Default.Mail, + // The label is beside it. + contentDescription = Decorative, + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.start_new_chat)) + } +} + +@ConformancePreviews +@Composable +private fun ProfilePreviewScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProfilePreviewScreen( + activeUserPublicKey = "a".repeat(64), + profilePublicKey = "b".repeat(64), + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded( + profile = Profile( + publicKey = "b".repeat(64), + displayName = "John Doe", + nip05 = "john@doe.com", + about = "I am the stone that the builder refused.", + nostrEventId = "nostrEventId", + ), + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + onStartChat = {}, + onNavigateBack = {}, + ) + } + } +} 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 dfd6fda4..4ba2210d 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 @@ -86,8 +86,10 @@ import press.mantra.compose.ui.composable.navigation.routes.LandingRoute import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute +import press.mantra.compose.ui.composable.ProfilePreviewScreen import press.mantra.compose.ui.composable.ProfilesScreen import press.mantra.compose.ui.view.model.ProfilesViewModel import press.mantra.compose.ui.view.model.getAvailableWalletsMeta @@ -1450,6 +1452,30 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProfilePreviewScreen( + activeUserPublicKey = route.activeUserPublicKey, + profilePublicKey = route.profilePublicKey, + nostrRepository = databaseNostrRepository, + // The room replaces the preview, as it replaces the profile detail + // above: back from a chat is the list, not the person it was started + // from. + onStartChat = { chatRoom -> + navController.navigate( + route = chatRoom + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateBack = { + navController.popBackStack() + }, + ) + } composable { backStackEntry -> val route: ImplementationPendingRoute = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt new file mode 100644 index 00000000..3b7a1dac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt @@ -0,0 +1,18 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.serialization.Serializable + +/** + * The person behind a pasted npub, before a chat with them is started. + * + * Keyed by public key rather than by a kind 0's event id, because that is all the + * entrance has -- and it is why the same screen will serve a scanned code or a + * `nostr:npub…` link. No relay hint: the dialog has none to give. + * See docs/npub-profile-preview.md. + */ +@Serializable +data class ProfilePreviewRoute( + val activeUserPublicKey: String, + val profilePublicKey: HexKey, +): Route() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt new file mode 100644 index 00000000..596b548c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt @@ -0,0 +1,153 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +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.ProfilePreviewUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The person behind a pasted npub on their own screen: what is drawn in each state, + * what the button hands over, and who is offered it. + */ +@OptIn(ExperimentalTestApi::class) +class ProfilePreviewScreenJvmTest { + + private val activeUserPublicKey = "a".repeat(64) + private val profilePublicKey = "b".repeat(64) + private val npub = profilePublicKey.hexToNpubHrp() + + private val alice = Profile( + publicKey = profilePublicKey, + displayName = "Alice", + nip05 = "alice@example.com", + about = "Translating hard books into Sesotho, one chapter at a time.", + nostrEventId = "kind0", + ) + + @Test + fun `a found profile is drawn whole, with the npub that was pasted`() = render( + ProfilePreviewUIState.Loaded(profile = alice) + ) { + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("alice@example.com").assertIsDisplayed() + onNodeWithText("Translating hard books into Sesotho, one chapter at a time.").assertIsDisplayed() + onNodeWithText(npub).assertIsDisplayed() + } + + @Test + fun `starting a chat hands over the route the dialog used to push, and creates nothing here`() { + var opened: Route? = null + + render(ProfilePreviewUIState.Loaded(profile = alice), onStartChat = { opened = it }) { + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed().performClick() + } + + assertEquals( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ), + opened, + ) + } + + /** A message is a gift wrap sealed with the sender's key; a read-only identity is shown the person and nothing to do. */ + @Test + fun `a read-only identity sees the person and no button`() = render( + ProfilePreviewUIState.Loaded(profile = alice), + canSign = false, + ) { + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + /** Arriving loading is the one case that runs `initiate`, so this render is given relays that never answer. */ + @Test + fun `while the relays are asked the npub is already on screen`() = render( + ProfilePreviewUIState.Loading, + nostrRepository = NothingArrives, + ) { + onNodeWithText(npub).assertIsDisplayed() + onNodeWithText("Looking for this profile on the relays.").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `nothing found says so and offers to ask again`() = render( + ProfilePreviewUIState.NotFound + ) { + onNodeWithText("We could not find a profile for this key on the relays we asked.").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a queue that refused is an error with a retry`() = render( + ProfilePreviewUIState.Error + ) { + onNodeWithText("Something went wrong").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + } + + /** No row on the device, and asks that go nowhere: the screen stays loading for as long as it is looked at. */ + private val NothingArrives = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithPublicKey(publicKey: String): Flow = flowOf(null) + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) = Unit + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit + } + + /** + * The screen at a phone's width. The state is passed in, so the NO_OP repository is + * never asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: ProfilePreviewUIState, + canSign: Boolean = true, + nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + onStartChat: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 1200) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CompositionLocalProvider(LocalCanSign provides canSign) { + Box(modifier = Modifier.fillMaxSize()) { + ProfilePreviewScreen( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = uiState, + nostrRepository = nostrRepository, + onStartChat = onStartChat, + onNavigateBack = {}, + ) + } + } + } + } + } + + assertions() + } +} 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 index 5239ebb5..f59dd64d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -44,6 +44,7 @@ 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 press.mantra.compose.ui.view.state.ProfilePreviewUIState import kotlin.test.Test import kotlin.test.assertEquals @@ -93,6 +94,24 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Pick a conversation to read it here.", useUnmergedTree = true).assertDoesNotExist() } + // --- Profile preview: the one thing to do about a person, or nothing --- + + @Test + fun `an identity that can sign is offered a chat with the person it looked up`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { ProfilePreview() } } + + onNodeWithText("Reader", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a read-only identity is shown the person and offered nothing`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ProfilePreview() } } + + onNodeWithText("Reader", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + // --- Profile: key packages and key recovery, or neither --- @Test @@ -266,6 +285,19 @@ class ReadOnlyEntrancesJvmTest { ) } + /** The person behind a pasted npub, already found: the state is passed in, so nothing is asked. */ + @Composable + private fun ProfilePreview() { + ProfilePreviewScreen( + activeUserPublicKey = publicKey, + profilePublicKey = publicKey, + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile.profile), + nostrRepository = FixedProfile, + onStartChat = {}, + onNavigateBack = {}, + ) + } + @Composable private fun ActiveProfile(signOut: SignOutDependencies? = null) { ActiveProfileScreen(