Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Registration/UserInterface/RegistrationProfileViewController.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SafariServices
import SignalServiceKit
import SignalUI

// MARK: - RegistrationProfileState

public struct RegistrationProfileState: Equatable {
    let e164: E164
    let phoneNumberDiscoverability: PhoneNumberDiscoverability
}

// MARK: - RegistrationProfilePresenter

protocol RegistrationProfilePresenter: AnyObject {
    func goToNextStep(
        givenName: OWSUserProfile.NameComponent,
        familyName: OWSUserProfile.NameComponent?,
        avatarData: Data?,
        phoneNumberDiscoverability: PhoneNumberDiscoverability,
    )
}

// MARK: - RegistrationProfileViewController

class RegistrationProfileViewController: OWSViewController {
    var state: RegistrationProfileState

    init(
        state: RegistrationProfileState,
        presenter: RegistrationProfilePresenter,
    ) {
        self.presenter = presenter
        self.state = state

        super.init()

        navigationItem.hidesBackButton = true
    }

    @available(*, unavailable)
    override init() {
        owsFail("This should not be called")
    }

    // MARK: Internal state

    private weak var presenter: RegistrationProfilePresenter?

    private var givenNameComponent: OWSUserProfile.NameComponent? {
        return OWSUserProfile.NameComponent(truncating: givenNameTextField.text ?? "")
    }

    private var familyNameComponent: OWSUserProfile.NameComponent? {
        return OWSUserProfile.NameComponent(truncating: familyNameTextField.text ?? "")
    }

    private var avatarData: Data? {
        didSet { updateUI() }
    }

    // MARK: UI

    private lazy var titleLabel: UILabel = {
        let result = UILabel.titleLabelForRegistration(text: OWSLocalizedString(
            "REGISTRATION_PROFILE_SETUP_TITLE",
            comment: "During registration, users set up their profile. This is the title on the screen where this is done.",
        ))
        result.accessibilityIdentifier = "registration.profile.titleLabel"
        return result
    }()

    private lazy var explanationView: LinkingTextView = {
        let result = LinkingTextView()
        result.attributedText = .composed(of: [
            OWSLocalizedString(
                "REGISTRATION_PROFILE_SETUP_SUBTITLE",
                comment: "During registration, users set up their profile. This is the subtitle on the screen where this is done. It tells users about the privacy of their profile. A \"learn more\" link will be added to the end of this string.",
            ),
            " ",
            CommonStrings.learnMore.styled(with: {
                // We'd like a link that doesn't go anywhere, because we'd like to handle the
                // tapping ourselves. We use a "fake" URL because BonMot needs one.
                return StringStyle.Part.link(URL.Support.profilesAndMessageRequests)
            }()),
        ])
        result.textColor = .Signal.secondaryLabel
        result.font = .dynamicTypeBody
        result.textAlignment = .center
        result.delegate = self
        return result
    }()

    private let avatarSize: CGFloat = 64
    private lazy var avatarView: AvatarImageView = {
        let result = AvatarImageView()
        result.translatesAutoresizingMaskIntoConstraints = false
        result.addConstraints([
            result.widthAnchor.constraint(equalToConstant: avatarSize),
            result.heightAnchor.constraint(equalToConstant: avatarSize),
        ])
        result.accessibilityIdentifier = "registration.profile.avatarView"
        result.addGestureRecognizer(UITapGestureRecognizer(
            target: self,
            action: #selector(didTapAvatar),
        ))
        result.isUserInteractionEnabled = true
        return result
    }()

    private lazy var cameraIconButton: UIButton = {
        let buttonSize: CGFloat = 28

        var buttonConfiguration: UIButton.Configuration?
        if #available(iOS 26, *) {
            buttonConfiguration = .prominentClearGlass()
        }
        if buttonConfiguration == nil {
            buttonConfiguration = .filled()
            buttonConfiguration?.baseBackgroundColor = .Signal.background
            buttonConfiguration?.baseForegroundColor = .Signal.secondaryLabel
        }
        buttonConfiguration?.cornerStyle = .capsule
        buttonConfiguration?.image = UIImage(named: "camera-compact")

