ability to import/export auth data and some refactoring
This commit is contained in:
@@ -23,6 +23,7 @@ object AuthGraph
|
||||
|
||||
fun NavGraphBuilder.authNavGraph(
|
||||
onNavigateToMain: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
navController: NavController
|
||||
) {
|
||||
navigation<AuthGraph>(startDestination = Login) {
|
||||
@@ -54,6 +55,7 @@ fun NavGraphBuilder.authNavGraph(
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToSettings = onNavigateToSettings,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
|
||||
@@ -35,49 +35,13 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface LoginViewModel {
|
||||
val screenState: StateFlow<LoginScreenState>
|
||||
val loginDialog: StateFlow<LoginDialog?>
|
||||
|
||||
val validationArguments: StateFlow<LoginValidationArguments?>
|
||||
val captchaArguments: StateFlow<CaptchaArguments?>
|
||||
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
|
||||
val isNeedToOpenMain: StateFlow<Boolean>
|
||||
|
||||
val isNeedToClearCaptchaCode: StateFlow<Boolean>
|
||||
val isNeedToClearValidationCode: StateFlow<Boolean>
|
||||
|
||||
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
|
||||
fun onDialogDismissed(dialog: LoginDialog)
|
||||
|
||||
fun onBackPressed()
|
||||
|
||||
fun onPasswordVisibilityButtonClicked()
|
||||
|
||||
fun onLoginInputChanged(newLogin: String)
|
||||
fun onPasswordInputChanged(newPassword: String)
|
||||
|
||||
fun onSignInButtonClicked()
|
||||
|
||||
fun onLogoClicked()
|
||||
|
||||
fun onNavigatedToMain()
|
||||
fun onNavigatedToUserBanned()
|
||||
fun onNavigatedToCaptcha()
|
||||
fun onNavigatedToValidation()
|
||||
|
||||
fun onValidationCodeReceived(code: String?)
|
||||
fun onValidationCodeCleared()
|
||||
fun onCaptchaCodeReceived(code: String?)
|
||||
fun onCaptchaCodeCleared()
|
||||
}
|
||||
|
||||
class LoginViewModelImpl(
|
||||
class LoginViewModel(
|
||||
private val oAuthUseCase: OAuthUseCase,
|
||||
private val authRepository: AuthRepository,
|
||||
private val loadUserByIdUseCase: LoadUserByIdUseCase,
|
||||
@@ -85,18 +49,30 @@ class LoginViewModelImpl(
|
||||
private val loginValidator: LoginValidator,
|
||||
private val longPollController: LongPollController,
|
||||
private val userSettings: UserSettings
|
||||
) : ViewModel(), LoginViewModel {
|
||||
) : ViewModel() {
|
||||
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
|
||||
val screenState = _screenState.asStateFlow()
|
||||
|
||||
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
|
||||
override val loginDialog = MutableStateFlow<LoginDialog?>(null)
|
||||
private val _loginDialog = MutableStateFlow<LoginDialog?>(null)
|
||||
val loginDialog = _loginDialog.asStateFlow()
|
||||
|
||||
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
|
||||
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
|
||||
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
|
||||
override val isNeedToOpenMain = MutableStateFlow(false)
|
||||
private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
|
||||
val validationArguments = _validationArguments.asStateFlow()
|
||||
|
||||
override val isNeedToClearCaptchaCode = MutableStateFlow(false)
|
||||
override val isNeedToClearValidationCode = MutableStateFlow(false)
|
||||
private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
|
||||
val captchaArguments = _captchaArguments.asStateFlow()
|
||||
|
||||
private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
|
||||
val userBannedArguments = _userBannedArguments.asStateFlow()
|
||||
|
||||
private val _isNeedToOpenMain = MutableStateFlow(false)
|
||||
val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow()
|
||||
|
||||
private val _isNeedToClearCaptchaCode = MutableStateFlow(false)
|
||||
val isNeedToClearCaptchaCode = _isNeedToClearCaptchaCode.asStateFlow()
|
||||
|
||||
private val _isNeedToClearValidationCode = MutableStateFlow(false)
|
||||
val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow()
|
||||
|
||||
private val validationState: StateFlow<List<LoginValidationResult>> =
|
||||
screenState.map(loginValidator::validate)
|
||||
@@ -120,7 +96,7 @@ class LoginViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
|
||||
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
|
||||
onDialogDismissed(dialog)
|
||||
|
||||
when (dialog) {
|
||||
@@ -128,20 +104,24 @@ class LoginViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDialogDismissed(dialog: LoginDialog) {
|
||||
loginDialog.setValue { null }
|
||||
fun onDialogDismissed(dialog: LoginDialog) {
|
||||
when (dialog) {
|
||||
is LoginDialog.Error -> Unit
|
||||
}
|
||||
|
||||
_loginDialog.setValue { null }
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
screenState.setValue { old -> old.copy(showLogo = true) }
|
||||
fun onBackPressed() {
|
||||
_screenState.setValue { old -> old.copy(showLogo = true) }
|
||||
}
|
||||
|
||||
override fun onPasswordVisibilityButtonClicked() {
|
||||
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
|
||||
fun onPasswordVisibilityButtonClicked() {
|
||||
_screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
|
||||
}
|
||||
|
||||
override fun onLoginInputChanged(newLogin: String) {
|
||||
screenState.setValue { old ->
|
||||
fun onLoginInputChanged(newLogin: String) {
|
||||
_screenState.setValue { old ->
|
||||
old.copy(
|
||||
login = newLogin.trim(),
|
||||
loginError = false
|
||||
@@ -149,8 +129,8 @@ class LoginViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPasswordInputChanged(newPassword: String) {
|
||||
screenState.setValue { old ->
|
||||
fun onPasswordInputChanged(newPassword: String) {
|
||||
_screenState.setValue { old ->
|
||||
old.copy(
|
||||
password = newPassword.trim(),
|
||||
passwordError = false
|
||||
@@ -158,18 +138,18 @@ class LoginViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSignInButtonClicked() {
|
||||
fun onSignInButtonClicked() {
|
||||
if (screenState.value.isLoading) return
|
||||
|
||||
if (screenState.value.showLogo) {
|
||||
screenState.setValue { old -> old.copy(showLogo = false) }
|
||||
_screenState.setValue { old -> old.copy(showLogo = false) }
|
||||
return
|
||||
}
|
||||
|
||||
login()
|
||||
}
|
||||
|
||||
override fun onLogoClicked() {
|
||||
fun onLogoClicked() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
userSettings.onEnableDynamicColorsChanged(
|
||||
!userSettings.enableDynamicColors.value
|
||||
@@ -177,36 +157,36 @@ class LoginViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigatedToMain() {
|
||||
isNeedToOpenMain.update { false }
|
||||
fun onNavigatedToMain() {
|
||||
_isNeedToOpenMain.update { false }
|
||||
}
|
||||
|
||||
override fun onNavigatedToUserBanned() {
|
||||
userBannedArguments.update { null }
|
||||
fun onNavigatedToUserBanned() {
|
||||
_userBannedArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onNavigatedToCaptcha() {
|
||||
captchaArguments.update { null }
|
||||
fun onNavigatedToCaptcha() {
|
||||
_captchaArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onNavigatedToValidation() {
|
||||
validationArguments.update { null }
|
||||
fun onNavigatedToValidation() {
|
||||
_validationArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onValidationCodeReceived(code: String?) {
|
||||
fun onValidationCodeReceived(code: String?) {
|
||||
validationCode.update { code }
|
||||
}
|
||||
|
||||
override fun onValidationCodeCleared() {
|
||||
isNeedToClearValidationCode.update { false }
|
||||
fun onValidationCodeCleared() {
|
||||
_isNeedToClearValidationCode.update { false }
|
||||
}
|
||||
|
||||
override fun onCaptchaCodeReceived(code: String?) {
|
||||
fun onCaptchaCodeReceived(code: String?) {
|
||||
captchaCode.update { code }
|
||||
}
|
||||
|
||||
override fun onCaptchaCodeCleared() {
|
||||
isNeedToClearCaptchaCode.update { false }
|
||||
fun onCaptchaCodeCleared() {
|
||||
_isNeedToClearCaptchaCode.update { false }
|
||||
}
|
||||
|
||||
private fun login(forceSms: Boolean = false) {
|
||||
@@ -223,7 +203,7 @@ class LoginViewModelImpl(
|
||||
processValidation()
|
||||
if (!validationState.value.contains(LoginValidationResult.Valid)) return
|
||||
|
||||
screenState.updateValue { copy(isLoading = true) }
|
||||
_screenState.updateValue { copy(isLoading = true) }
|
||||
|
||||
val currentValidationSid = validationSid.value
|
||||
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
|
||||
@@ -242,7 +222,7 @@ class LoginViewModelImpl(
|
||||
error = { error ->
|
||||
Log.d("LoginViewModelImpl", "login: error: $error")
|
||||
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
_screenState.updateValue { copy(isLoading = false) }
|
||||
captchaSid.setValue { null }
|
||||
|
||||
parseError(error)
|
||||
@@ -250,8 +230,8 @@ class LoginViewModelImpl(
|
||||
success = { response ->
|
||||
val exceptionHandler =
|
||||
CoroutineExceptionHandler { _, _ ->
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
_screenState.updateValue { copy(isLoading = false) }
|
||||
_loginDialog.setValue { LoginDialog.Error() }
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
|
||||
@@ -277,8 +257,8 @@ class LoginViewModelImpl(
|
||||
}
|
||||
|
||||
if (exchangeToken == null) {
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
_screenState.updateValue { copy(isLoading = false) }
|
||||
_loginDialog.setValue { LoginDialog.Error() }
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -316,15 +296,15 @@ class LoginViewModelImpl(
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
any = {
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
_screenState.updateValue { copy(isLoading = false) }
|
||||
},
|
||||
error = ::parseError,
|
||||
success = { user ->
|
||||
if (user == null) {
|
||||
loginDialog.update { LoginDialog.Error() }
|
||||
_loginDialog.update { LoginDialog.Error() }
|
||||
} else {
|
||||
screenState.updateValue { copy(login = "", password = "") }
|
||||
isNeedToOpenMain.update { true }
|
||||
_screenState.updateValue { copy(login = "", password = "") }
|
||||
_isNeedToOpenMain.update { true }
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -347,7 +327,7 @@ class LoginViewModelImpl(
|
||||
validationType = error.validationType.value,
|
||||
canResendSms = error.validationResend == "sms"
|
||||
)
|
||||
validationArguments.update { arguments }
|
||||
_validationArguments.update { arguments }
|
||||
validationSid.update { error.validationSid }
|
||||
}
|
||||
|
||||
@@ -356,12 +336,12 @@ class LoginViewModelImpl(
|
||||
captchaSid = error.captchaSid,
|
||||
captchaImageUrl = error.captchaImageUrl
|
||||
)
|
||||
captchaArguments.update { arguments }
|
||||
_captchaArguments.update { arguments }
|
||||
captchaSid.update { error.captchaSid }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.InvalidCredentialsError -> {
|
||||
loginDialog.setValue {
|
||||
_loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = "Wrong login or password.")
|
||||
}
|
||||
}
|
||||
@@ -373,33 +353,33 @@ class LoginViewModelImpl(
|
||||
restoreUrl = error.restoreUrl,
|
||||
accessToken = error.accessToken
|
||||
)
|
||||
userBannedArguments.update { arguments }
|
||||
_userBannedArguments.update { arguments }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.WrongValidationCode -> {
|
||||
isNeedToClearValidationCode.update { true }
|
||||
_isNeedToClearValidationCode.update { true }
|
||||
validationCode.update { null }
|
||||
loginDialog.setValue {
|
||||
_loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = "Wrong validation code.")
|
||||
}
|
||||
}
|
||||
|
||||
OAuthErrorDomain.WrongValidationCodeFormat -> {
|
||||
isNeedToClearValidationCode.update { true }
|
||||
_isNeedToClearValidationCode.update { true }
|
||||
validationCode.update { null }
|
||||
loginDialog.setValue {
|
||||
_loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = "Wrong validation code format.")
|
||||
}
|
||||
}
|
||||
|
||||
OAuthErrorDomain.TooManyTriesError -> {
|
||||
loginDialog.setValue {
|
||||
_loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
|
||||
}
|
||||
}
|
||||
|
||||
OAuthErrorDomain.UnknownError -> {
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
_loginDialog.setValue { LoginDialog.Error() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,11 +392,11 @@ class LoginViewModelImpl(
|
||||
validationState.value.forEach { result ->
|
||||
when (result) {
|
||||
LoginValidationResult.LoginEmpty -> {
|
||||
screenState.setValue { old -> old.copy(loginError = true) }
|
||||
_screenState.setValue { old -> old.copy(loginError = true) }
|
||||
}
|
||||
|
||||
LoginValidationResult.PasswordEmpty -> {
|
||||
screenState.setValue { old -> old.copy(passwordError = true) }
|
||||
_screenState.setValue { old -> old.copy(passwordError = true) }
|
||||
}
|
||||
|
||||
LoginValidationResult.Empty -> Unit
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package dev.meloda.fast.auth.login.di
|
||||
|
||||
import dev.meloda.fast.auth.login.LoginViewModelImpl
|
||||
import dev.meloda.fast.auth.login.LoginViewModel
|
||||
import dev.meloda.fast.auth.login.validation.LoginValidator
|
||||
import dev.meloda.fast.domain.OAuthUseCase
|
||||
import dev.meloda.fast.domain.OAuthUseCaseImpl
|
||||
import dev.meloda.fast.auth.login.validation.LoginValidator
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.bind
|
||||
@@ -11,6 +11,6 @@ import org.koin.dsl.module
|
||||
|
||||
val loginModule = module {
|
||||
singleOf(::LoginValidator)
|
||||
viewModelOf(::LoginViewModelImpl) bind dev.meloda.fast.auth.login.LoginViewModel::class
|
||||
viewModelOf(::LoginViewModel)
|
||||
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,7 +8,6 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.auth.login.LoginViewModel
|
||||
import dev.meloda.fast.auth.login.LoginViewModelImpl
|
||||
import dev.meloda.fast.auth.login.model.CaptchaArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationArguments
|
||||
@@ -24,11 +23,12 @@ fun NavGraphBuilder.loginScreen(
|
||||
onNavigateToValidation: (LoginValidationArguments) -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
navController: NavController
|
||||
) {
|
||||
composable<Login> { backStackEntry ->
|
||||
val viewModel: LoginViewModel =
|
||||
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
|
||||
backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
|
||||
|
||||
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
|
||||
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
|
||||
@@ -55,6 +55,7 @@ fun NavGraphBuilder.loginScreen(
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
onNavigateToCaptcha = onNavigateToCaptcha,
|
||||
onNavigateToValidation = onNavigateToValidation,
|
||||
onNavigateToSettings = onNavigateToSettings,
|
||||
validationCode = validationCode,
|
||||
captchaCode = captchaCode,
|
||||
viewModel = viewModel
|
||||
|
||||
+12
-5
@@ -39,6 +39,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.ContentType
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@@ -56,7 +58,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.auth.login.LoginViewModel
|
||||
import dev.meloda.fast.auth.login.LoginViewModelImpl
|
||||
import dev.meloda.fast.auth.login.model.CaptchaArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginDialog
|
||||
import dev.meloda.fast.auth.login.model.LoginScreenState
|
||||
@@ -76,9 +77,10 @@ fun LoginRoute(
|
||||
onNavigateToMain: () -> Unit,
|
||||
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
|
||||
onNavigateToValidation: (LoginValidationArguments) -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
validationCode: String?,
|
||||
captchaCode: String?,
|
||||
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
|
||||
viewModel: LoginViewModel = koinViewModel()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
|
||||
@@ -132,7 +134,8 @@ fun LoginRoute(
|
||||
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
|
||||
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
|
||||
onSignInButtonClicked = viewModel::onSignInButtonClicked,
|
||||
onLogoClicked = viewModel::onLogoClicked
|
||||
onLogoClicked = viewModel::onLogoClicked,
|
||||
onLogoLongClicked = onNavigateToSettings
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
@@ -151,7 +154,8 @@ fun LoginScreen(
|
||||
onPasswordVisibilityButtonClicked: () -> Unit = {},
|
||||
onPasswordFieldGoAction: () -> Unit = {},
|
||||
onSignInButtonClicked: () -> Unit = {},
|
||||
onLogoClicked: () -> Unit = {}
|
||||
onLogoClicked: () -> Unit = {},
|
||||
onLogoLongClicked: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSizeConfig.current
|
||||
@@ -185,7 +189,10 @@ fun LoginScreen(
|
||||
exit = fadeOut(),
|
||||
label = "Logo visibility"
|
||||
) {
|
||||
Logo(onLogoClicked = onLogoClicked)
|
||||
Logo(
|
||||
onLogoClicked = onLogoClicked,
|
||||
onLogoLongClicked = onLogoLongClicked
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
|
||||
@@ -30,7 +30,8 @@ import dev.meloda.fast.ui.theme.LocalSizeConfig
|
||||
@Composable
|
||||
fun Logo(
|
||||
modifier: Modifier = Modifier,
|
||||
onLogoClicked: () -> Unit = {}
|
||||
onLogoClicked: () -> Unit = {},
|
||||
onLogoLongClicked: () -> Unit = {}
|
||||
) {
|
||||
val size = LocalSizeConfig.current
|
||||
|
||||
@@ -60,7 +61,7 @@ fun Logo(
|
||||
.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onLongClick = null,
|
||||
onLongClick = onLogoLongClicked,
|
||||
onClick = onLogoClicked
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user