diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 593c6b04..de74e248 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -267,6 +267,10 @@
Read only
Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent.
Sign in with the nsec
+ Sign out of this profile?
+ Use a different key?
+ This device holds no key for %1$s, so there is nothing to lose. The profile stays on the relays, and you can sign in again any time.
+ Could not sign out. Please try again.
This will give you write access to the profile.
Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised.
Translate chunk
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt
new file mode 100644
index 00000000..d37c2c03
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt
@@ -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
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt
index cf1920e1..f467485c 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt
@@ -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,
+ 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() {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt
index 6223c329..69c81a63 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt
@@ -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),
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 7ce0f604..c70940d1 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -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 { backStackEntry ->
@@ -847,7 +867,8 @@ fun MantraNavHost(
navController.navigate(
route = route
)
- }
+ },
+ signOut = signOutOfReadOnlyIdentity,
)
}
composable { backStackEntry ->
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt
new file mode 100644
index 00000000..7c0fdd7e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt
@@ -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)) }
+ },
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt
index ff121980..88bce8c0 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt
@@ -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
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt
new file mode 100644
index 00000000..29773e92
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt
@@ -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,
+ 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.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,
+ nostrRepository: NostrRepository,
+ forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult,
+ hideIdentityMetadata: suspend (Identity) -> Unit,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ SignOutViewModel(
+ activeIdentityStateFlow = activeIdentityStateFlow,
+ nostrRepository = nostrRepository,
+ forgetNostrCredential = forgetNostrCredential,
+ hideIdentityMetadata = hideIdentityMetadata,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt
new file mode 100644
index 00000000..e746653e
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt
@@ -0,0 +1,95 @@
+package press.mantra.compose.identity
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import fr.acinq.bitcoin.ByteVector32
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.phoenix.managers.nostrPublicKeyHex
+import fr.acinq.phoenix.utils.preferences.InternalPrefs
+import fr.acinq.phoenix.utils.preferences.UserPrefs
+import kotlinx.coroutines.runBlocking
+import okio.Path.Companion.toPath
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+import press.mantra.compose.repository.NostrRepository
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * The sequence, and where it stops.
+ *
+ * Forgetting is four effects in an order that matters -- the credential out of the
+ * file, the metadata hidden, the account rows gone, then the caller's tail -- so that a
+ * failure partway leaves the credential on disk rather than an identity the selector
+ * lists but nothing can open. A refusal at the first step must leave the other two
+ * untouched: an identity that is still on the device must still have its account.
+ *
+ * The two writers are recorded rather than run; `IdentityWriterJvmTest` covers what
+ * they do to the disk.
+ */
+class ForgetIdentityJvmTest {
+
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ private val privateKey = PrivateKey(ByteVector32("04".repeat(32)))
+
+ private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath {
+ temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath()
+ }
+
+ private fun readOnlyIdentity() = Identity.readOnly(
+ id = privateKey.publicKey().xOnly().toWalletId(),
+ nostrPublicKey = privateKey.nostrPublicKeyHex(),
+ userPrefs = UserPrefs(prefsStore("user")),
+ internalPrefs = InternalPrefs(prefsStore("internal")),
+ )
+
+ private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
+ val effects = mutableListOf()
+ override suspend fun forgetLocalAccount(publicKey: HexKey) {
+ effects += "forgetLocalAccount:$publicKey"
+ }
+ }
+
+ private suspend fun forget(recorder: Recorder, result: IdentityWriter.ForgetNostrCredentialResult) =
+ ForgetIdentity.forget(
+ identity = readOnlyIdentity(),
+ nostrRepository = recorder,
+ forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; result },
+ hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" },
+ )
+
+ @Test
+ fun `the credential comes out first, then the metadata, then the account`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten)
+
+ assertEquals(ForgetIdentity.Outcome.Forgotten, outcome)
+ assertEquals(
+ listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount:${privateKey.nostrPublicKeyHex()}"),
+ recorder.effects,
+ )
+ }
+
+ @Test
+ fun `a mnemonic identity is refused before anything else is touched`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.NotACredential)
+
+ assertEquals(ForgetIdentity.Outcome.NotACredential, outcome)
+ assertEquals(listOf("forgetNostrCredential"), recorder.effects)
+ }
+
+ @Test
+ fun `a store that cannot be read stops the sequence at its first step`() = runBlocking {
+ val recorder = Recorder()
+
+ val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys)
+
+ assertEquals(ForgetIdentity.Outcome.CannotForget, outcome)
+ assertEquals(listOf("forgetNostrCredential"), recorder.effects)
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt
index a6e95041..2dfc62ac 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt
@@ -3,9 +3,29 @@ package press.mantra.compose.ui.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runDesktopComposeUiTest
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import fr.acinq.phoenix.data.WalletId
+import fr.acinq.phoenix.utils.preferences.InternalPrefs
+import fr.acinq.phoenix.utils.preferences.UserPrefs
+import kotlinx.coroutines.flow.MutableStateFlow
+import okio.Path.Companion.toPath
+import press.mantra.compose.database.GENESIS_AT
+import press.mantra.compose.database.model.SynchronizeNostrEventRequest
+import press.mantra.compose.database.model.UnsignedNostrEvent
+import press.mantra.compose.database.model.intermdiate.LocalAccount
+import press.mantra.compose.database.model.types.SynchronizationFilter
+import press.mantra.compose.extensions.hexToNpubHrp
+import press.mantra.compose.identity.Identity
+import press.mantra.compose.nostr.SignInSync
+import java.nio.file.Files
+import kotlin.time.Instant
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.flow.Flow
@@ -92,8 +112,99 @@ class ReadOnlyEntrancesJvmTest {
onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed()
}
+ // --- Profile: sign out is real for a read-only identity, and confirms first ---
+
+ @Test
+ fun `a read-only identity's sign out asks before it acts, naming the npub`() = runDesktopComposeUiTest(400, 1200) {
+ setContent { AsIdentity(canSign = false) { ActiveProfile(signOut = signOutDependencies) } }
+
+ onNodeWithText("Sign out", useUnmergedTree = true).performClick()
+
+ onNodeWithText("Sign out of this profile?", useUnmergedTree = true).assertIsDisplayed()
+ // The screen shows the npub once already; the dialog names it a second time.
+ onAllNodesWithText(publicKey.hexToNpubHrp(), substring = true, useUnmergedTree = true).assertCountEquals(2)
+ onNodeWithText("nothing to lose", substring = true, useUnmergedTree = true).assertIsDisplayed()
+ }
+
+ // --- Not found: set one up, or use a different key ---
+
+ @Test
+ fun `an identity that can sign is offered the set-up form when its profile is not found`() = runDesktopComposeUiTest(400, 1200) {
+ setContent { AsIdentity(canSign = true) { NotFound() } }
+
+ onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed()
+ // Heading and button both say it; either is the form.
+ onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed()
+ onNodeWithText("Use a different key", useUnmergedTree = true).assertDoesNotExist()
+ }
+
+ @Test
+ fun `a read-only identity is offered a different key instead, and it confirms first`() = runDesktopComposeUiTest(400, 1200) {
+ setContent { AsIdentity(canSign = false) { NotFound() } }
+
+ onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed()
+ onNodeWithText("Set up a profile", useUnmergedTree = true).assertDoesNotExist()
+ onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed()
+
+ onNodeWithText("Use a different key", useUnmergedTree = true).performClick()
+
+ onNodeWithText("Use a different key?", useUnmergedTree = true).assertIsDisplayed()
+ }
+
// --- harness ---
+ private val readOnlyIdentity = MutableStateFlow(
+ Identity.readOnly(
+ id = WalletId("ab".repeat(20)),
+ nostrPublicKey = publicKey,
+ userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("user", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }),
+ internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("internal", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }),
+ )
+ )
+
+ /** Never run in these tests: the dialogs are looked at, not confirmed. */
+ private val signOutDependencies = SignOutDependencies(
+ activeIdentityStateFlow = readOnlyIdentity,
+ forgetNostrCredential = { error("not reached") },
+ hideIdentityMetadata = { error("not reached") },
+ onSignedOut = { error("not reached") },
+ )
+
+ @Composable
+ private fun NotFound() {
+ UnsyncedProfileScreen(
+ unsyncedProfilePublicKey = publicKey,
+ nostrRepository = NotFoundAccount,
+ signOut = signOutDependencies,
+ )
+ }
+
+ private val at = Instant.fromEpochSeconds(1_700_000_000)
+
+ /** An account whose one sign-in request has finished with nothing: the not-found state. */
+ private val NotFoundAccount = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
+ override suspend fun observeLocalAccount(publicKey: String): Flow = flowOf(
+ LocalAccount(
+ unsignedNostrEvent = UnsignedNostrEvent(
+ id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}",
+ signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at,
+ ),
+ nostrEvent = null,
+ profile = null,
+ broadcastNostrEventRequest = null,
+ broadcastNostrEventReceipt = null,
+ synchronizeNostrEventRequests = listOf(
+ SynchronizeNostrEventRequest(
+ id = "req", purpose = SignInSync.PURPOSE, status = SignInSync.STATUS_COMPLETE,
+ relayURL = "wss://relay.example", level = 0,
+ synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)),
+ unsignedNostrEventId = 1, createdAt = at, updatedAt = at,
+ )
+ ),
+ )
+ )
+ }
+
@Composable
private fun AsIdentity(canSign: Boolean, content: @Composable () -> Unit) {
MantraTheme {
@@ -121,7 +232,7 @@ class ReadOnlyEntrancesJvmTest {
}
@Composable
- private fun ActiveProfile() {
+ private fun ActiveProfile(signOut: SignOutDependencies? = null) {
ActiveProfileScreen(
initialActiveProfileUIState = ActiveProfileUIState.Loaded(
localNostrEvent = LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile),
@@ -131,6 +242,7 @@ class ReadOnlyEntrancesJvmTest {
onNavigateBack = {},
onNavigateToRoute = {},
nostrRepository = FixedProfile,
+ signOut = signOut,
)
}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt
new file mode 100644
index 00000000..2dabb167
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt
@@ -0,0 +1,106 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import fr.acinq.bitcoin.ByteVector32
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.phoenix.managers.nostrPublicKeyHex
+import fr.acinq.phoenix.utils.preferences.InternalPrefs
+import fr.acinq.phoenix.utils.preferences.UserPrefs
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import okio.Path.Companion.toPath
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+import press.mantra.compose.identity.Identity
+import press.mantra.compose.identity.IdentityWriter
+import press.mantra.compose.identity.toWalletId
+import press.mantra.compose.repository.NostrRepository
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Sign out for a read-only identity: the confirmation, the in-flight state, and the
+ * caller being told only once the device holds nothing for the identity. The sequence
+ * itself is `ForgetIdentity`'s and is pinned there; this is the state around it, the
+ * way `NostrSecretViewModelJvmTest` pins the nsec screen's.
+ */
+class SignOutViewModelJvmTest {
+
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ private val privateKey = PrivateKey(ByteVector32("05".repeat(32)))
+
+ private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath {
+ temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath()
+ }
+
+ private fun identity() = Identity.readOnly(
+ id = privateKey.publicKey().xOnly().toWalletId(),
+ nostrPublicKey = privateKey.nostrPublicKeyHex(),
+ userPrefs = UserPrefs(prefsStore("user")),
+ internalPrefs = InternalPrefs(prefsStore("internal")),
+ )
+
+ private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
+ val effects = mutableListOf()
+ override suspend fun forgetLocalAccount(publicKey: HexKey) {
+ effects += "forgetLocalAccount"
+ }
+ }
+
+ private fun viewModel(recorder: Recorder, outcome: IdentityWriter.ForgetNostrCredentialResult) = SignOutViewModel(
+ activeIdentityStateFlow = MutableStateFlow(identity()),
+ nostrRepository = recorder,
+ forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; outcome },
+ hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" },
+ )
+
+ @Test
+ fun `asking, then confirming, forgets and tells the caller, and the state is idle again`() = runBlocking {
+ val recorder = Recorder()
+ val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten)
+ val told = CompletableDeferred()
+
+ assertEquals(SignOutViewModel.State.Idle, viewModel.state.value)
+ viewModel.askToSignOut()
+ assertEquals(SignOutViewModel.State.Confirming, viewModel.state.value)
+ assertEquals(privateKey.nostrPublicKeyHex(), viewModel.nostrPublicKey, "the confirmation names the identity")
+
+ viewModel.signOut { told.complete(Unit) }
+ withTimeout(10_000) { told.await() }
+
+ assertEquals(listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), recorder.effects)
+ withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Idle } }
+ }
+
+ @Test
+ fun `cancelling from the confirmation touches nothing`() {
+ val recorder = Recorder()
+ val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten)
+
+ viewModel.askToSignOut()
+ viewModel.cancel()
+
+ assertEquals(SignOutViewModel.State.Idle, viewModel.state.value)
+ assertEquals(emptyList(), recorder.effects)
+ }
+
+ @Test
+ fun `a failure is a state, the caller is not told, and nothing after the first step ran`() = runBlocking {
+ val recorder = Recorder()
+ val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys)
+ var told = false
+
+ viewModel.askToSignOut()
+ viewModel.signOut { told = true }
+ withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Failed } }
+
+ assertEquals(false, told)
+ assertEquals(listOf("forgetNostrCredential"), recorder.effects)
+ }
+}