// // Copyright (c) 2017 Open Whisper Systems. All rights reserved. // #import "MessageComposeTableViewController.h" #import #import "ContactTableViewCell.h" #import "ContactsUpdater.h" #import "Environment.h" #import "OWSContactsSearcher.h" #import "Signal-Swift.h" #import "UIColor+OWS.h" #import "UIUtil.h" NS_ASSUME_NONNULL_BEGIN @interface MessageComposeTableViewController () @property (nonatomic) IBOutlet UITableViewCell *inviteCell; @property (nonatomic) UITableViewCell *conversationForNonContactCell; @property (nonatomic) UITableViewCell *inviteViaSMSCell; @property (nonatomic) IBOutlet OWSNoSignalContactsView *noSignalContactsView; @property (nonatomic) UISearchController *searchController; @property (nonatomic) UIActivityIndicatorView *activityIndicator; @property (nonatomic) UIBarButtonItem *addGroup; @property (nonatomic) UIView *loadingBackgroundView; @property (nonatomic) NSString *currentSearchTerm; @property (copy) NSArray *contacts; @property (copy) NSArray *searchResults; @property (nonatomic, readonly) OWSContactsManager *contactsManager; // This property should be set IFF the current search text can // be parsed as a phone number. If set, it contains a E164 value. @property (nonatomic) NSString *searchPhoneNumber; // This dictionary is used to cache the set of phone numbers // which are known to correspond to Signal accounts. @property (nonatomic) NSMutableSet *phoneNumberAccountSet; @property (nonatomic) BOOL isBackgroundViewHidden; @end // The "special" sections are used to display (at most) one of three cells: // // * "New conversation for non-contact" if user has entered a phone // number which corresponds to a signal account, or: // * "Send invite via SMS" if user has entered a phone number // which is not known to correspond to a signal account, or: // * "Invite contacts" if the invite flow is available, or: // * Nothing, otherwise. NSInteger const MessageComposeTableViewControllerSectionInviteNonContactConversation = 0; NSInteger const MessageComposeTableViewControllerSectionInviteViaSMS = 1; NSInteger const MessageComposeTableViewControllerSectionInviteFlow = 2; NSInteger const MessageComposeTableViewControllerSectionContacts = 3; NSString *const MessageComposeTableViewControllerCellInvite = @"ContactTableInviteCell"; NSString *const MessageComposeTableViewControllerCellContact = @"ContactTableViewCell"; @implementation MessageComposeTableViewController - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (!self) { return self; } _contactsManager = [Environment getCurrent].contactsManager; return self; } - (instancetype)init { self = [super init]; if (!self) { return self; } _contactsManager = [Environment getCurrent].contactsManager; return self; } - (void)viewDidLoad { [super viewDidLoad]; [self.navigationController.navigationBar setTranslucent:NO]; self.tableView.estimatedRowHeight = (CGFloat)60.0; self.tableView.rowHeight = UITableViewAutomaticDimension; self.contacts = self.contactsManager.signalContacts; self.searchResults = self.contacts; [self initializeSearch]; self.searchController.searchBar.hidden = NO; self.searchController.searchBar.backgroundColor = [UIColor whiteColor]; self.inviteCell.textLabel.text = NSLocalizedString( @"INVITE_FRIENDS_CONTACT_TABLE_BUTTON", @"Text for button at the top of the contact picker"); self.conversationForNonContactCell = [UITableViewCell new]; self.inviteViaSMSCell = [UITableViewCell new]; self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero]; [self createLoadingAndBackgroundViews]; self.title = NSLocalizedString(@"MESSAGE_COMPOSEVIEW_TITLE", @""); } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; if ([self.contacts count] == 0) { [self showEmptyBackgroundView:YES]; } } - (UILabel *)createLabelWithFirstLine:(NSString *)firstLine andSecondLine:(NSString *)secondLine { UILabel *label = [[UILabel alloc] init]; label.textColor = [UIColor grayColor]; label.font = [UIFont ows_regularFontWithSize:18.f]; label.textAlignment = NSTextAlignmentCenter; label.numberOfLines = 4; NSMutableAttributedString *fullLabelString = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@\n%@", firstLine, secondLine]]; [fullLabelString addAttribute:NSFontAttributeName value:[UIFont ows_boldFontWithSize:15.f] range:NSMakeRange(0, firstLine.length)]; [fullLabelString addAttribute:NSFontAttributeName value:[UIFont ows_regularFontWithSize:14.f] range:NSMakeRange(firstLine.length + 1, secondLine.length)]; [fullLabelString addAttribute:NSForegroundColorAttributeName value:[UIColor blackColor] range:NSMakeRange(0, firstLine.length)]; [fullLabelString addAttribute:NSForegroundColorAttributeName value:[UIColor ows_darkGrayColor] range:NSMakeRange(firstLine.length + 1, secondLine.length)]; label.attributedText = fullLabelString; // 250, 66, 140 [label setFrame:CGRectMake([self marginSize], 100 + 140, [self contentWidth], 66)]; return label; } - (void)createLoadingAndBackgroundViews { // This will be further tweaked per design recs. It must currently be hardcoded (or we can place in separate .xib I // suppose) as the controller must be a TableViewController to have access to the native pull to refresh // capabilities. That means we can't do a UIView in the storyboard _loadingBackgroundView = [[UIView alloc] initWithFrame:self.tableView.frame]; UIImageView *loadingImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"uiEmpty"]]; [loadingImageView setBackgroundColor:[UIColor whiteColor]]; [loadingImageView setContentMode:UIViewContentModeCenter]; [loadingImageView setFrame:CGRectMake(self.tableView.frame.size.width / 2.0f - 115.0f / 2.0f, 100, 115, 110)]; loadingImageView.contentMode = UIViewContentModeCenter; loadingImageView.contentMode = UIViewContentModeScaleAspectFit; UIActivityIndicatorView *loadingProgressView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; [loadingProgressView setFrame:CGRectMake(self.tableView.frame.size.width / 2.0f - loadingProgressView.frame.size.width / 2.0f, 100 + 110 / 2.0f - loadingProgressView.frame.size.height / 2.0f, loadingProgressView.frame.size.width, loadingProgressView.frame.size.height)]; [loadingProgressView setHidesWhenStopped:NO]; [loadingProgressView startAnimating]; UILabel *loadingLabel = [self createLabelWithFirstLine:NSLocalizedString(@"LOADING_CONTACTS_LABEL_LINE1", @"") andSecondLine:NSLocalizedString(@"LOADING_CONTACTS_LABEL_LINE2", @"")]; [_loadingBackgroundView addSubview:loadingImageView]; [_loadingBackgroundView addSubview:loadingProgressView]; [_loadingBackgroundView addSubview:loadingLabel]; UIButton *inviteButton = self.noSignalContactsView.inviteButton; [inviteButton addTarget:self action:@selector(presentInviteFlow) forControlEvents:UIControlEventTouchUpInside]; [inviteButton setTitleColor:[UIColor ows_materialBlueColor] forState:UIControlStateNormal]; [inviteButton.titleLabel setFont:[UIFont ows_regularFontWithSize:17.f]]; UIButton *searchByPhoneNumberButton = [UIButton buttonWithType:UIButtonTypeCustom]; [searchByPhoneNumberButton setTitle:NSLocalizedString(@"NO_CONTACTS_SEARCH_BY_PHONE_NUMBER", @"Label for a button that lets users search for contacts by phone number") forState:UIControlStateNormal]; [searchByPhoneNumberButton setTitleColor:[UIColor ows_materialBlueColor] forState:UIControlStateNormal]; [searchByPhoneNumberButton.titleLabel setFont:[UIFont ows_regularFontWithSize:17.f]]; [inviteButton.superview addSubview:searchByPhoneNumberButton]; [searchByPhoneNumberButton autoHCenterInSuperview]; [searchByPhoneNumberButton autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:inviteButton withOffset:20]; [searchByPhoneNumberButton addTarget:self action:@selector(hideBackgroundView) forControlEvents:UIControlEventTouchUpInside]; } - (void)hideBackgroundView { self.isBackgroundViewHidden = YES; [self showEmptyBackgroundView:NO]; } - (void)presentInviteFlow { OWSInviteFlow *inviteFlow = [[OWSInviteFlow alloc] initWithPresentingViewController:self contactsManager:self.contactsManager]; [self presentViewController:inviteFlow.actionSheetController animated:YES completion:nil]; } - (void)showLoadingBackgroundView:(BOOL)show { if (show && !self.isBackgroundViewHidden) { _addGroup = self.navigationItem.rightBarButtonItem != nil ? _addGroup : self.navigationItem.rightBarButtonItem; self.navigationItem.rightBarButtonItem = nil; self.searchController.searchBar.hidden = YES; self.tableView.backgroundView = _loadingBackgroundView; self.refreshControl = nil; self.tableView.backgroundView.opaque = YES; } else { [self initializeRefreshControl]; self.navigationItem.rightBarButtonItem = self.navigationItem.rightBarButtonItem != nil ? self.navigationItem.rightBarButtonItem : _addGroup; self.searchController.searchBar.hidden = NO; self.tableView.backgroundView = nil; } } - (void)showEmptyBackgroundView:(BOOL)show { if (show) { self.refreshControl = nil; _addGroup = self.navigationItem.rightBarButtonItem != nil ? _addGroup : self.navigationItem.rightBarButtonItem; self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithImage:[[UIImage imageNamed:@"btnRefresh--white"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] style:UIBarButtonItemStylePlain target:self action:@selector(refreshContacts)]; self.navigationItem.rightBarButtonItem.imageInsets = UIEdgeInsetsMake(8, 8, 8, 8); self.inviteCell.hidden = YES; self.conversationForNonContactCell.hidden = YES; self.inviteViaSMSCell.hidden = YES; self.searchController.searchBar.hidden = YES; self.tableView.backgroundView = self.noSignalContactsView; self.tableView.backgroundView.opaque = YES; } else { [self initializeRefreshControl]; self.refreshControl.enabled = YES; self.navigationItem.rightBarButtonItem = self.navigationItem.rightBarButtonItem != nil ? self.navigationItem.rightBarButtonItem : _addGroup; self.searchController.searchBar.hidden = NO; self.tableView.backgroundView = nil; self.inviteCell.hidden = NO; self.conversationForNonContactCell.hidden = NO; self.inviteViaSMSCell.hidden = NO; } } #pragma mark - Initializers - (void)initializeSearch { self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; self.searchController.searchResultsUpdater = self; self.searchController.dimsBackgroundDuringPresentation = NO; self.searchController.hidesNavigationBarDuringPresentation = NO; self.searchController.searchBar.frame = CGRectMake(self.searchController.searchBar.frame.origin.x, self.searchController.searchBar.frame.origin.y, self.searchController.searchBar.frame.size.width, 44.0); self.tableView.tableHeaderView = self.searchController.searchBar; self.searchController.searchBar.searchBarStyle = UISearchBarStyleMinimal; self.searchController.searchBar.delegate = self; self.searchController.searchBar.placeholder = NSLocalizedString(@"SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT", @""); [self initializeRefreshControl]; } - (void)initializeRefreshControl { UIRefreshControl *refreshControl = [[UIRefreshControl alloc] init]; [refreshControl addTarget:self action:@selector(refreshContacts) forControlEvents:UIControlEventValueChanged]; self.refreshControl = refreshControl; [self.tableView addSubview:self.refreshControl]; } #pragma mark - UISearchResultsUpdating - (void)updateSearchResultsForSearchController:(UISearchController *)searchController { NSString *searchString = [self.searchController.searchBar text]; [self filterContentForSearchText:searchString]; [self.tableView reloadData]; } #pragma mark - UISearchBarDelegate - (void)searchBar:(UISearchBar *)searchBar selectedScopeButtonIndexDidChange:(NSInteger)selectedScope { [self updateSearchResultsForSearchController:self.searchController]; } - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar { } #pragma mark - Filter - (void)filterContentForSearchText:(NSString *)searchText { OWSContactsSearcher *contactsSearcher = [[OWSContactsSearcher alloc] initWithContacts: self.contacts]; self.searchResults = [contactsSearcher filterWithString:searchText]; NSString *formattedNumber = [PhoneNumber tryParsePhoneNumberFromUserSpecifiedText:searchText].toE164; // text to a non-signal number if we have no results and a valid phone # if (self.searchResults.count == 0 && searchText.length > 8 && formattedNumber) { self.currentSearchTerm = formattedNumber; self.searchPhoneNumber = formattedNumber; // Kick off account lookup if necessary. [self checkIsNonContactPhoneNumberSignalUser:formattedNumber]; } else { _searchPhoneNumber = nil; } } - (void)checkIsNonContactPhoneNumberSignalUser:(NSString *)phoneNumber { if ([self.phoneNumberAccountSet containsObject:phoneNumber]) { return; } __weak MessageComposeTableViewController *weakSelf = self; [[ContactsUpdater sharedUpdater] lookupIdentifier:phoneNumber success:^(SignalRecipient *recipient) { MessageComposeTableViewController *strongSelf = weakSelf; if (!strongSelf) { return; } if (!strongSelf.phoneNumberAccountSet) { strongSelf.phoneNumberAccountSet = [NSMutableSet set]; } if (![strongSelf.phoneNumberAccountSet containsObject:phoneNumber]) { [strongSelf.phoneNumberAccountSet addObject:phoneNumber]; [strongSelf.tableView reloadData]; } } failure:^(NSError *error) { // Ignore. }]; } - (void)setSearchPhoneNumber:(NSString *)searchPhoneNumber { if ([_searchPhoneNumber isEqualToString:searchPhoneNumber]) { return; } _searchPhoneNumber = searchPhoneNumber; [self.tableView reloadData]; } #pragma mark - Send Normal Text to Unknown Contact - (void)sendText { NSString *confirmMessage = NSLocalizedString(@"SEND_SMS_CONFIRM_TITLE", @""); if ([self.currentSearchTerm length] > 0) { confirmMessage = NSLocalizedString(@"SEND_SMS_INVITE_TITLE", @""); confirmMessage = [confirmMessage stringByAppendingString:self.currentSearchTerm]; confirmMessage = [confirmMessage stringByAppendingString:NSLocalizedString(@"QUESTIONMARK_PUNCTUATION", @"")]; } UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRMATION_TITLE", @"") message:confirmMessage preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"TXT_CANCEL_TITLE", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { DDLogDebug(@"Cancel action"); }]; UIAlertAction *okAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self.searchController setActive:NO]; if ([MFMessageComposeViewController canSendText]) { MFMessageComposeViewController *picker = [[MFMessageComposeViewController alloc] init]; picker.messageComposeDelegate = self; picker.recipients = [self.currentSearchTerm length] > 0 ? [NSArray arrayWithObject:self.currentSearchTerm] : nil; picker.body = [NSLocalizedString(@"SMS_INVITE_BODY", @"") stringByAppendingString: @" https://itunes.apple.com/us/app/signal-private-messenger/id874139669?mt=8"]; [self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]]; } else { UIAlertView *notPermitted = [[UIAlertView alloc] initWithTitle:@"" message:NSLocalizedString(@"UNSUPPORTED_FEATURE_ERROR", @"") delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", @"") otherButtonTitles:nil]; [notPermitted show]; } }]; [alertController addAction:cancelAction]; [alertController addAction:okAction]; self.searchController.searchBar.text = @""; //must dismiss search controller before presenting alert. if ([self presentedViewController]) { [self dismissViewControllerAnimated:YES completion:^{ [self presentViewController:alertController animated:YES completion:[UIUtil modalCompletionBlock]]; }]; } else { [self presentViewController:alertController animated:YES completion:[UIUtil modalCompletionBlock]]; } } #pragma mark - SMS Composer Delegate // called on completion of message screen - (void)messageComposeViewController:(MFMessageComposeViewController *)controller didFinishWithResult:(MessageComposeResult)result { switch (result) { case MessageComposeResultCancelled: break; case MessageComposeResultFailed: { UIAlertView *warningAlert = [[UIAlertView alloc] initWithTitle:@"" message:NSLocalizedString(@"SEND_INVITE_FAILURE", @"") delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", @"") otherButtonTitles:nil]; [warningAlert show]; break; } case MessageComposeResultSent: { [self dismissViewControllerAnimated:NO completion:^{ DDLogDebug(@"view controller dismissed"); }]; UIAlertView *successAlert = [[UIAlertView alloc] initWithTitle:@"" message:NSLocalizedString(@"SEND_INVITE_SUCCESS", @"Alert body after invite succeeded") delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", @"") otherButtonTitles:nil]; [successAlert show]; break; } default: break; } [self dismissViewControllerAnimated:YES completion:nil]; } #pragma mark - Table View Data Source - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return MessageComposeTableViewControllerSectionContacts + 1; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { // This logic will determine which one (if any) of the following special controls // should be shown. No more than one should be shown at a time. BOOL showNonContactConversation = NO; BOOL showInviteViaSMS = NO; BOOL showInviteFlow = NO; BOOL hasPhoneNumber = self.searchPhoneNumber.length > 0; BOOL isKnownSignalUser = hasPhoneNumber && [self.phoneNumberAccountSet containsObject:self.searchPhoneNumber]; BOOL isInviteFlowSupported = floor(NSFoundationVersionNumber) >= NSFoundationVersionNumber_iOS_9_0; if (hasPhoneNumber && isKnownSignalUser) { showNonContactConversation = YES; } else if (hasPhoneNumber) { showInviteViaSMS = YES; } else if (isInviteFlowSupported) { showInviteFlow = YES; } if (section == MessageComposeTableViewControllerSectionInviteNonContactConversation) { return showNonContactConversation ? 1 : 0; } else if (section == MessageComposeTableViewControllerSectionInviteViaSMS) { return showInviteViaSMS ? 1 : 0; } else if (section == MessageComposeTableViewControllerSectionInviteFlow) { return showInviteFlow ? 1 : 0; } else { OWSAssert(section == MessageComposeTableViewControllerSectionContacts) if (self.searchController.active) { return (NSInteger)[self.searchResults count]; } else { return (NSInteger)[self.contacts count]; } } } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { if (indexPath.section == MessageComposeTableViewControllerSectionInviteNonContactConversation) { self.conversationForNonContactCell.textLabel.text = [NSString stringWithFormat:NSLocalizedString(@"NEW_CONVERSATION_FOR_NON_CONTACT_FORMAT", @"Text for button to start a new conversation with a non-contact"), self.searchPhoneNumber]; return self.conversationForNonContactCell; } else if (indexPath.section == MessageComposeTableViewControllerSectionInviteViaSMS) { // TODO: We should rework this string to be a format, to account for languages where the // phone number should not appear at the end of the copy. self.inviteViaSMSCell.textLabel.text = [NSLocalizedString(@"SEND_SMS_BUTTON", @"Text for button to send a Signal invite via SMS") stringByAppendingString:self.searchPhoneNumber]; return self.inviteViaSMSCell; } else if (indexPath.section == MessageComposeTableViewControllerSectionInviteFlow) { return self.inviteCell; } else { OWSAssert(indexPath.section == MessageComposeTableViewControllerSectionContacts) ContactTableViewCell *cell = (ContactTableViewCell *)[tableView dequeueReusableCellWithIdentifier:MessageComposeTableViewControllerCellContact]; [cell configureWithContact:[self contactForIndexPath:indexPath] contactsManager:self.contactsManager]; return cell; } } #pragma mark - Table View delegate - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { if (indexPath.section == MessageComposeTableViewControllerSectionInviteNonContactConversation) { OWSAssert(self.searchPhoneNumber.length > 0); if (self.searchPhoneNumber.length > 0) { [self dismissViewControllerAnimated:YES completion:^() { [Environment messageIdentifier:self.searchPhoneNumber withCompose:YES]; }]; } } else if (indexPath.section == MessageComposeTableViewControllerSectionInviteViaSMS) { [self sendText]; } else if (indexPath.section == MessageComposeTableViewControllerSectionInviteFlow) { void (^showInvite)() = ^{ OWSInviteFlow *inviteFlow = [[OWSInviteFlow alloc] initWithPresentingViewController:self contactsManager:self.contactsManager]; [self presentViewController:inviteFlow.actionSheetController animated:YES completion:^{ [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; }]; }; if (self.presentedViewController) { // If search controller is active, dismiss it first. [self dismissViewControllerAnimated:YES completion:showInvite]; } else { showInvite(); } } else { OWSAssert(indexPath.section == MessageComposeTableViewControllerSectionContacts) NSString *identifier = [[[self contactForIndexPath:indexPath] textSecureIdentifiers] firstObject]; [self dismissViewControllerAnimated:YES completion:^() { [Environment messageIdentifier:identifier withCompose:YES]; }]; } } - (Contact *)contactForIndexPath:(NSIndexPath *)indexPath { Contact *contact = nil; if (self.searchController.active) { contact = [self.searchResults objectAtIndex:(NSUInteger)indexPath.row]; } else { contact = [self.contacts objectAtIndex:(NSUInteger)indexPath.row]; } return contact; } #pragma mark Refresh controls - (void)updateAfterRefreshTry { [self.refreshControl endRefreshing]; [self showLoadingBackgroundView:NO]; if ([self.contacts count] == 0) { [self showEmptyBackgroundView:YES]; } else { [self showEmptyBackgroundView:NO]; } } - (void)refreshContacts { [[ContactsUpdater sharedUpdater] updateSignalContactIntersectionWithABContacts:self.contactsManager.allContacts success:^{ self.contacts = self.contactsManager.signalContacts; dispatch_async(dispatch_get_main_queue(), ^{ [self updateSearchResultsForSearchController:self.searchController]; [self.tableView reloadData]; [self updateAfterRefreshTry]; }); } failure:^(NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ UIAlertView *alert = [[UIAlertView alloc] initWithTitle:NSLocalizedString(@"ERROR_WAS_DETECTED_TITLE", @"") message:NSLocalizedString(@"TIMEOUT_CONTACTS_DETAIL", @"") delegate:nil cancelButtonTitle:NSLocalizedString(@"OK", @"") otherButtonTitles:nil]; [alert show]; [self updateAfterRefreshTry]; }); }]; if ([self.contacts count] == 0) { [self showLoadingBackgroundView:YES]; } } #pragma mark - Navigation - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(nullable id)sender { self.searchController.active = NO; } - (IBAction)closeAction:(id)sender { [self dismissViewControllerAnimated:YES completion:nil]; } - (CGFloat)contentWidth { return [UIScreen mainScreen].bounds.size.width - 2 * [self marginSize]; } - (CGFloat)marginSize { return 20; } @end NS_ASSUME_NONNULL_END