// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import SafariServices import PromiseKit @objc public enum ProfileViewMode: UInt { case appSettings case registration case experienceUpgrade fileprivate var hasBio: Bool { switch self { case .appSettings: return true case .registration, .experienceUpgrade: return false } } fileprivate var hasSaveButton: Bool { switch self { case .appSettings: return false case .registration, .experienceUpgrade: return true } } } // MARK: - @objc public class ProfileViewController: OWSTableViewController { private let avatarViewHelper = AvatarViewHelper() private lazy var givenNameTextField = OWSTextField() private lazy var familyNameTextField = OWSTextField() private let profileNamePreviewLabel = UILabel() private let usernameLabel = UILabel() private let avatarView = AvatarImageView() private var saveButton: OWSFlatButton? private var username: String? private var profileBio: String? private var profileBioEmoji: String? private var avatarData: Data? private var hasUnsavedChanges = false { didSet { updateNavigationItem() } } private let mode: ProfileViewMode private let completionHandler: (ProfileViewController) -> Void @objc public required init(mode: ProfileViewMode, completionHandler: @escaping (ProfileViewController) -> Void) { self.mode = mode self.completionHandler = completionHandler super.init() self.shouldAvoidKeyboard = true } // MARK: - Orientation public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UIDevice.current.isIPad { return .all } return (mode == .registration ? .portrait : .allButUpsideDown) } // MARK: - public override func loadView() { super.loadView() self.useThemeBackgroundColors = true self.title = NSLocalizedString("PROFILE_VIEW_TITLE", comment: "Title for the profile view.") avatarViewHelper.delegate = self let profileSnapshot = profileManager.localProfileSnapshot(shouldIncludeAvatar: true) givenNameTextField.text = profileSnapshot.givenName familyNameTextField.text = profileSnapshot.familyName self.profileBio = profileSnapshot.bio self.profileBioEmoji = profileSnapshot.bioEmoji self.username = profileSnapshot.username self.avatarData = profileSnapshot.avatarData createViews() updateNavigationItem() if mode == .registration { // mark as dirty if re-registration has content if let familyName = familyNameTextField.text, !familyName.isEmpty { hasUnsavedChanges = true } else if let givenName = givenNameTextField.text, !givenName.isEmpty { hasUnsavedChanges = true } else if avatarData != nil { hasUnsavedChanges = true } } updateTableContents() } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) updateNavigationItem() switch mode { case .appSettings: break case .registration, .experienceUpgrade: givenNameTextField.becomeFirstResponder() @unknown default: owsFailDebug("Unknown value.") } updateUsername() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) updateNavigationItem() } private func createViews() { avatarView.autoSetDimensions(to: CGSize.square(96)) avatarView.accessibilityIdentifier = "avatar_view" givenNameTextField.returnKeyType = .next givenNameTextField.autocorrectionType = .no givenNameTextField.spellCheckingType = .no givenNameTextField.placeholder = NSLocalizedString("PROFILE_VIEW_GIVEN_NAME_DEFAULT_TEXT", comment: "Default text for the given name field of the profile view.") givenNameTextField.delegate = self givenNameTextField.textAlignment = .right givenNameTextField.accessibilityIdentifier = "given_name_textfield" givenNameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) familyNameTextField.returnKeyType = .done familyNameTextField.autocorrectionType = .no familyNameTextField.spellCheckingType = .no familyNameTextField.placeholder = NSLocalizedString("PROFILE_VIEW_FAMILY_NAME_DEFAULT_TEXT", comment: "Default text for the family name field of the profile view.") familyNameTextField.delegate = self familyNameTextField.textAlignment = .right familyNameTextField.accessibilityIdentifier = "family_name_textfield" familyNameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) profileNamePreviewLabel.textAlignment = .center profileNamePreviewLabel.textColor = Theme.secondaryTextAndIconColor profileNamePreviewLabel.font = .ows_dynamicTypeSubheadlineClamped } func updateTableContents() { let contents = OWSTableContents() let givenNameTextField = self.givenNameTextField let familyNameTextField = self.familyNameTextField let avatarView = self.avatarView let profileNamePreviewLabel = self.profileNamePreviewLabel updateAvatarView() updateProfileNamePreview() let avatarSection = OWSTableSection() avatarSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in let cell = OWSTableItem.newCell() let stackView = UIStackView(arrangedSubviews: [avatarView, profileNamePreviewLabel]) stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 16 stackView.layoutMargins = UIEdgeInsets(top: 24, leading: 0, bottom: 32, trailing: 0) stackView.isLayoutMarginsRelativeArrangement = true cell.contentView.addSubview(stackView) stackView.autoPinEdgesToSuperviewMargins() let cameraImageView = UIImageView.withTemplateImageName("camera-outline-24", tintColor: Theme.secondaryTextAndIconColor) cameraImageView.autoSetDimensions(to: CGSize.square(32)) cameraImageView.contentMode = .center cameraImageView.backgroundColor = Theme.backgroundColor cameraImageView.layer.cornerRadius = 16 cameraImageView.layer.shadowColor = (Theme.isDarkThemeEnabled ? Theme.darkThemeWashColor : Theme.primaryTextColor).cgColor cameraImageView.layer.shadowOffset = CGSize.square(1) cameraImageView.layer.shadowOpacity = 0.5 cameraImageView.layer.shadowRadius = 4 stackView.addSubview(cameraImageView) cameraImageView.autoPinTrailing(toEdgeOf: avatarView) cameraImageView.autoPinEdge(.bottom, to: .bottom, of: avatarView) return cell }, actionBlock: { [weak self] in self?.didTapAvatar() })) contents.addSection(avatarSection) let namesSection = OWSTableSection() func addGivenNameRow() { namesSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in givenNameTextField.font = .ows_dynamicTypeBodyClamped givenNameTextField.textColor = Theme.primaryTextColor return Self.buildNameCell(title: NSLocalizedString("PROFILE_VIEW_GIVEN_NAME_FIELD", comment: "Label for the given name field of the profile view."), valueView: givenNameTextField, accessibilityIdentifier: "given_name") }, actionBlock: { givenNameTextField.becomeFirstResponder() })) } func addFamilyNameRow() { namesSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in familyNameTextField.font = .ows_dynamicTypeBodyClamped familyNameTextField.textColor = Theme.primaryTextColor return Self.buildNameCell(title: NSLocalizedString("PROFILE_VIEW_FAMILY_NAME_FIELD", comment: "Label for the family name field of the profile view."), valueView: familyNameTextField, accessibilityIdentifier: "given_name") }, actionBlock: { familyNameTextField.becomeFirstResponder() })) } // For CJKV locales, display family name field first. if NSLocale.current.isCJKV { addFamilyNameRow() addGivenNameRow() // Otherwise, display given name field first. } else { addGivenNameRow() addFamilyNameRow() } if shouldShowUsernameRow { namesSection.add(buildUsernameItem()) } contents.addSection(namesSection) var lastSection = namesSection if mode.hasBio { let aboutSection = OWSTableSection() aboutSection.headerTitle = NSLocalizedString("PROFILE_VIEW_BIO_SECTION_HEADER", comment: "Header for the 'bio' section of the profile view.") let profileBio = self.normalizedProfileBio let profileBioEmoji = self.normalizedProfileBioEmoji if let bioForDisplay = OWSUserProfile.bioForDisplay(bio: profileBio, bioEmoji: profileBioEmoji) { aboutSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in let cell = OWSTableItem.newCell() let label = UILabel() label.text = bioForDisplay label.textColor = Theme.primaryTextColor label.font = .ows_dynamicTypeBodyClamped label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping cell.contentView.addSubview(label) label.autoPinEdgesToSuperviewMargins() cell.accessoryType = .disclosureIndicator cell.accessibilityIdentifier = "profile_bio" return cell }, actionBlock: { [weak self] in self?.didTapBio() })) } else { aboutSection.add(OWSTableItem.item(name: NSLocalizedString("PROFILE_VIEW_ADD_BIO_TO_PROFILE", comment: "Button to add a 'bio' to the user's profile in the profile view."), textColor: Theme.accentBlueColor, accessoryType: .disclosureIndicator, accessibilityIdentifier: "profile_bio") { [weak self] in self?.didTapBio() }) } contents.addSection(aboutSection) lastSection = aboutSection } // Information Footer let infoGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapInfo)) lastSection.customFooterView = { () -> UIView in let label = UILabel() label.textColor = Theme.secondaryTextAndIconColor label.font = .ows_dynamicTypeCaption1Clamped let attributedText = NSMutableAttributedString() attributedText.append(NSLocalizedString("PROFILE_VIEW_PROFILE_DESCRIPTION", comment: "Description of the user profile.")) attributedText.append(" ") attributedText.append(NSAttributedString(string: CommonStrings.learnMore, attributes: [ NSAttributedString.Key.foregroundColor: Theme.accentBlueColor, NSAttributedString.Key.underlineStyle: 0 ])) label.attributedText = attributedText label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.backgroundColor = Theme.tableViewBackgroundColor label.isUserInteractionEnabled = true label.addGestureRecognizer(infoGestureRecognizer) let footer = UIView() footer.layoutMargins = UIEdgeInsets(hMargin: 20, vMargin: 12) footer.addSubview(label) label.autoPinEdgesToSuperviewMargins() return footer }() // Big Button if mode.hasSaveButton { let buttonSection = OWSTableSection() let target = self let selector = #selector(updateProfile) buttonSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in let cell = OWSTableItem.newCell() let buttonHeight: CGFloat = 47 let button = OWSFlatButton.button(title: NSLocalizedString("PROFILE_VIEW_SAVE_BUTTON", comment: "Button to save the profile view in the profile view."), font: OWSFlatButton.fontForHeight(buttonHeight), titleColor: UIColor.ows_white, backgroundColor: UIColor.ows_accentBlue, target: target, selector: selector) button.accessibilityIdentifier = "save_button" button.autoSetDimension(.height, toSize: buttonHeight) cell.contentView.addSubview(button) button.autoPinEdgesToSuperviewMargins() cell.backgroundColor = Theme.tableViewBackgroundColor cell.contentView.backgroundColor = Theme.tableViewBackgroundColor return cell }, actionBlock: { [weak self] in self?.updateProfile() })) contents.addSection(buttonSection) } self.contents = contents } private func buildUsernameItem() -> OWSTableItem { updateUsername() let usernameLabel = self.usernameLabel return OWSTableItem(customCellBlock: { () -> UITableViewCell in let title = NSLocalizedString("PROFILE_VIEW_USERNAME_FIELD", comment: "Label for the username field of the profile view.") usernameLabel.font = .ows_dynamicTypeBodyClamped usernameLabel.textAlignment = .right let cell = Self.buildNameCell(title: title, valueView: usernameLabel, accessibilityIdentifier: "username") cell.accessoryType = .disclosureIndicator return cell }, actionBlock: { [weak self] in self?.didTapUsername() }) } private static func buildNameLabel(title: String) -> UILabel { let label = UILabel() label.text = title label.textColor = Theme.primaryTextColor label.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold label.setCompressionResistanceHorizontalHigh() label.setContentHuggingHorizontalHigh() return label } private static func buildNameCell(title: String, valueView: UIView, accessibilityIdentifier: String, accessoryView: UIView? = nil) -> UITableViewCell { let cell = OWSTableItem.newCell() let titleLabel = buildNameLabel(title: title) valueView.setCompressionResistanceHorizontalLow() valueView.setContentHuggingHorizontalLow() var subviews = [titleLabel, valueView] if let accessoryView = accessoryView { accessoryView.setCompressionResistanceHorizontalHigh() accessoryView.setContentHuggingHorizontalHigh() subviews.append(accessoryView) } let stackView = UIStackView(arrangedSubviews: subviews) stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = 10 cell.contentView.addSubview(stackView) stackView.autoPinEdgesToSuperviewMargins() cell.accessibilityIdentifier = accessibilityIdentifier return cell } // MARK: - Event Handling private func leaveViewCheckingForUnsavedChanges() { familyNameTextField.resignFirstResponder() givenNameTextField.resignFirstResponder() if !hasUnsavedChanges { // If user made no changes, return to conversation settings view. profileCompleted() return } OWSActionSheets.showPendingChangesActionSheet(discardAction: { [weak self] in self?.profileCompleted() }) } private func updateNavigationItem() { guard let navigationController = navigationController else { return } // The navigation bar is hidden in the registration workflow. if navigationController.isNavigationBarHidden { navigationController.setNavigationBarHidden(false, animated: true) } var forceSaveButtonEnabled = false switch mode { case .appSettings: if hasUnsavedChanges { // If we have a unsaved changes, right item should be a "save" button. let saveButton = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(updateProfile), accessibilityIdentifier: "save_button") navigationItem.rightBarButtonItem = saveButton } else { navigationItem.rightBarButtonItem = nil } case .registration: navigationItem.hidesBackButton = true navigationItem.rightBarButtonItem = nil // During registration, if you have a name pre-populatd we want // to enable the save button even if you haven't edited anything. if let givenName = givenNameTextField.text, !givenName.isEmpty { forceSaveButtonEnabled = true } case .experienceUpgrade: navigationItem.rightBarButtonItem = nil // During the experience upgrade, if you have a name we want // to enable the save button even if you haven't edited anything. if let givenName = givenNameTextField.text, !givenName.isEmpty { forceSaveButtonEnabled = true } @unknown default: owsFailDebug("Unknown mode.") navigationItem.rightBarButtonItem = nil } if let saveButton = saveButton { if hasUnsavedChanges || forceSaveButtonEnabled { saveButton.setEnabled(true) saveButton.setBackgroundColors(upColor: .ows_accentBlue) } else { saveButton.setEnabled(false) saveButton.setBackgroundColors(upColor: UIColor.ows_accentBlue.blended(with: Theme.backgroundColor, alpha: 0.5)) } } } @objc func updateProfile() { let normalizedGivenName = self.normalizedGivenName let normalizedFamilyName = self.normalizedFamilyName let normalizedProfileBio = self.normalizedProfileBio let normalizedProfileBioEmoji = self.normalizedProfileBioEmoji if normalizedGivenName.isEmpty { OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_ERROR_GIVEN_NAME_REQUIRED", comment: "Error message shown when user tries to update profile without a given name")) return } if profileManager.isProfileNameTooLong(normalizedGivenName) { OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_ERROR_GIVEN_NAME_TOO_LONG", comment: "Error message shown when user tries to update profile with a given name that is too long.")) return } if profileManager.isProfileNameTooLong(normalizedFamilyName) { OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_ERROR_FAMILY_NAME_TOO_LONG", comment: "Error message shown when user tries to update profile with a family name that is too long.")) return } if !self.reachabilityManager.isReachable { OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_NO_CONNECTION", comment: "Error shown when the user tries to update their profile when the app is not connected to the internet.")) return } // Show an activity indicator to block the UI during the profile upload. let avatarData = self.avatarData ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modalActivityIndicator in firstly(on: .global()) { () -> Promise in OWSProfileManager.updateLocalProfilePromise(profileGivenName: normalizedGivenName, profileFamilyName: normalizedFamilyName, profileBio: normalizedProfileBio, profileBioEmoji: normalizedProfileBioEmoji, profileAvatarData: avatarData) }.done { _ in modalActivityIndicator.dismiss { [weak self] in AssertIsOnMainThread() self?.profileCompleted() } }.catch { error in owsFailDebug("Error: \(error)") modalActivityIndicator.dismiss { [weak self] in AssertIsOnMainThread() self?.profileCompleted() } } } } private var normalizedGivenName: String { (givenNameTextField.text ?? "").ows_stripped() } private var normalizedFamilyName: String { (familyNameTextField.text ?? "").ows_stripped() } private var normalizedProfileBio: String? { profileBio?.ows_stripped() } private var normalizedProfileBioEmoji: String? { profileBioEmoji?.ows_stripped() } private func profileCompleted() { AssertIsOnMainThread() Logger.verbose("") completionHandler(self) } // MARK: - Avatar private func setAvatarImage(_ avatarImage: UIImage?) { AssertIsOnMainThread() var avatarData: Data? if let avatarImage = avatarImage { avatarData = OWSProfileManager.avatarData(forAvatarImage: avatarImage) } hasUnsavedChanges = hasUnsavedChanges || avatarData != self.avatarData self.avatarData = avatarData updateAvatarView() } private let avatarSize: UInt = 96 private func updateAvatarView() { if let avatarData = avatarData { avatarView.image = UIImage(data: avatarData) } else { avatarView.image = OWSContactAvatarBuilder(forLocalUserWithDiameter: avatarSize).buildDefaultImage() } } private func updateProfileNamePreview() { var components = PersonNameComponents() components.givenName = normalizedGivenName components.familyName = normalizedFamilyName let previewText = PersonNameComponentsFormatter.localizedString(from: components, style: .`default`) if previewText.isEmpty { profileNamePreviewLabel.text = " " } else { profileNamePreviewLabel.text = previewText } } private func updateUsername() { if let username = username { usernameLabel.text = CommonFormats.formatUsername(username) usernameLabel.textColor = Theme.primaryTextColor } else { usernameLabel.text = NSLocalizedString("PROFILE_VIEW_CREATE_USERNAME", comment: "A string indicating that the user can create a username on the profile view.") usernameLabel.textColor = Theme.accentBlueColor } } private func didTapUsername() { let usernameVC = UsernameViewController() if mode == .registration { usernameVC.modalPresentation = true presentFormSheet(OWSNavigationController(rootViewController: usernameVC), animated: true) } else { navigationController?.pushViewController(usernameVC, animated: true) } } private func didTapBio() { let view = ProfileBioViewController(bio: profileBio, bioEmoji: profileBioEmoji, mode: mode, profileDelegate: self) navigationController?.pushViewController(view, animated: true) } private var shouldShowUsernameRow: Bool { switch mode { case .experienceUpgrade, .registration: return false case .appSettings: return RemoteConfig.usernames @unknown default: owsFailDebug("Unknown value.") return false } } private func didTapAvatar() { avatarViewHelper.showChangeAvatarUI() } @objc private func didTapInfo() { let vc = SFSafariViewController(url: URL(string: "https://support.signal.org/hc/articles/360007459591")!) present(vc, animated: true, completion: nil) } } // MARK: - extension ProfileViewController: AvatarViewHelperDelegate { public func avatarActionSheetTitle() -> String? { NSLocalizedString("PROFILE_VIEW_AVATAR_ACTIONSHEET_TITLE", comment: "Action Sheet title prompting the user for a profile avatar") } public func avatarDidChange(_ image: UIImage) { AssertIsOnMainThread() setAvatarImage(image.resizedImage(toFillPixelSize: .square(CGFloat(kOWSProfileManager_MaxAvatarDiameter)))) } public func fromViewController() -> UIViewController { self } public func hasClearAvatarAction() -> Bool { avatarData != nil } public func clearAvatar() { setAvatarImage(nil) } public func clearAvatarActionLabel() -> String { return NSLocalizedString("PROFILE_VIEW_CLEAR_AVATAR", comment: "Label for action that clear's the user's profile avatar") } } // MARK: - extension ProfileViewController: UITextFieldDelegate { private var firstTextField: UITextField { NSLocale.current.isCJKV ? familyNameTextField : givenNameTextField } private var secondTextField: UITextField { NSLocale.current.isCJKV ? givenNameTextField : familyNameTextField } public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { TextFieldHelper.textField( textField, shouldChangeCharactersInRange: range, replacementString: string.withoutBidiControlCharacters, maxByteCount: OWSUserProfile.maxNameLengthBytes ) } public func textFieldShouldReturn(_ textField: UITextField) -> Bool { if textField == firstTextField { secondTextField.becomeFirstResponder() } else { textField.resignFirstResponder() } return false } @objc func textFieldDidChange(_ textField: UITextField) { hasUnsavedChanges = true updateProfileNamePreview() // TODO: Update length warning. } } // MARK: - extension ProfileViewController: OWSNavigationView { public func shouldCancelNavigationBack() -> Bool { let result = hasUnsavedChanges if result { leaveViewCheckingForUnsavedChanges() } return result } } // MARK: - extension ProfileViewController: ProfileBioViewControllerDelegate { public func profileBioViewDidComplete(bio: String?, bioEmoji: String?) { profileBio = bio profileBioEmoji = bioEmoji hasUnsavedChanges = true updateTableContents() } }