Split it based on whether the purpose is “editing” or “sharing”. If we’re editing, it generally means we have full access to contacts, and that we’ve checked which of the contacts are registered on Signal. If we’re “sharing”, we don’t care whether or not they’re registered. If we’re “sharing”, we check whether or not we’ve prompted for the contacts permission yet. If not, we’ll show the prompt. If we’re “editing”, we assume we’ve already prompted (which is already the case today), so `.notDetermined` isn’t a possible state. Other things to note: - The `supportsContactEditing` was always true, so it’s been removed. - The error when contacts access isn’t allowed is now shown more consistently. Previously, it was sometimes shown in response to initializing a view controller. - Some RecipientPickerViewController code was moved from Obj-C to Swift. This is probably useful on its own, but it’ll also make it easier to build some new UI in subsequent commits.
326 lines
11 KiB
Swift
326 lines
11 KiB
Swift
//
|
|
// Copyright 2016 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
// Originally based on EPContacts
|
|
//
|
|
// Created by Prabaharan Elangovan on 12/10/15.
|
|
// Parts Copyright © 2015 Prabaharan Elangovan. All rights reserved
|
|
|
|
import Contacts
|
|
import SignalMessaging
|
|
import SignalServiceKit
|
|
import UIKit
|
|
|
|
@objc
|
|
public protocol ContactsPickerDelegate: AnyObject {
|
|
func contactsPickerDidCancel(_: ContactsPicker)
|
|
func contactsPicker(_: ContactsPicker, didSelectContact contact: Contact)
|
|
func contactsPicker(_: ContactsPicker, didSelectMultipleContacts contacts: [Contact])
|
|
func contactsPicker(_: ContactsPicker, shouldSelectContact contact: Contact) -> Bool
|
|
}
|
|
|
|
@objc
|
|
public enum SubtitleCellValue: Int {
|
|
case phoneNumber, email, none
|
|
}
|
|
|
|
@objc
|
|
open class ContactsPicker: OWSViewController, UITableViewDelegate, UITableViewDataSource, UISearchBarDelegate {
|
|
|
|
var tableView: UITableView!
|
|
var searchBar: UISearchBar!
|
|
|
|
// MARK: - Properties
|
|
|
|
private let collation = UILocalizedIndexedCollation.current()
|
|
private let contactStore = CNContactStore()
|
|
|
|
// Data Source State
|
|
private lazy var sections = [[CNContact]]()
|
|
private lazy var filteredSections = [[CNContact]]()
|
|
private lazy var selectedContacts = [Contact]()
|
|
|
|
// Configuration
|
|
@objc
|
|
public weak var contactsPickerDelegate: ContactsPickerDelegate?
|
|
private let subtitleCellType: SubtitleCellValue
|
|
private let allowsMultipleSelection: Bool
|
|
private let allowedContactKeys: [CNKeyDescriptor] = ContactsFrameworkContactStoreAdaptee.allowedContactKeys
|
|
private let sortOrder: CNContactSortOrder = CNContactsUserDefaults.shared().sortOrder
|
|
|
|
// MARK: - Initializers
|
|
|
|
@objc
|
|
required public init(allowsMultipleSelection: Bool, subtitleCellType: SubtitleCellValue) {
|
|
self.allowsMultipleSelection = allowsMultipleSelection
|
|
self.subtitleCellType = subtitleCellType
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Lifecycle Methods
|
|
|
|
override public func loadView() {
|
|
self.view = UIView()
|
|
let tableView = UITableView()
|
|
self.tableView = tableView
|
|
|
|
view.addSubview(tableView)
|
|
tableView.autoPinEdge(toSuperviewEdge: .top)
|
|
tableView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
tableView.autoPinEdge(toSuperviewSafeArea: .leading)
|
|
tableView.autoPinEdge(toSuperviewSafeArea: .trailing)
|
|
tableView.delegate = self
|
|
tableView.dataSource = self
|
|
|
|
let searchBar = OWSSearchBar()
|
|
self.searchBar = searchBar
|
|
searchBar.delegate = self
|
|
searchBar.sizeToFit()
|
|
|
|
tableView.tableHeaderView = searchBar
|
|
}
|
|
|
|
override open func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
searchBar.placeholder = CommonStrings.searchBarPlaceholder
|
|
|
|
// Auto size cells for dynamic type
|
|
tableView.estimatedRowHeight = 60.0
|
|
tableView.rowHeight = UITableView.automaticDimension
|
|
tableView.estimatedRowHeight = 60
|
|
|
|
tableView.allowsMultipleSelection = allowsMultipleSelection
|
|
|
|
tableView.separatorInset = UIEdgeInsets(top: 0, left: ContactCell.kSeparatorHInset, bottom: 0, right: 16)
|
|
|
|
registerContactCell()
|
|
initializeBarButtons()
|
|
reloadContacts()
|
|
updateSearchResults(searchText: "")
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(self.didChangePreferredContentSize), name: UIContentSizeCategory.didChangeNotification, object: nil)
|
|
}
|
|
|
|
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
|
|
view.backgroundColor = Theme.backgroundColor
|
|
tableView.backgroundColor = Theme.backgroundColor
|
|
tableView.separatorColor = Theme.cellSeparatorColor
|
|
}
|
|
|
|
@objc
|
|
public func didChangePreferredContentSize() {
|
|
self.tableView.reloadData()
|
|
}
|
|
|
|
private func initializeBarButtons() {
|
|
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(onTouchCancelButton))
|
|
self.navigationItem.leftBarButtonItem = cancelButton
|
|
|
|
if allowsMultipleSelection {
|
|
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onTouchDoneButton))
|
|
self.navigationItem.rightBarButtonItem = doneButton
|
|
}
|
|
}
|
|
|
|
private func registerContactCell() {
|
|
tableView.register(ContactCell.self, forCellReuseIdentifier: ContactCell.reuseIdentifier)
|
|
}
|
|
|
|
// MARK: - Contact Operations
|
|
|
|
private func reloadContacts() {
|
|
guard contactsManagerImpl.sharingAuthorization == .authorized else {
|
|
return owsFailDebug("Not authorized.")
|
|
}
|
|
do {
|
|
var contacts = [CNContact]()
|
|
let contactFetchRequest = CNContactFetchRequest(keysToFetch: allowedContactKeys)
|
|
contactFetchRequest.sortOrder = .userDefault
|
|
try contactStore.enumerateContacts(with: contactFetchRequest) { (contact, _) -> Void in
|
|
contacts.append(contact)
|
|
}
|
|
self.sections = collatedContacts(contacts)
|
|
} catch {
|
|
Logger.error("Failed to fetch contacts with error: \(error)")
|
|
}
|
|
}
|
|
|
|
public func collatedContacts(_ contacts: [CNContact]) -> [[CNContact]] {
|
|
let selector: Selector
|
|
if sortOrder == .familyName {
|
|
selector = #selector(getter: CNContact.collationNameSortedByFamilyName)
|
|
} else {
|
|
selector = #selector(getter: CNContact.collationNameSortedByGivenName)
|
|
}
|
|
|
|
var collated = Array(repeating: [CNContact](), count: collation.sectionTitles.count)
|
|
for contact in contacts {
|
|
let sectionNumber = collation.section(for: contact, collationStringSelector: selector)
|
|
collated[sectionNumber].append(contact)
|
|
}
|
|
return collated
|
|
}
|
|
|
|
// MARK: - Table View DataSource
|
|
|
|
open func numberOfSections(in tableView: UITableView) -> Int {
|
|
return self.collation.sectionTitles.count
|
|
}
|
|
|
|
open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
let dataSource = filteredSections
|
|
|
|
guard section < dataSource.count else {
|
|
return 0
|
|
}
|
|
|
|
return dataSource[section].count
|
|
}
|
|
|
|
// MARK: - Table View Delegates
|
|
|
|
open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
let cell = tableView.dequeueReusableCell(ContactCell.self, for: indexPath)!
|
|
|
|
let dataSource = filteredSections
|
|
let cnContact = dataSource[indexPath.section][indexPath.row]
|
|
let contact = Contact(systemContact: cnContact)
|
|
|
|
cell.configure(contact: contact, sortOrder: sortOrder, subtitleType: subtitleCellType, showsWhenSelected: self.allowsMultipleSelection)
|
|
let isSelected = selectedContacts.contains(where: { $0.uniqueId == contact.uniqueId })
|
|
cell.isSelected = isSelected
|
|
|
|
// Make sure we preserve selection across tableView.reloadData which happens when toggling between
|
|
// search controller
|
|
if isSelected {
|
|
self.tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
|
} else {
|
|
self.tableView.deselectRow(at: indexPath, animated: false)
|
|
}
|
|
|
|
return cell
|
|
}
|
|
|
|
open func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
|
|
let cell = tableView.cellForRow(at: indexPath) as! ContactCell
|
|
let deselectedContact = cell.contact!
|
|
|
|
selectedContacts = selectedContacts.filter {
|
|
return $0.uniqueId != deselectedContact.uniqueId
|
|
}
|
|
}
|
|
|
|
open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
Logger.verbose("")
|
|
|
|
let cell = tableView.cellForRow(at: indexPath) as! ContactCell
|
|
let selectedContact = cell.contact!
|
|
|
|
guard contactsPickerDelegate == nil || contactsPickerDelegate!.contactsPicker(self, shouldSelectContact: selectedContact) else {
|
|
self.tableView.deselectRow(at: indexPath, animated: false)
|
|
return
|
|
}
|
|
|
|
selectedContacts.append(selectedContact)
|
|
|
|
if !allowsMultipleSelection {
|
|
// Single selection code
|
|
self.contactsPickerDelegate?.contactsPicker(self, didSelectContact: selectedContact)
|
|
}
|
|
}
|
|
|
|
open func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int {
|
|
return collation.section(forSectionIndexTitle: index)
|
|
}
|
|
|
|
open func sectionIndexTitles(for tableView: UITableView) -> [String]? {
|
|
return collation.sectionIndexTitles
|
|
}
|
|
|
|
open func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
|
let dataSource = filteredSections
|
|
|
|
guard section < dataSource.count else {
|
|
return nil
|
|
}
|
|
|
|
// Don't show empty sections
|
|
if dataSource[section].count > 0 {
|
|
guard section < collation.sectionTitles.count else {
|
|
return nil
|
|
}
|
|
|
|
return collation.sectionTitles[section]
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
searchBar.resignFirstResponder()
|
|
}
|
|
|
|
// MARK: - Button Actions
|
|
|
|
@objc
|
|
func onTouchCancelButton() {
|
|
contactsPickerDelegate?.contactsPickerDidCancel(self)
|
|
}
|
|
|
|
@objc
|
|
func onTouchDoneButton() {
|
|
contactsPickerDelegate?.contactsPicker(self, didSelectMultipleContacts: selectedContacts)
|
|
}
|
|
|
|
// MARK: - Search Actions
|
|
open func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
|
updateSearchResults(searchText: searchText)
|
|
}
|
|
|
|
public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
|
|
searchBar.resignFirstResponder()
|
|
}
|
|
|
|
open func updateSearchResults(searchText: String) {
|
|
let predicate: NSPredicate
|
|
if searchText.isEmpty {
|
|
filteredSections = sections
|
|
} else {
|
|
do {
|
|
predicate = CNContact.predicateForContacts(matchingName: searchText)
|
|
let filteredContacts = try contactStore.unifiedContacts(matching: predicate, keysToFetch: allowedContactKeys)
|
|
filteredSections = collatedContacts(filteredContacts)
|
|
} catch {
|
|
Logger.error("updating search results failed with error: \(error)")
|
|
}
|
|
}
|
|
self.tableView.reloadData()
|
|
}
|
|
}
|
|
|
|
extension CNContact {
|
|
@objc
|
|
fileprivate var collationNameSortedByGivenName: String { collationName(sortOrder: .givenName) }
|
|
|
|
@objc
|
|
fileprivate var collationNameSortedByFamilyName: String { collationName(sortOrder: .familyName) }
|
|
|
|
func collationName(sortOrder: CNContactSortOrder) -> String {
|
|
return (collationContactName(sortOrder: sortOrder) ?? (emailAddresses.first?.value as String?) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func collationContactName(sortOrder: CNContactSortOrder) -> String? {
|
|
let contactNames: [String] = [familyName.nilIfEmpty, givenName.nilIfEmpty].compacted()
|
|
guard !contactNames.isEmpty else {
|
|
return nil
|
|
}
|
|
return ((sortOrder == .familyName) ? contactNames : contactNames.reversed()).joined(separator: " ")
|
|
}
|
|
}
|