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