Allow editing username discriminent in AddUsernameScreen.

This commit is contained in:
Greyson Parrelli
2026-08-31 16:41:12 -04:00
committed by Michelle Tang
parent f529caca5b
commit 3b2bf29ba0
16 changed files with 513 additions and 134 deletions
@@ -456,8 +456,8 @@ class AppRegistrationNetworkController(
RequestResult.Success(Unit)
}
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
return AppDependencies.usernameService.reserveUsername(nickname)
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> {
return AppDependencies.usernameService.reserveUsername(nickname, discriminator)
}
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
@@ -260,12 +260,12 @@ class DebugNetworkController(
return delegate.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber)
}
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> {
NetworkDebugState.getOverride<RequestResult<Username, ReserveUsernameError>>("reserveUsername")?.let {
Log.d(TAG, "[reserveUsername] Returning debug override")
return it
}
return delegate.reserveUsername(nickname)
return delegate.reserveUsername(nickname, discriminator)
}
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
@@ -92,6 +92,7 @@ import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
import org.whispersystems.signalservice.api.util.Usernames
import org.whispersystems.signalservice.api.websocket.HealthMonitor
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.api.websocket.WebSocketFactory
@@ -957,7 +958,7 @@ class DemoNetworkController(
RequestResult.Success(Unit)
}
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val password = RegistrationPreferences.servicePassword
@@ -967,7 +968,11 @@ class DemoNetworkController(
}
val candidates: List<Username> = try {
Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH)
if (discriminator == null) {
Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH)
} else {
listOf(Username("$nickname${Usernames.DELIMITER}$discriminator"))
}
} catch (e: BaseUsernameException) {
Log.w(TAG, "[reserveUsername] Failed to generate candidates.", e)
return@withContext RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid)
@@ -416,13 +416,14 @@ interface NetworkController {
suspend fun restoreAccountRecord(timeout: Duration): RequestResult<Unit, RestoreAccountRecordError>
/**
* Reserves a username composed of [nickname] plus a server-assigned numeric discriminator. The service holds the
* reservation for a short time (~5 minutes), during which it can be finalized via [confirmUsername].
* Reserving again replaces any previous reservation.
* Reserves a username composed of [nickname] plus a numeric discriminator. If [discriminator] is provided, only that
* exact username is attempted, otherwise the service assigns one. The service holds the reservation for a short time
* (~5 minutes), during which it can be finalized via [confirmUsername]. Reserving again replaces any previous
* reservation.
*
* `PUT /v1/accounts/username_hash/reserve`
*/
suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError>
suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult<Username, ReserveUsernameError>
/**
* Confirms a reservation previously made via [reserveUsername], assigning the username to the account and creating
@@ -51,7 +51,6 @@ import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
import org.signal.registration.screens.accountlocked.AccountLockedState
import org.signal.registration.screens.addusername.AddUsernameScreen
import org.signal.registration.screens.addusername.AddUsernameScreenActions
import org.signal.registration.screens.addusername.AddUsernameViewModel
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupResult
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel
@@ -328,7 +327,6 @@ private const val AEP_FOR_LOCAL_BACKUP_RESULT = "aep_for_local_backup_result"
private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result"
private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result"
private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059792"
private const val USERNAME_LEARN_MORE_URL = "https://support.signal.org/hc/articles/5389476324250"
// TODO [phonenumberless] Point at the real support article once it exists.
private const val SIGNAL_LOGIN_LEARN_MORE_URL = "https://support.signal.org/"
@@ -703,12 +701,6 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
AddUsernameScreenActions.OpenLearnMoreArticle -> openUrl(context, USERNAME_LEARN_MORE_URL)
}
}
AddUsernameScreen(
state = state,
@@ -769,11 +769,11 @@ class RegistrationRepository(
}
/**
* Reserves a username made from [nickname] plus a server-assigned discriminator.
* Reserves a username made from [nickname] plus [discriminator], or a server-assigned discriminator if none is given.
* See [NetworkController.reserveUsername].
*/
suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
networkController.reserveUsername(nickname)
suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
networkController.reserveUsername(nickname, discriminator)
}
/**
@@ -5,7 +5,6 @@
package org.signal.registration.screens.addusername
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -21,11 +20,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -40,6 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -50,6 +53,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
@@ -59,6 +64,7 @@ import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.UsernameUtil
import org.signal.libsignal.usernames.Username
import org.signal.registration.R
import org.signal.registration.screens.OnePaneRegistrationScaffold
@@ -66,7 +72,6 @@ import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
import org.signal.registration.screens.attachDebugLogHelper
import org.signal.registration.test.TestTags
import org.whispersystems.signalservice.api.util.discriminator
/** Size of the avatar artwork, whose sphere occupies the inner 72dp of its 80dp box. */
private val AVATAR_SIZE = 80.dp
@@ -74,6 +79,12 @@ private val AVATAR_SIZE = 80.dp
/** Size of the glyph centered on the avatar, per the design's 36dp icon box. */
private val AVATAR_GLYPH_SIZE = 36.dp
/** The discriminator field is sized to its content, but never narrower than this many digits. */
private const val DISCRIMINATOR_MIN_WIDTH_TEMPLATE = "00"
/** Extra room on the discriminator field so the caret isn't clipped at the end of the text. */
private val DISCRIMINATOR_CARET_ALLOWANCE = 2.dp
/**
* Offers the user an optional username so people can reach them without a phone number.
*/
@@ -100,6 +111,15 @@ fun AddUsernameScreen(
)
}
if (state.dialogs.learnMore) {
Dialogs.SimpleMessageDialog(
title = stringResource(R.string.AddUsernameScreen__what_is_this_number),
message = stringResource(R.string.AddUsernameScreen__these_digits_help_keep),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(AddUsernameScreenEvents.LearnMoreDialogDismissed) }
)
}
Surface(
modifier = modifier
.fillMaxSize()
@@ -201,6 +221,7 @@ private fun ColumnScope.UsernameEntry(
onEvent: (AddUsernameScreenEvents) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val validationMessage: String? = state.validationError?.message()
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@@ -227,10 +248,8 @@ private fun ColumnScope.UsernameEntry(
singleLine = true,
enabled = !state.showSpinner,
isError = state.validationError != null,
supportingText = state.validationError?.let { error ->
{ Text(stringResource(error.messageId)) }
},
suffix = discriminatorSuffix(state),
supportingText = validationMessage?.let { message -> { Text(message) } },
suffix = discriminatorSuffix(state, onEvent),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
@@ -278,41 +297,77 @@ private fun ColumnScope.UsernameEntry(
}
/**
* The trailing content of the username field: a spinner while a username is being reserved, and once one is
* reserved, its discriminator behind a divider (per the design, the discriminator is server-assigned and not
* directly editable).
* The trailing content of the username field: a spinner while a username is being reserved, and, behind a divider, the
* discriminator. The discriminator is assigned by the service, but the user can overwrite it to claim a specific one.
*/
private fun discriminatorSuffix(state: AddUsernameState): (@Composable () -> Unit)? {
val discriminator = state.reservation?.discriminator
private fun discriminatorSuffix(state: AddUsernameState, onEvent: (AddUsernameScreenEvents) -> Unit): (@Composable () -> Unit)? {
if (!state.isReserving && !state.showDiscriminator) {
return null
}
return when {
state.isReserving -> {
{
return {
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.isReserving) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
if (state.showDiscriminator) {
Dividers.Vertical(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.height(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
DiscriminatorField(state = state, onEvent = onEvent)
}
}
}
}
discriminator != null -> {
{
Row(verticalAlignment = Alignment.CenterVertically) {
Dividers.Vertical(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.height(20.dp)
)
/** The editable discriminator, sized to its content so it hugs the right edge of the username field. */
@Composable
private fun DiscriminatorField(
state: AddUsernameState,
onEvent: (AddUsernameScreenEvents) -> Unit
) {
val textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface)
val textMeasurer = rememberTextMeasurer()
Spacer(modifier = Modifier.width(16.dp))
val width = with(LocalDensity.current) {
val content = textMeasurer.measure(state.discriminator, textStyle).size.width
val minimum = textMeasurer.measure(DISCRIMINATOR_MIN_WIDTH_TEMPLATE, textStyle).size.width
maxOf(content, minimum).toDp() + DISCRIMINATOR_CARET_ALLOWANCE
}
Text(text = discriminator)
BasicTextField(
value = state.discriminator,
onValueChange = { onEvent(AddUsernameScreenEvents.DiscriminatorChanged(it)) },
textStyle = textStyle,
singleLine = true,
enabled = !state.showSpinner,
cursorBrush = SolidColor(if (state.validationError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (state.isSubmittable) {
onEvent(AddUsernameScreenEvents.NextClicked)
}
}
}
else -> null
}
),
modifier = Modifier
.width(width)
.testTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD)
)
}
/**
@@ -382,15 +437,20 @@ private fun Footer(
}
}
@get:StringRes
private val AddUsernameState.ValidationError.messageId: Int
get() = when (this) {
AddUsernameState.ValidationError.TOO_SHORT -> R.string.AddUsernameScreen__usernames_must_be_at_least_3_characters
AddUsernameState.ValidationError.TOO_LONG -> R.string.AddUsernameScreen__usernames_must_be_at_most_32_characters
AddUsernameState.ValidationError.INVALID_CHARACTERS -> R.string.AddUsernameScreen__usernames_can_only_contain
AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT -> R.string.AddUsernameScreen__usernames_cannot_begin_with_a_number
AddUsernameState.ValidationError.NOT_AVAILABLE -> R.string.AddUsernameScreen__this_username_is_not_available
}
@Composable
private fun AddUsernameState.ValidationError.message(): String = when (this) {
AddUsernameState.ValidationError.TOO_SHORT -> stringResource(R.string.AddUsernameScreen__usernames_must_be_at_least_3_characters)
AddUsernameState.ValidationError.TOO_LONG -> stringResource(R.string.AddUsernameScreen__usernames_must_be_at_most_32_characters)
AddUsernameState.ValidationError.INVALID_CHARACTERS -> stringResource(R.string.AddUsernameScreen__usernames_can_only_contain)
AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT -> stringResource(R.string.AddUsernameScreen__usernames_cannot_begin_with_a_number)
AddUsernameState.ValidationError.NOT_AVAILABLE -> stringResource(R.string.AddUsernameScreen__this_username_is_not_available)
AddUsernameState.ValidationError.DISCRIMINATOR_TOO_SHORT -> stringResource(R.string.AddUsernameScreen__enter_a_minimum_of_d_digits, UsernameUtil.MIN_DISCRIMINATOR_LENGTH)
AddUsernameState.ValidationError.DISCRIMINATOR_TOO_LONG -> stringResource(R.string.AddUsernameScreen__enter_a_maximum_of_d_digits, UsernameUtil.MAX_DISCRIMINATOR_LENGTH)
AddUsernameState.ValidationError.DISCRIMINATOR_INVALID_CHARACTERS -> stringResource(R.string.AddUsernameScreen__numbers_can_only_contain_digits)
AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_BE_00 -> stringResource(R.string.AddUsernameScreen__this_number_cant_be_00)
AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_START_WITH_ZERO -> stringResource(R.string.AddUsernameScreen__this_number_cant_start_with_0)
AddUsernameState.ValidationError.DISCRIMINATOR_NOT_AVAILABLE -> stringResource(R.string.AddUsernameScreen__this_username_is_not_available_try_another_number)
}
@AllDevicePreviews
@Composable
@@ -419,7 +479,12 @@ private fun AddUsernameScreenFilledPreview() {
private fun AddUsernameScreenReservedPreview() {
Previews.Preview {
AddUsernameScreen(
state = AddUsernameState(username = "alice", reservation = Username("alice.45")),
state = AddUsernameState(
username = "alice",
discriminator = "45",
showDiscriminator = true,
reservation = Username("alice.45")
),
onEvent = {}
)
}
@@ -1,11 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.addusername
sealed interface AddUsernameScreenActions {
/** Open the article explaining Signal usernames. */
data object OpenLearnMoreArticle : AddUsernameScreenActions
}
@@ -16,19 +16,30 @@ sealed class AddUsernameScreenEvents {
override fun toString(): String = "UsernameChanged(value=${value.censor()})"
}
/** Internal: the user paused typing long enough for the entered nickname to be validated and reserved. */
data class NicknameSettled(val value: String) : AddUsernameScreenEvents() {
override fun toString(): String = "NicknameSettled(value=${value.censor()})"
/** The user edited the discriminator field. */
data class DiscriminatorChanged(val value: String) : AddUsernameScreenEvents() {
override fun toString(): String = "DiscriminatorChanged(value=${value.censor()})"
}
/** Internal: a reservation attempt for [nickname] finished with [result]. */
data class ReservationCompleted(val nickname: String, val result: RequestResult<Username, ReserveUsernameError>) : AddUsernameScreenEvents() {
override fun toString(): String = "ReservationCompleted(nickname=${nickname.censor()}, result=${result.javaClass.simpleName})"
/**
* Internal: the user paused typing long enough for the entered username to be validated and reserved. A null
* [discriminator] means the service should assign one.
*/
data class EntrySettled(val nickname: String, val discriminator: String?) : AddUsernameScreenEvents() {
override fun toString(): String = "EntrySettled(nickname=${nickname.censor()}, discriminator=${discriminator?.censor()})"
}
/** Internal: a reservation attempt for [nickname] and [discriminator] finished with [result]. */
data class ReservationCompleted(val nickname: String, val discriminator: String?, val result: RequestResult<Username, ReserveUsernameError>) : AddUsernameScreenEvents() {
override fun toString(): String = "ReservationCompleted(nickname=${nickname.censor()}, discriminator=${discriminator?.censor()}, result=${result.javaClass.simpleName})"
}
/** The user tapped the "learn more" link under the username field. */
data object LearnMoreClicked : AddUsernameScreenEvents()
/** The user dismissed the dialog explaining the digits after the username. */
data object LearnMoreDialogDismissed : AddUsernameScreenEvents()
/** The user opted out of choosing a username. */
data object SkipClicked : AddUsernameScreenEvents()
@@ -14,7 +14,13 @@ import org.signal.libsignal.usernames.Username
data class AddUsernameState(
/** The nickname (the part of the username before the discriminator) as typed by the user. */
val username: String = "",
/** Set when the entered nickname fails validation, describing why. */
/** The discriminator (the digits after the nickname), either server-assigned or typed by the user. */
val discriminator: String = "",
/** True when [discriminator] was typed by the user, meaning we reserve that exact discriminator rather than letting the service pick one. */
val isDiscriminatorUserSet: Boolean = false,
/** Whether the discriminator field is shown. It stays hidden until the service has assigned a discriminator to display. */
val showDiscriminator: Boolean = false,
/** Set when the entered username fails validation, describing why. */
val validationError: ValidationError? = null,
/** The reserved username (nickname + discriminator) for the entered nickname, once one has been reserved. */
val reservation: Username? = null,
@@ -27,7 +33,11 @@ data class AddUsernameState(
val isSubmittable: Boolean
get() = !showSpinner && !isReserving && username.isNotBlank() && validationError == null && reservation != null
override fun toString(): String = "AddUsernameState(username=${username.censor()}, validationError=$validationError, reservation=${reservation?.username?.censor()}, isReserving=$isReserving, showSpinner=$showSpinner, dialogs=$dialogs)"
/** The discriminator to reserve, or null when the service should assign one. */
val requestedDiscriminator: String?
get() = discriminator.takeIf { isDiscriminatorUserSet }
override fun toString(): String = "AddUsernameState(username=${username.censor()}, discriminator=${discriminator.censor()}, isDiscriminatorUserSet=$isDiscriminatorUserSet, showDiscriminator=$showDiscriminator, validationError=$validationError, reservation=${reservation?.username?.censor()}, isReserving=$isReserving, showSpinner=$showSpinner, dialogs=$dialogs)"
enum class ValidationError {
TOO_SHORT,
@@ -36,10 +46,21 @@ data class AddUsernameState(
CANNOT_START_WITH_DIGIT,
/** The nickname is valid, but no username could be reserved for it. */
NOT_AVAILABLE
NOT_AVAILABLE,
DISCRIMINATOR_TOO_SHORT,
DISCRIMINATOR_TOO_LONG,
DISCRIMINATOR_INVALID_CHARACTERS,
DISCRIMINATOR_CANNOT_BE_00,
DISCRIMINATOR_CANNOT_START_WITH_ZERO,
/** The nickname and user-chosen discriminator are both valid, but that pairing is already taken. */
DISCRIMINATOR_NOT_AVAILABLE
}
data class Dialogs(
/** Explains what the digits after the username are for. */
val learnMore: Boolean = false,
val networkError: Boolean = false,
val unknownError: Boolean = false,
/** The reserved username was claimed by someone else before it could be confirmed. */
@@ -12,8 +12,6 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,7 +20,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.UsernameUtil
@@ -34,15 +31,16 @@ import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.util.navigateTo
import org.whispersystems.signalservice.api.util.discriminator
import kotlin.time.Duration.Companion.milliseconds
/**
* View model for [AddUsernameScreen].
*
* As the user types a nickname, we debounce their input and then validate it locally. If it's valid, we reserve a
* username for it on the service (the nickname plus a server-assigned numeric discriminator), which is what lets us
* show the discriminator while they type and detect taken nicknames early. Tapping "next" confirms the reservation,
* making it the account's actual username.
* As the user types, we debounce their input and then validate it locally. If it's valid, we reserve a username on the
* service, which is what lets us show the discriminator while they type and detect taken nicknames early. The
* discriminator is normally assigned by the service, but the user can type their own, in which case we reserve that
* exact pairing instead. Tapping "next" confirms the reservation, making it the account's actual username.
*/
@OptIn(FlowPreview::class)
class AddUsernameViewModel(
@@ -53,16 +51,13 @@ class AddUsernameViewModel(
companion object {
private val TAG = Log.tag(AddUsernameViewModel::class)
private val NICKNAME_DEBOUNCE = 500.milliseconds
private val ENTRY_DEBOUNCE = 500.milliseconds
}
private val _state = MutableStateFlow(AddUsernameState())
val state: StateFlow<AddUsernameState> = _state.asStateFlow()
private val _actions = Channel<AddUsernameScreenActions>(Channel.BUFFERED)
val actions: Flow<AddUsernameScreenActions> = _actions.receiveAsFlow()
private val nicknameChanges = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val entryChanges = MutableSharedFlow<AddUsernameScreenEvents.EntrySettled>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/** The in-flight reservation request. Only one may be live at a time -- starting a new one cancels the old one. */
private var reserveJob: Job? = null
@@ -72,10 +67,10 @@ class AddUsernameViewModel(
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
nicknameChanges
entryChanges
.distinctUntilChanged()
.debounce(NICKNAME_DEBOUNCE)
.onEach { onEvent(AddUsernameScreenEvents.NicknameSettled(it)) }
.debounce(ENTRY_DEBOUNCE)
.onEach { onEvent(it) }
.launchIn(viewModelScope)
}
@@ -92,9 +87,11 @@ class AddUsernameViewModel(
) {
when (event) {
is AddUsernameScreenEvents.UsernameChanged -> applyUsernameChanged(state, event.value, stateEmitter)
is AddUsernameScreenEvents.NicknameSettled -> applyNicknameSettled(state, event.value, stateEmitter)
is AddUsernameScreenEvents.DiscriminatorChanged -> applyDiscriminatorChanged(state, event.value, stateEmitter)
is AddUsernameScreenEvents.EntrySettled -> applyEntrySettled(state, event, stateEmitter)
is AddUsernameScreenEvents.ReservationCompleted -> applyReservationCompleted(state, event, stateEmitter)
is AddUsernameScreenEvents.LearnMoreClicked -> _actions.trySend(AddUsernameScreenActions.OpenLearnMoreArticle)
is AddUsernameScreenEvents.LearnMoreClicked -> stateEmitter(state.copy(dialogs = state.dialogs.copy(learnMore = true)))
is AddUsernameScreenEvents.LearnMoreDialogDismissed -> stateEmitter(state.copy(dialogs = state.dialogs.copy(learnMore = false)))
is AddUsernameScreenEvents.SkipClicked -> applySkipClicked(parentEventEmitter)
is AddUsernameScreenEvents.NextClicked -> applyNextClicked(state, parentEventEmitter, stateEmitter)
is AddUsernameScreenEvents.NetworkErrorDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(networkError = false) }
@@ -112,28 +109,60 @@ class AddUsernameViewModel(
reserveJob?.cancel()
stateEmitter(
state.copy(
username = username,
validationError = null,
reservation = null,
isReserving = false
)
val updated = state.copy(
username = username,
validationError = null,
reservation = null,
isReserving = false
)
if (username.isNotBlank()) {
nicknameChanges.tryEmit(username)
}
stateEmitter(updated)
scheduleReservation(updated)
}
private fun applyNicknameSettled(state: AddUsernameState, nickname: String, stateEmitter: (AddUsernameState) -> Unit) {
if (nickname != state.username || nickname.isBlank()) {
/**
* A blank discriminator hands control back to the service, matching the behavior of clearing the field in the app's
* username editor.
*/
private fun applyDiscriminatorChanged(state: AddUsernameState, discriminator: String, stateEmitter: (AddUsernameState) -> Unit) {
if (discriminator == state.discriminator) {
return
}
val validationError = checkNickname(nickname)
if (validationError != null) {
stateEmitter(state.copy(validationError = validationError))
reserveJob?.cancel()
val updated = state.copy(
discriminator = discriminator,
isDiscriminatorUserSet = discriminator.isNotBlank(),
validationError = null,
reservation = null,
isReserving = false
)
stateEmitter(updated)
scheduleReservation(updated)
}
private fun scheduleReservation(state: AddUsernameState) {
if (state.username.isNotBlank()) {
entryChanges.tryEmit(AddUsernameScreenEvents.EntrySettled(state.username, state.requestedDiscriminator))
}
}
private fun applyEntrySettled(state: AddUsernameState, event: AddUsernameScreenEvents.EntrySettled, stateEmitter: (AddUsernameState) -> Unit) {
if (event.nickname != state.username || event.discriminator != state.requestedDiscriminator || event.nickname.isBlank()) {
return
}
val nicknameError = checkNickname(event.nickname)
if (nicknameError != null) {
stateEmitter(state.copy(validationError = nicknameError))
return
}
val discriminatorError = event.discriminator?.let { checkDiscriminator(it) }
if (discriminatorError != null) {
stateEmitter(state.copy(validationError = discriminatorError))
return
}
@@ -141,8 +170,8 @@ class AddUsernameViewModel(
reserveJob?.cancel()
reserveJob = viewModelScope.launch {
val result = repository.reserveUsername(nickname)
onEvent(AddUsernameScreenEvents.ReservationCompleted(nickname, result))
val result = repository.reserveUsername(event.nickname, event.discriminator)
onEvent(AddUsernameScreenEvents.ReservationCompleted(event.nickname, event.discriminator, result))
}
}
@@ -151,20 +180,32 @@ class AddUsernameViewModel(
event: AddUsernameScreenEvents.ReservationCompleted,
stateEmitter: (AddUsernameState) -> Unit
) {
if (event.nickname != state.username) {
if (event.nickname != state.username || event.discriminator != state.requestedDiscriminator) {
return
}
when (val result = event.result) {
is RequestResult.Success -> {
Log.i(TAG, "Successfully reserved a username.")
stateEmitter(state.copy(isReserving = false, reservation = result.result))
stateEmitter(
state.copy(
isReserving = false,
reservation = result.result,
discriminator = result.result.discriminator,
showDiscriminator = true
)
)
}
is RequestResult.NonSuccess -> when (result.error) {
is ReserveUsernameError.NicknameInvalid, is ReserveUsernameError.NotAvailable -> {
Log.w(TAG, "Could not reserve a username: ${result.error}")
stateEmitter(state.copy(isReserving = false, validationError = AddUsernameState.ValidationError.NOT_AVAILABLE))
val error = if (event.discriminator != null) {
AddUsernameState.ValidationError.DISCRIMINATOR_NOT_AVAILABLE
} else {
AddUsernameState.ValidationError.NOT_AVAILABLE
}
stateEmitter(state.copy(isReserving = false, validationError = error))
}
is ReserveUsernameError.RateLimited -> {
@@ -254,7 +295,7 @@ class AddUsernameViewModel(
stateEmitter(state.copy(dialogs = clearDialog(state.dialogs)))
if (state.username.isNotBlank() && state.validationError == null && state.reservation == null && !state.isReserving) {
onEvent(AddUsernameScreenEvents.NicknameSettled(state.username))
onEvent(AddUsernameScreenEvents.EntrySettled(state.username, state.requestedDiscriminator))
}
}
@@ -268,6 +309,17 @@ class AddUsernameViewModel(
}
}
private fun checkDiscriminator(discriminator: String): AddUsernameState.ValidationError? {
return when (UsernameUtil.checkDiscriminator(discriminator)) {
null -> null
UsernameUtil.InvalidReason.TOO_SHORT -> AddUsernameState.ValidationError.DISCRIMINATOR_TOO_SHORT
UsernameUtil.InvalidReason.TOO_LONG -> AddUsernameState.ValidationError.DISCRIMINATOR_TOO_LONG
UsernameUtil.InvalidReason.INVALID_NUMBER_00 -> AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_BE_00
UsernameUtil.InvalidReason.INVALID_NUMBER_PREFIX_0 -> AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_START_WITH_ZERO
else -> AddUsernameState.ValidationError.DISCRIMINATOR_INVALID_CHARACTERS
}
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
@@ -71,6 +71,7 @@ object TestTags {
// Add Username Screen
const val ADD_USERNAME_SCREEN = "add_username_screen"
const val ADD_USERNAME_FIELD = "add_username_field"
const val ADD_USERNAME_DISCRIMINATOR_FIELD = "add_username_discriminator_field"
const val ADD_USERNAME_LEARN_MORE_LINK = "add_username_learn_more_link"
const val ADD_USERNAME_SKIP_BUTTON = "add_username_skip_button"
const val ADD_USERNAME_NEXT_BUTTON = "add_username_next_button"
@@ -681,8 +681,12 @@
<string name="AddUsernameScreen__username">Username</string>
<!-- Helper text below the username field. Followed by a "learn more" link. -->
<string name="AddUsernameScreen__usernames_are_always_paired_with_a_set_of_numbers">Usernames are always paired with a set of numbers.</string>
<!-- Link in the helper text that opens a support article about usernames. -->
<!-- Link in the helper text that opens an explanation of the digits paired with a username. -->
<string name="AddUsernameScreen__learn_more">Learn more</string>
<!-- Title of the dialog explaining the digits paired with a username. -->
<string name="AddUsernameScreen__what_is_this_number">What is this number?</string>
<!-- Body of the dialog explaining the digits paired with a username. -->
<string name="AddUsernameScreen__these_digits_help_keep">These digits help keep your username private so you can avoid unwanted messages. Share your username with only the people and groups you\'d like to chat with. If you change usernames you\'ll get a new set of digits.</string>
<!-- Button that skips choosing a username. -->
<string name="AddUsernameScreen__skip">Skip</string>
<!-- Button that submits the chosen username. -->
@@ -699,6 +703,18 @@
<string name="AddUsernameScreen__this_username_is_not_available">This username is not available. Please try another.</string>
<!-- Error shown when the user\'s temporary hold on a username expired before they submitted it. -->
<string name="AddUsernameScreen__your_username_reservation_expired">Your username reservation expired. Please try again.</string>
<!-- Error shown when the username formed by the entered name and the user's chosen number is already taken. -->
<string name="AddUsernameScreen__this_username_is_not_available_try_another_number">This username is not available, try another number.</string>
<!-- Validation error shown when the number the user entered after their username has too few digits. -->
<string name="AddUsernameScreen__enter_a_minimum_of_d_digits">Invalid username, enter a minimum of %1$d digits.</string>
<!-- Validation error shown when the number the user entered after their username has too many digits. -->
<string name="AddUsernameScreen__enter_a_maximum_of_d_digits">Invalid username, enter a maximum of %1$d digits.</string>
<!-- Validation error shown when the number the user entered after their username contains something other than digits. -->
<string name="AddUsernameScreen__numbers_can_only_contain_digits">Invalid username, numbers can only contain digits.</string>
<!-- Validation error shown when the number the user entered after their username is 00. -->
<string name="AddUsernameScreen__this_number_cant_be_00">This number can\'t be 00. Enter a digit between 19</string>
<!-- Validation error shown when the number the user entered after their username has a leading zero. -->
<string name="AddUsernameScreen__this_number_cant_start_with_0">Numbers with more than 2 digits can\'t start with 0</string>
<!-- ContactSupportDialog -->
<!-- Title of the dialog asking the user whether they want to attach a debug log to their support request -->
@@ -114,6 +114,8 @@ class FakeNetworkController(
private set
var lastReservedNickname: String? = null
private set
var lastReservedDiscriminator: String? = null
private set
var lastConfirmedUsername: Username? = null
private set
@@ -211,8 +213,8 @@ class FakeNetworkController(
RequestResult.Success(Unit)
}
var onReserveUsername: suspend (nickname: String) -> RequestResult<Username, ReserveUsernameError> = { nickname ->
RequestResult.Success(Username("$nickname.42"))
var onReserveUsername: suspend (nickname: String, discriminator: String?) -> RequestResult<Username, ReserveUsernameError> = { nickname, discriminator ->
RequestResult.Success(Username("$nickname.${discriminator ?: "42"}"))
}
var onConfirmUsername: suspend (Username) -> RequestResult<ConfirmedUsername, ConfirmUsernameError> = { username ->
@@ -439,9 +441,10 @@ class FakeNetworkController(
return RequestResult.Success(Unit)
}
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> {
lastReservedNickname = nickname
return onReserveUsername(nickname)
lastReservedDiscriminator = discriminator
return onReserveUsername(nickname, discriminator)
}
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
@@ -0,0 +1,116 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.addusername
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.libsignal.usernames.Username
import org.signal.registration.test.TestTags
/**
* Tests for [AddUsernameScreen] that validate event emissions.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AddUsernameScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val reservedState = AddUsernameState(
username = "maya",
discriminator = "45",
showDiscriminator = true,
reservation = Username("maya.45")
)
@Test
fun `the discriminator field is hidden until the service assigns one`() {
composeTestRule.setContent {
SignalTheme {
AddUsernameScreen(state = AddUsernameState(), onEvent = {})
}
}
composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD).assertDoesNotExist()
}
@Test
fun `the discriminator field is shown once one has been assigned`() {
composeTestRule.setContent {
SignalTheme {
AddUsernameScreen(state = reservedState, onEvent = {})
}
}
composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD).assertIsDisplayed()
}
@Test
fun `when typing in the discriminator field, DiscriminatorChanged is emitted`() {
var emittedEvent: AddUsernameScreenEvents? = null
composeTestRule.setContent {
SignalTheme {
AddUsernameScreen(state = reservedState, onEvent = { emittedEvent = it })
}
}
composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD).performTextReplacement("77")
assertThat(emittedEvent).isNotNull()
assertThat(emittedEvent).isEqualTo(AddUsernameScreenEvents.DiscriminatorChanged("77"))
}
@Test
fun `when typing in the username field, UsernameChanged is emitted`() {
var emittedEvent: AddUsernameScreenEvents? = null
composeTestRule.setContent {
SignalTheme {
AddUsernameScreen(state = AddUsernameState(), onEvent = { emittedEvent = it })
}
}
composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_FIELD).performTextInput("maya")
assertThat(emittedEvent).isEqualTo(AddUsernameScreenEvents.UsernameChanged("maya"))
}
@Test
fun `skip emits SkipClicked`() {
var emittedEvent: AddUsernameScreenEvents? = null
composeTestRule.setContent {
SignalTheme {
AddUsernameScreen(state = AddUsernameState(), onEvent = { emittedEvent = it })
}
}
composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_SKIP_BUTTON).performClick()
assertThat(emittedEvent).isEqualTo(AddUsernameScreenEvents.SkipClicked)
}
}
@@ -17,8 +17,6 @@ import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -63,19 +61,17 @@ class AddUsernameViewModelTest {
Dispatchers.resetMain()
}
private fun TestScope.collectActions(): List<AddUsernameScreenActions> {
val actions = mutableListOf<AddUsernameScreenActions>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
@Test
fun `LearnMoreClicked emits an action to open the learn more article`() = runTest(testDispatcher) {
val actions = collectActions()
fun `LearnMoreClicked shows the dialog explaining the discriminator`() = runTest(testDispatcher) {
viewModel.onEvent(AddUsernameScreenEvents.LearnMoreClicked)
advanceUntilIdle()
viewModel.applyEvent(AddUsernameState(), AddUsernameScreenEvents.LearnMoreClicked, parentEventEmitter) {}
assertThat(viewModel.state.value.dialogs.learnMore).isTrue()
assertThat(actions).containsExactly(AddUsernameScreenActions.OpenLearnMoreArticle)
viewModel.onEvent(AddUsernameScreenEvents.LearnMoreDialogDismissed)
advanceUntilIdle()
assertThat(viewModel.state.value.dialogs.learnMore).isFalse()
}
@Test
@@ -90,6 +86,117 @@ class AddUsernameViewModelTest {
assertThat(viewModel.state.value.isSubmittable).isTrue()
}
@Test
fun `a successful reservation surfaces the service-assigned discriminator`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
assertThat(viewModel.state.value.discriminator).isEqualTo("45")
assertThat(viewModel.state.value.showDiscriminator).isTrue()
assertThat(viewModel.state.value.isDiscriminatorUserSet).isFalse()
}
@Test
fun `typing a discriminator reserves that exact username`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77"))
advanceUntilIdle()
assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.77"))
assertThat(viewModel.state.value.isDiscriminatorUserSet).isTrue()
assertThat(viewModel.state.value.isSubmittable).isTrue()
}
@Test
fun `editing the nickname keeps a user-chosen discriminator`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77"))
coEvery { mockRepository.reserveUsername("mayab", "77") } returns RequestResult.Success(Username("mayab.77"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("mayab"))
advanceUntilIdle()
assertThat(viewModel.state.value.reservation).isEqualTo(Username("mayab.77"))
}
@Test
fun `clearing the discriminator hands it back to the service`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged(""))
advanceUntilIdle()
assertThat(viewModel.state.value.isDiscriminatorUserSet).isFalse()
assertThat(viewModel.state.value.discriminator).isEqualTo("45")
assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.45"))
}
@Test
fun `a too-short discriminator produces a validation error`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("7"))
advanceUntilIdle()
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_TOO_SHORT)
assertThat(viewModel.state.value.isSubmittable).isFalse()
}
@Test
fun `a discriminator of 00 produces a validation error`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("00"))
advanceUntilIdle()
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_BE_00)
}
@Test
fun `a discriminator with a leading zero produces a validation error`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("012"))
advanceUntilIdle()
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_START_WITH_ZERO)
}
@Test
fun `an unavailable user-chosen discriminator points the error at the number`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.NonSuccess(ReserveUsernameError.NotAvailable)
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77"))
advanceUntilIdle()
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_NOT_AVAILABLE)
assertThat(viewModel.state.value.isSubmittable).isFalse()
}
@Test
fun `editing the nickname clears any existing reservation`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))