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

import ContactsUI
import MessageUI
import SignalServiceKit
import SignalUI

class ContactViewController: OWSTableViewController2 {

    private enum ContactViewMode {
        case systemContactWithSignal
        case systemContactWithoutSignal
        case nonSystemContact
        case noPhoneNumber
    }

    private var viewMode: ContactViewMode {
        didSet {
            AssertIsOnMainThread()

            if oldValue != viewMode, isViewLoaded {
                updateContent()
            }
        }
    }

    private let contactShare: ContactShareViewModel
    private var sendablePhoneNumbers: [String]

    private lazy var contactShareViewHelper: ContactShareViewHelper = {
        let helper = ContactShareViewHelper()
        helper.delegate = self
        return helper
    }()

    // MARK: View Controller

    init(contactShare: ContactShareViewModel) {
        self.contactShare = contactShare
        let phoneNumberPartition = Self.phoneNumberPartition(for: contactShare)
        self.viewMode = Self.viewMode(for: phoneNumberPartition)
        self.sendablePhoneNumbers = phoneNumberPartition.sendablePhoneNumbers

        super.init()

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(updateMode),
            name: .OWSContactsManagerSignalAccountsDidChange,
            object: nil,
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(updateMode),
            name: SSKReachability.owsReachabilityDidChange,
            object: nil,
        )
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        updateContent()
    }

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

        SSKEnvironment.shared.contactManagerImplRef.requestSystemContactsOnce { [weak self] _ in
            self?.updateMode()
        }
    }

    // MARK: Contact Data

    private static func phoneNumberPartition(for contactShare: ContactShareViewModel) -> OWSContact.PhoneNumberPartition {
        return SSKEnvironment.shared.databaseStorageRef.read(block: contactShare.dbRecord.phoneNumberPartition(tx:))
    }

    private static func viewMode(for phoneNumberPartition: OWSContact.PhoneNumberPartition) -> ContactViewMode {
        return phoneNumberPartition.map(
            ifSendablePhoneNumbers: { _ in .systemContactWithSignal },
            elseIfInvitablePhoneNumbers: { _ in .systemContactWithoutSignal },
            elseIfAddablePhoneNumbers: { _ in .nonSystemContact },
            elseIfNoPhoneNumbers: { .noPhoneNumber },
        )
    }

    @objc
    private func updateMode() {
        AssertIsOnMainThread()

        let phoneNumberPartition = Self.phoneNumberPartition(for: contactShare)
        viewMode = Self.viewMode(for: phoneNumberPartition)
        sendablePhoneNumbers = phoneNumberPartition.sendablePhoneNumbers
    }

    private func showInviteToSignal() -> Bool {
        switch viewMode {
        case .systemContactWithoutSignal, .nonSystemContact:
            return true
        default:
            return false
        }
    }

    private func showAddToContacts() -> Bool {
        switch viewMode {
        case .nonSystemContact:
            return true
        default:
            return false
        }
    }

    private func updateContent() {
        AssertIsOnMainThread()

        var sections = [OWSTableSection]()

        // Header
        let headerSection = OWSTableSection(items: [], headerView: buildHeaderView())
        sections.append(headerSection)

        // Contact Actions
        let actionsSection = OWSTableSection()

        // Message, Video, Audio buttons for Signal contacts as a horizontal stack of buttons
        if viewMode == .systemContactWithSignal {
            let buttonMessage = SettingsHeaderButton(
                title: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_MESSAGE_BUTTON",
                    comment: "Button to message the chat",
                ).capitalized,
                icon: .settingsChats,
            ) { [weak self] in
                self?.didPressSendMessage()
            }
            let buttonVideoCall = SettingsHeaderButton(
                title: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_VIDEO_CALL_BUTTON",
                    comment: "Button to start a video call",
                ).capitalized,
                icon: .buttonVideoCall,
            ) { [weak self] in
                self?.didPressVideoCall()
            }
            let buttonAudioCall = SettingsHeaderButton(
                title: OWSLocalizedString(
                    "CONVERSATION_SETTINGS_VOICE_CALL_BUTTON",
                    comment: "Button to start a voice call",
                ).capitalized,
                icon: .buttonVoiceCall,
            ) { [weak self] in
                self?.didPressAudioCall()
            }
            let buttonStack = UIStackView(arrangedSubviews: [buttonMessage, buttonVideoCall, buttonAudioCall])
            buttonStack.axis = .horizontal
            buttonStack.spacing = 8
            buttonStack.distribution = .fillEqually

            let sectionHeaderView = UIView()
            sectionHeaderView.addSubview(buttonStack)
            buttonStack.autoPinHeightToSuperview()
            buttonStack.autoHCenterInSuperview()
            buttonStack.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual)
            actionsSection.customHeaderView = sectionHeaderView
        }

        if showInviteToSignal() {
            actionsSection.add(.disclosureItem(
                icon: .settingsInvite,
                withText: OWSLocalizedString("ACTION_INVITE", comment: ""),
                actionBlock: { [weak self] in
                    self?.didPressInvite()
                },
            ))
        }

        if showAddToContacts() {
            actionsSection.add(.disclosureItem(
                icon: .contactInfoAddToContacts,
                withText: OWSLocalizedString(
                    "CONVERSATION_VIEW_ADD_TO_CONTACTS_OFFER",
                    comment: "",
                )
                ,
                actionBlock: { [weak self] in
                    self?.didPressAddToContacts()
                },
            ))
        }

        if actionsSection.customHeaderView != nil || !actionsSection.items.isEmpty {
            sections.append(actionsSection)
        }

        // Contact Info
        let infoSection = OWSTableSection()
        infoSection.add(items: contactShare.phoneNumbers.map({ phoneNumber in
            return OWSTableItem(
                customCellBlock: {
                    return Self.buildPhoneNumberCell(phoneNumber)
                },
                actionBlock: { [weak self] in
                    self?.didPressPhoneNumber(phoneNumber: phoneNumber)
                },
            )
        }))
        infoSection.add(items: contactShare.emails.map({ email in
            return OWSTableItem(
                customCellBlock: {
                    return Self.buildEmailCell(email)
                },
                actionBlock: { [weak self] in
                    self?.didPressEmail(email: email)
                },
            )
        }))
        infoSection.add(items: contactShare.addresses.map({ address in
            return OWSTableItem(
                customCellBlock: {
                    return Self.buildAddressCell(address)
                },
                actionBlock: { [weak self] in
                    self?.didPressAddress(address: address)
                },
            )
        }))
        sections.append(infoSection)

        contents = OWSTableContents(sections: sections)
    }

    private func buildHeaderView() -> UIView {
        AssertIsOnMainThread()

        let headerView = UIView.container()
        headerView.preservesSuperviewLayoutMargins = true

        // Contact info
        //           ________
        //          [        ]
        //          [ Avatar ]
        //          [________]
        //            [Name]
        //      [Organization Name]
        //    [Signal Contact Actions]
        //
        let verticalContentStack = UIStackView()
        verticalContentStack.axis = .vertical
        verticalContentStack.spacing = 8
        verticalContentStack.alignment = .center
        headerView.addSubview(verticalContentStack)
        verticalContentStack.autoPinEdge(toSuperviewEdge: .top, withInset: 20)
        verticalContentStack.autoPinWidthToSuperviewMargins()
        verticalContentStack.autoPinEdge(toSuperviewEdge: .bottom, withInset: 24)

        // Avatar
        let avatarSize: CGFloat = 100
        let avatarView = AvatarImageView()
        avatarView.image = contactShare.getAvatarImageWithSneakyTransaction(diameter: avatarSize)
        avatarView.autoSetDimension(.width, toSize: avatarSize)
        avatarView.autoSetDimension(.height, toSize: avatarSize)
        verticalContentStack.addArrangedSubview(avatarView)

        // Name
        let nameLabel = UILabel()
        nameLabel.text = contactShare.displayName
        // 26pt with default size
        let fontPointSize = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title1).pointSize - 2
        nameLabel.font = UIFont.semiboldFont(ofSize: fontPointSize)
        nameLabel.textColor = Theme.primaryTextColor
        nameLabel.lineBreakMode = .byWordWrapping
        nameLabel.textAlignment = .center
        nameLabel.numberOfLines = 5
        verticalContentStack.addArrangedSubview(nameLabel)

        // Organization Name
        if
            let organizationName = contactShare.name.organizationName?.ows_stripped().nilIfEmpty,
            contactShare.name.hasAnyNamePart
        {
            let label = UILabel()
            label.text = organizationName
            label.font = .dynamicTypeSubheadline
            label.textColor = Theme.secondaryTextAndIconColor
            label.lineBreakMode = .byWordWrapping
            label.textAlignment = .center
            label.numberOfLines = 3
            verticalContentStack.addArrangedSubview(label)
        }

        return headerView
    }

    // MARK: Custom cells

    private class func buildTableViewCellWith(_ fieldContentView: UIView) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        cell.contentView.addSubview(fieldContentView)
        fieldContentView.autoPinHeightToSuperview(withMargin: 10)
        fieldContentView.autoPinWidthToSuperviewMargins()
        return cell
    }

    private class func buildPhoneNumberCell(_ phoneNumber: OWSContactPhoneNumber) -> UITableViewCell {
        let fieldContentView = ContactFieldViewHelper.contactFieldView(forPhoneNumber: phoneNumber)
        return buildTableViewCellWith(fieldContentView)
    }

    private class func buildEmailCell(_ email: OWSContactEmail) -> UITableViewCell {
        let fieldContentView = ContactFieldViewHelper.contactFieldView(forEmail: email)
        return buildTableViewCellWith(fieldContentView)
    }

    private class func buildAddressCell(_ address: OWSContactAddress) -> UITableViewCell {
        let fieldContentView = ContactFieldViewHelper.contactFieldView(forAddress: address)
        return buildTableViewCellWith(fieldContentView)
    }
}