        let button = UIButton(
            configuration: buttonConfiguration!,
            primaryAction: UIAction { [weak self] _ in
                self?.didTapAvatar()
            },
        )
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addConstraints([
            button.widthAnchor.constraint(equalToConstant: buttonSize),
            button.heightAnchor.constraint(equalToConstant: buttonSize),
        ])

        return button
    }()

    private func textField(
        placeholder: String,
        textContentType: UITextContentType,
        accessibilityIdentifierSuffix: String,
    ) -> UITextField {
        let result = OWSTextField()
        result.font = .dynamicTypeBodyClamped
        result.textColor = .Signal.label
        if #available(iOS 26, *) {
            result.tintColor = result.textColor
        }
        result.adjustsFontForContentSizeCategory = true
        result.textAlignment = .natural
        result.autocorrectionType = .no
        result.spellCheckingType = .no
        result.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [.foregroundColor: UIColor.Signal.secondaryLabel])
        result.textContentType = textContentType
        result.accessibilityIdentifier = "registration.profile.\(accessibilityIdentifierSuffix)"
        result.delegate = self
        result.addTarget(self, action: #selector(didTextFieldChange), for: .editingChanged)
        result.autoSetDimension(.height, toSize: 50, relation: .greaterThanOrEqual)
        return result
    }

    private lazy var givenNameTextField: UITextField = textField(
        placeholder: OWSLocalizedString(
            "REGISTRATION_PROFILE_SETUP_GIVEN_NAME_FIELD_PLACEHOLDER",
            comment: "During registration, users set up their profile. Users input a given name. This is the placeholder for that field.",
        ),
        textContentType: .givenName,
        accessibilityIdentifierSuffix: "givenName",
    )

    private lazy var familyNameTextField: UITextField = textField(
        placeholder: OWSLocalizedString(
            "REGISTRATION_PROFILE_SETUP_FAMILY_NAME_FIELD_PLACEHOLDER",
            comment: "During registration, users set up their profile. Users input a family name. This is the placeholder for that field.",
        ),
        textContentType: .familyName,
        accessibilityIdentifierSuffix: "familyName",
    )

    private enum NameOrder {
        case familyNameFirst
        case givenNameFirst
    }

    private var nameOrder: NameOrder { Locale.current.isCJKV ? .familyNameFirst : .givenNameFirst }

    private lazy var firstTextField: UITextField = {
        switch nameOrder {
        case .givenNameFirst: return givenNameTextField
        case .familyNameFirst: return familyNameTextField
        }
    }()

    private lazy var secondTextField: UITextField = {
        switch nameOrder {
        case .givenNameFirst: return familyNameTextField
        case .familyNameFirst: return givenNameTextField
        }
    }()

    private lazy var nameStackView: UIView = {
        let stackView = UIStackView(arrangedSubviews: [firstTextField, secondTextField])
        stackView.axis = .vertical
        stackView.distribution = .fillEqually
        if #available(iOS 26, *) {
            // Can't use `addBottomStroke` because trailing edge of the stroke must extend beyond text field's edge.
            let textField = firstTextField
            let strokeView = UIView()
            strokeView.backgroundColor = .Signal.opaqueSeparator
            stackView.addSubview(strokeView)
            strokeView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                strokeView.heightAnchor.constraint(equalToConstant: .hairlineWidth),
                strokeView.bottomAnchor.constraint(equalTo: textField.bottomAnchor),
                strokeView.leadingAnchor.constraint(equalTo: textField.leadingAnchor),
                strokeView.trailingAnchor.constraint(equalTo: stackView.trailingAnchor),
            ])

            stackView.backgroundColor = .Signal.secondaryBackground
            // Stack view has a background so horizontal margins are necessary.
            stackView.directionalLayoutMargins = .init(top: 0, leading: 16, bottom: 0, trailing: 8)
            stackView.isLayoutMarginsRelativeArrangement = true
            stackView.cornerConfiguration = .uniformCorners(radius: 26)
        } else {
            firstTextField.addBottomStroke(color: .Signal.opaqueSeparator, strokeWidth: .hairlineWidth)
            secondTextField.addBottomStroke(color: .Signal.opaqueSeparator, strokeWidth: .hairlineWidth)
        }
        return stackView
    }()

    private lazy var phoneNumberPrivacyButton: PhoneNumberPrivacyButton = {
        let button = PhoneNumberPrivacyButton(phoneNumberDiscoverability: state.phoneNumberDiscoverability)
        button.addAction(
            UIAction { [weak self] _ in
                guard let self else { return }
                let vc = RegistrationPhoneNumberDiscoverabilityViewController(
                    state: RegistrationPhoneNumberDiscoverabilityState(
                        e164: self.state.e164,
                        phoneNumberDiscoverability: self.state.phoneNumberDiscoverability,
                    ),
                    presenter: self,
                )
                self.presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
            },
            for: .primaryActionTriggered,
        )
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .Signal.background

        navigationItem.rightBarButtonItem?.tintColor = .Signal.accent
        navigationItem.rightBarButtonItem = UIBarButtonItem(
            title: CommonStrings.nextButton,
            style: .done,
            target: self,
            action: #selector(didTapNext),
            accessibilityIdentifier: "registration.profile.nextButton",
        )

        let avatarContainerView = UIView.container()
        avatarContainerView.addSubview(avatarView)
        avatarContainerView.addSubview(cameraIconButton)
        avatarView.translatesAutoresizingMaskIntoConstraints = false
        cameraIconButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            avatarView.topAnchor.constraint(equalTo: avatarContainerView.topAnchor),
            avatarView.centerXAnchor.constraint(equalTo: avatarContainerView.centerXAnchor),
            avatarView.bottomAnchor.constraint(equalTo: avatarContainerView.bottomAnchor),

            // Looks better with tiny offset.
            cameraIconButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 1),
            cameraIconButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 1),
        ])

        let stackView = addStaticContentStackView(
            arrangedSubviews: [
                titleLabel,
                explanationView,
                avatarContainerView,
                nameStackView,
                phoneNumberPrivacyButton,
                .vStretchingSpacer(),
            ],
            isScrollable: true,
            shouldAvoidKeyboard: true,
        )
        stackView.spacing = 24
        stackView.setCustomSpacing(12, after: titleLabel)
        stackView.setCustomSpacing(20, after: nameStackView)

        firstTextField.returnKeyType = .next
        secondTextField.returnKeyType = .done

        updateUI()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        if !UIDevice.current.isIPhone5OrShorter {
            // Small devices may obscure parts of the UI behind the keyboard, especially with larger
            // font sizes.
            firstTextField.becomeFirstResponder()
        }
    }

    private func updateUI() {
        navigationItem.rightBarButtonItem?.isEnabled = givenNameComponent != nil

        avatarView.image = avatarData?.asImage ?? SSKEnvironment.shared.databaseStorageRef.read { transaction in
            SSKEnvironment.shared.avatarBuilderRef.defaultAvatarImageForLocalUser(
                diameterPoints: UInt(avatarSize),
                transaction: transaction,
            )
        }
    }

    // MARK: Events

    @objc
    private func didTextFieldChange() {
        updateUI()
    }

    @objc
    private func didTapAvatar() {
        Logger.info("")

        let vc = AvatarSettingsViewController(
            context: .profile,
            currentAvatarImage: avatarData?.asImage,
        ) { [weak self] newAvatarImage in
            guard let self else { return }
            if let newAvatarImage {
                self.avatarData = OWSProfileManager.avatarData(avatarImage: newAvatarImage)
            } else {
                self.avatarData = nil
            }
        }
        presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
    }

    @objc
    private func didTapNext() {
        Logger.info("")
        goToNextStepIfPossible()
    }

    private func goToNextStepIfPossible() {
        Logger.info("")

        guard let givenNameComponent else {
            // This can happen if you try to advance via the keyboard.
            return
        }

        presenter?.goToNextStep(
            givenName: givenNameComponent,
            familyName: familyNameComponent,
            avatarData: avatarData,
            phoneNumberDiscoverability: state.phoneNumberDiscoverability,
        )
    }
}

