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