// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation @objc class UsernameViewController: OWSViewController { private static let minimumUsernameLength = 4 private static let maximumUsernameLength: UInt = 26 private let usernameTextField = OWSTextField() private var hasPendingChanges: Bool { return pendingUsername != (OWSProfileManager.shared().localUsername() ?? "") } private var pendingUsername: String { return usernameTextField.text?.stripped.lowercased() ?? "" } private var errorLabel = UILabel() private var errorRow = UIView() private enum ValidationState { case valid case tooShort case invalidCharacters case inUse } private var validationState: ValidationState = .valid { didSet { updateValidationError() } } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Theme.backgroundColor title = NSLocalizedString("USERNAME_TITLE", comment: "The title for the username view.") navigationItem.leftBarButtonItem = UIBarButtonItem(title: CommonStrings.cancelButton, style: .plain, target: self, action: #selector(didTapCancel)) let stackView = UIStackView() stackView.axis = .vertical stackView.spacing = 0 view.addSubview(stackView) stackView.autoPinWidthToSuperview() stackView.autoPinTopToSuperviewMargin(withInset: 35) let topLine = UIView() let bottomLine = UIView() for line in [topLine, bottomLine] { stackView.addArrangedSubview(line) line.backgroundColor = Theme.cellSeparatorColor line.autoSetDimension(.height, toSize: CGHairlineWidth()) } // Username let usernameRow = UIView() stackView.insertArrangedSubview(usernameRow, at: 1) usernameRow.layoutMargins = UIEdgeInsets(top: 10, leading: 18, bottom: 10, trailing: 18) let titleLabel = UILabel() titleLabel.text = NSLocalizedString("USERNAME_FIELD", comment: "Label for the username field in the username view.") titleLabel.textColor = Theme.primaryColor titleLabel.font = UIFont.ows_dynamicTypeBodyClamped.ows_mediumWeight() usernameRow.addSubview(titleLabel) titleLabel.autoPinLeadingToSuperviewMargin() titleLabel.autoPinHeightToSuperviewMargins() usernameTextField.font = .ows_dynamicTypeBodyClamped usernameTextField.textColor = Theme.primaryColor usernameTextField.autocorrectionType = .no usernameTextField.autocapitalizationType = .none usernameTextField.placeholder = NSLocalizedString("USERNAME_PLACEHOLDER", comment: "The placeholder for the username text entry in the username view.") usernameTextField.text = OWSProfileManager.shared().localUsername() usernameTextField.textAlignment = .right usernameTextField.delegate = self usernameTextField.returnKeyType = .done usernameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) usernameTextField.becomeFirstResponder() usernameRow.addSubview(usernameTextField) usernameTextField.autoPinLeading(toTrailingEdgeOf: titleLabel, offset: 10) usernameTextField.autoPinTrailingToSuperviewMargin() usernameTextField.autoVCenterInSuperview() // Error stackView.addArrangedSubview(errorRow) errorRow.isHidden = true errorLabel.textColor = .ows_red errorLabel.textAlignment = .center errorLabel.numberOfLines = 0 errorLabel.lineBreakMode = .byWordWrapping errorLabel.font = .ows_dynamicTypeCaption1Clamped errorRow.addSubview(errorLabel) errorLabel.autoPinWidthToSuperview(withMargin: 18) errorLabel.autoPinHeightToSuperview(withMargin: 5) // Info let infoRow = UIView() stackView.addArrangedSubview(infoRow) let infoLabel = UILabel() infoLabel.textColor = Theme.secondaryColor infoLabel.text = NSLocalizedString("USERNAME_DESCRIPTION", comment: "An explanation of how usernames work on the username view.") infoLabel.font = .ows_dynamicTypeCaption1Clamped infoLabel.numberOfLines = 0 infoLabel.lineBreakMode = .byWordWrapping infoRow.addSubview(infoLabel) infoLabel.autoPinWidthToSuperview(withMargin: 18) infoLabel.autoPinHeightToSuperview(withMargin: 10) } func updateSaveButton() { guard hasPendingChanges else { navigationItem.rightBarButtonItem = nil return } navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(didTapSave)) } func updateValidationError() { switch validationState { case .valid: errorRow.isHidden = true case .tooShort: errorRow.isHidden = false errorLabel.text = NSLocalizedString("USERNAME_TOO_SHORT_ERROR", comment: "An error indicating that the supplied username is too short.") case .invalidCharacters: errorRow.isHidden = false errorLabel.text = NSLocalizedString("USERNAME_INVALID_CHARACTERS_ERROR", comment: "An error indicating that the supplied username contains disallowed characters.") case .inUse: errorRow.isHidden = false let unavailableErrorFormat = NSLocalizedString("USERNAME_UNAVAIALBE_ERROR_FORMAT", comment: "An error indicating that the supplied username is in use by another user. Embeds {{requested username}}.") errorLabel.text = String(format: unavailableErrorFormat, pendingUsername) } } func saveUsername() { // If we're trying to save, but we have nothing to save, just dismiss immediately. guard hasPendingChanges else { return usernameSavedOrCanceled() } guard usernameIsValid() else { return } var usernameToUse: String? = pendingUsername if usernameToUse?.isEmpty == true { usernameToUse = nil } ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modalView in let usernameRequest: TSRequest if let usernameToUse = usernameToUse { usernameRequest = OWSRequestFactory.usernameSetRequest(usernameToUse) } else { usernameRequest = OWSRequestFactory.usernameDeleteRequest() } SSKEnvironment.shared.networkManager.makePromise(request: usernameRequest).done { _ in modalView.dismiss { OWSProfileManager.shared().updateLocalUsername(usernameToUse) self.usernameSavedOrCanceled() } }.catch { error in if case .taskError(let task, _)? = error as? NetworkManagerError, task.statusCode() == 409 { modalView.dismiss { self.validationState = .inUse } return } owsFailDebug("Unexpected username update error \(error)") modalView.dismiss { OWSAlerts.showErrorAlert(message: NSLocalizedString("USERNAME_VIEW_ERROR_UPDATE_FAILED", comment: "Error moessage shown when a username update fails.")) } } } } func usernameIsValid() -> Bool { // We allow empty usernames, as this is how you delete your username guard !pendingUsername.isEmpty else { return true } guard pendingUsername.count >= UsernameViewController.minimumUsernameLength else { validationState = .tooShort return false } // Usernames only allow a-z, 0-9, and underscore let validUsernameRegex = try! NSRegularExpression(pattern: "^[a-z0-9_]+$", options: []) guard validUsernameRegex.hasMatch(input: pendingUsername) else { validationState = .invalidCharacters return false } return true } func usernameSavedOrCanceled() { usernameTextField.resignFirstResponder() dismiss(animated: true, completion: nil) } // MARK: - @objc func didTapCancel() { guard hasPendingChanges else { return usernameSavedOrCanceled() } OWSAlerts.showPendingChangesAlert { [weak self] in self?.usernameSavedOrCanceled() } } @objc func didTapSave() { saveUsername() } @objc func textFieldDidChange() { // Clear validation errors when the user starts typing again. validationState = .valid updateSaveButton() } } // MARK: - extension UsernameViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { usernameTextField.resignFirstResponder() return false } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { return TextFieldHelper.textField( textField, shouldChangeCharactersInRange: range, replacementString: string, byteLimit: UsernameViewController.maximumUsernameLength ) } }