// MARK: - UITextViewDelegate

extension RegistrationProfileViewController: UITextViewDelegate {
    func textView(
        _ textView: UITextView,
        shouldInteractWith URL: URL,
        in characterRange: NSRange,
        interaction: UITextItemInteraction,
    ) -> Bool {
        if textView == explanationView {
            showLearnMoreUI()
        }
        return false
    }

    private func showLearnMoreUI() {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "REGISTRATION_PROFILE_SETUP_MORE_INFO_TITLE",
                comment: "During registration, users set up their profile. They can learn more about the privacy of their profile by clicking a \"learn more\" button. This is the title on a sheet that appears when they do that.",
            ),
            message: OWSLocalizedString(
                "REGISTRATION_PROFILE_SETUP_MORE_INFO_DETAILS",
                comment: "During registration, users set up their profile. They can learn more about the privacy of their profile by clicking a \"learn more\" button. This is the message on a sheet that appears when they do that.",
            ),
        )

        actionSheet.addAction(.init(title: CommonStrings.learnMore) { [weak self] _ in
            guard let self else { return }
            self.present(SFSafariViewController(url: URL.Support.profilesAndMessageRequests), animated: true)
        })

        actionSheet.addAction(.init(title: CommonStrings.okayButton, style: .cancel))

        presentActionSheet(actionSheet)
    }
}

