feat(identity): two exits for an identity that holds no key

Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors.

ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out --
the credential out of the file, the metadata hidden, the account rows
gone, in that order so a failure partway leaves the credential on disk
rather than an identity the selector lists but nothing can open. Since the
credentials file the first step is the same call for both credential
kinds, so it is one function; a mnemonic identity is refused at the first
step, because removing a seed is a wallet question this does not answer.
The nsec view model calls it now and keeps only its own state.

Sign out on the profile tab does, for a read-only identity only, what its
colour has been promising: a confirmation naming the npub -- this device
holds no key for it, so there is nothing to lose; the profile stays on the
relays -- then the identity leaves the device and the nav host's tail
re-lists and clears the active identity, which shows the selector or, if
this was the last one, Landing. It is the first real sign out in the app.
For the other two kinds the button keeps its pending route. SignOutViewModel
owns the confirmation and the in-flight state; SignOutDependencies bundles
what the screen needs so that a caller with none of it -- previews, tests
-- passes nothing.

The not-found screen had, for a read-only identity, no exit: Phase 5 hid
the set-up form (a kind 0 has to be signed), the profile tab is not
reachable before ProfileLoaded, and a user whose npub was found on no relay
could try again for ever. So a third action, shown only where the second is
not: use a different key, which is the same sequence behind the same
dialog, reached from the other end of the identity's life.

Tests: the sequence and where it stops, the view model's states around it,
and the two screens composed as each kind -- the profile's sign out asking
first and naming the npub, the not-found screen offering the form to one
kind and the other key to the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@a586b7c116
This commit is contained in:
Kgothatso Ngako
2026-09-12 20:45:34 +02:00
parent f5a6f74731
commit 786ac15959
11 changed files with 678 additions and 19 deletions

View File

@@ -0,0 +1,50 @@
package press.mantra.compose.identity
import press.mantra.compose.repository.NostrRepository
/**
* Takes an identity off the device: the credential out of the file, its preference
* files deleted, its metadata hidden, its account rows gone -- in that order, so a
* failure partway leaves the credential on disk rather than an identity the selector
* lists but nothing can open. The caller then re-lists and clears the active identity.
*
* Lifted out of `NostrSecretViewModel.forgetKey`, where it was written for an nsec, so
* that the two exits a read-only identity has -- sign out, and *use a different key* on
* the not-found screen -- run the same sequence rather than a copy of it. The first step
* is the same call for both credential kinds since the credentials file; only a mnemonic
* identity is refused, because removing a seed is a wallet question this does not
* answer.
*
* The two writers are injected as functions so the sequence can be pinned in a test
* without a key store, as the nsec view model's already is.
*/
object ForgetIdentity {
sealed interface Outcome {
/** The device no longer holds anything for the identity. */
data object Forgotten : Outcome
/** A mnemonic identity: its key is in the seed, and this does not remove seeds. */
data object NotACredential : Outcome
/** The credentials file could not be read; nothing was changed. */
data object CannotForget : Outcome
}
suspend fun forget(
identity: Identity,
nostrRepository: NostrRepository,
forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult,
hideIdentityMetadata: suspend (Identity) -> Unit,
): Outcome = when (forgetNostrCredential(identity)) {
is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> {
hideIdentityMetadata(identity)
// The kind 0 that made it a local account, and anything queued that can now
// never be signed. Published events and the profile cache stay.
nostrRepository.forgetLocalAccount(identity.nostrPublicKey)
Outcome.Forgotten
}
is IdentityWriter.ForgetNostrCredentialResult.NotACredential -> Outcome.NotACredential
is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> Outcome.CannotForget
}
}

View File

