// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI // MARK: - RegistrationPhoneNumberPresenter protocol RegistrationPhoneNumberPresenter: AnyObject { func goToNextStep(withE164: E164) func exitRegistration() } // MARK: - RegistrationPhoneNumberViewController class RegistrationPhoneNumberViewController: OWSViewController { public init( state: RegistrationPhoneNumberViewState.RegistrationMode, presenter: RegistrationPhoneNumberPresenter ) { self.state = state self.presenter = presenter self.phoneNumberInput = RegistrationPhoneNumberInputView(initialPhoneNumber: { switch state { case let .initialRegistration(state): if let e164 = state.previouslyEnteredE164, let result = RegistrationPhoneNumberParser(phoneNumberUtil: Self.phoneNumberUtil).parseE164(e164) { return result } return RegistrationPhoneNumber( countryState: .defaultValue, nationalNumber: "" ) case let .reregistration(state): guard let result = RegistrationPhoneNumberParser(phoneNumberUtil: Self.phoneNumberUtil).parseE164(state.e164) else { owsFail("Could not parse re-registration E164") } return result } }()) super.init() self.phoneNumberInput.delegate = self } public func updateState(_ state: RegistrationPhoneNumberViewState.RegistrationMode) { self.state = state } @available(*, unavailable) public override init() { owsFail("This should not be called") } deinit { nowTimer?.invalidate() nowTimer = nil } // MARK: Internal state private var state: RegistrationPhoneNumberViewState.RegistrationMode { didSet { render() } } private weak var presenter: RegistrationPhoneNumberPresenter? private var now = Date() { didSet { render() } } private var nowTimer: Timer? private var nationalNumber: String { phoneNumberInput.nationalNumber } private var e164: E164? { phoneNumberInput.e164 } private var localValidationError: RegistrationPhoneNumberViewState.ValidationError? { didSet { render() } } private var validationError: RegistrationPhoneNumberViewState.ValidationError? { switch state { case .initialRegistration(let initialRegistration): return initialRegistration.validationError ?? localValidationError case .reregistration(let reregistration): return reregistration.validationError ?? localValidationError } } private var canChangePhoneNumber: Bool { switch state { case .initialRegistration: return true case .reregistration: return false } } private var canSubmit: Bool { guard !nationalNumber.isEmpty, let e164 else { return false } switch state { case .initialRegistration: break case .reregistration: return true } if validationError?.canSubmit(e164: e164, dateProvider: Date.provider) == false { return false } return true } // MARK: Rendering private lazy var contextButton: ContextMenuButton = { let result = ContextMenuButton() result.showsContextMenuAsPrimaryAction = true result.autoSetDimensions(to: .square(40)) return result }() private lazy var contextBarButton = UIBarButtonItem( customView: contextButton, accessibilityIdentifier: "registration.verificationCode.contextButton" ) private lazy var nextBarButton = UIBarButtonItem( title: CommonStrings.nextButton, style: .done, target: self, action: #selector(didTapNext), accessibilityIdentifier: "registration.phonenumber.nextButton" ) private lazy var titleLabel: UILabel = { let result = UILabel.titleLabelForRegistration(text: OWSLocalizedString( "REGISTRATION_PHONE_NUMBER_TITLE", comment: "During registration, users are asked to enter their phone number. This is the title on that screen." )) result.accessibilityIdentifier = "registration.phonenumber.titleLabel" return result }() private lazy var explanationLabel: UILabel = { let result = UILabel.explanationLabelForRegistration(text: OWSLocalizedString( "REGISTRATION_PHONE_NUMBER_SUBTITLE", comment: "During registration, users are asked to enter their phone number. This is the subtitle on that screen, which gives users some instructions." )) result.accessibilityIdentifier = "registration.phonenumber.explanationLabel" return result }() private let phoneNumberInput: RegistrationPhoneNumberInputView private var phoneStrokeNormal: UIView? private var phoneStrokeError: UIView? private lazy var validationWarningLabel: UILabel = { let result = UILabel() result.textColor = .ows_accentRed result.numberOfLines = 0 result.font = UIFont.dynamicTypeSubheadlineClamped result.accessibilityIdentifier = "registration.phonenumber.validationWarningLabel" return result }() public override func viewDidLoad() { super.viewDidLoad() initialRender() // We only need this timer if the user has been rate limited, but it's simpler to always // start it. nowTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self else { return } self.now = Date() } } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) Logger.info("") let shouldBecomeFirstResponder: Bool = { switch validationError { case .rateLimited: return false case nil, .invalidNumber: break } switch state { case .reregistration: return false case .initialRegistration: return true } }() if shouldBecomeFirstResponder { phoneNumberInput.becomeFirstResponder() } } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if contextButton.isShowingContextMenu { contextButton.dismissContextMenu(animated: animated) } } public override func themeDidChange() { super.themeDidChange() render() } private func initialRender() { let stackView = UIStackView() stackView.axis = .vertical view.addSubview(stackView) stackView.autoPinEdgesToSuperviewMargins() stackView.addArrangedSubview(titleLabel) stackView.setCustomSpacing(12, after: titleLabel) stackView.addArrangedSubview(explanationLabel) stackView.setCustomSpacing(24, after: explanationLabel) stackView.addArrangedSubview(phoneNumberInput) stackView.setCustomSpacing(11, after: phoneNumberInput) stackView.addArrangedSubview(validationWarningLabel) stackView.addArrangedSubview(UIView.vStretchingSpacer()) render() } private func render() { var actions: [ContextMenuAction] = [ .init( title: OWSLocalizedString( "USE_PROXY_BUTTON", comment: "Button to activate the signal proxy" ), handler: { [weak self] _ in guard let self else { return } let vc = ProxySettingsViewController(validationMethod: .restGetRegistrationSession) self.presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true) } ) ] let canExitRegistration: Bool switch state { case .initialRegistration(let subState): canExitRegistration = subState.canExitRegistration case .reregistration(let subState): canExitRegistration = subState.canExitRegistration } if canExitRegistration { actions.append(.init( title: OWSLocalizedString( "EXIT_REREGISTRATION", comment: "Button to exit re-registration, shown in context menu." ), handler: { [weak self] _ in self?.presenter?.exitRegistration() } )) } contextButton.contextMenu = ContextMenu(actions) navigationItem.leftBarButtonItem = contextBarButton contextButton.setImage(Theme.iconImage(.buttonMore), for: .normal) contextButton.tintColor = Theme.accentBlueColor navigationItem.rightBarButtonItem = canSubmit ? nextBarButton : nil phoneNumberInput.isEnabled = canChangePhoneNumber phoneNumberInput.render() // We always render the warning label but sometimes invisibly. This avoids UI jumpiness. if let e164, let warningLabelText = validationError?.warningLabelText(e164: e164, dateProvider: Date.provider) { validationWarningLabel.alpha = 1 validationWarningLabel.text = warningLabelText } else { validationWarningLabel.alpha = 0 } switch validationError { case nil, .rateLimited: break case let .invalidNumber(error): showInvalidPhoneNumberAlertIfNecessary(for: error.invalidE164.stringValue) } view.backgroundColor = Theme.backgroundColor nextBarButton.tintColor = Theme.accentBlueColor titleLabel.textColor = .colorForRegistrationTitleLabel explanationLabel.textColor = .colorForRegistrationExplanationLabel // In some cases, the safe area insets will change unexpectedly after presenting a view // controller. This causes layout jumpiness. // // After several of us investigated, we believe it to be an iOS bug with Dynamic Island // devices, but we aren't sure. In any case, forcing a relayout fixes the bug and seems to // keep the safe area insets from changing. view.layoutSubviews() } private var previousInvalidE164: String? private func showInvalidPhoneNumberAlertIfNecessary(for e164: String) { let shouldShowAlert = e164 != previousInvalidE164 if shouldShowAlert { OWSActionSheets.showActionSheet( title: OWSLocalizedString( "REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_TITLE", comment: "Title of alert indicating that users needs to enter a valid phone number to register." ), message: OWSLocalizedString( "REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_MESSAGE", comment: "Message of alert indicating that users needs to enter a valid phone number to register." ) ) } previousInvalidE164 = e164 } // MARK: Events @objc private func didTapNext() { goToNextStep() } private func goToNextStep() { Logger.info("") phoneNumberInput.resignFirstResponder() guard let e164 = self.e164 else { return } guard let phoneNumber = phoneNumberUtil.parseE164(e164.stringValue), PhoneNumberValidator().isValidForRegistration(phoneNumber: phoneNumber) else { localValidationError = .invalidNumber(.init(invalidE164: e164)) return } localValidationError = nil presentActionSheet(.forRegistrationVerificationConfirmation( mode: .sms, e164: e164.stringValue, didConfirm: { [weak self] in self?.presenter?.goToNextStep(withE164: e164) }, didRequestEdit: { [weak self] in self?.phoneNumberInput.becomeFirstResponder() } )) } } // MARK: - RegistrationPhoneNumberInputViewDelegate extension RegistrationPhoneNumberViewController: RegistrationPhoneNumberInputViewDelegate { func present(_ countryCodeViewController: CountryCodeViewController) { let navController = OWSNavigationController(rootViewController: countryCodeViewController) present(navController, animated: true) } func didChange() { render() } func didPressReturn() { goToNextStep() } }