mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
7e8b2e3034
There are multiple places in the codebase we present a conversation. We used to have some very conservative machinery around how this was done, for fear of failing to present the call view controller, which would have left a hidden call in the background. We've since addressed that concern more thoroughly via the separate calling UIWindow. As such, the remaining presentation machinery is overly complex and inflexible for what we need. Sometimes we want to animate-push the conversation. (tap on home, tap on "send message" in contact card/group members) Sometimes we want to dismiss a modal, to reveal the conversation behind it (contact picker, group creation) Sometimes we want to present the conversation with no animation (becoming active from a notification) We also want to ensure that we're never pushing more than one conversation view controller, which was previously a problem since we were "pushing" a newly constructed VC in response to these myriad actions. It turned out there were certain code paths that caused multiple actions to be fired in rapid succession which pushed multiple ConversationVC's. The built-in method: `setViewControllers:animated` easily ensures we only have one ConversationVC on the stack, while being composable enough to faciliate the various more efficient animations we desire. The only thing lost with the complex methods is that the naive `presentViewController:` can fail, e.g. if another view is already presented. E.g. if an alert appears *just* before the user taps compose, the contact picker will fail to present. Since we no longer depend on this for presenting the CallViewController, this isn't catostrophic, and in fact, arguable preferable, since we want the user to read and dismiss any alert explicitly. // FREEBIE
1024 lines
41 KiB
Objective-C
1024 lines
41 KiB
Objective-C
//
|
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
#import "NewContactThreadViewController.h"
|
|
#import "ContactTableViewCell.h"
|
|
#import "ContactsViewHelper.h"
|
|
#import "NewGroupViewController.h"
|
|
#import "NewNonContactConversationViewController.h"
|
|
#import "OWSTableViewController.h"
|
|
#import "Signal-Swift.h"
|
|
#import "SignalApp.h"
|
|
#import "UIColor+OWS.h"
|
|
#import "UIView+OWS.h"
|
|
#import <MessageUI/MessageUI.h>
|
|
#import <SignalMessaging/Environment.h>
|
|
#import <SignalMessaging/UIUtil.h>
|
|
#import <SignalServiceKit/AppVersion.h>
|
|
#import <SignalServiceKit/PhoneNumberUtil.h>
|
|
#import <SignalServiceKit/SignalAccount.h>
|
|
#import <SignalServiceKit/TSAccountManager.h>
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
@interface SignalAccount (Collation)
|
|
|
|
- (NSString *)stringForCollation;
|
|
|
|
@end
|
|
|
|
@implementation SignalAccount (Collation)
|
|
|
|
- (NSString *)stringForCollation
|
|
{
|
|
OWSContactsManager *contactsManager = [Environment current].contactsManager;
|
|
return [contactsManager comparableNameForSignalAccount:self];
|
|
}
|
|
|
|
@end
|
|
|
|
@interface NewContactThreadViewController () <UISearchBarDelegate,
|
|
ContactsViewHelperDelegate,
|
|
OWSTableViewControllerDelegate,
|
|
NewNonContactConversationViewControllerDelegate,
|
|
MFMessageComposeViewControllerDelegate>
|
|
|
|
@property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper;
|
|
@property (nonatomic, readonly) ConversationSearcher *conversationSearcher;
|
|
|
|
@property (nonatomic, readonly) UIView *noSignalContactsView;
|
|
|
|
@property (nonatomic, readonly) OWSTableViewController *tableViewController;
|
|
|
|
@property (nonatomic, readonly) UILocalizedIndexedCollation *collation;
|
|
|
|
@property (nonatomic, readonly) UISearchBar *searchBar;
|
|
|
|
// A list of possible phone numbers parsed from the search text as
|
|
// E164 values.
|
|
@property (nonatomic) NSArray<NSString *> *searchPhoneNumbers;
|
|
|
|
// This set is used to cache the set of non-contact phone numbers
|
|
// which are known to correspond to Signal accounts.
|
|
@property (nonatomic, readonly) NSMutableSet *nonContactAccountSet;
|
|
|
|
@property (nonatomic) BOOL isNoContactsModeActive;
|
|
|
|
@end
|
|
|
|
#pragma mark -
|
|
|
|
@implementation NewContactThreadViewController
|
|
|
|
- (void)loadView
|
|
{
|
|
[super loadView];
|
|
|
|
_contactsViewHelper = [[ContactsViewHelper alloc] initWithDelegate:self];
|
|
_conversationSearcher = [ConversationSearcher shared];
|
|
_nonContactAccountSet = [NSMutableSet set];
|
|
_collation = [UILocalizedIndexedCollation currentCollation];
|
|
|
|
self.navigationItem.leftBarButtonItem =
|
|
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop
|
|
target:self
|
|
action:@selector(dismissPressed)];
|
|
// TODO: We should use separate RTL and LTR flavors of this asset.
|
|
UIImage *newGroupImage = [UIImage imageNamed:@"btnGroup--white"];
|
|
OWSAssert(newGroupImage);
|
|
UIBarButtonItem *newGroupButton = [[UIBarButtonItem alloc] initWithImage:newGroupImage
|
|
style:UIBarButtonItemStylePlain
|
|
target:self
|
|
action:@selector(showNewGroupView:)];
|
|
newGroupButton.accessibilityLabel
|
|
= NSLocalizedString(@"CREATE_NEW_GROUP", @"Accessibility label for the create group new group button");
|
|
self.navigationItem.rightBarButtonItem = newGroupButton;
|
|
|
|
// Search
|
|
UISearchBar *searchBar = [OWSSearchBar new];
|
|
_searchBar = searchBar;
|
|
searchBar.delegate = self;
|
|
searchBar.placeholder = NSLocalizedString(@"SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT", @"");
|
|
[searchBar sizeToFit];
|
|
|
|
_tableViewController = [OWSTableViewController new];
|
|
_tableViewController.delegate = self;
|
|
_tableViewController.tableViewStyle = UITableViewStylePlain;
|
|
|
|
// To automatically adjust our content inset appropriately on iOS9/10
|
|
// 1. the tableViewController must be a childView
|
|
// 2. the scrollable view (tableView in this case) must be at index 0.
|
|
[self addChildViewController:self.tableViewController];
|
|
[self.view insertSubview:self.tableViewController.view atIndex:0];
|
|
|
|
[_tableViewController.view autoPinWidthToSuperview];
|
|
[_tableViewController.view autoPinEdgeToSuperviewEdge:ALEdgeTop];
|
|
|
|
self.tableViewController.tableView.rowHeight = UITableViewAutomaticDimension;
|
|
self.tableViewController.tableView.estimatedRowHeight = 60;
|
|
|
|
[self autoPinViewToBottomOfViewControllerOrKeyboard:self.tableViewController.view avoidNotch:NO];
|
|
_tableViewController.tableView.tableHeaderView = searchBar;
|
|
|
|
_noSignalContactsView = [self createNoSignalContactsView];
|
|
self.noSignalContactsView.hidden = YES;
|
|
[self.view addSubview:self.noSignalContactsView];
|
|
[self.noSignalContactsView autoPinWidthToSuperview];
|
|
[self.noSignalContactsView autoPinEdgeToSuperviewEdge:ALEdgeTop];
|
|
[self.noSignalContactsView autoPinToBottomLayoutGuideOfViewController:self withInset:0];
|
|
|
|
UIRefreshControl *pullToRefreshView = [UIRefreshControl new];
|
|
pullToRefreshView.tintColor = [UIColor grayColor];
|
|
[pullToRefreshView addTarget:self
|
|
action:@selector(pullToRefreshPerformed:)
|
|
forControlEvents:UIControlEventValueChanged];
|
|
[self.tableViewController.tableView insertSubview:pullToRefreshView atIndex:0];
|
|
|
|
[self updateTableContents];
|
|
|
|
[self applyTheme];
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(themeDidChange:)
|
|
name:ThemeDidChangeNotification
|
|
object:nil];
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
}
|
|
|
|
- (void)pullToRefreshPerformed:(UIRefreshControl *)refreshControl
|
|
{
|
|
OWSAssertIsOnMainThread();
|
|
|
|
[self.contactsViewHelper.contactsManager
|
|
userRequestedSystemContactsRefreshWithCompletion:^(NSError *_Nullable error) {
|
|
if (error) {
|
|
DDLogError(@"%@ refreshing contacts failed with error: %@", self.logTag, error);
|
|
}
|
|
[refreshControl endRefreshing];
|
|
}];
|
|
}
|
|
|
|
- (void)showSearchBar:(BOOL)isVisible
|
|
{
|
|
if (isVisible) {
|
|
self.tableViewController.tableView.tableHeaderView = self.searchBar;
|
|
} else {
|
|
self.tableViewController.tableView.tableHeaderView = nil;
|
|
}
|
|
}
|
|
|
|
- (UIView *)createNoSignalContactsView
|
|
{
|
|
UIView *view = [UIView new];
|
|
view.backgroundColor = [Theme backgroundColor];
|
|
|
|
UIView *contents = [UIView new];
|
|
[view addSubview:contents];
|
|
[contents autoCenterInSuperview];
|
|
|
|
UIImage *heroImage = [UIImage imageNamed:@"uiEmptyContact"];
|
|
OWSAssert(heroImage);
|
|
UIImageView *heroImageView = [[UIImageView alloc] initWithImage:heroImage];
|
|
heroImageView.layer.minificationFilter = kCAFilterTrilinear;
|
|
heroImageView.layer.magnificationFilter = kCAFilterTrilinear;
|
|
[contents addSubview:heroImageView];
|
|
[heroImageView autoHCenterInSuperview];
|
|
[heroImageView autoPinEdgeToSuperviewEdge:ALEdgeTop];
|
|
const CGFloat kHeroSize = ScaleFromIPhone5To7Plus(100, 150);
|
|
[heroImageView autoSetDimension:ALDimensionWidth toSize:kHeroSize];
|
|
[heroImageView autoSetDimension:ALDimensionHeight toSize:kHeroSize];
|
|
UIView *lastSubview = heroImageView;
|
|
|
|
UILabel *titleLabel = [UILabel new];
|
|
titleLabel.text = NSLocalizedString(
|
|
@"EMPTY_CONTACTS_LABEL_LINE1", "Full width label displayed when attempting to compose message");
|
|
titleLabel.textColor = [Theme primaryColor];
|
|
titleLabel.font = [UIFont ows_mediumFontWithSize:ScaleFromIPhone5To7Plus(17.f, 20.f)];
|
|
titleLabel.textAlignment = NSTextAlignmentCenter;
|
|
titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
|
titleLabel.numberOfLines = 0;
|
|
[contents addSubview:titleLabel];
|
|
[titleLabel autoPinWidthToSuperview];
|
|
[titleLabel autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview withOffset:30];
|
|
lastSubview = titleLabel;
|
|
|
|
UILabel *subtitleLabel = [UILabel new];
|
|
subtitleLabel.text = NSLocalizedString(
|
|
@"EMPTY_CONTACTS_LABEL_LINE2", "Full width label displayed when attempting to compose message");
|
|
subtitleLabel.textColor = [Theme secondaryColor];
|
|
subtitleLabel.font = [UIFont ows_regularFontWithSize:ScaleFromIPhone5To7Plus(12.f, 14.f)];
|
|
subtitleLabel.textAlignment = NSTextAlignmentCenter;
|
|
subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
|
subtitleLabel.numberOfLines = 0;
|
|
[contents addSubview:subtitleLabel];
|
|
[subtitleLabel autoPinWidthToSuperview];
|
|
[subtitleLabel autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview withOffset:15];
|
|
lastSubview = subtitleLabel;
|
|
|
|
UIButton *inviteContactsButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
|
[inviteContactsButton setTitle:NSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
|
|
"Label for the cell that presents the 'invite contacts' workflow.")
|
|
forState:UIControlStateNormal];
|
|
[inviteContactsButton setTitleColor:[UIColor ows_materialBlueColor] forState:UIControlStateNormal];
|
|
[inviteContactsButton.titleLabel setFont:[UIFont ows_regularFontWithSize:17.f]];
|
|
[contents addSubview:inviteContactsButton];
|
|
[inviteContactsButton autoHCenterInSuperview];
|
|
[inviteContactsButton autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview withOffset:50];
|
|
[inviteContactsButton addTarget:self
|
|
action:@selector(presentInviteFlow)
|
|
forControlEvents:UIControlEventTouchUpInside];
|
|
lastSubview = inviteContactsButton;
|
|
|
|
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]];
|
|
[contents addSubview:searchByPhoneNumberButton];
|
|
[searchByPhoneNumberButton autoHCenterInSuperview];
|
|
[searchByPhoneNumberButton autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview withOffset:20];
|
|
[searchByPhoneNumberButton addTarget:self
|
|
action:@selector(hideBackgroundView)
|
|
forControlEvents:UIControlEventTouchUpInside];
|
|
lastSubview = searchByPhoneNumberButton;
|
|
|
|
[lastSubview autoPinEdgeToSuperviewMargin:ALEdgeBottom];
|
|
|
|
return view;
|
|
}
|
|
|
|
- (void)viewDidLoad
|
|
{
|
|
[super viewDidLoad];
|
|
|
|
self.title = NSLocalizedString(@"MESSAGE_COMPOSEVIEW_TITLE", @"");
|
|
}
|
|
|
|
- (void)viewWillAppear:(BOOL)animated
|
|
{
|
|
[super viewWillAppear:animated];
|
|
|
|
// Make sure we have requested contact access at this point if, e.g.
|
|
// the user has no messages in their inbox and they choose to compose
|
|
// a message.
|
|
[self.contactsViewHelper.contactsManager requestSystemContactsOnce];
|
|
|
|
[self showContactAppropriateViews];
|
|
}
|
|
|
|
- (void)viewDidAppear:(BOOL)animated
|
|
{
|
|
[super viewDidAppear:animated];
|
|
|
|
[OWSAlerts showIOSUpgradeNagIfNecessary];
|
|
}
|
|
|
|
#pragma mark - Table Contents
|
|
|
|
- (void)updateTableContents
|
|
{
|
|
OWSTableContents *contents = [OWSTableContents new];
|
|
|
|
if (self.isNoContactsModeActive) {
|
|
self.tableViewController.contents = contents;
|
|
return;
|
|
}
|
|
|
|
__weak NewContactThreadViewController *weakSelf = self;
|
|
|
|
// App is killed and restarted when the user changes their contact permissions, so need need to "observe" anything
|
|
// to re-render this.
|
|
if (self.contactsViewHelper.contactsManager.isSystemContactsDenied) {
|
|
OWSTableItem *contactReminderItem = [OWSTableItem
|
|
itemWithCustomCellBlock:^{
|
|
UITableViewCell *newCell = [OWSTableItem newCell];
|
|
|
|
ReminderView *reminderView = [ReminderView
|
|
nagWithText:NSLocalizedString(@"COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION",
|
|
@"Multi-line label explaining why compose-screen contact picker is empty.")
|
|
tapAction:^{
|
|
[[UIApplication sharedApplication] openSystemSettings];
|
|
}];
|
|
[newCell.contentView addSubview:reminderView];
|
|
[reminderView autoPinEdgesToSuperviewEdges];
|
|
|
|
return newCell;
|
|
}
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:nil];
|
|
|
|
OWSTableSection *reminderSection = [OWSTableSection new];
|
|
[reminderSection addItem:contactReminderItem];
|
|
[contents addSection:reminderSection];
|
|
}
|
|
|
|
OWSTableSection *staticSection = [OWSTableSection new];
|
|
|
|
// Find Non-Contacts by Phone Number
|
|
[staticSection
|
|
addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"NEW_CONVERSATION_FIND_BY_PHONE_NUMBER",
|
|
@"A label the cell that lets you add a new member to a group.")
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
NewNonContactConversationViewController *viewController =
|
|
[NewNonContactConversationViewController new];
|
|
viewController.nonContactConversationDelegate = weakSelf;
|
|
[weakSelf.navigationController pushViewController:viewController
|
|
animated:YES];
|
|
}]];
|
|
|
|
if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized) {
|
|
// Invite Contacts
|
|
[staticSection
|
|
addItem:[OWSTableItem
|
|
disclosureItemWithText:NSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
|
|
@"Label for the cell that presents the 'invite contacts' workflow.")
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf presentInviteFlow];
|
|
}]];
|
|
}
|
|
[contents addSection:staticSection];
|
|
|
|
BOOL hasSearchText = [self.searchBar text].length > 0;
|
|
|
|
if (hasSearchText) {
|
|
for (OWSTableSection *section in [self contactsSectionsForSearch]) {
|
|
[contents addSection:section];
|
|
}
|
|
} else {
|
|
// Count the none collated sections, before we add our collated sections.
|
|
// Later we'll need to offset which sections our collation indexes reference
|
|
// by this amount. e.g. otherwise the "B" index will reference names starting with "A"
|
|
// And the "A" index will reference the static non-collated section(s).
|
|
NSInteger noncollatedSections = (NSInteger)contents.sections.count;
|
|
for (OWSTableSection *section in [self collatedContactsSections]) {
|
|
[contents addSection:section];
|
|
}
|
|
contents.sectionForSectionIndexTitleBlock = ^NSInteger(NSString *_Nonnull title, NSInteger index) {
|
|
typeof(self) strongSelf = weakSelf;
|
|
if (!strongSelf) {
|
|
return 0;
|
|
}
|
|
|
|
// Offset the collation section to account for the noncollated sections.
|
|
NSInteger sectionIndex =
|
|
[strongSelf.collation sectionForSectionIndexTitleAtIndex:index] + noncollatedSections;
|
|
if (sectionIndex < 0) {
|
|
// Sentinal in case we change our section ordering in a surprising way.
|
|
OWSCFail(@"Unexpected negative section index");
|
|
return 0;
|
|
}
|
|
if (sectionIndex >= (NSInteger)contents.sections.count) {
|
|
// Sentinal in case we change our section ordering in a surprising way.
|
|
OWSCFail(@"Unexpectedly large index");
|
|
return 0;
|
|
}
|
|
|
|
return sectionIndex;
|
|
};
|
|
contents.sectionIndexTitlesForTableViewBlock = ^NSArray<NSString *> *_Nonnull
|
|
{
|
|
typeof(self) strongSelf = weakSelf;
|
|
if (!strongSelf) {
|
|
return @[];
|
|
}
|
|
|
|
return strongSelf.collation.sectionTitles;
|
|
};
|
|
}
|
|
|
|
self.tableViewController.contents = contents;
|
|
}
|
|
|
|
- (NSArray<OWSTableSection *> *)collatedContactsSections
|
|
{
|
|
if (self.contactsViewHelper.signalAccounts.count < 1) {
|
|
// No Contacts
|
|
OWSTableSection *contactsSection = [OWSTableSection new];
|
|
|
|
if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized) {
|
|
if (self.contactsViewHelper.hasUpdatedContactsAtLeastOnce) {
|
|
|
|
[contactsSection
|
|
addItem:[OWSTableItem softCenterLabelItemWithText:
|
|
NSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_CONTACTS",
|
|
@"A label that indicates the user has no Signal contacts.")
|
|
customRowHeight:UITableViewAutomaticDimension]];
|
|
} else {
|
|
UITableViewCell *loadingCell = [OWSTableItem newCell];
|
|
OWSAssert(loadingCell.contentView);
|
|
|
|
UIActivityIndicatorView *activityIndicatorView =
|
|
[[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
|
|
[loadingCell.contentView addSubview:activityIndicatorView];
|
|
[activityIndicatorView startAnimating];
|
|
|
|
[activityIndicatorView autoCenterInSuperview];
|
|
[activityIndicatorView setCompressionResistanceHigh];
|
|
[activityIndicatorView setContentHuggingHigh];
|
|
|
|
// hide separator for loading cell. The loading cell doesn't really feel like a cell
|
|
loadingCell.backgroundView = [UIView new];
|
|
|
|
OWSTableItem *loadingItem =
|
|
[OWSTableItem itemWithCustomCell:loadingCell customRowHeight:40 actionBlock:nil];
|
|
[contactsSection addItem:loadingItem];
|
|
}
|
|
}
|
|
|
|
return @[ contactsSection ];
|
|
}
|
|
__weak NewContactThreadViewController *weakSelf = self;
|
|
|
|
NSMutableArray<OWSTableSection *> *contactSections = [NSMutableArray new];
|
|
|
|
NSMutableArray<NSMutableArray<SignalAccount *> *> *collatedSignalAccounts = [NSMutableArray new];
|
|
for (NSUInteger i = 0; i < self.collation.sectionTitles.count; i++) {
|
|
collatedSignalAccounts[i] = [NSMutableArray new];
|
|
}
|
|
for (SignalAccount *signalAccount in self.contactsViewHelper.signalAccounts) {
|
|
NSInteger section =
|
|
[self.collation sectionForObject:signalAccount collationStringSelector:@selector(stringForCollation)];
|
|
|
|
if (section < 0) {
|
|
OWSFail(@"Unexpected collation for name:%@", signalAccount.stringForCollation);
|
|
continue;
|
|
}
|
|
NSUInteger sectionIndex = (NSUInteger)section;
|
|
|
|
[collatedSignalAccounts[sectionIndex] addObject:signalAccount];
|
|
}
|
|
|
|
for (NSUInteger i = 0; i < collatedSignalAccounts.count; i++) {
|
|
NSArray<SignalAccount *> *signalAccounts = collatedSignalAccounts[i];
|
|
NSMutableArray <OWSTableItem *> *contactItems = [NSMutableArray new];
|
|
for (SignalAccount *signalAccount in signalAccounts) {
|
|
[contactItems addObject:[OWSTableItem
|
|
itemWithCustomCellBlock:^{
|
|
ContactTableViewCell *cell = [ContactTableViewCell new];
|
|
BOOL isBlocked = [self.contactsViewHelper
|
|
isRecipientIdBlocked:signalAccount.recipientId];
|
|
if (isBlocked) {
|
|
cell.accessoryMessage = NSLocalizedString(@"CONTACT_CELL_IS_BLOCKED",
|
|
@"An indicator that a contact has been blocked.");
|
|
}
|
|
|
|
[cell configureWithRecipientId:signalAccount.recipientId
|
|
contactsManager:self.contactsViewHelper.contactsManager];
|
|
|
|
return cell;
|
|
}
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf newConversationWithRecipientId:signalAccount.recipientId];
|
|
}]];
|
|
}
|
|
|
|
// Don't show empty sections.
|
|
// To accomplish this we add a section with a blank title rather than omitting the section altogether,
|
|
// in order for section indexes to match up correctly
|
|
NSString *sectionTitle = contactItems.count > 0 ? self.collation.sectionTitles[i] : nil;
|
|
[contactSections addObject:[OWSTableSection sectionWithTitle:sectionTitle items:contactItems]];
|
|
}
|
|
|
|
return [contactSections copy];
|
|
}
|
|
|
|
- (NSArray<OWSTableSection *> *)contactsSectionsForSearch
|
|
{
|
|
__weak NewContactThreadViewController *weakSelf = self;
|
|
|
|
NSMutableArray<OWSTableSection *> *sections = [NSMutableArray new];
|
|
|
|
ContactsViewHelper *helper = self.contactsViewHelper;
|
|
|
|
OWSTableSection *phoneNumbersSection = [OWSTableSection new];
|
|
// FIXME we should make sure "invite via SMS" cells appear *below* any matching signal-account cells.
|
|
//
|
|
// If the search string looks like a phone number, show either "new conversation..." cells and/or
|
|
// "invite via SMS..." cells.
|
|
NSArray<NSString *> *searchPhoneNumbers = [self parsePossibleSearchPhoneNumbers];
|
|
for (NSString *phoneNumber in searchPhoneNumbers) {
|
|
OWSAssert(phoneNumber.length > 0);
|
|
|
|
if ([self.nonContactAccountSet containsObject:phoneNumber]) {
|
|
[phoneNumbersSection
|
|
addItem:[OWSTableItem
|
|
itemWithCustomCellBlock:^{
|
|
ContactTableViewCell *cell = [ContactTableViewCell new];
|
|
BOOL isBlocked = [helper isRecipientIdBlocked:phoneNumber];
|
|
if (isBlocked) {
|
|
cell.accessoryMessage = NSLocalizedString(
|
|
@"CONTACT_CELL_IS_BLOCKED", @"An indicator that a contact has been blocked.");
|
|
}
|
|
[cell configureWithRecipientId:phoneNumber contactsManager:helper.contactsManager];
|
|
return cell;
|
|
}
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf newConversationWithRecipientId:phoneNumber];
|
|
}]];
|
|
} else {
|
|
NSString *text = [NSString stringWithFormat:NSLocalizedString(@"SEND_INVITE_VIA_SMS_BUTTON_FORMAT",
|
|
@"Text for button to send a Signal invite via SMS. %@ is "
|
|
@"placeholder for the recipient's phone number."),
|
|
phoneNumber];
|
|
[phoneNumbersSection addItem:[OWSTableItem disclosureItemWithText:text
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf sendTextToPhoneNumber:phoneNumber];
|
|
}]];
|
|
}
|
|
}
|
|
if (searchPhoneNumbers.count > 0) {
|
|
[sections addObject:phoneNumbersSection];
|
|
}
|
|
|
|
// Contacts, filtered with the search text.
|
|
NSArray<SignalAccount *> *filteredSignalAccounts = [self filteredSignalAccounts];
|
|
BOOL hasSearchResults = NO;
|
|
|
|
OWSTableSection *contactsSection = [OWSTableSection new];
|
|
contactsSection.headerTitle = NSLocalizedString(@"COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
|
|
@"Table section header for contact listing when composing a new message");
|
|
for (SignalAccount *signalAccount in filteredSignalAccounts) {
|
|
hasSearchResults = YES;
|
|
|
|
if ([searchPhoneNumbers containsObject:signalAccount.recipientId]) {
|
|
// Don't show a contact if they already appear in the "search phone numbers"
|
|
// results.
|
|
continue;
|
|
}
|
|
[contactsSection
|
|
addItem:[OWSTableItem
|
|
itemWithCustomCellBlock:^{
|
|
ContactTableViewCell *cell = [ContactTableViewCell new];
|
|
BOOL isBlocked = [helper isRecipientIdBlocked:signalAccount.recipientId];
|
|
if (isBlocked) {
|
|
cell.accessoryMessage = NSLocalizedString(
|
|
@"CONTACT_CELL_IS_BLOCKED", @"An indicator that a contact has been blocked.");
|
|
}
|
|
|
|
[cell configureWithRecipientId:signalAccount.recipientId
|
|
contactsManager:helper.contactsManager];
|
|
|
|
return cell;
|
|
}
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf newConversationWithRecipientId:signalAccount.recipientId];
|
|
}]];
|
|
}
|
|
if (filteredSignalAccounts.count > 0) {
|
|
[sections addObject:contactsSection];
|
|
}
|
|
|
|
// When searching, we include matching groups
|
|
OWSTableSection *groupSection = [OWSTableSection new];
|
|
groupSection.headerTitle = NSLocalizedString(
|
|
@"COMPOSE_MESSAGE_GROUP_SECTION_TITLE", @"Table section header for group listing when composing a new message");
|
|
NSArray<TSGroupThread *> *filteredGroupThreads = [self filteredGroupThreads];
|
|
for (TSGroupThread *thread in filteredGroupThreads) {
|
|
hasSearchResults = YES;
|
|
|
|
[groupSection addItem:[OWSTableItem
|
|
itemWithCustomCellBlock:^{
|
|
GroupTableViewCell *cell = [GroupTableViewCell new];
|
|
[cell configureWithThread:thread contactsManager:helper.contactsManager];
|
|
return cell;
|
|
}
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf newConversationWithThread:thread];
|
|
}]];
|
|
}
|
|
if (filteredGroupThreads.count > 0) {
|
|
[sections addObject:groupSection];
|
|
}
|
|
|
|
// Invitation offers for non-signal contacts
|
|
OWSTableSection *inviteeSection = [OWSTableSection new];
|
|
inviteeSection.headerTitle = NSLocalizedString(@"COMPOSE_MESSAGE_INVITE_SECTION_TITLE",
|
|
@"Table section header for invite listing when composing a new message");
|
|
NSArray<Contact *> *invitees = [helper nonSignalContactsMatchingSearchString:[self.searchBar text]];
|
|
for (Contact *contact in invitees) {
|
|
hasSearchResults = YES;
|
|
|
|
OWSAssert(contact.parsedPhoneNumbers.count > 0);
|
|
// TODO: Should we invite all of their phone numbers?
|
|
PhoneNumber *phoneNumber = contact.parsedPhoneNumbers[0];
|
|
NSString *displayName = contact.fullName;
|
|
if (displayName.length < 1) {
|
|
displayName = phoneNumber.toE164;
|
|
}
|
|
|
|
NSString *text = [NSString stringWithFormat:NSLocalizedString(@"SEND_INVITE_VIA_SMS_BUTTON_FORMAT",
|
|
@"Text for button to send a Signal invite via SMS. %@ is "
|
|
@"placeholder for the recipient's phone number."),
|
|
displayName];
|
|
[inviteeSection addItem:[OWSTableItem disclosureItemWithText:text
|
|
customRowHeight:UITableViewAutomaticDimension
|
|
actionBlock:^{
|
|
[weakSelf sendTextToPhoneNumber:phoneNumber.toE164];
|
|
}]];
|
|
}
|
|
if (invitees.count > 0) {
|
|
[sections addObject:inviteeSection];
|
|
}
|
|
|
|
|
|
if (!hasSearchResults) {
|
|
// No Search Results
|
|
OWSTableSection *noResultsSection = [OWSTableSection new];
|
|
[noResultsSection
|
|
addItem:[OWSTableItem softCenterLabelItemWithText:
|
|
NSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_SEARCH_RESULTS",
|
|
@"A label that indicates the user's search has no matching results.")
|
|
customRowHeight:UITableViewAutomaticDimension]];
|
|
|
|
[sections addObject:noResultsSection];
|
|
}
|
|
|
|
return [sections copy];
|
|
}
|
|
|
|
- (NSArray<SignalAccount *> *)filteredSignalAccounts
|
|
{
|
|
NSString *searchString = self.searchBar.text;
|
|
|
|
ContactsViewHelper *helper = self.contactsViewHelper;
|
|
return [helper signalAccountsMatchingSearchString:searchString];
|
|
}
|
|
|
|
- (NSArray<TSGroupThread *> *)filteredGroupThreads
|
|
{
|
|
NSMutableArray<TSGroupThread *> *groupThreads = [NSMutableArray new];
|
|
[TSGroupThread enumerateCollectionObjectsUsingBlock:^(id obj, BOOL *stop) {
|
|
if (![obj isKindOfClass:[TSGroupThread class]]) {
|
|
// group and contact threads are in the same collection.
|
|
return;
|
|
}
|
|
TSGroupThread *groupThread = (TSGroupThread *)obj;
|
|
[groupThreads addObject:groupThread];
|
|
}];
|
|
|
|
return [self.conversationSearcher filterGroupThreads:groupThreads withSearchText:self.searchBar.text];
|
|
}
|
|
|
|
#pragma mark - No Contacts Mode
|
|
|
|
- (void)hideBackgroundView
|
|
{
|
|
[[Environment preferences] setHasDeclinedNoContactsView:YES];
|
|
|
|
[self showContactAppropriateViews];
|
|
}
|
|
|
|
- (void)presentInviteFlow
|
|
{
|
|
OWSInviteFlow *inviteFlow =
|
|
[[OWSInviteFlow alloc] initWithPresentingViewController:self
|
|
contactsManager:self.contactsViewHelper.contactsManager];
|
|
[self presentViewController:inviteFlow.actionSheetController animated:YES completion:nil];
|
|
}
|
|
|
|
- (void)showContactAppropriateViews
|
|
{
|
|
if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized) {
|
|
if (self.contactsViewHelper.hasUpdatedContactsAtLeastOnce && self.contactsViewHelper.signalAccounts.count < 1
|
|
&& ![[Environment preferences] hasDeclinedNoContactsView]) {
|
|
self.isNoContactsModeActive = YES;
|
|
} else {
|
|
self.isNoContactsModeActive = NO;
|
|
}
|
|
|
|
[self showSearchBar:YES];
|
|
} else {
|
|
// don't show "no signal contacts", show "no contact access"
|
|
self.isNoContactsModeActive = NO;
|
|
[self showSearchBar:NO];
|
|
}
|
|
}
|
|
|
|
- (void)setIsNoContactsModeActive:(BOOL)isNoContactsModeActive
|
|
{
|
|
if (isNoContactsModeActive == _isNoContactsModeActive) {
|
|
return;
|
|
}
|
|
|
|
_isNoContactsModeActive = isNoContactsModeActive;
|
|
|
|
if (isNoContactsModeActive) {
|
|
self.tableViewController.tableView.hidden = YES;
|
|
self.searchBar.hidden = YES;
|
|
self.noSignalContactsView.hidden = NO;
|
|
} else {
|
|
self.tableViewController.tableView.hidden = NO;
|
|
self.searchBar.hidden = NO;
|
|
self.noSignalContactsView.hidden = YES;
|
|
}
|
|
|
|
[self updateTableContents];
|
|
}
|
|
|
|
#pragma mark - Send Invite By SMS
|
|
|
|
- (void)sendTextToPhoneNumber:(NSString *)phoneNumber
|
|
{
|
|
|
|
OWSInviteFlow *inviteFlow =
|
|
[[OWSInviteFlow alloc] initWithPresentingViewController:self
|
|
contactsManager:self.contactsViewHelper.contactsManager];
|
|
|
|
OWSAssert([phoneNumber length] > 0);
|
|
NSString *confirmMessage = NSLocalizedString(@"SEND_SMS_CONFIRM_TITLE", @"");
|
|
if ([phoneNumber length] > 0) {
|
|
confirmMessage = [[NSLocalizedString(@"SEND_SMS_INVITE_TITLE", @"") stringByAppendingString:phoneNumber]
|
|
stringByAppendingString:NSLocalizedString(@"QUESTIONMARK_PUNCTUATION", @"")];
|
|
}
|
|
|
|
UIAlertController *alertController =
|
|
[UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRMATION_TITLE", @"")
|
|
message:confirmMessage
|
|
preferredStyle:UIAlertControllerStyleAlert];
|
|
|
|
UIAlertAction *okAction = [UIAlertAction
|
|
actionWithTitle:NSLocalizedString(@"OK", @"")
|
|
style:UIAlertActionStyleDefault
|
|
handler:^(UIAlertAction *action) {
|
|
[self.searchBar resignFirstResponder];
|
|
if ([MFMessageComposeViewController canSendText]) {
|
|
[inviteFlow sendSMSToPhoneNumbers:@[ phoneNumber ]];
|
|
} else {
|
|
[OWSAlerts showErrorAlertWithMessage:NSLocalizedString(@"UNSUPPORTED_FEATURE_ERROR", @"")];
|
|
}
|
|
}];
|
|
|
|
[alertController addAction:[OWSAlerts cancelAction]];
|
|
[alertController addAction:okAction];
|
|
self.searchBar.text = @"";
|
|
[self searchTextDidChange];
|
|
|
|
// must dismiss search controller before presenting alert.
|
|
if ([self presentedViewController]) {
|
|
[self dismissViewControllerAnimated:YES
|
|
completion:^{
|
|
[self presentViewController:alertController animated:YES completion:nil];
|
|
}];
|
|
} else {
|
|
[self presentViewController:alertController animated:YES completion:nil];
|
|
}
|
|
}
|
|
|
|
#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: {
|
|
[OWSAlerts showErrorAlertWithMessage:NSLocalizedString(@"SEND_INVITE_FAILURE", @"")];
|
|
break;
|
|
}
|
|
case MessageComposeResultSent: {
|
|
[self dismissViewControllerAnimated:NO
|
|
completion:^{
|
|
DDLogDebug(@"view controller dismissed");
|
|
}];
|
|
[OWSAlerts
|
|
showAlertWithTitle:NSLocalizedString(@"SEND_INVITE_SUCCESS", @"Alert body after invite succeeded")];
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
|
|
[self dismissViewControllerAnimated:YES completion:nil];
|
|
}
|
|
|
|
#pragma mark - Methods
|
|
|
|
- (void)dismissPressed
|
|
{
|
|
[self dismissViewControllerAnimated:YES completion:nil];
|
|
}
|
|
|
|
- (void)newConversationWithRecipientId:(NSString *)recipientId
|
|
{
|
|
OWSAssert(recipientId.length > 0);
|
|
TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactId:recipientId];
|
|
[self newConversationWithThread:thread];
|
|
}
|
|
|
|
- (void)newConversationWithThread:(TSThread *)thread
|
|
{
|
|
OWSAssert(thread != nil);
|
|
[SignalApp.sharedApp presentConversationForThread:thread action:ConversationViewActionCompose animated:NO];
|
|
[self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
|
|
}
|
|
|
|
- (void)showNewGroupView:(id)sender
|
|
{
|
|
NewGroupViewController *newGroupViewController = [NewGroupViewController new];
|
|
[self.navigationController pushViewController:newGroupViewController animated:YES];
|
|
}
|
|
|
|
#pragma mark - OWSTableViewControllerDelegate
|
|
|
|
- (void)tableViewWillBeginDragging
|
|
{
|
|
[self.searchBar resignFirstResponder];
|
|
}
|
|
|
|
#pragma mark - ContactsViewHelperDelegate
|
|
|
|
- (void)contactsViewHelperDidUpdateContacts
|
|
{
|
|
[self updateTableContents];
|
|
|
|
[self showContactAppropriateViews];
|
|
}
|
|
|
|
- (BOOL)shouldHideLocalNumber
|
|
{
|
|
return NO;
|
|
}
|
|
|
|
#pragma mark - NewNonContactConversationViewControllerDelegate
|
|
|
|
- (void)recipientIdWasSelected:(NSString *)recipientId
|
|
{
|
|
OWSAssert(recipientId.length > 0);
|
|
|
|
[self newConversationWithRecipientId:recipientId];
|
|
}
|
|
|
|
#pragma mark - UISearchBarDelegate
|
|
|
|
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
|
|
{
|
|
[self searchTextDidChange];
|
|
}
|
|
|
|
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar
|
|
{
|
|
[self searchTextDidChange];
|
|
}
|
|
|
|
- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar
|
|
{
|
|
[self searchTextDidChange];
|
|
}
|
|
|
|
- (void)searchBarResultsListButtonClicked:(UISearchBar *)searchBar
|
|
{
|
|
[self searchTextDidChange];
|
|
}
|
|
|
|
- (void)searchBar:(UISearchBar *)searchBar selectedScopeButtonIndexDidChange:(NSInteger)selectedScope
|
|
{
|
|
[self searchTextDidChange];
|
|
}
|
|
|
|
- (void)searchTextDidChange
|
|
{
|
|
[self updateSearchPhoneNumbers];
|
|
|
|
[self updateTableContents];
|
|
}
|
|
|
|
- (NSDictionary<NSString *, NSString *> *)callingCodesToCountryCodeMap
|
|
{
|
|
static NSDictionary<NSString *, NSString *> *result = nil;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
NSMutableDictionary *map = [NSMutableDictionary new];
|
|
for (NSString *countryCode in [PhoneNumberUtil countryCodesForSearchTerm:nil]) {
|
|
OWSAssert(countryCode.length > 0);
|
|
NSString *callingCode = [PhoneNumberUtil callingCodeFromCountryCode:countryCode];
|
|
OWSAssert(callingCode.length > 0);
|
|
OWSAssert([callingCode hasPrefix:@"+"]);
|
|
OWSAssert(![callingCode isEqualToString:@"+0"]);
|
|
|
|
map[callingCode] = countryCode;
|
|
}
|
|
result = [map copy];
|
|
});
|
|
return result;
|
|
}
|
|
|
|
- (nullable NSString *)callingCodeForPossiblePhoneNumber:(NSString *)phoneNumber
|
|
{
|
|
OWSAssert([phoneNumber hasPrefix:@"+"]);
|
|
|
|
for (NSString *callingCode in [self callingCodesToCountryCodeMap].allKeys) {
|
|
if ([phoneNumber hasPrefix:callingCode]) {
|
|
return callingCode;
|
|
}
|
|
}
|
|
return nil;
|
|
}
|
|
|
|
- (NSArray<NSString *> *)parsePossibleSearchPhoneNumbers
|
|
{
|
|
NSString *searchText = self.searchBar.text;
|
|
|
|
if (searchText.length < 8) {
|
|
return @[];
|
|
}
|
|
|
|
NSMutableSet<NSString *> *parsedPhoneNumbers = [NSMutableSet new];
|
|
for (PhoneNumber *phoneNumber in
|
|
[PhoneNumber tryParsePhoneNumbersFromsUserSpecifiedText:searchText
|
|
clientPhoneNumber:[TSAccountManager localNumber]]) {
|
|
|
|
NSString *phoneNumberString = phoneNumber.toE164;
|
|
|
|
// Ignore phone numbers with an unrecognized calling code.
|
|
NSString *_Nullable callingCode = [self callingCodeForPossiblePhoneNumber:phoneNumberString];
|
|
if (!callingCode) {
|
|
continue;
|
|
}
|
|
|
|
// Ignore phone numbers which are too long.
|
|
NSString *phoneNumberWithoutCallingCode = [phoneNumberString substringFromIndex:callingCode.length];
|
|
if (phoneNumberWithoutCallingCode.length < 1 || phoneNumberWithoutCallingCode.length > 15) {
|
|
continue;
|
|
}
|
|
[parsedPhoneNumbers addObject:phoneNumberString];
|
|
}
|
|
|
|
return [parsedPhoneNumbers.allObjects sortedArrayUsingSelector:@selector(compare:)];
|
|
}
|
|
|
|
- (void)updateSearchPhoneNumbers
|
|
{
|
|
[self checkForAccountsForPhoneNumbers:[self parsePossibleSearchPhoneNumbers]];
|
|
}
|
|
|
|
- (void)checkForAccountsForPhoneNumbers:(NSArray<NSString *> *)phoneNumbers
|
|
{
|
|
NSMutableArray<NSString *> *unknownPhoneNumbers = [NSMutableArray new];
|
|
for (NSString *phoneNumber in phoneNumbers) {
|
|
if (![self.nonContactAccountSet containsObject:phoneNumber]) {
|
|
[unknownPhoneNumbers addObject:phoneNumber];
|
|
}
|
|
}
|
|
if ([unknownPhoneNumbers count] < 1) {
|
|
return;
|
|
}
|
|
|
|
__weak NewContactThreadViewController *weakSelf = self;
|
|
[[ContactsUpdater sharedUpdater] lookupIdentifiers:unknownPhoneNumbers
|
|
success:^(NSArray<SignalRecipient *> *recipients) {
|
|
[weakSelf updateNonContactAccountSet:recipients];
|
|
}
|
|
failure:^(NSError *error){
|
|
// Ignore.
|
|
}];
|
|
}
|
|
|
|
- (void)updateNonContactAccountSet:(NSArray<SignalRecipient *> *)recipients
|
|
{
|
|
BOOL didUpdate = NO;
|
|
for (SignalRecipient *recipient in recipients) {
|
|
if ([self.nonContactAccountSet containsObject:recipient.recipientId]) {
|
|
continue;
|
|
}
|
|
[self.nonContactAccountSet addObject:recipient.recipientId];
|
|
didUpdate = YES;
|
|
}
|
|
if (didUpdate) {
|
|
[self updateTableContents];
|
|
}
|
|
}
|
|
|
|
#pragma mark - Theme
|
|
|
|
- (void)themeDidChange:(NSNotification *)notification
|
|
{
|
|
OWSAssertIsOnMainThread();
|
|
|
|
[self applyTheme];
|
|
}
|
|
|
|
- (void)applyTheme
|
|
{
|
|
OWSAssertIsOnMainThread();
|
|
|
|
self.view.backgroundColor = Theme.backgroundColor;
|
|
}
|
|
|
|
@end
|
|
|
|
NS_ASSUME_NONNULL_END
|