// MARK: - UITextFieldDelegate

extension RegistrationProfileViewController: UITextFieldDelegate {
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        switch textField {
        case firstTextField:
            secondTextField.becomeFirstResponder()
        case secondTextField:
            goToNextStepIfPossible()
        default:
            owsFailBeta("Got a \"return\" event for an unexpected text field")
        }
        return false
    }
}

// MARK: - RegistrationPhoneNumberDiscoverabilityPresenter

extension RegistrationProfileViewController: RegistrationPhoneNumberDiscoverabilityPresenter {

    var presentedAsModal: Bool { return true }

    func setPhoneNumberDiscoverability(_ phoneNumberDiscoverability: PhoneNumberDiscoverability) {
        phoneNumberPrivacyButton.phoneNumberDiscoverability = phoneNumberDiscoverability
        self.state = RegistrationProfileState(
            e164: self.state.e164,
            phoneNumberDiscoverability: phoneNumberDiscoverability,
        )
        self.presentedViewController?.dismiss(animated: true)
    }
}

// MARK: - Phone number privacy button

extension RegistrationProfileViewController {

    private class PhoneNumberPrivacyButton: UIButton {

        private lazy var contentView = PhoneNumberPrivacyButtonContentView(
            configuration: .init(phoneNumberDiscoverability: phoneNumberDiscoverability),
        )

        var phoneNumberDiscoverability: PhoneNumberDiscoverability {
            didSet {
                contentView.configuration = PhoneNumberPrivacyButtonContentConfiguration(
                    phoneNumberDiscoverability: phoneNumberDiscoverability,
                )
            }
        }

        init(phoneNumberDiscoverability: PhoneNumberDiscoverability) {
            self.phoneNumberDiscoverability = phoneNumberDiscoverability

            super.init(frame: .zero)

            configuration = .filled()
            configuration?.baseBackgroundColor = .Signal.background

            addSubview(contentView)
            contentView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
                contentView.topAnchor.constraint(equalTo: topAnchor),
                contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
                contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        // MARK: Accessibility

        override var accessibilityLabel: String? {
            get {
                OWSLocalizedString(
                    "REGISTRATION_PROFILE_SETUP_FIND_MY_NUMBER_TITLE",
                    comment: "During registration, users can choose who can see their phone number.",
                )
            }
            set { super.accessibilityLabel = newValue }
        }

        override var accessibilityValue: String? {
            get { phoneNumberDiscoverability.nameForDiscoverability }
            set { super.accessibilityValue = newValue }
        }

        override var accessibilityHint: String? {
            get { phoneNumberDiscoverability.descriptionForDiscoverability }
            set { super.accessibilityHint = newValue }
        }
    }

