blob: 0d6e00d8e83b663878cf2b778f094d2306327961 [file] [log] [blame]
// Copyright 2016 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import Contacts
import ContactsUI
import Syncbase
import UIKit
enum Category: Int {
case NearbyContacts
case Nearby
case Contacts
static let allRawValues = [NearbyContacts.rawValue, Nearby.rawValue, Contacts.rawValue]
}
class InviteViewController: UITableViewController {
// Represents the search bar.
var searchController: UISearchController!
// All known people -- this is the model that the tableview loads from.
var people: [[Contact]] = [[], [], []]
// Allows us to look up address book contacts we might discover via BLE/mDNS.
var contactsByLowercaseEmail: [String: Contact] = [:]
var contactsByLowercaseEmailMu = NSLock()
// True if the search controller's text matches any existing contacts.
var searchResultsFound = false
// All matching people for a given search -- this is the model the tableview loads from.
var searchResults: [[Contact]] = [[], [], []]
// True if contacts were able to be loaded from the iOS Address Book.
var didLoadContacts = false
// The TodoList we are inviting users to.
var todoList: TodoList!
// Regex used to know when a search represents an email address.
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
// The cell that displays people's names or emails.
let personCellId = "personCellId"
// The cell that represents sending an invite directly to an email address.
let sendEmailCell = "sendEmailCellId"
// The names of each section of the table view.
var sectionNames: [Category: String] = [
.NearbyContacts: "Nearby Contacts",
.Nearby: "Nearby",
.Contacts: "Contacts",
]
// Set to true if the user clicked on a name to invite while the seearch controller was active.
// The invite logic first waits for the search controller to turn inactive before the view
// controller is dismissed. This variable keeps track for the didDismissSearchController callback
// to know if it should dismiss the VC or not.
var shouldDismiss = false
override func viewDidLoad() {
super.viewDidLoad()
guard todoList.collection != nil else {
print("Missing collection from todo list \(todoList)")
// Pop view since we can't invite a user. This must be done outside of the viewDidLoad.
dispatch_async(dispatch_get_main_queue()) {
self.dismissViewControllerAnimated(true) { }
}
return
}
initSearchController()
loadContacts() {
do {
try Syncbase.startScanForUsersInNeighborhood(
ScanNeighborhoodForUsersHandler(onFound: self.onFound, onLost: self.onLost))
} catch {
print("Unable to scan for other users: \(error)")
}
}
}
override func viewWillDisappear(animated: Bool) {
Syncbase.stopAllScansForUsersInNeighborhood()
}
func initSearchController() {
// Cannot be done in IB yet.
searchController = UISearchController(searchResultsController: nil)
searchController.delegate = self
searchController.searchResultsUpdater = self
searchController.dimsBackgroundDuringPresentation = false
searchController.searchBar.sizeToFit()
tableView.tableHeaderView = searchController.searchBar
definesPresentationContext = true
}
// MARK: Address Book contacts
func loadContacts(doneCallback: Void -> Void) {
CNContactStore().requestAccessForEntityType(.Contacts) { (granted, error) in
guard granted && error == nil else {
print("Contants not able to load: granted=\(granted) error=\(error)")
dispatch_async(dispatch_get_main_queue()) {
self.disableContacts()
doneCallback()
}
return
}
let request = CNContactFetchRequest(keysToFetch: Contact.keysToFetch)
var results: [Contact] = []
do {
self.contactsByLowercaseEmailMu.lock()
defer { self.contactsByLowercaseEmailMu.unlock() }
// This is a bit slow loading everything at once. In a real app you want to take a more
// advanced strategy, perhaps updating the UI in batches or some other methodology.
try CNContactStore().enumerateContactsWithFetchRequest(request) { (cncontact, stop) in
let contact = Contact(contact: cncontact)
results.append(contact)
if let emails = contact.emails {
for email in emails {
// The email was lowercased in the Contact class's init.
self.contactsByLowercaseEmail[email] = contact
}
}
}
dispatch_async(dispatch_get_main_queue()) {
self.didLoadContacts = true
self.people[Category.Contacts.rawValue] = results
self.tableView.reloadData()
doneCallback()
}
} catch {
print("Unable to fetch contacts: \(error)")
dispatch_async(dispatch_get_main_queue()) {
self.disableContacts()
doneCallback()
}
}
}
}
/// disableContacts is used when the user has not granted authorization to contacts or some other
/// error has occured loading contacts. Must be called from main.
func disableContacts() {
didLoadContacts = false
sectionNames[.Contacts] = nil
sectionNames[.NearbyContacts] = nil
people = [people[Category.Nearby.rawValue]]
searchResults = [searchResults[Category.Nearby.rawValue]]
tableView.reloadData()
}
// MARK: Tableview delegates
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
if searchController.active && !searchResultsFound {
// We only have one section in the case of the send email cell.
return 1
}
return sectionNames.count
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if searchController.active {
if searchResultsFound {
return searchResults[section].count
} else {
// We only have one row in the case of the send email cell.
return 1
}
}
return people[section].count
}
override func tableView(tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
view.tintColor = UIColor.whiteColor()
}
override func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if searchController.active && !searchResultsFound {
// Don't show a section title if we're just showing send an email cell.
return nil
}
return sectionNames[Category(rawValue: section)!]
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
// In the case where we have are searching and have found no results, offer the send email cell.
if searchController.active && !searchResultsFound {
// This cell is a prototype inside the Main.storyboard. Cannot fail.
let cell = tableView.dequeueReusableCellWithIdentifier(sendEmailCell, forIndexPath: indexPath) as! SendEmailCell
cell.emailLabel.text = searchController.searchBar.text
return cell
}
// This cell is a prototype inside the Main.storyboard. Cannot fail.
let cell = self.tableView.dequeueReusableCellWithIdentifier(personCellId, forIndexPath: indexPath) as! PersonCell
if searchController.active {
// Show search results.
cell.contact = searchResults[indexPath.section][indexPath.row]
} else {
// Not searching, show entire list.
cell.contact = people[indexPath.section][indexPath.row]
}
cell.updateView()
return cell
}
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if searchController.active && !searchResultsFound {
return SendEmailCell.height
}
return PersonCell.height
}
// MARK: Inviting
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
// No-op if can't create a person from index path.
if let contact = contactAtIndexPath(indexPath) {
do {
try inviteContact(contact)
} catch {
print("Unable to invite \(contact): \(error)")
let ac = UIAlertController(
title: "Oops!",
message: "Unable to send invite. Try again.",
preferredStyle: .Alert)
ac.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil))
presentViewController(ac, animated: true, completion: nil)
return
}
// Dismiss the view.
if searchController.active {
// The search bar needs to close before we pop, so handle in didDismissSearch callback.
shouldDismiss = true
searchController.active = false
} else {
navigationController?.popViewControllerAnimated(true)
}
}
}
func contactAtIndexPath(indexPath: NSIndexPath) -> Contact? {
// Determine if we entered an email or not. If email, validate email and construct a Person obj.
var personToInvite: Contact?
if let text = searchController.searchBar.text where searchController.active && !searchResultsFound {
let validEmail = NSPredicate(format: "SELF MATCHES %@", emailRegEx).evaluateWithObject(text)
if validEmail {
// Create new person with this email.
personToInvite = Contact(emails: [text])
} else {
// Invalid email, show alert.
let alert = UIAlertController(title: "Invalid email",
message: "Please enter a valid email",
preferredStyle: .Alert)
alert.addAction(UIAlertAction(title: "Ok", style: .Default, handler: nil))
navigationController?.presentViewController(alert, animated: true, completion: nil)
}
} else if people.indices.contains(indexPath.section) &&
people[indexPath.section].indices.contains(indexPath.row) {
// We're inviting an existing Person.
personToInvite = people[indexPath.section][indexPath.row]
}
return personToInvite
}
// This is called after selecting a contact if the search controller is active.
func didDismissSearchController(searchController: UISearchController) {
// If we were searching when we selected a person, we need to wait for its dismiss animation
// to finish before popping the view.
if shouldDismiss {
navigationController?.popViewControllerAnimated(true)
}
}
// inviteContact is the function responsible for actually inviting a user to the collection's
// syncgroup.
func inviteContact(contact: Contact) throws {
if let user = contact.user {
// Admin allows them to invite people to the syncgroup as well.
try todoList.collection!.syncgroup().inviteUser(user, level: .READ_WRITE_ADMIN)
} else if let emails = contact.emails {
for email in emails {
try todoList.collection!.syncgroup().inviteUser(User(alias: email), level: .READ_WRITE_ADMIN)
}
}
}
// MARK: Discovery
func onFound(user: User) {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0)) {
// Determine if they're an existing contact.
self.contactsByLowercaseEmailMu.lock()
defer { self.contactsByLowercaseEmailMu.unlock() }
var contact = Contact(user: user)
var section = Category.Nearby.rawValue
if let existingContact = self.contactsByLowercaseEmail[user.alias.lowercaseString] {
contact = existingContact
contact.user = user
section = Category.NearbyContacts.rawValue
}
// If we were never able to load contacts, then they should be disabled and we only have 1
// section.
if !self.didLoadContacts {
section = 0
}
dispatch_async(dispatch_get_main_queue()) {
self.people[section].append(contact)
self.people[section].sortInPlace { (lhs, rhs) -> Bool in
return lhs.description.compare(rhs.description) == NSComparisonResult.OrderedAscending
}
self.tableView.reloadSections(NSIndexSet(index: section), withRowAnimation: .Automatic)
}
}
}
func onLost(user: User) {
for section in nearbySections {
if let idx = people[section].indexOf({ $0.user == user }) {
people[section].removeAtIndex(idx)
tableView.reloadSections(NSIndexSet(index: section), withRowAnimation: .Automatic)
break
}
}
}
var nearbySections: [Int] {
if didLoadContacts {
return [Category.NearbyContacts.rawValue, Category.Nearby.rawValue]
}
return [0]
}
}
// MARK: Search
// Filters people to match the text user searched for.
extension InviteViewController: UISearchControllerDelegate, UISearchResultsUpdating {
func updateSearchResultsForSearchController(searchController: UISearchController) {
if let searchText = searchController.searchBar.text where searchText.characters.count > 0 {
for section in nearbySections {
searchResults[section] = people[section].filter { contact -> Bool in
return contact.description.rangeOfString(searchText,
options: .CaseInsensitiveSearch,
range: contact.description.startIndex ..< contact.description.endIndex,
locale: nil) != nil
}
}
if didLoadContacts {
searchResults[Category.Contacts.rawValue] = []
if let contacts = try? CNContactStore().unifiedContactsMatchingPredicate(
CNContact.predicateForContactsMatchingName(searchText),
keysToFetch: Contact.keysToFetch) {
searchResults[Category.Contacts.rawValue] = contacts.map { Contact(contact: $0) }
}
}
} else {
for i in 0 ..< searchResults.count {
searchResults[i] = []
}
}
searchResultsFound = false
for section in searchResults {
if !section.isEmpty {
searchResultsFound = true
break
}
}
tableView.reloadData()
}
}
// MARK: Tableview Cells
// Displays a person with name and their profile photo.
class PersonCell: UITableViewCell {
static let height: CGFloat = 44
@IBOutlet weak var nameLabel: UILabel!
var contact: Contact?
func updateView() {
nameLabel.text = contact?.description
}
}
// Display a cell that shows the email the invite will be sent to.
class SendEmailCell: UITableViewCell {
static let height: CGFloat = 74
@IBOutlet weak var emailLabel: UILabel!
}