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(