feat(ui): what a read-only identity is shown, and what it is not
Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a product. One question, asked in one way. LocalCanSign is a composition local for the snackbar host's reason: screens do not receive the identity, MetadataEventDetail -- where follow and send message live -- is several composables below anything that could be handed more, and a parameter threaded through twenty-six lists is forgotten in the twenty-seventh. ProvideSigningCapability sits once above the navigation suite. The default is true rather than an error, the one way this differs from the snackbar host: the provider cannot be forgotten per screen, so the only things composed outside it are previews and tests. And it is a capability, not a kind -- "can this identity sign?", not "is this an npub?" -- so that a remote signer is not a fourth value in every when. The inventory, hidden rather than disabled because the empty state beside each says why: HomeScreen's New chat in both layouts, its sheet and its npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit profile and send message (the "follows you" state still shows -- a fact, not an action); ActiveProfileScreen's key package management and key recovery; ShareProfileScreen's re-broadcast, which signs nothing but queues a copy for the finder relays, and a read-only identity puts nothing on a relay; SocialPreconditionScreen's invite and view invites, pending today and writes when they exist; and the not-found screen's set-up form, since a kind 0 has to be signed. Everything else that writes is behind a chat room, which a read-only identity can never open. The Messages tab, for a read-only identity, is an EmptyState where the rooms would be -- one column at every width, since a two-pane layout is a list beside a detail and there is no list -- whose message says which absence it is and whose action is the upgrade: Sign in with the nsec, which opens the same screen as landing's, hits the credentials file's upgrade rule, and comes back through startup with rooms in it. ChatRoomListViewModel is not composed, so the inbox sync and the MLS negentropy it would queue are not queued. Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens under each value of LocalCanSign and looks for the controls by text, on the unmerged tree so that "does not exist" is not vacuous. The M3 audit's budgets hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@e31033e857
This commit is contained in:
@@ -50,6 +50,7 @@ import press.mantra.compose.ui.view.model.ActiveProfileViewModel
|
||||
import press.mantra.compose.ui.view.state.ActiveProfileUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -84,6 +85,9 @@ fun ActiveProfileScreen(
|
||||
nostrRepository: NostrRepository,
|
||||
) {
|
||||
|
||||
// Whether this identity can sign, which decides which rows below exist.
|
||||
val canSign = LocalCanSign.current
|
||||
|
||||
val activeProfileViewModel: ActiveProfileViewModel = viewModel(
|
||||
factory = ActiveProfileViewModel.factory(
|
||||
nostrEventId = nostrEventId,
|
||||
@@ -226,7 +230,10 @@ fun ActiveProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
// Key packages are signed, and there is nothing to recover for
|
||||
// an identity the device holds no secret for: neither row for a
|
||||
// read-only identity.
|
||||
if (canSign) item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
@@ -279,7 +286,7 @@ fun ActiveProfileScreen(
|
||||
}
|
||||
|
||||
|
||||
item {
|
||||
if (canSign) item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -24,6 +25,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -45,6 +47,7 @@ import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SignInRoute
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog
|
||||
import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog
|
||||
@@ -74,6 +77,9 @@ import press.mantra.compose.ui.theme.breakpoint
|
||||
import press.mantra.compose.ui.theme.listPaneWidthFor
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here
|
||||
import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_profile
|
||||
import mantra.composeapp.generated.resources.sign_in_with_the_nsec
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
|
||||
@@ -120,6 +126,9 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Whether this identity can sign: the one thing the read-only branch below turns on.
|
||||
val canSign = LocalCanSign.current
|
||||
|
||||
val homeScreenViewModel: HomeViewModel = viewModel(
|
||||
factory = HomeViewModel.factory(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
@@ -178,7 +187,11 @@ fun HomeScreen(
|
||||
// the bottom-right of the *window*, which is on top of the
|
||||
// transcript's send button; M3 puts a list-detail layout's primary
|
||||
// action in the list pane, and so does the branch below.
|
||||
if (listPaneWidth == null) NewChatButton { showBottomSheet = true }
|
||||
// And only for an identity that can sign: a new chat is a key
|
||||
// package, a welcome and a gift wrap, none of which a read-only
|
||||
// identity can produce. Hidden, not disabled -- the empty state
|
||||
// beside it says why.
|
||||
if (listPaneWidth == null && canSign) NewChatButton { showBottomSheet = true }
|
||||
}
|
||||
) { innerPadding ->
|
||||
@Composable
|
||||
@@ -242,7 +255,21 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (listPaneWidth == null) {
|
||||
if (!canSign) {
|
||||
// One column at every width. The two-pane layout is a list beside
|
||||
// a detail, and there is no list: every room is MLS or a gift wrap,
|
||||
// encrypted to the key this identity does not hold. The empty state
|
||||
// is the only thing on the screen, which is the one case
|
||||
// readableContent()'s centring is for -- and its action is where
|
||||
// the upgrade lives. ChatRoomListViewModel is not composed, so the
|
||||
// inbox sync and the MLS negentropy it would queue are not queued.
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
ReadOnlyInbox(onSignInWithTheNsec = { onNavigateToRoute(SignInRoute) })
|
||||
}
|
||||
} else if (listPaneWidth == null) {
|
||||
// Compact and medium: the list is the screen, and a room is a route.
|
||||
// Byte for byte the layout this screen has always had.
|
||||
ChatRoomListPane(
|
||||
@@ -311,7 +338,10 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
// Neither can open without the button above, and the button is hidden for
|
||||
// an identity that cannot sign; the guard here is so that the two cannot
|
||||
// drift apart.
|
||||
if (showBottomSheet && canSign) {
|
||||
NewChatBottomSheetDialog(
|
||||
scope = scope,
|
||||
sheetState = sheetState,
|
||||
@@ -327,7 +357,7 @@ fun HomeScreen(
|
||||
}
|
||||
|
||||
when {
|
||||
openNpubDialog.value -> {
|
||||
openNpubDialog.value && canSign -> {
|
||||
StartDirectMessageToNpubOrNip05Dialog(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
scope = scope,
|
||||
@@ -394,6 +424,28 @@ It has survived not only five centuries, but also the leap into electronic types
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a read-only identity sees where its rooms would be, and the one thing that fills
|
||||
* it.
|
||||
*
|
||||
* `EmptyState`'s message is required for exactly this reason -- an absence has to say
|
||||
* which absence it is -- and its action slot is where the upgrade lives: the nsec pasted
|
||||
* on the sign-in screen hits the credentials file's upgrade rule, and startup takes the
|
||||
* user back here with rooms in it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReadOnlyInbox(onSignInWithTheNsec: () -> Unit) {
|
||||
EmptyState(
|
||||
message = stringResource(Res.string.messages_need_the_secret_key_this_profile),
|
||||
icon = Icons.Default.Lock,
|
||||
action = {
|
||||
TextButton(onClick = onSignInWithTheNsec) {
|
||||
Text(stringResource(Res.string.sign_in_with_the_nsec))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one action the chat list offers, in whichever slot the layout has for it.
|
||||
*
|
||||
|
||||
@@ -41,6 +41,7 @@ import press.mantra.compose.ui.view.model.ShareProfileViewModel
|
||||
import press.mantra.compose.ui.view.state.ShareProfileUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -176,7 +177,11 @@ fun ShareProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
// Nothing is signed by a re-broadcast, but a copy of the kind 0 is
|
||||
// queued for the finder relays, and a read-only identity puts nothing
|
||||
// on a relay -- not even a copy. The broadcast pump is not running for
|
||||
// it to carry the request anyway.
|
||||
if (LocalCanSign.current) Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -88,36 +89,40 @@ fun SocialPreconditionScreen(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onNavigateToInviteFriend.invoke()
|
||||
}
|
||||
) {
|
||||
// Both pending today, and both writes when they exist: an invite is sent,
|
||||
// an acceptance signed. A read-only identity is offered only the way past.
|
||||
if (LocalCanSign.current) {
|
||||
Text(
|
||||
stringResource(Res.string.invite_a_friend)
|
||||
stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.view_and_accept_invites_you_may_have),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onNavigateToViewInvites.invoke()
|
||||
Button(
|
||||
onClick = {
|
||||
onNavigateToInviteFriend.invoke()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.invite_a_friend)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.view_and_accept_invites_you_may_have),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onNavigateToViewInvites.invoke()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.view_invites))
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.view_invites))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.widgets.Decorative
|
||||
import press.mantra.compose.ui.composable.widgets.EmptyState
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
@@ -181,6 +182,11 @@ private fun NotFound(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// Setting a profile up is signing a kind 0, which a read-only identity cannot do.
|
||||
// The form is not offered to it; the way out for that kind is Phase 6 of
|
||||
// docs/npub-sign-in.md.
|
||||
if (!LocalCanSign.current) return@Column
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.set_up_a_profile),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
|
||||
@@ -124,6 +124,7 @@ import press.mantra.compose.ui.view.state.NavigationUIState
|
||||
import press.mantra.compose.ui.view.state.NostrEventDetailUIState
|
||||
import press.mantra.compose.ui.view.state.SearchUIState
|
||||
import press.mantra.compose.ui.theme.NavigationMotion
|
||||
import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability
|
||||
import press.mantra.compose.ui.theme.breakpoint
|
||||
import co.touchlab.kermit.Logger
|
||||
import fr.acinq.phoenix.PhoenixGlobal
|
||||
@@ -430,6 +431,9 @@ fun MantraNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Once, above every screen: what a read-only identity may not be offered is decided
|
||||
// where the control is drawn, and this is how the control finds out.
|
||||
ProvideSigningCapability(sovereignWalletViewModel.activeIdentity) {
|
||||
MantraNavigationSuite(
|
||||
navController = navController,
|
||||
breakpoint = MaterialTheme.breakpoint,
|
||||
@@ -1655,5 +1659,6 @@ fun MantraNavHost(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package press.mantra.compose.ui.composable.widgets
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import press.mantra.compose.identity.Identity
|
||||
|
||||
/**
|
||||
* Whether the identity the app is running as can sign -- the one question every write
|
||||
* entrance asks before it is drawn.
|
||||
*
|
||||
* A composition local rather than a parameter for the snackbar host's reason: screens
|
||||
* do not receive the identity, `MetadataEventDetail` -- where *follow* and *send message*
|
||||
* live -- is several composables below anything that could be handed more, and a
|
||||
* parameter threaded through twenty-six lists is forgotten in the twenty-seventh.
|
||||
* [ProvideSigningCapability] sits once, above every screen, in the nav host.
|
||||
*
|
||||
* The default is `true` rather than an error, and that is the one way this differs from
|
||||
* [LocalSnackbarHostState]: the provider cannot be forgotten per screen, so the only
|
||||
* things composed outside it are previews and tests, and those should render as they
|
||||
* always have.
|
||||
*
|
||||
* A capability, not a kind. A screen asks "can this identity sign?", not "is this an
|
||||
* npub?", because the answer is what it needs -- and because a remote signer, which can
|
||||
* sign and holds no local key, would otherwise be a fourth value in every `when`. See
|
||||
* docs/npub-sign-in.md, Phase 5.
|
||||
*/
|
||||
val LocalCanSign: ProvidableCompositionLocal<Boolean> = staticCompositionLocalOf { true }
|
||||
|
||||
/** Provides [LocalCanSign] from the active identity for everything inside [content]. */
|
||||
@Composable
|
||||
fun ProvideSigningCapability(
|
||||
activeIdentity: StateFlow<Identity?>,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val identity by activeIdentity.collectAsState()
|
||||
// No identity yet -- startup, landing, sign-in -- is not read-only; it is nobody. Those
|
||||
// screens have no write entrances to hide, and `true` keeps them drawn as they were.
|
||||
val canSign = identity?.canSign ?: true
|
||||
CompositionLocalProvider(LocalCanSign provides canSign) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import press.mantra.compose.ui.view.model.MetadataEventDetailViewModel
|
||||
import press.mantra.compose.ui.view.state.MetadataEventDetailUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import press.mantra.compose.ui.composable.widgets.LocalCanSign
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -83,6 +84,9 @@ fun MetadataEventDetail(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Whether this identity can sign, which decides which of the controls below exist.
|
||||
val canSign = LocalCanSign.current
|
||||
|
||||
val profile = localNostrEvent.profile
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
@@ -178,6 +182,12 @@ fun MetadataEventDetail(
|
||||
|
||||
if (metadataEventDetailViewModel.isActionPending.value) {
|
||||
LoadingIndicator()
|
||||
} else if (!canSign) {
|
||||
// Edit profile is a kind 0, follow and unfollow a
|
||||
// kind 3: none of them can be signed by a read-only
|
||||
// identity, so none is offered. The "follows you"
|
||||
// state below still shows; it is a fact, not an
|
||||
// action.
|
||||
} else {
|
||||
if (metadataEventDetailUIState.isActiveUser) {
|
||||
Button(
|
||||
@@ -280,7 +290,9 @@ fun MetadataEventDetail(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
// A message is a gift wrap, sealed with the sender's key. Not for a
|
||||
// read-only identity.
|
||||
if (canSign) item {
|
||||
Button(
|
||||
onClick = {
|
||||
onNavigateToChatRoom.invoke(
|
||||
|
||||
Reference in New Issue
Block a user