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:
Kgothatso Ngako
2026-09-12 20:38:18 +02:00
parent 4c04d3b12b
commit f5a6f74731
10 changed files with 353 additions and 34 deletions

View File

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

View File

@@ -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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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