// MARK: Actions

extension ContactViewController {

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

        contactShareViewHelper.sendMessage(to: sendablePhoneNumbers, from: self)
    }

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

        contactShareViewHelper.audioCall(to: sendablePhoneNumbers, from: self)
    }

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

        contactShareViewHelper.videoCall(to: sendablePhoneNumbers, from: self)
    }

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

        contactShareViewHelper.showInviteContact(contactShare: contactShare, from: self)
    }

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

        contactShareViewHelper.showAddToContactsPrompt(contactShare: contactShare, from: self)
    }

    private func didPressPhoneNumber(phoneNumber: OWSContactPhoneNumber) {
        Logger.info("")

        let actionSheet = ActionSheetController(title: nil, message: nil)

        if let phoneNumber = phoneNumber.e164 {
            let isRegistered = sendablePhoneNumbers.contains(phoneNumber)
            if isRegistered {
                func addAction(title: String, action: ConversationViewAction) {
                    actionSheet.addAction(ActionSheetAction(
                        title: title,
                        style: .default,
                        handler: { _ in
                            let address = SignalServiceAddress(phoneNumber: phoneNumber)
                            SignalApp.shared.presentConversationForAddress(address, action: action, animated: true)
                        },
                    ))
                }
                addAction(title: CommonStrings.sendMessage, action: .compose)
                addAction(
                    title: OWSLocalizedString(
                        "ACTION_VOICE_CALL",
                        comment: "Label for 'voice call' button in contact view.",
                    ),
                    action: .voiceCall,
                )
                addAction(
                    title: OWSLocalizedString(
                        "ACTION_VIDEO_CALL",
                        comment: "Label for 'video call' button in contact view.",
                    ),
                    action: .voiceCall,
                )
            } else {
                // TODO: We could offer callPhoneNumberWithSystemCall.
            }
        }
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "EDIT_ITEM_COPY_ACTION",
                comment: "Short name for edit menu item to copy contents of media message.",
            ),
            style: .default,
        ) { _ in
            UIPasteboard.general.string = phoneNumber.phoneNumber
        })
        actionSheet.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(actionSheet)
    }

    private func callPhoneNumberWithSystemCall(phoneNumber: OWSContactPhoneNumber) {
        Logger.info("")

        guard let url = NSURL(string: "tel:\(phoneNumber.phoneNumber)") else {
            owsFailDebug("could not open phone number.")
            return
        }
        UIApplication.shared.open(url as URL, options: [:])
    }

    private func didPressEmail(email: OWSContactEmail) {
        Logger.info("")

        let actionSheet = ActionSheetController(title: nil, message: nil)
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "CONTACT_VIEW_OPEN_EMAIL_IN_EMAIL_APP",
                comment: "Label for 'open email in email app' button in contact view.",
            ),
            style: .default,
        ) { [weak self] _ in
            self?.openEmailInEmailApp(email: email)
        })
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "EDIT_ITEM_COPY_ACTION",
                comment: "Short name for edit menu item to copy contents of media message.",
            ),
            style: .default,
        ) { _ in
            UIPasteboard.general.string = email.email
        })
        actionSheet.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(actionSheet)
    }

    private func openEmailInEmailApp(email: OWSContactEmail) {
        Logger.info("")

        guard let url = NSURL(string: "mailto:\(email.email)") else {
            owsFailDebug("could not open email.")
            return
        }
        UIApplication.shared.open(url as URL, options: [:])
    }

    private func didPressAddress(address: OWSContactAddress) {
        Logger.info("")

        let actionSheet = ActionSheetController(title: nil, message: nil)
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "CONTACT_VIEW_OPEN_ADDRESS_IN_MAPS_APP",
                comment: "Label for 'open address in maps app' button in contact view.",
            ),
            style: .default,
        ) { [weak self] _ in
            self?.openAddressInMaps(address: address)
        })
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "EDIT_ITEM_COPY_ACTION",
                comment: "Short name for edit menu item to copy contents of media message.",
            ),
            style: .default,
        ) { [weak self] _ in
            guard let self else { return }
            UIPasteboard.general.string = self.formatAddressForQuery(address: address)
        })
        actionSheet.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(actionSheet)
    }

    private func openAddressInMaps(address: OWSContactAddress) {
        Logger.info("")

        let mapAddress = formatAddressForQuery(address: address)
        guard let escapedMapAddress = mapAddress.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
            owsFailDebug("could not open address.")
            return
        }
        // Note that we use "q" (i.e. query) rather than "address" since we can't assume
        // this is a well-formed address.
        guard let url = URL(string: "maps://?q=\(escapedMapAddress)") else {
            owsFailDebug("could not open address.")
            return
        }

        UIApplication.shared.open(url as URL, options: [:])
    }

    private func formatAddressForQuery(address: OWSContactAddress) -> String {
        Logger.info("")

        // Open address in Apple Maps app.
        var addressParts = [String]()
        let addAddressPart: ((String?) -> Void) = { part in
            guard let part, !part.isEmpty else { return }

            addressParts.append(part)
        }
        addAddressPart(address.street)
        addAddressPart(address.neighborhood)
        addAddressPart(address.city)
        addAddressPart(address.region)
        addAddressPart(address.postcode)
        addAddressPart(address.country)
        return addressParts.joined(separator: ", ")
    }
}

extension ContactViewController: ContactShareViewHelperDelegate {

    func didCreateOrEditContact() {
        updateContent()
    }
}