Signal-iOS/Signal/src/ViewControllers/ProfileViewController.swift

792 lines
29 KiB
Swift

//
// 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<Void> 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()
}
}