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 <noreply@anthropic.com>
Pulled-From: curated/curated@8973c0058e
This commit is contained in:
Kgothatso Ngako
2026-09-13 12:55:19 +02:00
parent f84ae13394
commit 39c056a2f2
6 changed files with 514 additions and 0 deletions

View File

@@ -485,4 +485,7 @@
<string name="sign_in_with_a_key">Sign in with a key</string>
<string name="switch_to_it">Switch to it</string>
<string name="the_profile_you_are_in_stays_on_this_device">The profile you are in stays on this device.</string>
<!-- The profile preview: the person behind a pasted npub, before a chat with them is started. -->
<string name="looking_for_this_profile_on_the_relays">Looking for this profile on the relays.</string>
<string name="start_new_chat">Start new chat</string>
</resources>

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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<ProfilePreviewRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ProfilePreviewRoute>()
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<ImplementationPendingRoute> { backStackEntry ->
val route: ImplementationPendingRoute = backStackEntry.toRoute()

View File

@@ -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()

View File

@@ -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<Profile?> = flowOf(null)
override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>) = Unit
override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List<NegentropySynchronizeRequest>) = 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()
}
}

View File

@@ -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(