@@ -62,6 +62,16 @@ import mantra.composeapp.generated.resources.profile
import mantra.composeapp.generated.resources.key_recovery
import mantra.composeapp.generated.resources.share_profile
import mantra.composeapp.generated.resources.sign_out
import mantra.composeapp.generated.resources.sign_out_of_this_profile_question
import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.view.model.SignOutViewModel
import kotlinx.coroutines.flow.StateFlow
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import mantra.composeapp.generated.resources.something_went_wrong
import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please
import press.mantra.compose.ui.composable.widgets.ErrorState
@@ -83,11 +93,42 @@ fun ActiveProfileScreen(
onNavigateBack: () -> Unit,
onNavigateToRoute: (Route) -> Unit,
nostrRepository: NostrRepository,
// Sign out is real for a read-only identity only -- there is nothing on the device
// to lose -- and these are what it takes. Null where the screen is composed without
// an identity to sign out of (previews, tests), in which case the button keeps its
// pending route for every kind.
signOut: SignOutDependencies? = null,
) {
// Whether this identity can sign, which decides which rows below exist.
val canSign = LocalCanSign.current
val signOutViewModel: SignOutViewModel? = signOut?.let {
viewModel(
factory = SignOutViewModel.factory(
activeIdentityStateFlow = it.activeIdentityStateFlow,
nostrRepository = nostrRepository,
forgetNostrCredential = it.forgetNostrCredential,
hideIdentityMetadata = it.hideIdentityMetadata,
),
)
}
val signOutState = signOutViewModel?.state?.collectAsState()?.value
if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) {
SignOutOfReadOnlyDialog(
title = Res.string.sign_out_of_this_profile_question,
nostrPublicKey = signOutViewModel.nostrPublicKey,
onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) },
onDismiss = { signOutViewModel.cancel() },
)
}
// The failure is short and needs no action: the button is still there to try again.
val notify = rememberNotifier(rememberCoroutineScope())
val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again)
LaunchedEffect(signOutState) {
if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut)
}
val activeProfileViewModel: ActiveProfileViewModel = viewModel(
factory = ActiveProfileViewModel.factory(
nostrEventId = nostrEventId,
@@ -339,14 +380,26 @@ fun ActiveProfileScreen(
// destructive action. It now matches how leaving and
// deleting a group are already treated in
// ChatRoomDetailScreen: a TextButton in the error colour.
//
// For a read-only identity it does what its colour has been
// promising: a confirmation naming the npub, then the
// identity leaves the device. For the other two kinds it
// keeps routing to the pending screen -- removing a seed is
// a wallet question, and an nsec has its own forget under
// key recovery.
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = signOutState !is SignOutViewModel.State.InProgress,
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Sign out")
)
if (!canSign && signOutViewModel != null) {
signOutViewModel.askToSignOut()
} else {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Sign out")
)
}
}
) {
Icon(
@@ -387,6 +440,19 @@ fun ActiveProfileScreen(
}
}
/**
* What signing out of a read-only identity needs, bundled so that the screen's parameter
* list gains one line rather than four, and so a caller that has none of them passes
* nothing rather than three lambdas that throw.
*/
class SignOutDependencies(
val activeIdentityStateFlow: StateFlow<Identity?>,
val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult,
val hideIdentityMetadata: suspend (Identity) -> Unit,
/** The nav host's tail: re-list, and clear the active identity so startup shows the selector or Landing. */
val onSignedOut: () -> Unit,
)
@ConformancePreviews
@Composable
private fun UnannouncedProfileScreenPreview() {

View File

@@ -41,6 +41,14 @@ import mantra.composeapp.generated.resources.it_may_be_new_or_it_may_live_on_rel
import mantra.composeapp.generated.resources.name_eg_alan_turing
import mantra.composeapp.generated.resources.set_up_a_profile
import mantra.composeapp.generated.resources.try_again
import mantra.composeapp.generated.resources.use_a_different_key
import mantra.composeapp.generated.resources.use_a_different_key_question
import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again
import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.view.model.SignOutViewModel
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many
import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your
import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key
@@ -70,6 +78,10 @@ import press.mantra.compose.ui.view.state.UnsyncedProfileUIState
fun UnsyncedProfileScreen(
unsyncedProfilePublicKey: String,
nostrRepository: NostrRepository,
// The not-found state's exit for an identity that cannot set a profile up: the same
// act as signing out, reached from the other end of the identity's life. Null where
// there is no identity to leave (previews), in which case that kind has no exit here.
signOut: SignOutDependencies? = null,
) {
val viewModel: UnsyncedProfileViewModel = viewModel(
factory = UnsyncedProfileViewModel.factory(
@@ -79,6 +91,31 @@ fun UnsyncedProfileScreen(
)
val uiState by viewModel.uiState.collectAsState()
val signOutViewModel: SignOutViewModel? = signOut?.let {
viewModel(
factory = SignOutViewModel.factory(
activeIdentityStateFlow = it.activeIdentityStateFlow,
nostrRepository = nostrRepository,
forgetNostrCredential = it.forgetNostrCredential,
hideIdentityMetadata = it.hideIdentityMetadata,
),
)
}
val signOutState = signOutViewModel?.state?.collectAsState()?.value
if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) {
SignOutOfReadOnlyDialog(
title = Res.string.use_a_different_key_question,
nostrPublicKey = signOutViewModel.nostrPublicKey,
onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) },
onDismiss = { signOutViewModel.cancel() },
)
}
val notify = rememberNotifier(rememberCoroutineScope())
val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again)
LaunchedEffect(signOutState) {
if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut)
}
// imePadding for the not-found form's fields.
Scaffold(
modifier = Modifier.imePadding(),
@@ -94,6 +131,7 @@ fun UnsyncedProfileScreen(
is UnsyncedProfileUIState.NotFound -> NotFound(
viewModel = viewModel,
unsignedNostrEventId = state.unsignedNostrEventId,
onUseADifferentKey = signOutViewModel?.let { { it.askToSignOut() } },
)
}
}
@@ -150,6 +188,8 @@ private fun Searching() {
private fun NotFound(
viewModel: UnsyncedProfileViewModel,
unsignedNostrEventId: Long,
/** Shown only where the set-up form is not; null when there is no identity to leave. */
onUseADifferentKey: (() -> Unit)?,
) {
val nameField = viewModel.formState.nameField.textFieldState
val biographyField = viewModel.formState.biographyField.textFieldState
@@ -183,9 +223,23 @@ private fun NotFound(
)
// 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
// The form is not offered to it -- and then this state would have no exit: the
// profile tab is not reachable before ProfileLoaded, so a user whose npub was not
// found on any relay could try again for ever. So, for that kind only, the third
// action: the identity leaves the device, and the way back is the selector or
// Landing.
if (!LocalCanSign.current) {
if (onUseADifferentKey != null) {
TextButton(
modifier = Modifier.align(Alignment.End),
enabled = !isActionPending,
onClick = onUseADifferentKey,
) {
Text(text = stringResource(Res.string.use_a_different_key))
}
}
return@Column
}
Text(
text = stringResource(Res.string.set_up_a_profile),

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.SignOutDependencies
import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability
import press.mantra.compose.ui.theme.breakpoint
import co.touchlab.kermit.Logger
@@ -431,6 +432,24 @@ fun MantraNavHost(
}
}
// What signing out of a read-only identity takes, for the two screens that offer it.
// The tail is the nsec forget's: the device no longer holds anything for the
// identity, so re-list -- the selector drops it -- then clear the active identity,
// and the navigation observer sends a null identity to startup, which shows the
// selector or, if this was the last one, Landing.
val signOutOfReadOnlyIdentity = remember(sovereignWalletViewModel) {
SignOutDependencies(
activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) },
hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) },
onSignedOut = {
sovereignWalletViewModel.listIdentities {
sovereignWalletViewModel.resetToSelector()
}
},
)
}
// 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) {
@@ -747,6 +766,7 @@ fun MantraNavHost(
UnsyncedProfileScreen(
unsyncedProfilePublicKey = route.publicKey,
nostrRepository = databaseNostrRepository,
signOut = signOutOfReadOnlyIdentity,
)
}
composable<UnqueuedProfileSynchronizationRoute> { backStackEntry ->
@@ -847,7 +867,8 @@ fun MantraNavHost(
navController.navigate(
route = route
)
}
},
signOut = signOutOfReadOnlyIdentity,
)
}
composable<ShareProfileRoute> { backStackEntry ->

View File

@@ -0,0 +1,43 @@
package press.mantra.compose.ui.composable.widgets.dialogs
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.cancel
import mantra.composeapp.generated.resources.sign_out
import mantra.composeapp.generated.resources.this_device_holds_no_key_for_s_so_there
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.ui.composable.widgets.Decorative
/**
* The confirmation before a read-only identity leaves the device, naming the npub so
* the user sees which one. Shared by the profile tab's *sign out* and the not-found
* screen's *use a different key*, which differ only in what the title calls it.
*/
@Composable
fun SignOutOfReadOnlyDialog(
title: StringResource,
nostrPublicKey: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Logout, contentDescription = Decorative) },
title = { Text(stringResource(title)) },
text = { Text(stringResource(Res.string.this_device_holds_no_key_for_s_so_there, nostrPublicKey?.hexToNpubHrp() ?: "")) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(Res.string.sign_out)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
},
)
}

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.extensions.hexToNsecHrp
import press.mantra.compose.identity.ForgetIdentity
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.IdentityWriter
@@ -46,8 +47,9 @@ import press.mantra.compose.ui.view.state.NostrSecretUIState
* secret it is.
*
* It also owns the inverse of an import: [forgetKey], which removes the key from the
* device and the account with it. Only for a bare key; a mnemonic identity's key is in
* the seed, and removing a seed is a wallet question this does not answer.
* device and the account with it, through [ForgetIdentity]. Only for a bare key; a
* mnemonic identity's key is in the seed, and removing a seed is a wallet question this
* does not answer.
*/
class NostrSecretViewModel(
val phoenixGlobal: PhoenixGlobal,
@@ -163,9 +165,9 @@ class NostrSecretViewModel(
/**
* Key out of the file, preferences deleted, metadata hidden, account rows gone -- in
* that order, so a failure partway leaves the key on disk rather than an identity the
* selector lists but nothing can open. [onForgotten] runs on the main thread once the
* device no longer holds the key; the caller clears the active identity and re-lists.
* that order, for the reason [ForgetIdentity] gives. [onForgotten] runs on the main
* thread once the device no longer holds the key; the caller clears the active
* identity and re-lists.
*/
fun forgetKey(onForgotten: () -> Unit) {
val identity = activeIdentityStateFlow.value ?: return
@@ -177,15 +179,15 @@ class NostrSecretViewModel(
logger.e("could not forget the key", throwable)
_forgetting.value = NostrSecretUIState.Forgetting.Failed
}) {
when (forgetNostrCredential(identity)) {
is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> {
hideIdentityMetadata(identity)
nostrRepository.forgetLocalAccount(identity.nostrPublicKey)
// The sequence is ForgetIdentity's, shared with the two exits a read-only
// identity has; only the state it drives is this screen's.
when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) {
is ForgetIdentity.Outcome.Forgotten -> {
_forgetting.value = NostrSecretUIState.Forgetting.Idle
withContext(Dispatchers.Main) { onForgotten() }
}
is IdentityWriter.ForgetNostrCredentialResult.NotACredential,
is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> {
is ForgetIdentity.Outcome.NotACredential,
is ForgetIdentity.Outcome.CannotForget -> {
_forgetting.value = NostrSecretUIState.Forgetting.Failed
}
}

View File

@@ -0,0 +1,106 @@
package press.mantra.compose.ui.view.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.identity.ForgetIdentity
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.repository.NostrRepository
/**
* Signing out of a read-only identity, which is forgetting it: there is nothing on the
* device to lose, so it is the first real sign out in the app. The general case -- a
* seed, with funds behind it -- stays pending, for the reason docs/nsec-sign-in.md gives.
*
* Two screens drive this with the same state: the profile tab's *sign out*, and the
* not-found screen's *use a different key*, which is the same act reached from the other
* end of the identity's life. The sequence is [ForgetIdentity]'s; this owns only the
* confirmation and the in-flight state, the way `NostrSecretViewModel` does for an nsec.
*/
class SignOutViewModel(
private val activeIdentityStateFlow: StateFlow<Identity?>,
private val nostrRepository: NostrRepository,
private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult,
private val hideIdentityMetadata: suspend (Identity) -> Unit,
) : ViewModel() {
sealed interface State {
data object Idle : State
data object Confirming : State
data object InProgress : State
data object Failed : State
}
private val logger = Logger.withTag(TAG)
private val _state = MutableStateFlow<State>(State.Idle)
val state = _state.asStateFlow()
/** The npub the confirmation names, so the user sees which identity is leaving. */
val nostrPublicKey: String? get() = activeIdentityStateFlow.value?.nostrPublicKey
fun askToSignOut() {
if (_state.value is State.InProgress) return
_state.value = State.Confirming
}
fun cancel() {
if (_state.value is State.InProgress) return
_state.value = State.Idle
}
/** [onSignedOut] runs on the main thread once the device holds nothing for the identity. */
fun signOut(onSignedOut: () -> Unit) {
val identity = activeIdentityStateFlow.value ?: return
if (_state.value is State.InProgress) return
_state.value = State.InProgress
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable ->
logger.e("could not sign out", throwable)
_state.value = State.Failed
}) {
when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) {
is ForgetIdentity.Outcome.Forgotten -> {
_state.value = State.Idle
withContext(Dispatchers.Main) { onSignedOut() }
}
is ForgetIdentity.Outcome.NotACredential,
is ForgetIdentity.Outcome.CannotForget -> {
_state.value = State.Failed
}
}
}
}
companion object {
private const val TAG = "SignOutViewModel"
fun factory(
activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: NostrRepository,
forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult,
hideIdentityMetadata: suspend (Identity) -> Unit,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SignOutViewModel(
activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository,
forgetNostrCredential = forgetNostrCredential,
hideIdentityMetadata = hideIdentityMetadata,
)
}
}
}
}