1054 lines
41 KiB
Swift
1054 lines
41 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import BonMot
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
protocol UsernameSelectionDelegate: AnyObject {
|
|
/// Called after the `UsernameSelectionViewController` dismisses after a
|
|
/// successful username confirmation.
|
|
func usernameSelectionDidDismissAfterConfirmation(username: String)
|
|
}
|
|
|
|
/// Provides UX allowing a user to select or delete a username for their
|
|
/// account.
|
|
///
|
|
/// Usernames consist of a user-chosen "nickname" and a programmatically-
|
|
/// generated numeric "discriminator", which are then concatenated.
|
|
class UsernameSelectionViewController: OWSViewController, OWSNavigationChildController {
|
|
|
|
/// A wrapper for injected dependencies.
|
|
struct Context {
|
|
let networkManager: NetworkManager
|
|
let databaseStorage: SDSDatabaseStorage
|
|
let localUsernameManager: LocalUsernameManager
|
|
let schedulers: Schedulers
|
|
let storageServiceManager: StorageServiceManager
|
|
}
|
|
|
|
enum Constants {
|
|
/// Minimum length for a nickname, in Unicode code points.
|
|
static let minNicknameCodepointLength: UInt32 = RemoteConfig.current.minNicknameLength
|
|
|
|
/// Maximum length for a nickname, in Unicode code points.
|
|
static let maxNicknameCodepointLength: UInt32 = RemoteConfig.current.maxNicknameLength
|
|
|
|
/// Amount of time to wait after the username text field is edited
|
|
/// before kicking off a reservation attempt.
|
|
static let reservationDebounceTimeInternal: TimeInterval = 0.5
|
|
|
|
/// Amount of time to wait after the username text field is edited with
|
|
/// a too-short value before showing the corresponding error.
|
|
static let tooShortDebounceTimeInterval: TimeInterval = 1
|
|
|
|
/// Size of the header view's icon.
|
|
static let headerViewIconSize: CGFloat = 64
|
|
|
|
/// A well-known URL associated with a "learn more" string in the
|
|
/// explanation footer. Can be any value - we will intercept this
|
|
/// locally rather than actually open it.
|
|
static let learnMoreLink: URL = URL(string: "sgnl://username-selection-learn-more")!
|
|
}
|
|
|
|
private enum UsernameSelectionState: Equatable, CustomStringConvertible {
|
|
/// The user's existing username is unchanged.
|
|
case noChangesToExisting
|
|
/// The user's existing username has changed, but only in letter casing.
|
|
case caseOnlyChange(newUsername: ParsedUsername)
|
|
/// Username state is pending. Stores an ID, to disambiguate multiple
|
|
/// potentially-overlapping state updates.
|
|
case pending(id: UUID)
|
|
/// The username has been successfully reserved.
|
|
case reservationSuccessful(
|
|
username: ParsedUsername,
|
|
hashedUsername: Usernames.HashedUsername
|
|
)
|
|
/// The username was rejected by the server during reservation.
|
|
case reservationRejected
|
|
/// The reservation was rejected by the server due to rate limiting.
|
|
case reservationRateLimited
|
|
/// The reservation failed due to a network error.
|
|
case reservationFailedNetworkError
|
|
/// The reservation failed, for an unknown reason.
|
|
case reservationFailed
|
|
/// The username is too short.
|
|
case tooShort
|
|
/// The username is too long.
|
|
case tooLong
|
|
/// The username's first character is a digit.
|
|
case cannotStartWithDigit
|
|
/// The username contains invalid characters.
|
|
case invalidCharacters
|
|
/// The custom-set discriminator is too short, but not empty.
|
|
case customDiscriminatorTooShort
|
|
/// The custom-set discriminator is 00, which is not valid.
|
|
case customDiscriminatorIs00
|
|
/// The discriminator has been manually cleared.
|
|
case emptyDiscriminator(nickname: String)
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .noChangesToExisting:
|
|
return "noChangesToExisting"
|
|
case .caseOnlyChange:
|
|
return "caseOnlyChange"
|
|
case .pending(id: _):
|
|
return "pending"
|
|
case .reservationSuccessful(username: _, hashedUsername: _):
|
|
return "reservationSuccessful"
|
|
case .reservationRejected:
|
|
return "reservationRejected"
|
|
case .reservationRateLimited:
|
|
return "reservationRateLimited"
|
|
case .reservationFailedNetworkError:
|
|
return "reservationFailedNetworkError"
|
|
case .reservationFailed:
|
|
return "reservationFailed"
|
|
case .tooShort:
|
|
return "tooShort"
|
|
case .tooLong:
|
|
return "tooLong"
|
|
case .cannotStartWithDigit:
|
|
return "cannotStartWithDigit"
|
|
case .invalidCharacters:
|
|
return "invalidCharacters"
|
|
case .customDiscriminatorTooShort:
|
|
return "customDiscriminatorTooShort"
|
|
case .customDiscriminatorIs00:
|
|
return "customDiscriminatorIs00"
|
|
case .emptyDiscriminator(nickname: _):
|
|
return "emptyDiscriminator"
|
|
}
|
|
}
|
|
}
|
|
|
|
typealias ParsedUsername = Usernames.ParsedUsername
|
|
|
|
// MARK: Private members
|
|
|
|
/// Backing value for ``currentUsernameState``. Do not access directly!
|
|
private var _currentUsernameState: UsernameSelectionState = .noChangesToExisting {
|
|
didSet {
|
|
guard oldValue != _currentUsernameState else {
|
|
return
|
|
}
|
|
|
|
updateContent()
|
|
}
|
|
}
|
|
|
|
/// Represents the current state of username selection. Must only be
|
|
/// accessed on the main thread.
|
|
private var currentUsernameState: UsernameSelectionState {
|
|
get {
|
|
AssertIsOnMainThread()
|
|
return _currentUsernameState
|
|
}
|
|
set {
|
|
AssertIsOnMainThread()
|
|
_currentUsernameState = newValue
|
|
}
|
|
}
|
|
|
|
/// A pre-existing username this controller was seeded with.
|
|
private let existingUsername: ParsedUsername?
|
|
|
|
/// If the user is attempting to recover a corrupted username.
|
|
private var isAttemptingRecovery: Bool
|
|
|
|
/// Injected dependencies.
|
|
private let context: Context
|
|
|
|
// MARK: Public members
|
|
|
|
weak var usernameChangeDelegate: UsernameChangeDelegate?
|
|
weak var usernameSelectionDelegate: (any UsernameSelectionDelegate)?
|
|
|
|
// MARK: Init
|
|
|
|
init(
|
|
existingUsername: ParsedUsername?,
|
|
isAttemptingRecovery: Bool,
|
|
context: Context
|
|
) {
|
|
self.existingUsername = existingUsername
|
|
self.isAttemptingRecovery = isAttemptingRecovery
|
|
self.context = context
|
|
|
|
super.init()
|
|
}
|
|
|
|
// MARK: Getters
|
|
|
|
/// Whether the user has edited the username to a value other than what we
|
|
/// started with.
|
|
private var hasUnsavedEdits: Bool {
|
|
if case .noChangesToExisting = currentUsernameState {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: Views
|
|
|
|
/// Navbar button for finishing this view.
|
|
private lazy var doneBarButtonItem: UIBarButtonItem = .doneButton { [weak self] in
|
|
self?.didTapDone()
|
|
}
|
|
|
|
private lazy var wrapperScrollView = UIScrollView()
|
|
|
|
private lazy var headerView: HeaderView = {
|
|
let view = HeaderView(withIconSize: Constants.headerViewIconSize)
|
|
|
|
view.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
return view
|
|
}()
|
|
|
|
/// Manages editing of the nickname and presents additional visual state
|
|
/// such as the current discriminator.
|
|
private lazy var usernameTextFieldWrapper: UsernameTextFieldWrapper = {
|
|
let wrapper = UsernameTextFieldWrapper(username: existingUsername)
|
|
|
|
wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
wrapper.textField.discriminatorView.delegate = self
|
|
wrapper.textField.delegate = self
|
|
wrapper.textField.addTarget(self, action: #selector(usernameTextFieldContentsDidChange), for: .editingChanged)
|
|
|
|
return wrapper
|
|
}()
|
|
|
|
private lazy var usernameErrorTextView: UITextView = {
|
|
let textView = LinkingTextView()
|
|
|
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
|
textView.textContainerInset = UIEdgeInsets(top: 12, leading: 16, bottom: 0, trailing: 16)
|
|
textView.textColor = .ows_accentRed
|
|
|
|
return textView
|
|
}()
|
|
|
|
private lazy var usernameErrorTextViewZeroHeightConstraint: NSLayoutConstraint = {
|
|
return usernameErrorTextView.heightAnchor.constraint(equalToConstant: 0)
|
|
}()
|
|
|
|
private lazy var usernameFooterTextView: UITextView = {
|
|
let textView = LinkingTextView()
|
|
|
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
|
textView.textContainerInset = UIEdgeInsets(top: 12, leading: 16, bottom: 24, trailing: 16)
|
|
textView.delegate = self
|
|
|
|
return textView
|
|
}()
|
|
|
|
// MARK: View lifecycle
|
|
|
|
var navbarBackgroundColorOverride: UIColor? {
|
|
Theme.tableView2PresentedBackgroundColor
|
|
}
|
|
|
|
override func themeDidChange() {
|
|
super.themeDidChange()
|
|
|
|
view.backgroundColor = Theme.tableView2PresentedBackgroundColor
|
|
owsNavigationController?.updateNavbarAppearance()
|
|
|
|
headerView.setColorsForCurrentTheme()
|
|
usernameTextFieldWrapper.setColorsForCurrentTheme()
|
|
|
|
usernameFooterTextView.textColor = Theme.secondaryTextAndIconColor
|
|
}
|
|
|
|
override func contentSizeCategoryDidChange() {
|
|
headerView.updateFontsForCurrentPreferredContentSize()
|
|
usernameTextFieldWrapper.updateFontsForCurrentPreferredContentSize()
|
|
|
|
usernameErrorTextView.font = .dynamicTypeCaption1Clamped
|
|
usernameFooterTextView.font = .dynamicTypeCaption1Clamped
|
|
}
|
|
|
|
/// Only allow gesture-based dismissal when there have been no edits.
|
|
override var isModalInPresentation: Bool {
|
|
get { hasUnsavedEdits }
|
|
set {}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
setupNavBar()
|
|
setupViewConstraints()
|
|
setupErrorText()
|
|
|
|
themeDidChange()
|
|
contentSizeCategoryDidChange()
|
|
updateContent()
|
|
}
|
|
|
|
private func setupNavBar() {
|
|
title = OWSLocalizedString(
|
|
"USERNAME_SELECTION_TITLE",
|
|
comment: "The title for the username selection view."
|
|
)
|
|
|
|
navigationItem.leftBarButtonItem = .cancelButton(
|
|
dismissingFrom: self,
|
|
hasUnsavedChanges: { [weak self] in self?.hasUnsavedEdits }
|
|
)
|
|
|
|
navigationItem.rightBarButtonItem = doneBarButtonItem
|
|
}
|
|
|
|
private func setupViewConstraints() {
|
|
view.addSubview(wrapperScrollView)
|
|
|
|
wrapperScrollView.addSubview(headerView)
|
|
wrapperScrollView.addSubview(usernameTextFieldWrapper)
|
|
wrapperScrollView.addSubview(usernameErrorTextView)
|
|
wrapperScrollView.addSubview(usernameFooterTextView)
|
|
|
|
wrapperScrollView.autoPinTopToSuperviewMargin()
|
|
wrapperScrollView.autoPinLeadingToSuperviewMargin()
|
|
wrapperScrollView.autoPinTrailingToSuperviewMargin()
|
|
wrapperScrollView.autoPinEdge(.bottom, to: .bottom, of: keyboardLayoutGuideViewSafeArea)
|
|
|
|
let contentLayoutGuide = wrapperScrollView.contentLayoutGuide
|
|
|
|
contentLayoutGuide.widthAnchor.constraint(
|
|
equalTo: wrapperScrollView.widthAnchor
|
|
).isActive = true
|
|
|
|
func constrainHorizontal(_ view: UIView) {
|
|
view.leadingAnchor.constraint(
|
|
equalTo: contentLayoutGuide.leadingAnchor
|
|
).isActive = true
|
|
|
|
view.trailingAnchor.constraint(
|
|
equalTo: contentLayoutGuide.trailingAnchor
|
|
).isActive = true
|
|
}
|
|
|
|
constrainHorizontal(headerView)
|
|
constrainHorizontal(usernameTextFieldWrapper)
|
|
constrainHorizontal(usernameFooterTextView)
|
|
|
|
headerView.topAnchor.constraint(
|
|
equalTo: contentLayoutGuide.topAnchor
|
|
).isActive = true
|
|
|
|
headerView.autoPinEdge(.bottom, to: .top, of: usernameTextFieldWrapper)
|
|
|
|
usernameTextFieldWrapper.autoPinEdge(.bottom, to: .top, of: usernameErrorTextView)
|
|
|
|
usernameErrorTextView.autoPinEdge(.bottom, to: .top, of: usernameFooterTextView)
|
|
usernameErrorTextView.autoPinWidthToSuperview()
|
|
|
|
usernameFooterTextView.bottomAnchor.constraint(
|
|
equalTo: contentLayoutGuide.bottomAnchor
|
|
).isActive = true
|
|
}
|
|
|
|
private func setupErrorText() {
|
|
usernameErrorTextView.layer.opacity = 0
|
|
usernameErrorTextViewZeroHeightConstraint.isActive = true
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
usernameTextFieldWrapper.textField.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
// MARK: - Dynamic contents
|
|
|
|
private extension UsernameSelectionViewController {
|
|
|
|
func updateContent() {
|
|
updateNavigationItems()
|
|
updateHeaderViewContent()
|
|
updateUsernameTextFieldContent()
|
|
updateFooterTextViewContent()
|
|
// If this is done synchronously with `updateUsernameTextFieldContent`,
|
|
// there will be unwanted animations on the text field.
|
|
DispatchQueue.main.async {
|
|
self.updateErrorTextViewContent()
|
|
}
|
|
}
|
|
|
|
/// Update the contents of navigation items for the current internal
|
|
/// controller state.
|
|
private func updateNavigationItems() {
|
|
doneBarButtonItem.isEnabled = {
|
|
switch currentUsernameState {
|
|
case
|
|
.caseOnlyChange,
|
|
.reservationSuccessful:
|
|
return true
|
|
case
|
|
.noChangesToExisting,
|
|
.pending,
|
|
.reservationRejected,
|
|
.reservationRateLimited,
|
|
.reservationFailedNetworkError,
|
|
.reservationFailed,
|
|
.tooShort,
|
|
.tooLong,
|
|
.cannotStartWithDigit,
|
|
.invalidCharacters,
|
|
.customDiscriminatorTooShort,
|
|
.customDiscriminatorIs00,
|
|
.emptyDiscriminator:
|
|
return false
|
|
}
|
|
}()
|
|
}
|
|
|
|
/// Update the contents of the header view for the current internal
|
|
/// controller state.
|
|
private func updateHeaderViewContent() {
|
|
// If we are able to finalize a username (i.e., have a
|
|
// reservation or deletion primed), we should display it.
|
|
let usernameDisplayText: String? = {
|
|
switch self.currentUsernameState {
|
|
case .noChangesToExisting:
|
|
if let existingUsername = self.existingUsername {
|
|
return existingUsername.reassembled
|
|
}
|
|
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_HEADER_TEXT_FOR_PLACEHOLDER",
|
|
comment: "When the user has entered text into a text field for setting their username, a header displays the username text. This string is shown in the header when the text field is empty."
|
|
)
|
|
case let .caseOnlyChange(newUsername):
|
|
return newUsername.reassembled
|
|
case let .reservationSuccessful(username, _):
|
|
return username.reassembled
|
|
case
|
|
.pending,
|
|
.reservationRejected,
|
|
.reservationRateLimited,
|
|
.reservationFailedNetworkError,
|
|
.reservationFailed,
|
|
.tooShort,
|
|
.tooLong,
|
|
.cannotStartWithDigit,
|
|
.invalidCharacters,
|
|
.customDiscriminatorTooShort,
|
|
.customDiscriminatorIs00,
|
|
.emptyDiscriminator:
|
|
return nil
|
|
}
|
|
}()
|
|
|
|
if let usernameDisplayText {
|
|
self.headerView.setUsernameText(to: usernameDisplayText)
|
|
}
|
|
}
|
|
|
|
/// Update the contents of the username text field for the current internal
|
|
/// controller state.
|
|
private func updateUsernameTextFieldContent() {
|
|
switch self.currentUsernameState {
|
|
case .noChangesToExisting:
|
|
self.usernameTextFieldWrapper.textField.configure(forConfirmedUsername: self.existingUsername)
|
|
case let .caseOnlyChange(newUsername):
|
|
self.usernameTextFieldWrapper.textField.configure(forConfirmedUsername: newUsername)
|
|
case .pending:
|
|
self.usernameTextFieldWrapper.textField.configureForSomethingPending()
|
|
case let .reservationSuccessful(username, _):
|
|
self.usernameTextFieldWrapper.textField.configure(forConfirmedUsername: username)
|
|
case
|
|
.reservationRejected,
|
|
.reservationRateLimited,
|
|
.reservationFailedNetworkError,
|
|
.reservationFailed,
|
|
.tooShort,
|
|
.tooLong,
|
|
.cannotStartWithDigit,
|
|
.invalidCharacters,
|
|
.customDiscriminatorTooShort,
|
|
.customDiscriminatorIs00,
|
|
.emptyDiscriminator:
|
|
self.usernameTextFieldWrapper.textField.configureForError()
|
|
}
|
|
}
|
|
|
|
/// Update the contents of the error text view for the current internal
|
|
/// controller state.
|
|
private func updateErrorTextViewContent() {
|
|
let errorText: String? = {
|
|
switch currentUsernameState {
|
|
case
|
|
.noChangesToExisting,
|
|
.caseOnlyChange,
|
|
.pending,
|
|
.reservationSuccessful:
|
|
return nil
|
|
case .reservationRejected:
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_NOT_AVAILABLE_ERROR_MESSAGE",
|
|
comment: "An error message shown when the user wants to set their username to an unavailable value."
|
|
)
|
|
case .reservationRateLimited:
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_RESERVATION_RATE_LIMITED_ERROR_MESSAGE",
|
|
comment: "An error message shown when the user has attempted too many username reservations."
|
|
)
|
|
case .reservationFailedNetworkError:
|
|
return Usernames.RemoteMutationError.networkError.localizedDescription
|
|
case .reservationFailed:
|
|
return CommonStrings.somethingWentWrongTryAgainLaterError
|
|
case .tooShort:
|
|
return String(
|
|
format: OWSLocalizedString(
|
|
"USERNAME_SELECTION_TOO_SHORT_ERROR_MESSAGE_%d",
|
|
tableName: "PluralAware",
|
|
comment: "An error message shown when the user has typed a username that is below the minimum character limit. Embeds {{ %d the minimum character count }}."
|
|
),
|
|
Constants.minNicknameCodepointLength
|
|
)
|
|
case .tooLong:
|
|
owsFail("This should be impossible from the UI, as we limit the text field length.")
|
|
case .cannotStartWithDigit:
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_CANNOT_START_WITH_DIGIT_ERROR_MESSAGE",
|
|
comment: "An error message shown when the user has typed a username that starts with a digit, which is invalid."
|
|
)
|
|
case .invalidCharacters:
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_INVALID_CHARACTERS_ERROR_MESSAGE",
|
|
comment: "An error message shown when the user has typed a username that has invalid characters. The character ranges \"a-z\", \"0-9\", \"_\" should not be translated, as they are literal."
|
|
)
|
|
case .customDiscriminatorTooShort, .emptyDiscriminator:
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_INVALID_DISCRIMINATOR_ERROR_MESSAGE",
|
|
comment: "An error message shown when the user has typed an invalid discriminator for their username."
|
|
)
|
|
case .customDiscriminatorIs00:
|
|
return OWSLocalizedString(
|
|
"USERNAME_SELECTION_ZERO_DISCRIMINATOR_ERROR_MESSAGE",
|
|
comment: "An error message shown when the user has typed '00' as their discriminator for their username."
|
|
)
|
|
}
|
|
}()
|
|
|
|
var layoutBlock: ((UITextView) -> Void)?
|
|
|
|
if let errorText {
|
|
usernameErrorTextView.text = errorText
|
|
|
|
if usernameErrorTextViewZeroHeightConstraint.isActive {
|
|
usernameErrorTextViewZeroHeightConstraint.isActive = false
|
|
layoutBlock = { $0.layer.opacity = 1 }
|
|
}
|
|
} else if !usernameErrorTextViewZeroHeightConstraint.isActive {
|
|
usernameErrorTextViewZeroHeightConstraint.isActive = true
|
|
layoutBlock = { $0.layer.opacity = 0 }
|
|
}
|
|
|
|
guard let layoutBlock else {
|
|
return
|
|
}
|
|
|
|
if UIAccessibility.isReduceMotionEnabled {
|
|
layoutBlock(self.usernameErrorTextView)
|
|
self.view.layoutIfNeeded()
|
|
} else {
|
|
let animator = UIViewPropertyAnimator(duration: 0.3, springDamping: 1, springResponse: 0.3)
|
|
|
|
animator.addAnimations {
|
|
layoutBlock(self.usernameErrorTextView)
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
|
|
animator.startAnimation()
|
|
}
|
|
}
|
|
|
|
/// Update the contents of the footer text view for the current internal
|
|
/// controller state.
|
|
private func updateFooterTextViewContent() {
|
|
let content = NSAttributedString.make(
|
|
fromFormat: OWSLocalizedString(
|
|
"USERNAME_SELECTION_EXPLANATION_FOOTER_FORMAT",
|
|
comment: "Footer text below a text field in which users type their desired username, which explains how usernames work. Embeds a {{ \"learn more\" link. }}."
|
|
),
|
|
attributedFormatArgs: [
|
|
.string(
|
|
CommonStrings.learnMore,
|
|
attributes: [.link: Constants.learnMoreLink]
|
|
)
|
|
]
|
|
).styled(
|
|
with: .font(.dynamicTypeCaption1Clamped),
|
|
.color(Theme.secondaryTextAndIconColor)
|
|
)
|
|
|
|
usernameFooterTextView.linkTextAttributes = [
|
|
.foregroundColor: Theme.primaryTextColor,
|
|
]
|
|
usernameFooterTextView.attributedText = content
|
|
}
|
|
}
|
|
|
|
// MARK: - Nav bar events
|
|
|
|
private extension UsernameSelectionViewController {
|
|
|
|
/// Called when the user taps "Done". Attempts to finalize the new chosen
|
|
/// username.
|
|
private func didTapDone() {
|
|
AssertIsOnMainThread()
|
|
|
|
let usernameState = self.currentUsernameState
|
|
|
|
switch usernameState {
|
|
case let .caseOnlyChange(newUsername):
|
|
changeUsernameCaseBehindModalActivityIndicator(
|
|
newUsername: newUsername
|
|
)
|
|
case let .reservationSuccessful(_, hashedUsername):
|
|
confirmNewUsername(reservedUsername: hashedUsername)
|
|
case
|
|
.noChangesToExisting,
|
|
.pending,
|
|
.reservationRejected,
|
|
.reservationRateLimited,
|
|
.reservationFailedNetworkError,
|
|
.reservationFailed,
|
|
.tooShort,
|
|
.tooLong,
|
|
.cannotStartWithDigit,
|
|
.invalidCharacters,
|
|
.customDiscriminatorTooShort,
|
|
.emptyDiscriminator,
|
|
.customDiscriminatorIs00:
|
|
owsFail("Unexpected username state: \(usernameState). Should be impossible from the UI!")
|
|
}
|
|
}
|
|
|
|
private func changeUsernameCaseBehindModalActivityIndicator(
|
|
newUsername: ParsedUsername
|
|
) {
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: self,
|
|
canCancel: false
|
|
) { modal in
|
|
UsernameLogger.shared.info("Changing username case.")
|
|
|
|
firstly(on: self.context.schedulers.sync) { () -> Guarantee<Usernames.RemoteMutationResult<Void>> in
|
|
return self.context.databaseStorage.write { tx in
|
|
self.context.localUsernameManager.updateVisibleCaseOfExistingUsername(
|
|
newUsername: newUsername.reassembled,
|
|
tx: tx.asV2Write
|
|
)
|
|
}
|
|
}.map(on: self.context.schedulers.main) { remoteMutationResult -> Usernames.RemoteMutationResult<Void> in
|
|
let newState = self.context.databaseStorage.read { tx in
|
|
return self.context.localUsernameManager.usernameState(tx: tx.asV2Read)
|
|
}
|
|
|
|
self.usernameChangeDelegate?.usernameStateDidChange(newState: newState)
|
|
|
|
return remoteMutationResult
|
|
}.done(on: self.context.schedulers.main) { remoteMutationResult -> Void in
|
|
switch remoteMutationResult {
|
|
case .success:
|
|
UsernameLogger.shared.info("Changed username case!")
|
|
|
|
modal.dismiss {
|
|
self.dismiss(animated: true)
|
|
}
|
|
case .failure(let remoteMutationError):
|
|
self.dismiss(
|
|
modalActivityIndicator: modal,
|
|
andPresentErrorMessage: remoteMutationError.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func confirmNewUsername(reservedUsername: Usernames.HashedUsername) {
|
|
if existingUsername == nil, !isAttemptingRecovery {
|
|
self.confirmReservationBehindModalActivityIndicator(
|
|
reservedUsername: reservedUsername
|
|
)
|
|
} else {
|
|
OWSActionSheets.showConfirmationAlert(
|
|
message: OWSLocalizedString(
|
|
"USERNAME_SELECTION_CHANGE_USERNAME_CONFIRMATION_MESSAGE",
|
|
comment: "A message explaining the side effects of changing your username."
|
|
),
|
|
proceedTitle: CommonStrings.continueButton,
|
|
proceedAction: { [weak self] _ in
|
|
self?.confirmReservationBehindModalActivityIndicator(
|
|
reservedUsername: reservedUsername
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Confirm the given reservation, with an activity indicator blocking the
|
|
/// UI.
|
|
private func confirmReservationBehindModalActivityIndicator(
|
|
reservedUsername: Usernames.HashedUsername
|
|
) {
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: self,
|
|
canCancel: false
|
|
) { modal in
|
|
UsernameLogger.shared.info("Confirming username.")
|
|
|
|
firstly(on: self.context.schedulers.sync) { () -> Guarantee<Usernames.RemoteMutationResult<Usernames.ConfirmationResult>> in
|
|
return self.context.databaseStorage.write { tx in
|
|
return self.context.localUsernameManager.confirmUsername(
|
|
reservedUsername: reservedUsername,
|
|
tx: tx.asV2Write
|
|
)
|
|
}
|
|
}.map(on: self.context.schedulers.main) { remoteMutationResult -> Usernames.RemoteMutationResult<Usernames.ConfirmationResult> in
|
|
let newState = self.context.databaseStorage.read { tx in
|
|
return self.context.localUsernameManager.usernameState(tx: tx.asV2Read)
|
|
}
|
|
|
|
self.usernameChangeDelegate?.usernameStateDidChange(newState: newState)
|
|
|
|
return remoteMutationResult
|
|
}.done(on: self.context.schedulers.main) { remoteMutationResult -> Void in
|
|
switch remoteMutationResult {
|
|
case .success(.success):
|
|
UsernameLogger.shared.info("Confirmed username!")
|
|
|
|
modal.dismiss {
|
|
self.dismiss(animated: true) {
|
|
self.usernameSelectionDelegate?.usernameSelectionDidDismissAfterConfirmation(username: reservedUsername.usernameString)
|
|
}
|
|
}
|
|
case .success(.rejected):
|
|
UsernameLogger.shared.error("Failed to confirm the username, server rejected.")
|
|
|
|
self.dismiss(
|
|
modalActivityIndicator: modal,
|
|
andPresentErrorMessage: CommonStrings.somethingWentWrongError
|
|
)
|
|
case .success(.rateLimited):
|
|
UsernameLogger.shared.error("Failed to confirm the username, rate-limited.")
|
|
|
|
self.dismiss(
|
|
modalActivityIndicator: modal,
|
|
andPresentErrorMessage: CommonStrings.somethingWentWrongTryAgainLaterError
|
|
)
|
|
case .failure(let remoteMutationError):
|
|
self.dismiss(
|
|
modalActivityIndicator: modal,
|
|
andPresentErrorMessage: remoteMutationError.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dismiss the given activity indicator and then present an error message
|
|
/// action sheet.
|
|
private func dismiss(
|
|
modalActivityIndicator modal: ModalActivityIndicatorViewController,
|
|
andPresentErrorMessage errorMessage: String
|
|
) {
|
|
modal.dismiss {
|
|
OWSActionSheets.showErrorAlert(message: errorMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Text field events
|
|
|
|
private extension UsernameSelectionViewController {
|
|
/// Called when the contents of the username text field have changed, and
|
|
/// sets local state as appropriate. If the username is believed to be
|
|
/// valid, kicks off a reservation attempt.
|
|
@objc
|
|
private func usernameTextFieldContentsDidChange() {
|
|
AssertIsOnMainThread()
|
|
|
|
let nicknameFromTextField: String? = usernameTextFieldWrapper.textField.nickname
|
|
|
|
// Only set when a discriminator was manually entered. nil indicates a
|
|
// new discriminator should be rolled.
|
|
var desiredDiscriminator = usernameTextFieldWrapper.textField.discriminatorView.customDiscriminator
|
|
|
|
let hasEnteredNewCustomDiscriminator: Bool = {
|
|
if
|
|
let desiredDiscriminator,
|
|
desiredDiscriminator != existingUsername?.discriminator
|
|
{
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}()
|
|
|
|
if
|
|
!hasEnteredNewCustomDiscriminator,
|
|
existingUsername?.nickname == nicknameFromTextField
|
|
{
|
|
currentUsernameState = .noChangesToExisting
|
|
} else if
|
|
!hasEnteredNewCustomDiscriminator,
|
|
let existingUsername,
|
|
let nicknameFromTextField,
|
|
existingUsername.nickname.lowercased() == nicknameFromTextField.lowercased()
|
|
{
|
|
currentUsernameState = .caseOnlyChange(
|
|
newUsername: existingUsername.updatingNickame(newNickname: nicknameFromTextField)
|
|
)
|
|
} else if desiredDiscriminator == "00" {
|
|
currentUsernameState = .customDiscriminatorIs00
|
|
} else if let desiredNickname = nicknameFromTextField {
|
|
if
|
|
let discriminatorString = desiredDiscriminator,
|
|
discriminatorString.count < 2
|
|
{
|
|
if discriminatorString.count > 0 {
|
|
currentUsernameState = .customDiscriminatorTooShort
|
|
return
|
|
} else {
|
|
// Empty string. If it was just set to empty, save the nickname.
|
|
// Once the nickname changes, roll a new discriminator.
|
|
if case let .emptyDiscriminator(nickname: oldNickname) = currentUsernameState {
|
|
if oldNickname != desiredNickname {
|
|
// Nickname changed. Roll new discriminator
|
|
desiredDiscriminator = nil
|
|
// continue
|
|
}
|
|
} else {
|
|
currentUsernameState = .emptyDiscriminator(nickname: desiredNickname)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
typealias CandidateError = Usernames.HashedUsername.CandidateGenerationError
|
|
|
|
do {
|
|
let usernameCandidates = try Usernames.HashedUsername.generateCandidates(
|
|
forNickname: desiredNickname,
|
|
minNicknameLength: Constants.minNicknameCodepointLength,
|
|
maxNicknameLength: Constants.maxNicknameCodepointLength,
|
|
desiredDiscriminator: desiredDiscriminator
|
|
)
|
|
|
|
attemptReservationAndUpdateValidationState(
|
|
forUsernameCandidates: usernameCandidates
|
|
)
|
|
} catch CandidateError.nicknameCannotStartWithDigit {
|
|
currentUsernameState = .cannotStartWithDigit
|
|
} catch CandidateError.nicknameContainsInvalidCharacters {
|
|
currentUsernameState = .invalidCharacters
|
|
} catch CandidateError.nicknameTooLong {
|
|
currentUsernameState = .tooLong
|
|
} catch CandidateError.nicknameTooShort {
|
|
// Wait a beat before showing a "too short" error, in case the
|
|
// user is going to enter more text that renders the error
|
|
// irrelevant.
|
|
|
|
let debounceId = UUID()
|
|
currentUsernameState = .pending(id: debounceId)
|
|
|
|
firstly(on: context.schedulers.sync) { () -> Guarantee<Void> in
|
|
return Guarantee.after(wallInterval: Constants.tooShortDebounceTimeInterval)
|
|
}.done(on: context.schedulers.main) {
|
|
if
|
|
case let .pending(id) = self.currentUsernameState,
|
|
debounceId == id
|
|
{
|
|
self.currentUsernameState = .tooShort
|
|
}
|
|
}
|
|
} catch CandidateError.nicknameCannotBeEmpty {
|
|
owsFail("We should never get here with an empty username string. Did something upstream break?")
|
|
} catch let error {
|
|
owsFailBeta("Unexpected error while generating candidate usernames! Did something upstream change? \(error)")
|
|
currentUsernameState = .reservationFailed
|
|
}
|
|
} else {
|
|
// We have an existing username, but no entered nickname.
|
|
currentUsernameState = .tooShort
|
|
}
|
|
}
|
|
|
|
/// Attempts to reserve the given nickname, and updates ``validationState``
|
|
/// as appropriate.
|
|
///
|
|
/// The desired nickname might change while prior reservation attempts are
|
|
/// in-flight. In order to disambiguate between reservation attempts, we
|
|
/// track an "attempt ID" that represents the current reservation attempt.
|
|
/// If a reservation completes successfully but the current attempt ID does
|
|
/// not match the ID with which the reservation was initiated, we discard
|
|
/// the result (as we have moved on to another desired nickname).
|
|
private func attemptReservationAndUpdateValidationState(
|
|
forUsernameCandidates usernameCandidates: Usernames.HashedUsername.GeneratedCandidates
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
enum ReservationResult {
|
|
case notAttempted
|
|
case success(Usernames.ReservationResult)
|
|
case networkError
|
|
case unknownError
|
|
}
|
|
|
|
let thisAttemptId = UUID()
|
|
let logger = UsernameLogger.shared.suffixed(with: "Attempt ID: \(thisAttemptId)")
|
|
|
|
firstly(on: self.context.schedulers.sync) { () -> Guarantee<Void> in
|
|
self.currentUsernameState = .pending(id: thisAttemptId)
|
|
|
|
// Delay to detect multiple rapid consecutive edits.
|
|
return Guarantee.after(
|
|
wallInterval: Constants.reservationDebounceTimeInternal
|
|
)
|
|
}.then(on: self.context.schedulers.main) { () -> Guarantee<ReservationResult> in
|
|
// If this attempt is no longer current after debounce, we should
|
|
// bail out without firing a reservation.
|
|
guard
|
|
case let .pending(id) = self.currentUsernameState,
|
|
thisAttemptId == id
|
|
else {
|
|
return .value(.notAttempted)
|
|
}
|
|
|
|
logger.info("Attempting to reserve username.")
|
|
|
|
return self.context.localUsernameManager.reserveUsername(
|
|
usernameCandidates: usernameCandidates
|
|
).map(on: self.context.schedulers.sync) { remoteMutationResult -> ReservationResult in
|
|
switch remoteMutationResult {
|
|
case .success(let reservationResult):
|
|
return .success(reservationResult)
|
|
case .failure(.networkError):
|
|
return .networkError
|
|
case .failure(.otherError):
|
|
return .unknownError
|
|
}
|
|
}
|
|
}.done(on: self.context.schedulers.main) { (reservationResult: ReservationResult) -> Void in
|
|
// If the reservation we just attempted is not current, we should
|
|
// drop it and bail out.
|
|
guard
|
|
case let .pending(id) = self.currentUsernameState,
|
|
thisAttemptId == id
|
|
else {
|
|
logger.info("Dropping reservation result, attempt is outdated.")
|
|
return
|
|
}
|
|
|
|
switch reservationResult {
|
|
case .notAttempted:
|
|
return
|
|
case let .success(.successful(username, hashedUsername)):
|
|
logger.info("Successfully reserved nickname!")
|
|
|
|
self.currentUsernameState = .reservationSuccessful(
|
|
username: username,
|
|
hashedUsername: hashedUsername
|
|
)
|
|
case .success(.rejected):
|
|
logger.warn("Reservation rejected.")
|
|
|
|
self.currentUsernameState = .reservationRejected
|
|
case .success(.rateLimited):
|
|
logger.error("Reservation rate-limited.")
|
|
|
|
self.currentUsernameState = .reservationRateLimited
|
|
case .networkError:
|
|
logger.error("Reservation failed due to a network error.")
|
|
|
|
self.currentUsernameState = .reservationFailedNetworkError
|
|
case .unknownError:
|
|
logger.error("Reservation failed due to an unknown error.")
|
|
|
|
self.currentUsernameState = .reservationFailed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - DiscriminatorTextFieldDelegate
|
|
|
|
extension UsernameSelectionViewController: DiscriminatorTextFieldDelegate {
|
|
func didManuallyChangeDiscriminator() {
|
|
usernameTextFieldContentsDidChange()
|
|
}
|
|
}
|
|
|
|
// MARK: - UITextFieldDelegate
|
|
|
|
extension UsernameSelectionViewController: UITextFieldDelegate {
|
|
/// Called when user action would result in changed contents in the text
|
|
/// field.
|
|
func textField(
|
|
_ textField: UITextField,
|
|
shouldChangeCharactersIn range: NSRange,
|
|
replacementString string: String
|
|
) -> Bool {
|
|
return TextFieldHelper.textField(
|
|
textField,
|
|
shouldChangeCharactersInRange: range,
|
|
replacementString: string,
|
|
maxUnicodeScalarCount: Int(Constants.maxNicknameCodepointLength)
|
|
)
|
|
}
|
|
|
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
guard let usernameTextField = textField as? UsernameTextField else { return true }
|
|
usernameTextField.discriminatorView.becomeFirstResponder()
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - UITextViewDelegate and Learn More
|
|
|
|
extension UsernameSelectionViewController: UITextViewDelegate {
|
|
func textView(
|
|
_ textView: UITextView,
|
|
shouldInteractWith url: URL,
|
|
in characterRange: NSRange,
|
|
interaction: UITextItemInteraction
|
|
) -> Bool {
|
|
guard url == Constants.learnMoreLink else {
|
|
owsFail("Unexpected URL in text view!")
|
|
}
|
|
|
|
presentLearnMoreActionSheet()
|
|
|
|
return false
|
|
}
|
|
|
|
/// Present an action sheet to the user with a detailed explanation of the
|
|
/// username discriminator.
|
|
private func presentLearnMoreActionSheet() {
|
|
let title = OWSLocalizedString(
|
|
"USERNAME_SELECTION_LEARN_MORE_ACTION_SHEET_TITLE",
|
|
comment: "The title of a sheet that pops up when the user taps \"Learn More\" in text that explains how usernames work. The sheet will present a more detailed explanation of the username's numeric suffix."
|
|
)
|
|
|
|
let message = OWSLocalizedString(
|
|
"USERNAME_SELECTION_LEARN_MORE_ACTION_SHEET_MESSAGE",
|
|
comment: "The message of a sheet that pops up when the user taps \"Learn More\" in text that explains how usernames work. This message help explain that the automatically-generated numeric suffix of their username helps keep their username private, to avoid them being contacted by people by whom they don't want to be contacted."
|
|
)
|
|
|
|
OWSActionSheets.showActionSheet(
|
|
title: title,
|
|
message: message
|
|
)
|
|
}
|
|
}
|