session-ios/Signal/src/ViewControllers/NewGroupViewController.m
Michael Kirk 7e8b2e3034 Faster conversation presentation.
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
2018-08-24 08:54:23 -06:00

652 lines
28 KiB
Objective-C

//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "NewGroupViewController.h"
#import "AddToGroupViewController.h"
#import "AvatarViewHelper.h"
#import "OWSNavigationController.h"
#import "Signal-Swift.h"
#import "SignalApp.h"
#import <Curve25519Kit/Randomness.h>
#import <SignalMessaging/BlockListUIUtils.h>
#import <SignalMessaging/ContactTableViewCell.h>
#import <SignalMessaging/ContactsViewHelper.h>
#import <SignalMessaging/Environment.h>
#import <SignalMessaging/NSString+OWS.h>
#import <SignalMessaging/OWSContactsManager.h>
#import <SignalMessaging/OWSTableViewController.h>
#import <SignalMessaging/SignalKeyingStorage.h>
#import <SignalMessaging/UIUtil.h>
#import <SignalMessaging/UIView+OWS.h>
#import <SignalMessaging/UIViewController+OWS.h>
#import <SignalServiceKit/NSDate+OWS.h>
#import <SignalServiceKit/OWSMessageSender.h>
#import <SignalServiceKit/SignalAccount.h>
#import <SignalServiceKit/TSGroupModel.h>
#import <SignalServiceKit/TSGroupThread.h>
#import <SignalServiceKit/TSOutgoingMessage.h>
NS_ASSUME_NONNULL_BEGIN
const NSUInteger kNewGroupViewControllerAvatarWidth = 68;
@interface NewGroupViewController () <UIImagePickerControllerDelegate,
UITextFieldDelegate,
ContactsViewHelperDelegate,
AvatarViewHelperDelegate,
AddToGroupViewControllerDelegate,
OWSTableViewControllerDelegate,
UINavigationControllerDelegate,
OWSNavigationView>
@property (nonatomic, readonly) OWSMessageSender *messageSender;
@property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper;
@property (nonatomic, readonly) AvatarViewHelper *avatarViewHelper;
@property (nonatomic, readonly) OWSTableViewController *tableViewController;
@property (nonatomic, readonly) AvatarImageView *avatarView;
@property (nonatomic, readonly) UITextField *groupNameTextField;
@property (nonatomic, nullable) UIImage *groupAvatar;
@property (nonatomic) NSMutableSet<NSString *> *memberRecipientIds;
@property (nonatomic) BOOL hasUnsavedChanges;
@property (nonatomic) BOOL hasAppeared;
@end
#pragma mark -
@implementation NewGroupViewController
- (instancetype)init
{
self = [super init];
if (!self) {
return self;
}
[self commonInit];
return self;
}
- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder
{
self = [super initWithCoder:aDecoder];
if (!self) {
return self;
}
[self commonInit];
return self;
}
- (void)commonInit
{
_messageSender = [Environment current].messageSender;
_contactsViewHelper = [[ContactsViewHelper alloc] initWithDelegate:self];
_avatarViewHelper = [AvatarViewHelper new];
_avatarViewHelper.delegate = self;
self.memberRecipientIds = [NSMutableSet new];
}
#pragma mark - View Lifecycle
- (void)loadView
{
[super loadView];
self.title = [MessageStrings newGroupDefaultTitle];
self.view.backgroundColor = Theme.backgroundColor;
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
initWithTitle:NSLocalizedString(@"NEW_GROUP_CREATE_BUTTON", @"The title for the 'create group' button.")
style:UIBarButtonItemStylePlain
target:self
action:@selector(createGroup)];
self.navigationItem.rightBarButtonItem.imageInsets = UIEdgeInsetsMake(0, -10, 0, 10);
self.navigationItem.rightBarButtonItem.accessibilityLabel
= NSLocalizedString(@"FINISH_GROUP_CREATION_LABEL", @"Accessibility label for finishing new group");
// First section.
UIView *firstSection = [self firstSectionHeader];
[self.view addSubview:firstSection];
[firstSection autoSetDimension:ALDimensionHeight toSize:100.f];
[firstSection autoPinWidthToSuperview];
[firstSection autoPinToTopLayoutGuideOfViewController:self withInset:0];
_tableViewController = [OWSTableViewController new];
_tableViewController.delegate = self;
[self.view addSubview:self.tableViewController.view];
[_tableViewController.view autoPinWidthToSuperview];
[_tableViewController.view autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:firstSection];
[self autoPinViewToBottomOfViewControllerOrKeyboard:self.tableViewController.view avoidNotch:NO];
self.tableViewController.tableView.rowHeight = UITableViewAutomaticDimension;
self.tableViewController.tableView.estimatedRowHeight = 60;
[self updateTableContents];
}
- (UIView *)firstSectionHeader
{
UIView *firstSectionHeader = [UIView new];
firstSectionHeader.userInteractionEnabled = YES;
[firstSectionHeader
addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(headerWasTapped:)]];
firstSectionHeader.backgroundColor = [Theme backgroundColor];
UIView *threadInfoView = [UIView new];
[firstSectionHeader addSubview:threadInfoView];
[threadInfoView autoPinWidthToSuperviewWithMargin:16.f];
[threadInfoView autoPinHeightToSuperviewWithMargin:16.f];
AvatarImageView *avatarView = [AvatarImageView new];
_avatarView = avatarView;
[threadInfoView addSubview:avatarView];
[avatarView autoVCenterInSuperview];
[avatarView autoPinLeadingToSuperviewMargin];
[avatarView autoSetDimension:ALDimensionWidth toSize:kNewGroupViewControllerAvatarWidth];
[avatarView autoSetDimension:ALDimensionHeight toSize:kNewGroupViewControllerAvatarWidth];
[self updateAvatarView];
UITextField *groupNameTextField = [OWSTextField new];
_groupNameTextField = groupNameTextField;
groupNameTextField.textColor = Theme.primaryColor;
groupNameTextField.font = [UIFont ows_dynamicTypeTitle2Font];
groupNameTextField.attributedPlaceholder =
[[NSAttributedString alloc] initWithString:NSLocalizedString(@"NEW_GROUP_NAMEGROUP_REQUEST_DEFAULT",
@"Placeholder text for group name field")
attributes:@{
NSForegroundColorAttributeName : Theme.secondaryColor,
}];
groupNameTextField.delegate = self;
[groupNameTextField addTarget:self
action:@selector(groupNameDidChange:)
forControlEvents:UIControlEventEditingChanged];
[threadInfoView addSubview:groupNameTextField];
[groupNameTextField autoVCenterInSuperview];
[groupNameTextField autoPinTrailingToSuperviewMargin];
[groupNameTextField autoPinLeadingToTrailingEdgeOfView:avatarView offset:16.f];
[avatarView
addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(avatarTouched:)]];
avatarView.userInteractionEnabled = YES;
return firstSectionHeader;
}
- (void)headerWasTapped:(UIGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateRecognized) {
[self.groupNameTextField becomeFirstResponder];
}
}
- (void)avatarTouched:(UIGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateRecognized) {
[self showChangeAvatarUI];
}
}
#pragma mark - Table Contents
- (void)updateTableContents
{
OWSTableContents *contents = [OWSTableContents new];
__weak NewGroupViewController *weakSelf = self;
ContactsViewHelper *contactsViewHelper = self.contactsViewHelper;
NSArray<SignalAccount *> *signalAccounts = self.contactsViewHelper.signalAccounts;
NSMutableSet *nonContactMemberRecipientIds = [self.memberRecipientIds mutableCopy];
for (SignalAccount *signalAccount in signalAccounts) {
[nonContactMemberRecipientIds removeObject:signalAccount.recipientId];
}
// Non-contact Members
if (nonContactMemberRecipientIds.count > 0 || signalAccounts.count < 1) {
OWSTableSection *nonContactsSection = [OWSTableSection new];
nonContactsSection.headerTitle = NSLocalizedString(
@"NEW_GROUP_NON_CONTACTS_SECTION_TITLE", @"a title for the non-contacts section of the 'new group' view.");
[nonContactsSection addItem:[self createAddNonContactItem]];
for (NSString *recipientId in
[nonContactMemberRecipientIds.allObjects sortedArrayUsingSelector:@selector(compare:)]) {
[nonContactsSection
addItem:[OWSTableItem
itemWithCustomCellBlock:^{
NewGroupViewController *strongSelf = weakSelf;
OWSCAssert(strongSelf);
ContactTableViewCell *cell = [ContactTableViewCell new];
BOOL isCurrentMember = [strongSelf.memberRecipientIds containsObject:recipientId];
BOOL isBlocked = [contactsViewHelper isRecipientIdBlocked:recipientId];
if (isCurrentMember) {
// In the "contacts" section, we label members as such when editing an existing
// group.
cell.accessoryMessage = NSLocalizedString(@"NEW_GROUP_MEMBER_LABEL",
@"An indicator that a user is a member of the new group.");
} else if (isBlocked) {
cell.accessoryMessage = NSLocalizedString(
@"CONTACT_CELL_IS_BLOCKED", @"An indicator that a contact has been blocked.");
}
[cell configureWithRecipientId:recipientId
contactsManager:contactsViewHelper.contactsManager];
return cell;
}
customRowHeight:UITableViewAutomaticDimension
actionBlock:^{
BOOL isCurrentMember = [weakSelf.memberRecipientIds containsObject:recipientId];
BOOL isBlocked = [contactsViewHelper isRecipientIdBlocked:recipientId];
if (isCurrentMember) {
[weakSelf removeRecipientId:recipientId];
} else if (isBlocked) {
[BlockListUIUtils
showUnblockPhoneNumberActionSheet:recipientId
fromViewController:weakSelf
blockingManager:contactsViewHelper.blockingManager
contactsManager:contactsViewHelper.contactsManager
completionBlock:^(BOOL isStillBlocked) {
if (!isStillBlocked) {
[weakSelf addRecipientId:recipientId];
}
}];
} else {
BOOL didShowSNAlert = [SafetyNumberConfirmationAlert
presentAlertIfNecessaryWithRecipientId:recipientId
confirmationText:NSLocalizedString(
@"SAFETY_NUMBER_CHANGED_CONFIRM_"
@"ADD_TO_GROUP_ACTION",
@"button title to confirm adding "
@"a recipient to a group when "
@"their safety "
@"number has recently changed")
contactsManager:contactsViewHelper.contactsManager
completion:^(BOOL didConfirmIdentity) {
if (didConfirmIdentity) {
[weakSelf addRecipientId:recipientId];
}
}];
if (didShowSNAlert) {
return;
}
[weakSelf addRecipientId:recipientId];
}
}]];
}
[contents addSection:nonContactsSection];
}
// Contacts
OWSTableSection *signalAccountSection = [OWSTableSection new];
signalAccountSection.headerTitle = NSLocalizedString(
@"EDIT_GROUP_CONTACTS_SECTION_TITLE", @"a title for the contacts section of the 'new/update group' view.");
if (signalAccounts.count > 0) {
if (nonContactMemberRecipientIds.count < 1) {
// If the group contains any non-contacts or has not contacts,
// the "add non-contact user" will show up in the previous section
// of the table. However, it's more attractive to hide that section
// for the common case where people want to create a group from just
// their contacts. Therefore, when that section is hidden, we want
// to allow people to add non-contacts.
[signalAccountSection addItem:[self createAddNonContactItem]];
}
for (SignalAccount *signalAccount in signalAccounts) {
[signalAccountSection
addItem:[OWSTableItem
itemWithCustomCellBlock:^{
NewGroupViewController *strongSelf = weakSelf;
OWSCAssert(strongSelf);
ContactTableViewCell *cell = [ContactTableViewCell new];
NSString *recipientId = signalAccount.recipientId;
BOOL isCurrentMember = [strongSelf.memberRecipientIds containsObject:recipientId];
BOOL isBlocked = [contactsViewHelper isRecipientIdBlocked:recipientId];
if (isCurrentMember) {
// In the "contacts" section, we label members as such when editing an existing
// group.
cell.accessoryMessage = NSLocalizedString(@"NEW_GROUP_MEMBER_LABEL",
@"An indicator that a user is a member of the new group.");
} else if (isBlocked) {
cell.accessoryMessage = NSLocalizedString(
@"CONTACT_CELL_IS_BLOCKED", @"An indicator that a contact has been blocked.");
}
[cell configureWithRecipientId:signalAccount.recipientId
contactsManager:contactsViewHelper.contactsManager];
return cell;
}
customRowHeight:UITableViewAutomaticDimension
actionBlock:^{
NSString *recipientId = signalAccount.recipientId;
BOOL isCurrentMember = [weakSelf.memberRecipientIds containsObject:recipientId];
BOOL isBlocked = [contactsViewHelper isRecipientIdBlocked:recipientId];
if (isCurrentMember) {
[weakSelf removeRecipientId:recipientId];
} else if (isBlocked) {
[BlockListUIUtils
showUnblockSignalAccountActionSheet:signalAccount
fromViewController:weakSelf
blockingManager:contactsViewHelper.blockingManager
contactsManager:contactsViewHelper.contactsManager
completionBlock:^(BOOL isStillBlocked) {
if (!isStillBlocked) {
[weakSelf addRecipientId:recipientId];
}
}];
} else {
BOOL didShowSNAlert = [SafetyNumberConfirmationAlert
presentAlertIfNecessaryWithRecipientId:signalAccount.recipientId
confirmationText:NSLocalizedString(
@"SAFETY_NUMBER_CHANGED_CONFIRM_"
@"ADD_TO_GROUP_ACTION",
@"button title to confirm adding "
@"a recipient to a group when "
@"their safety "
@"number has recently changed")
contactsManager:contactsViewHelper.contactsManager
completion:^(BOOL didConfirmIdentity) {
if (didConfirmIdentity) {
[weakSelf addRecipientId:recipientId];
}
}];
if (didShowSNAlert) {
return;
}
[weakSelf addRecipientId:recipientId];
}
}]];
}
} else {
[signalAccountSection
addItem:[OWSTableItem
softCenterLabelItemWithText:NSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_CONTACTS",
@"A label that indicates the user has no Signal contacts.")]];
}
[contents addSection:signalAccountSection];
self.tableViewController.contents = contents;
}
- (OWSTableItem *)createAddNonContactItem
{
__weak NewGroupViewController *weakSelf = self;
return [OWSTableItem
disclosureItemWithText:NSLocalizedString(@"NEW_GROUP_ADD_NON_CONTACT",
@"A label for the cell that lets you add a new non-contact member to a group.")
customRowHeight:UITableViewAutomaticDimension
actionBlock:^{
AddToGroupViewController *viewController = [AddToGroupViewController new];
viewController.addToGroupDelegate = weakSelf;
viewController.hideContacts = YES;
[weakSelf.navigationController pushViewController:viewController animated:YES];
}];
}
- (void)removeRecipientId:(NSString *)recipientId
{
OWSAssert(recipientId.length > 0);
[self.memberRecipientIds removeObject:recipientId];
[self updateTableContents];
}
- (void)addRecipientId:(NSString *)recipientId
{
OWSAssert(recipientId.length > 0);
[self.memberRecipientIds addObject:recipientId];
self.hasUnsavedChanges = YES;
[self updateTableContents];
}
#pragma mark - Methods
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if (!self.hasAppeared) {
[self.groupNameTextField becomeFirstResponder];
self.hasAppeared = YES;
}
}
#pragma mark - Actions
- (void)createGroup
{
OWSAssertIsOnMainThread();
TSGroupModel *model = [self makeGroup];
__block TSGroupThread *thread;
[OWSPrimaryStorage.dbReadWriteConnection
readWriteWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
thread = [TSGroupThread getOrCreateThreadWithGroupModel:model transaction:transaction];
}];
OWSAssert(thread);
[OWSProfileManager.sharedManager addThreadToProfileWhitelist:thread];
void (^successHandler)(void) = ^{
DDLogError(@"Group creation successful.");
dispatch_async(dispatch_get_main_queue(), ^{
[SignalApp.sharedApp presentConversationForThread:thread action:ConversationViewActionCompose animated:NO];
[self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
});
};
void (^failureHandler)(NSError *error) = ^(NSError *error) {
DDLogError(@"Group creation failed: %@", error);
// Add an error message to the new group indicating
// that group creation didn't succeed.
TSErrorMessage *errorMessage = [[TSErrorMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp]
inThread:thread
failedMessageType:TSErrorMessageGroupCreationFailed];
[errorMessage save];
dispatch_async(dispatch_get_main_queue(), ^{
[SignalApp.sharedApp presentConversationForThread:thread action:ConversationViewActionCompose animated:NO];
[self.presentingViewController dismissViewControllerAnimated:YES completion:nil];
});
};
[ModalActivityIndicatorViewController
presentFromViewController:self
canCancel:NO
backgroundBlock:^(ModalActivityIndicatorViewController *modalActivityIndicator) {
TSOutgoingMessage *message = [TSOutgoingMessage outgoingMessageInThread:thread
groupMetaMessage:TSGroupMessageNew
expiresInSeconds:0];
[message updateWithCustomMessage:NSLocalizedString(@"GROUP_CREATED", nil)];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
if (model.groupImage) {
NSData *data = UIImagePNGRepresentation(model.groupImage);
DataSource *_Nullable dataSource =
[DataSourceValue dataSourceWithData:data fileExtension:@"png"];
[self.messageSender enqueueTemporaryAttachment:dataSource
contentType:OWSMimeTypeImagePng
inMessage:message
success:successHandler
failure:failureHandler];
} else {
[self.messageSender enqueueMessage:message success:successHandler failure:failureHandler];
}
});
}];
}
- (TSGroupModel *)makeGroup
{
NSString *groupName = [self.groupNameTextField.text ows_stripped];
NSMutableArray<NSString *> *recipientIds = [self.memberRecipientIds.allObjects mutableCopy];
[recipientIds addObject:[self.contactsViewHelper localNumber]];
NSData *groupId = [Randomness generateRandomBytes:16];
return [[TSGroupModel alloc] initWithTitle:groupName memberIds:recipientIds image:self.groupAvatar groupId:groupId];
}
#pragma mark - Group Avatar
- (void)showChangeAvatarUI
{
[self.avatarViewHelper showChangeAvatarUI];
}
- (void)setGroupAvatar:(nullable UIImage *)groupAvatar
{
OWSAssertIsOnMainThread();
_groupAvatar = groupAvatar;
self.hasUnsavedChanges = YES;
[self updateAvatarView];
}
- (void)updateAvatarView
{
self.avatarView.image = (self.groupAvatar ?: [UIImage imageNamed:@"empty-group-avatar"]);
}
#pragma mark - Event Handling
- (void)backButtonPressed
{
[self.groupNameTextField resignFirstResponder];
if (!self.hasUnsavedChanges) {
// If user made no changes, return to conversation settings view.
[self.navigationController popViewControllerAnimated:YES];
return;
}
UIAlertController *controller = [UIAlertController
alertControllerWithTitle:
NSLocalizedString(@"NEW_GROUP_VIEW_UNSAVED_CHANGES_TITLE",
@"The alert title if user tries to exit the new group view without saving changes.")
message:
NSLocalizedString(@"NEW_GROUP_VIEW_UNSAVED_CHANGES_MESSAGE",
@"The alert message if user tries to exit the new group view without saving changes.")
preferredStyle:UIAlertControllerStyleAlert];
[controller
addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"ALERT_DISCARD_BUTTON",
@"The label for the 'discard' button in alerts and action sheets.")
style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *action) {
[self.navigationController popViewControllerAnimated:YES];
}]];
[controller addAction:[OWSAlerts cancelAction]];
[self presentViewController:controller animated:YES completion:nil];
}
- (void)groupNameDidChange:(id)sender
{
self.hasUnsavedChanges = YES;
}
#pragma mark - Text Field Delegate
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
[self.groupNameTextField resignFirstResponder];
return NO;
}
#pragma mark - OWSTableViewControllerDelegate
- (void)tableViewWillBeginDragging
{
[self.groupNameTextField resignFirstResponder];
}
#pragma mark - ContactsViewHelperDelegate
- (void)contactsViewHelperDidUpdateContacts
{
[self updateTableContents];
}
- (BOOL)shouldHideLocalNumber
{
return YES;
}
#pragma mark - AvatarViewHelperDelegate
- (NSString *)avatarActionSheetTitle
{
return NSLocalizedString(
@"NEW_GROUP_ADD_PHOTO_ACTION", @"Action Sheet title prompting the user for a group avatar");
}
- (void)avatarDidChange:(UIImage *)image
{
OWSAssertIsOnMainThread();
OWSAssert(image);
self.groupAvatar = image;
}
- (UIViewController *)fromViewController
{
return self;
}
- (BOOL)hasClearAvatarAction
{
return NO;
}
#pragma mark - AddToGroupViewControllerDelegate
- (void)recipientIdWasAdded:(NSString *)recipientId
{
[self addRecipientId:recipientId];
}
- (BOOL)isRecipientGroupMember:(NSString *)recipientId
{
OWSAssert(recipientId.length > 0);
return [self.memberRecipientIds containsObject:recipientId];
}
#pragma mark - OWSNavigationView
- (BOOL)shouldCancelNavigationBack
{
BOOL result = self.hasUnsavedChanges;
if (self.hasUnsavedChanges) {
[self backButtonPressed];
}
return result;
}
@end
NS_ASSUME_NONNULL_END