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:
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user