From 39c056a2f2702dd7f2df509f20975b5906282b34 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:55:19 +0200 Subject: [PATCH] feat(ui): the profile preview screen, reachable by nothing yet Phase 2 of docs/npub-profile-preview.md. ProfilePreviewRoute is keyed by public key, because that is all the entrance has, and ProfilePreviewScreen draws the four states over a constant bar: the npub above the indicator while the relays are asked, the person -- avatar, name, nip05, the whole of the about, the npub that was pasted -- and under them "Start new chat", which hands over the route the dialog pushes today and creates nothing here. Not found is the sign-in screen's sentence with a try-again; a read-only identity is shown the person and nothing to do, and ReadOnlyEntrancesJvmTest gains the row. Registered in the host with the room replacing the preview, as it replaces the profile detail. No button navigates to it until Phase 3. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@8973c0058eefe8e7c9aae2f9642391cd16a32553 --- .../composeResources/values/strings.xml | 3 + .../ui/composable/ProfilePreviewScreen.kt | 282 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 26 ++ .../navigation/routes/ProfilePreviewRoute.kt | 18 ++ .../composable/ProfilePreviewScreenJvmTest.kt | 153 ++++++++++ .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 32 ++ 6 files changed, 514 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt 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(