    private struct PhoneNumberPrivacyButtonContentConfiguration: UIContentConfiguration {
        var phoneNumberDiscoverability: PhoneNumberDiscoverability

        func makeContentView() -> UIView & UIContentView {
            PhoneNumberPrivacyButtonContentView(configuration: self)
        }

        func updated(for state: UIConfigurationState) -> PhoneNumberPrivacyButtonContentConfiguration {
            // Looks the same.
            self
        }
    }

    private class PhoneNumberPrivacyButtonContentView: UIView, UIContentView {

        private var _configuration: PhoneNumberPrivacyButtonContentConfiguration!

        var configuration: UIContentConfiguration {
            get { _configuration }
            set {
                guard let configuration = newValue as? PhoneNumberPrivacyButtonContentConfiguration else { return }
                _configuration = configuration
                apply(configuration)
            }
        }

        init(configuration: PhoneNumberPrivacyButtonContentConfiguration) {
            super.init(frame: .zero)

            isUserInteractionEnabled = false
            layoutMargins = .init(hMargin: 0, vMargin: 8)

            let vStack = UIStackView(arrangedSubviews: [titleLabel, subTitleLabel])
            vStack.axis = .vertical
            vStack.spacing = 4

            let disclosureView = UIImageView()
            disclosureView.contentMode = .scaleAspectFit
            disclosureView.setTemplateImage(
                UIImage(imageLiteralResourceName: "chevron-right-20"),
                tintColor: .Signal.tertiaryLabel,
            )
            disclosureView.translatesAutoresizingMaskIntoConstraints = false
            disclosureView.widthAnchor.constraint(equalToConstant: 24).isActive = true

            let hStack = UIStackView(arrangedSubviews: [
                iconView,
                vStack,
                disclosureView,
            ])
            hStack.axis = .horizontal
            hStack.spacing = 12
            hStack.alignment = .center

            addSubview(hStack)
            hStack.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                hStack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
                hStack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: 8),
                hStack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
                hStack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor, constant: -8),
            ])

            apply(configuration)
        }

        @available(*, unavailable, message: "Use other constructor")
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        private lazy var iconView: UIImageView = {
            let iconView = UIImageView()
            iconView.tintColor = .Signal.label
            iconView.contentMode = .scaleAspectFit
            iconView.translatesAutoresizingMaskIntoConstraints = false
            iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true
            return iconView
        }()

        private lazy var titleLabel: UILabel = {
            let titleLabel = UILabel()
            titleLabel.font = .dynamicTypeBodyClamped
            titleLabel.textColor = .Signal.label
            titleLabel.numberOfLines = 0
            titleLabel.lineBreakMode = .byWordWrapping
            titleLabel.text = OWSLocalizedString(
                "REGISTRATION_PROFILE_SETUP_FIND_MY_NUMBER_TITLE",
                comment: "During registration, users can choose who can see their phone number.",
            )
            return titleLabel
        }()

        private lazy var subTitleLabel: UILabel = {
            let subTitleLabel = UILabel()
            subTitleLabel.font = .dynamicTypeSubheadlineClamped
            subTitleLabel.textColor = .Signal.secondaryLabel
            subTitleLabel.numberOfLines = 0
            subTitleLabel.lineBreakMode = .byWordWrapping
            return subTitleLabel
        }()

        private func apply(_ configuration: PhoneNumberPrivacyButtonContentConfiguration) {
            let discoverability = configuration.phoneNumberDiscoverability

            subTitleLabel.text = discoverability.nameForDiscoverability

            let labelIconName: String = {
                switch discoverability {
                case .everybody:
                    return "group"
                case .nobody:
                    return "lock"
                }
            }()
            iconView.image = UIImage(named: labelIconName)
        }
    }
}

// MARK: - Data <-> UIImage conversions

private extension Data {
    var asImage: UIImage? { .init(data: self) }
}