Signal-iOS/SignalUI/ViewControllers/Recipient Picker/RecipientPickerViewController.m
Evan Hahn 370ff654e7
Change license to AGPL
Change license to AGPL

This commit:

- Updates the `LICENSE` file

- Start every file with something like:

      // Copyright YEAR_FIRST_PUBLISHED Signal Messenger, LLC
      // SPDX-License-Identifier: AGPL-3.0-only

---

First, I removed existing license headers with this Ruby 3.1.2 script:

    require 'set'

    EXTENSIONS_TO_CHECK = Set['.h', '.hpp', '.cpp', '.m', '.mm', '.pch', '.swift']

    same = 0
    different = 0

    all_files = `git ls-files`.lines.map { |line| line.strip }
    all_files.each do |relative_path|
      if relative_path == 'Pods'
        next
      end

      unless EXTENSIONS_TO_CHECK.include? File.extname(relative_path)
        next
      end

      path = File.expand_path(relative_path)

      contents = File.read(path)
      new_contents = contents.sub(/\/\/\n\/\/  Copyright .*\n\/\/\n\n/, '')

      if contents == new_contents
        same += 1
      else
        different += 1
      end

      File.write(path, new_contents)
    end

    puts "updated #{different} file(s), left #{same} untouched"

I'm sure this script could be improved, but it worked well enough.

Then, I created `Scripts/lint/lint-license-headers` and ran it to auto-
fix a lot of files. This changed the mode of some files, but I think
that's actually desirable. For example,
`SignalServiceKit/src/Util/AppContext.m` previously had a mode of
`0755/-rwxr-xr-x`, and it's now `0644/-rw-r--r--`.

Then I fixed some stragglers and updated the precommit script.

See [a similar change in the Desktop app][0].

[0]: 8bfaf598af
2022-10-13 08:25:37 -05:00

1342 lines
53 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2014 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
#import "RecipientPickerViewController.h"
#import "SignalApp.h"
#import <MessageUI/MessageUI.h>
#import <SignalCoreKit/NSString+OWS.h>
#import <SignalCoreKit/SignalCoreKit-Swift.h>
#import <SignalMessaging/Environment.h>
#import <SignalMessaging/SignalMessaging-Swift.h>
#import <SignalServiceKit/AppVersion.h>
#import <SignalServiceKit/PhoneNumberUtil.h>
#import <SignalServiceKit/SignalAccount.h>
#import <SignalServiceKit/SignalServiceKit-Swift.h>
#import <SignalServiceKit/TSAccountManager.h>
#import <SignalUI/ContactsViewHelper.h>
#import <SignalUI/OWSTableViewController.h>
#import <SignalUI/SignalUI-Swift.h>
#import <SignalUI/UIUtil.h>
#import <SignalUI/UIView+SignalUI.h>
NS_ASSUME_NONNULL_BEGIN
@interface SignalAccount (Collation)
- (NSString *)stringForCollation;
@end
@implementation SignalAccount (Collation)
- (NSString *)stringForCollation
{
return [self.contactsManagerImpl comparableNameForSignalAccountWithSneakyTransaction:self];
}
@end
const NSUInteger kMinimumSearchLength = 1;
@interface RecipientPickerViewController () <UISearchBarDelegate,
ContactsViewHelperObserver,
OWSTableViewControllerDelegate,
FindByPhoneNumberDelegate,
MFMessageComposeViewControllerDelegate>
@property (nonatomic, readonly) UIStackView *signalContactsStackView;
@property (nonatomic, readonly) UIView *noSignalContactsView;
@property (nonatomic, readonly) OWSTableViewController2 *tableViewController;
@property (nonatomic, readonly) UILocalizedIndexedCollation *collation;
@property (nonatomic, nullable, readonly) OWSSearchBar *searchBar;
@property (nonatomic, nullable) ComposeScreenSearchResultSet *searchResults;
@property (nonatomic, nullable) NSString *lastSearchText;
@property (nonatomic, nullable) OWSInviteFlow *inviteFlow;
// 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<SignalServiceAddress *> *nonContactAccountSet;
@property (nonatomic) BOOL isNoContactsModeActive;
@end
#pragma mark -
@implementation RecipientPickerViewController
@synthesize pickedRecipients = _pickedRecipients;
- (instancetype)init
{
self = [super init];
if (!self) {
return self;
}
_allowsAddByPhoneNumber = YES;
_shouldHideLocalRecipient = YES;
_allowsSelectingUnregisteredPhoneNumbers = YES;
_groupsToShow = RecipientPickerViewControllerGroupsToShow_ShowGroupsThatUserIsMemberOfWhenSearching;
_shouldShowInvites = NO;
_shouldShowAlphabetSlider = YES;
return self;
}
- (void)loadView
{
[super loadView];
_signalContactsStackView = [UIStackView new];
self.signalContactsStackView.axis = UILayoutConstraintAxisVertical;
self.signalContactsStackView.alignment = UIStackViewAlignmentFill;
[self.view addSubview:self.signalContactsStackView];
[self.signalContactsStackView autoPinEdgesToSuperviewEdges];
_searchResults = nil;
[self.contactsViewHelper addObserver:self];
_nonContactAccountSet = [NSMutableSet set];
_collation = [UILocalizedIndexedCollation currentCollation];
// Search
OWSSearchBar *searchBar = [OWSSearchBar new];
_searchBar = searchBar;
searchBar.delegate = self;
if (SSKFeatureFlags.usernames) {
searchBar.placeholder = OWSLocalizedString(@"SEARCH_BY_NAME_OR_USERNAME_OR_NUMBER_PLACEHOLDER_TEXT",
@"Placeholder text indicating the user can search for contacts by name, username, or phone number.");
} else {
searchBar.placeholder = OWSLocalizedString(@"SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT",
@"Placeholder text indicating the user can search for contacts by name or phone number.");
}
[searchBar sizeToFit];
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, searchBar);
searchBar.textField.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"contact_search");
[self.signalContactsStackView addArrangedSubview:searchBar];
[searchBar setCompressionResistanceVerticalHigh];
[searchBar setContentHuggingVerticalHigh];
for (UIView *view in self.delegate.recipientPickerCustomHeaderViews) {
[self.signalContactsStackView addArrangedSubview:view];
}
_tableViewController = [OWSTableViewController2 new];
_tableViewController.delegate = self;
self.tableViewController.defaultSeparatorInsetLeading = OWSTableViewController2.cellHInnerMargin
+ AvatarBuilder.smallAvatarSizePoints + ContactCellView.avatarTextHSpacing;
[self addChildViewController:self.tableViewController];
[self.signalContactsStackView addArrangedSubview:self.tableViewController.view];
[self.tableViewController.view setCompressionResistanceVerticalLow];
[self.tableViewController.view setContentHuggingVerticalLow];
[self.tableViewController.tableView registerClass:[ContactTableViewCell class]
forCellReuseIdentifier:ContactTableViewCell.reuseIdentifier];
_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];
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _noSignalContactsView);
UIRefreshControl *pullToRefreshView = [UIRefreshControl new];
pullToRefreshView.tintColor = [UIColor grayColor];
[pullToRefreshView addTarget:self
action:@selector(pullToRefreshPerformed:)
forControlEvents:UIControlEventValueChanged];
self.tableViewController.tableView.refreshControl = pullToRefreshView;
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, pullToRefreshView);
[self updateTableContents];
}
- (UITableView *)tableView {
return self.tableViewController.tableView;
}
- (void)viewSafeAreaInsetsDidChange
{
[super viewSafeAreaInsetsDidChange];
[self updateSearchBarMargins];
}
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
[self updateSearchBarMargins];
}
- (void)updateSearchBarMargins
{
// This should ideally compute the insets for self.tableView, but that
// view's size hasn't been updated when the viewDidLayoutSubviews method is
// called. As a quick fix, use self.view's size, which matches the eventual
// width of self.tableView. (A more complete fix would likely add a
// callback when self.tableViews size is available.)
self.searchBar.layoutMargins = [OWSTableViewController2 cellOuterInsetsIn:self.view];
}
- (void)pullToRefreshPerformed:(UIRefreshControl *)refreshControl
{
OWSAssertIsOnMainThread();
OWSLogInfo(@"beginning refreshing.");
[self.contactsManagerImpl userRequestedSystemContactsRefresh]
.then(^(id value) {
if (TSAccountManager.shared.isRegisteredPrimaryDevice) {
return [AnyPromise promiseWithValue:@1];
}
return [SSKEnvironment.shared.syncManager sendAllSyncRequestMessagesWithTimeout:20];
})
.ensure(^{
OWSLogInfo(@"ending refreshing.");
[refreshControl endRefreshing];
});
}
- (UIView *)createNoSignalContactsView
{
UIImage *heroImage = [UIImage imageNamed:@"uiEmptyContact"];
OWSAssertDebug(heroImage);
UIImageView *heroImageView = [[UIImageView alloc] initWithImage:heroImage];
heroImageView.layer.minificationFilter = kCAFilterTrilinear;
heroImageView.layer.magnificationFilter = kCAFilterTrilinear;
const CGFloat kHeroSize = ScaleFromIPhone5To7Plus(100, 150);
[heroImageView autoSetDimension:ALDimensionWidth toSize:kHeroSize];
[heroImageView autoSetDimension:ALDimensionHeight toSize:kHeroSize];
UILabel *titleLabel = [UILabel new];
titleLabel.text = OWSLocalizedString(
@"EMPTY_CONTACTS_LABEL_LINE1", "Full width label displayed when attempting to compose message");
titleLabel.textColor = Theme.primaryTextColor;
titleLabel.font = [UIFont ows_semiboldFontWithSize:ScaleFromIPhone5To7Plus(17.f, 20.f)];
titleLabel.textAlignment = NSTextAlignmentCenter;
titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
titleLabel.numberOfLines = 0;
UILabel *subtitleLabel = [UILabel new];
subtitleLabel.text = OWSLocalizedString(
@"EMPTY_CONTACTS_LABEL_LINE2", "Full width label displayed when attempting to compose message");
subtitleLabel.textColor = Theme.secondaryTextAndIconColor;
subtitleLabel.font = [UIFont ows_regularFontWithSize:ScaleFromIPhone5To7Plus(12.f, 14.f)];
subtitleLabel.textAlignment = NSTextAlignmentCenter;
subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping;
subtitleLabel.numberOfLines = 0;
UIStackView *headerStack = [[UIStackView alloc] initWithArrangedSubviews:@[
heroImageView,
[UIView spacerWithHeight:30],
titleLabel,
[UIView spacerWithHeight:15],
subtitleLabel,
]];
headerStack.axis = UILayoutConstraintAxisVertical;
headerStack.alignment = UIStackViewAlignmentCenter;
UIStackView *buttonStack = [[UIStackView alloc] init];
buttonStack.axis = UILayoutConstraintAxisVertical;
buttonStack.alignment = UIStackViewAlignmentFill;
buttonStack.spacing = 16;
void (^addButton)(NSString *, SEL, NSString *, ThemeIcon, NSUInteger)
= ^(NSString *title,
SEL selector,
NSString *accessibilityIdentifierName,
ThemeIcon icon,
NSUInteger innerIconSize) {
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
[button addTarget:self action:selector forControlEvents:UIControlEventTouchUpInside];
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, button);
button.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, accessibilityIdentifierName);
[buttonStack addArrangedSubview:button];
UIView *iconView = [OWSTableItem buildIconInCircleViewWithIcon:icon innerIconSize:innerIconSize];
iconView.backgroundColor = self.tableViewController.cellBackgroundColor;
UILabel *label = [UILabel new];
label.text = title;
label.font = [UIFont ows_regularFontWithSize:17.f];
label.textColor = Theme.primaryTextColor;
label.lineBreakMode = NSLineBreakByTruncatingTail;
UIStackView *hStack = [[UIStackView alloc] initWithArrangedSubviews:@[
iconView,
label,
]];
hStack.axis = UILayoutConstraintAxisHorizontal;
hStack.alignment = UIStackViewAlignmentCenter;
hStack.spacing = 12;
hStack.userInteractionEnabled = NO;
[button addSubview:hStack];
[hStack autoPinEdgesToSuperviewEdges];
};
if (self.shouldShowNewGroup) {
addButton(OWSLocalizedString(@"NEW_GROUP_BUTTON", comment
: @"Label for the 'create new group' button."),
@selector(newGroupButtonPressed),
@"newGroupButton",
ThemeIconComposeNewGroupLarge,
35);
}
if (self.allowsAddByPhoneNumber) {
addButton(OWSLocalizedString(@"NO_CONTACTS_SEARCH_BY_PHONE_NUMBER",
@"Label for a button that lets users search for contacts by phone number"),
@selector(hideBackgroundView),
@"searchByPhoneNumberButton",
ThemeIconComposeFindByPhoneNumberLarge,
42);
}
if (self.shouldShowInvites) {
addButton(OWSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
"Label for the cell that presents the 'invite contacts' workflow."),
@selector(presentInviteFlow),
@"inviteContactsButton",
ThemeIconComposeInviteLarge,
38);
}
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[
headerStack,
buttonStack,
]];
stackView.axis = UILayoutConstraintAxisVertical;
stackView.alignment = UIStackViewAlignmentCenter;
stackView.spacing = 50;
stackView.layoutMarginsRelativeArrangement = YES;
stackView.layoutMargins = UIEdgeInsetsMake(20, 20, 20, 20);
UIView *view = [UIView new];
view.backgroundColor = self.tableViewController.tableBackgroundColor;
[view addSubview:stackView];
[stackView autoPinWidthToSuperview];
[stackView autoVCenterInSuperview];
return view;
}
- (void)viewDidLoad
{
[super viewDidLoad];
[self.contactsViewHelper warmNonSignalContactsCacheAsync];
self.title = OWSLocalizedString(@"MESSAGE_COMPOSEVIEW_TITLE", @"");
[self applyTheme];
}
- (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.contactsManagerImpl requestSystemContactsOnce];
[self showContactAppropriateViews];
}
#pragma mark - Table Contents
- (void)reloadContent
{
[self updateTableContents];
}
- (void)updateTableContents
{
OWSAssertIsOnMainThread();
OWSTableContents *contents = [OWSTableContents new];
if (self.isNoContactsModeActive) {
self.tableViewController.contents = contents;
return;
}
__weak __typeof(self) 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.contactsManagerImpl.isSystemContactsDenied) {
OWSTableItem *contactReminderItem = [OWSTableItem
itemWithCustomCellBlock:^{
UITableViewCell *cell = [OWSTableItem newCell];
ReminderView *reminderView = [ReminderView
nagWithText:OWSLocalizedString(@"COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION",
@"Multi-line label explaining why compose-screen contact picker is empty.")
tapAction:^{
[CurrentAppContext() openSystemSettings];
}];
[cell.contentView addSubview:reminderView];
[reminderView autoPinEdgesToSuperviewEdges];
cell.accessibilityIdentifier
= ACCESSIBILITY_IDENTIFIER_WITH_NAME(RecipientPickerViewController, @"missing_contacts");
return cell;
}
actionBlock:nil];
OWSTableSection *reminderSection = [OWSTableSection new];
[reminderSection addItem:contactReminderItem];
[contents addSection:reminderSection];
}
OWSTableSection *staticSection = [OWSTableSection new];
staticSection.separatorInsetLeading = @(OWSTableViewController2.cellHInnerMargin + 24 + OWSTableItem.iconSpacing);
BOOL isSearching = self.searchResults != nil;
if (self.shouldShowNewGroup && !isSearching) {
[staticSection addItem:[OWSTableItem disclosureItemWithIcon:ThemeIconComposeNewGroup
name:OWSLocalizedString(
@"NEW_GROUP_BUTTON", comment
: @"Label for the 'create new group' button.")
accessoryText:nil
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
RecipientPickerViewController, @"new_group")
actionBlock:^{ [weakSelf newGroupButtonPressed]; }]];
}
// Find Non-Contacts by Phone Number
if (self.allowsAddByPhoneNumber && !isSearching) {
[staticSection
addItem:[OWSTableItem
disclosureItemWithIcon:ThemeIconComposeFindByPhoneNumber
name:OWSLocalizedString(@"NEW_CONVERSATION_FIND_BY_PHONE_NUMBER",
@"A label the cell that lets you add a new member to a group.")
accessoryText:nil
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
RecipientPickerViewController, @"find_by_phone")
actionBlock:^{
typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
FindByPhoneNumberViewController *viewController =
[[FindByPhoneNumberViewController alloc]
initWithDelegate:strongSelf
buttonText:strongSelf.findByPhoneNumberButtonTitle
requiresRegisteredNumber:!strongSelf
.allowsSelectingUnregisteredPhoneNumbers];
[strongSelf.navigationController pushViewController:viewController
animated:YES];
}]];
}
if (self.contactsManagerImpl.isSystemContactsAuthorized && self.shouldShowInvites && !isSearching) {
// Invite Contacts
[staticSection
addItem:[OWSTableItem
disclosureItemWithIcon:ThemeIconComposeInvite
name:OWSLocalizedString(@"INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
@"Label for the cell that presents the 'invite contacts' workflow.")
accessoryText:nil
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
RecipientPickerViewController, @"invite_contacts")
actionBlock:^{ [weakSelf presentInviteFlow]; }]];
}
if (staticSection.itemCount > 0) {
[contents addSection:staticSection];
}
// Render any non-contact picked recipients
if (self.pickedRecipients.count > 0 && self.searchResults == nil) {
OWSTableSection *pickedSection = [OWSTableSection new];
BOOL hadNonContactRecipient = NO;
for (PickedRecipient *recipient in self.pickedRecipients) {
if (self.shouldHideLocalRecipient &&
[recipient.address isEqualToAddress:self.contactsViewHelper.localAddress]) {
continue;
}
if (![self.contactsViewHelper fetchSignalAccountForAddress:recipient.address]) {
hadNonContactRecipient = YES;
[pickedSection addItem:[self itemForRecipient:recipient]];
}
}
// If we have non-contact selections, add a title to the picked section
if (hadNonContactRecipient) {
pickedSection.headerTitle = OWSLocalizedString(@"NEW_GROUP_NON_CONTACTS_SECTION_TITLE",
@"a title for the selected section of the 'recipient picker' view.");
[contents addSection:pickedSection];
}
}
if (self.searchResults != nil) {
for (OWSTableSection *section in [self contactsSectionsForSearchResults:self.searchResults]) {
[contents addSection:section];
}
} else {
// Count the non-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 beforeContactsSectionCount = (NSInteger)contents.sections.count;
for (OWSTableSection *section in [self contactsSection]) {
[contents addSection:section];
}
if (self.shouldShowAlphabetSlider) {
__weak OWSTableContents *weakContents = contents;
contents.sectionForSectionIndexTitleBlock = ^NSInteger(NSString *_Nonnull title, NSInteger index) {
typeof(self) strongSelf = weakSelf;
OWSTableContents *_Nullable strongContents = weakContents;
if (strongSelf == nil || strongContents == nil) {
return 0;
}
// Offset the collation section to account for the noncollated sections.
NSInteger sectionIndex =
[strongSelf.collation sectionForSectionIndexTitleAtIndex:index] + beforeContactsSectionCount;
if (sectionIndex < 0) {
// Sentinel in case we change our section ordering in a surprising way.
OWSCFailDebug(@"Unexpected negative section index");
return 0;
}
if (sectionIndex >= (NSInteger)strongContents.sections.count) {
// Sentinel in case we change our section ordering in a surprising way.
OWSCFailDebug(@"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<SignalAccount *> *)allSignalAccounts
{
return [self.contactsViewHelper signalAccountsIncludingLocalUser:!self.shouldHideLocalRecipient];
}
- (NSArray<OWSTableSection *> *)contactsSection
{
NSArray<SignalAccount *> *signalAccountsToShow = self.allSignalAccounts;
// As an optimization, we can skip the database lookup if you have no connections.
if (self.allSignalAccounts.count > 0) {
__block NSSet<SignalServiceAddress *> *addressesToSkip;
[self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) {
addressesToSkip = [self.blockingManager blockedAddressesWithTransaction:transaction];
}];
// This is an optimization for users that have no blocked addresses.
if (addressesToSkip.count > 0) {
NSPredicate *predicate = [NSPredicate predicateWithBlock:^BOOL(SignalAccount *signalAccount,
NSDictionary *bindings) { return ![addressesToSkip containsObject:signalAccount.recipientAddress]; }];
signalAccountsToShow = [self.allSignalAccounts filteredArrayUsingPredicate:predicate];
}
}
if (signalAccountsToShow.count < 1) {
// No Contacts
OWSTableSection *contactsSection = [OWSTableSection new];
if (self.contactsManagerImpl.isSystemContactsAuthorized) {
if (self.contactsViewHelper.hasUpdatedContactsAtLeastOnce) {
[contactsSection
addItem:[OWSTableItem
softCenterLabelItemWithText:OWSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_CONTACTS",
@"A label that indicates the user has no Signal "
@"contacts that they haven't blocked.")
customRowHeight:UITableViewAutomaticDimension]];
} else {
UITableViewCell *loadingCell = [OWSTableItem newCell];
OWSAssertDebug(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];
loadingCell.accessibilityIdentifier
= ACCESSIBILITY_IDENTIFIER_WITH_NAME(RecipientPickerViewController, @"loading");
OWSTableItem *loadingItem = [OWSTableItem
itemWithCustomCellBlock:^{ return loadingCell; }
customRowHeight:40
actionBlock:nil];
[contactsSection addItem:loadingItem];
}
}
return @[ contactsSection ];
}
NSMutableArray<OWSTableSection *> *contactSections = [NSMutableArray new];
if (self.shouldShowAlphabetSlider) {
NSMutableArray<NSMutableArray<SignalAccount *> *> *collatedSignalAccounts = [NSMutableArray new];
for (NSUInteger i = 0; i < self.collation.sectionTitles.count; i++) {
collatedSignalAccounts[i] = [NSMutableArray new];
}
for (SignalAccount *signalAccount in signalAccountsToShow) {
NSInteger section = [self.collation sectionForObject:signalAccount
collationStringSelector:@selector(stringForCollation)];
if (section < 0) {
OWSFailDebug(@"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) {
PickedRecipient *recipient = [PickedRecipient forAddress:signalAccount.recipientAddress];
[contactItems addObject:[self itemForRecipient:recipient]];
}
// 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:[self buildSectionWithTitle:sectionTitle.uppercaseString items:contactItems]];
}
} else {
OWSTableSection *contactsSection =
[self buildSectionWithTitle:OWSLocalizedString(@"COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
@"Table section header for contact listing when composing a new message")];
for (SignalAccount *signalAccount in signalAccountsToShow) {
[contactsSection
addItem:[self itemForRecipient:[PickedRecipient forAddress:signalAccount.recipientAddress]]];
}
[contactSections addObject:contactsSection];
}
return [contactSections copy];
}
- (OWSTableSection *)buildSectionWithTitle:(nullable NSString *)sectionTitle
{
return [self buildSectionWithTitle:sectionTitle items:@[]];
}
- (OWSTableSection *)buildSectionWithTitle:(nullable NSString *)sectionTitle items:(NSArray<OWSTableItem *> *)items
{
OWSTableSection *section = [OWSTableSection new];
[section addItems:items];
if (sectionTitle != nil) {
section.headerTitle = sectionTitle;
}
return section;
}
- (NSArray<OWSTableSection *> *)contactsSectionsForSearchResults:(ComposeScreenSearchResultSet *)searchResults
{
__weak __typeof(self) weakSelf = self;
NSMutableArray<OWSTableSection *> *sections = [NSMutableArray new];
// Contacts, filtered with the search text.
NSArray<SignalAccount *> *filteredSignalAccounts = searchResults.signalAccounts;
__block BOOL hasSearchResults = NO;
NSMutableSet<NSString *> *matchedAccountPhoneNumbers = [NSMutableSet new];
NSMutableSet<NSString *> *matchedAccountUsernames = [NSMutableSet new];
OWSTableSection *contactsSection =
[self buildSectionWithTitle:OWSLocalizedString(@"COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
@"Table section header for contact listing when composing a new message")];
OWSAssertIsOnMainThread();
[self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) {
NSSet<SignalServiceAddress *> *addressesToSkip = [self.blockingManager blockedAddressesWithTransaction:transaction];
for (SignalAccount *signalAccount in filteredSignalAccounts) {
hasSearchResults = YES;
if ([addressesToSkip containsObject:signalAccount.recipientAddress]) {
continue;
}
NSString *_Nullable phoneNumber = signalAccount.recipientAddress.phoneNumber;
if (phoneNumber) {
[matchedAccountPhoneNumbers addObject:phoneNumber];
}
NSString *_Nullable username = [self.profileManagerImpl usernameForAddress:signalAccount.recipientAddress
transaction:transaction];
if (username) {
[matchedAccountUsernames addObject:username];
}
PickedRecipient *recipient = [PickedRecipient forAddress:signalAccount.recipientAddress];
[contactsSection addItem:[self itemForRecipient:recipient]];
}
}];
if (filteredSignalAccounts.count > 0) {
[sections addObject:contactsSection];
}
OWSTableSection *groupSection = [self groupSectionForSearchResults:searchResults];
if (groupSection != nil) {
hasSearchResults = YES;
[sections addObject:groupSection];
}
OWSTableSection *phoneNumbersSection =
[self buildSectionWithTitle:OWSLocalizedString(@"COMPOSE_MESSAGE_PHONE_NUMBER_SEARCH_SECTION_TITLE",
@"Table section header for phone number search when composing a new message")];
NSArray<NSString *> *searchPhoneNumbers = [self parsePossibleSearchPhoneNumbers];
for (NSString *phoneNumber in searchPhoneNumbers) {
OWSAssertDebug(phoneNumber.length > 0);
// We're already showing this user, skip it.
if ([matchedAccountPhoneNumbers containsObject:phoneNumber]) {
continue;
}
SignalServiceAddress *address = [[SignalServiceAddress alloc] initWithPhoneNumber:phoneNumber];
BOOL isRegistered = [self.nonContactAccountSet containsObject:address];
PickedRecipient *recipient = [PickedRecipient forAddress:address];
if (self.shouldShowInvites) {
[phoneNumbersSection
addItem:[OWSTableItem
itemWithCustomCellBlock:^{
NonContactTableViewCell *cell = [NonContactTableViewCell new];
__strong typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return cell;
}
if (![strongSelf.delegate recipientPicker:strongSelf getRecipientState:recipient]) {
cell.selectionStyle = UITableViewCellSelectionStyleNone;
}
[self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) {
cell.accessoryMessage = [strongSelf.delegate recipientPicker:strongSelf
accessoryMessageForRecipient:recipient
transaction:transaction];
}];
[cell configureWithPhoneNumber:phoneNumber
isRegistered:isRegistered
hideHeaderLabel:!strongSelf.shouldShowInvites];
NSString *cellName = [NSString stringWithFormat:@"phone_number.%@", phoneNumber];
cell.accessibilityIdentifier
= ACCESSIBILITY_IDENTIFIER_WITH_NAME(RecipientPickerViewController, cellName);
[strongSelf.delegate recipientPicker:strongSelf willRenderRecipient:recipient];
return cell;
}
actionBlock:^{
[weakSelf tryToSelectRecipient:recipient];
}]];
} else if (isRegistered || self.allowsSelectingUnregisteredPhoneNumbers) {
[phoneNumbersSection addItem:[self itemForRecipient:recipient]];
}
}
if (phoneNumbersSection.itemCount > 0) {
hasSearchResults = YES;
[sections addObject:phoneNumbersSection];
}
// Username lookup
if (SSKFeatureFlags.usernames) {
NSString *usernameMatch = self.searchText;
NSString *_Nullable localUsername = self.profileManager.localUsername;
NSError *error;
NSRegularExpression *startsWithNumberRegex = [[NSRegularExpression alloc] initWithPattern:@"^[0-9]+"
options:0
error:&error];
if (!startsWithNumberRegex || error) {
OWSFailDebug(@"Unexpected error creating regex %@", error.userErrorDescription);
}
BOOL startsWithNumber = [startsWithNumberRegex hasMatchWithInput:usernameMatch];
// If user searches for e164 starting with +, don't treat that as a
// username search.
BOOL startsWithPlus = [usernameMatch hasPrefix:@"+"];
// TODO: Should we use validUsernameRegex?
if (usernameMatch.length > 0 && !startsWithNumber && !startsWithPlus
&& ![NSObject isNullableObject:usernameMatch equalTo:localUsername]
&& ![matchedAccountUsernames containsObject:usernameMatch]) {
hasSearchResults = YES;
OWSTableSection *usernameSection = [self
buildSectionWithTitle:OWSLocalizedString(@"COMPOSE_MESSAGE_USERNAME_SEARCH_SECTION_TITLE",
@"Table section header for username search when composing a new message")];
[usernameSection addItem:[OWSTableItem
itemWithCustomCellBlock:^{
NonContactTableViewCell *cell = [NonContactTableViewCell new];
__strong typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return cell;
}
[cell configureWithUsername:usernameMatch
hideHeaderLabel:!strongSelf.shouldShowInvites];
NSString *cellName =
[NSString stringWithFormat:@"username.%@", usernameMatch];
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
RecipientPickerViewController, cellName);
return cell;
}
actionBlock:^{
[weakSelf lookupUsername:usernameMatch];
}]];
[sections addObject:usernameSection];
}
}
if (!hasSearchResults) {
// No Search Results
OWSTableSection *noResultsSection = [OWSTableSection new];
[noResultsSection
addItem:[OWSTableItem softCenterLabelItemWithText:
OWSLocalizedString(@"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];
}
- (void)setPickedRecipients:(nullable NSArray<PickedRecipient *> *)pickedRecipients
{
@synchronized(self) {
_pickedRecipients = pickedRecipients;
}
[self updateTableContents];
}
- (nullable NSArray<PickedRecipient *> *)pickedRecipients
{
@synchronized(self) {
return _pickedRecipients;
}
}
#pragma mark - No Contacts Mode
- (void)hideBackgroundView
{
[Environment.shared.preferences setHasDeclinedNoContactsView:YES];
[self showContactAppropriateViews];
}
- (void)presentInviteFlow
{
OWSInviteFlow *inviteFlow = [[OWSInviteFlow alloc] initWithPresentingViewController:self];
self.inviteFlow = inviteFlow;
[inviteFlow presentWithIsAnimated:YES completion:nil];
}
- (void)showContactAppropriateViews
{
if (self.contactsManagerImpl.isSystemContactsAuthorized) {
if (self.contactsViewHelper.hasUpdatedContactsAtLeastOnce && self.allSignalAccounts.count < 1
&& ![Environment.shared.preferences hasDeclinedNoContactsView]) {
self.isNoContactsModeActive = YES;
} else {
self.isNoContactsModeActive = NO;
}
} else {
// don't show "no signal contacts", show "no contact access"
self.isNoContactsModeActive = NO;
}
}
- (void)newGroupButtonPressed
{
[self.delegate recipientPickerNewGroupButtonWasPressed];
}
- (void)setIsNoContactsModeActive:(BOOL)isNoContactsModeActive
{
if (isNoContactsModeActive == _isNoContactsModeActive) {
return;
}
_isNoContactsModeActive = isNoContactsModeActive;
if (isNoContactsModeActive) {
self.signalContactsStackView.hidden = YES;
self.noSignalContactsView.hidden = NO;
} else {
self.signalContactsStackView.hidden = NO;
self.noSignalContactsView.hidden = YES;
}
[self updateTableContents];
}
- (void)clearSearchText
{
self.searchBar.text = @"";
[self searchTextDidChange];
}
#pragma mark - Send Invite By SMS
- (void)sendTextToPhoneNumber:(NSString *)phoneNumber
{
OWSInviteFlow *inviteFlow = [[OWSInviteFlow alloc] initWithPresentingViewController:self];
self.inviteFlow = inviteFlow;
OWSAssertDebug([phoneNumber length] > 0);
NSString *confirmMessage = OWSLocalizedString(@"SEND_SMS_CONFIRM_TITLE", @"");
if ([phoneNumber length] > 0) {
confirmMessage = [[OWSLocalizedString(@"SEND_SMS_INVITE_TITLE", @"") stringByAppendingString:phoneNumber]
stringByAppendingString:OWSLocalizedString(@"QUESTIONMARK_PUNCTUATION", @"")];
}
ActionSheetController *alert =
[[ActionSheetController alloc] initWithTitle:OWSLocalizedString(@"CONFIRMATION_TITLE", @"")
message:confirmMessage];
ActionSheetAction *okAction = [[ActionSheetAction alloc]
initWithTitle:CommonStrings.okButton
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"ok")
style:ActionSheetActionStyleDefault
handler:^(ActionSheetAction *action) {
[self.searchBar resignFirstResponder];
if ([MFMessageComposeViewController canSendText]) {
[inviteFlow sendSMSToPhoneNumbers:@[ phoneNumber ]];
} else {
[OWSActionSheets
showErrorAlertWithMessage:OWSLocalizedString(@"UNSUPPORTED_FEATURE_ERROR", @"")];
}
}];
[alert addAction:[OWSActionSheets cancelAction]];
[alert addAction:okAction];
[self clearSearchText];
// must dismiss search controller before presenting alert.
if ([self presentedViewController]) {
[self dismissViewControllerAnimated:YES
completion:^{
[self presentActionSheet:alert];
}];
} else {
[self presentActionSheet:alert];
}
}
#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: {
[OWSActionSheets showErrorAlertWithMessage:OWSLocalizedString(@"SEND_INVITE_FAILURE", @"")];
break;
}
case MessageComposeResultSent: {
[self dismissViewControllerAnimated:NO
completion:^{
OWSLogDebug(@"view controller dismissed");
}];
[OWSActionSheets showActionSheetWithTitle:OWSLocalizedString(@"SEND_INVITE_SUCCESS",
@"Alert body after invite succeeded")];
break;
}
default:
break;
}
[self dismissViewControllerAnimated:YES completion:nil];
}
#pragma mark - Methods
- (void)lookupUsername:(NSString *)username
{
OWSAssertDebug(username.length > 0);
__weak __typeof(self) weakSelf = self;
[ModalActivityIndicatorViewController
presentFromViewController:self
canCancel:YES
backgroundBlock:^(ModalActivityIndicatorViewController *modal) {
[self.profileManagerImpl fetchProfileForUsername:username
success:^(SignalServiceAddress *address) {
if (modal.wasCancelled) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
[modal dismissWithCompletion:^{
[weakSelf tryToSelectRecipient:[PickedRecipient forAddress:address]];
}];
});
}
notFound:^{
if (modal.wasCancelled) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
[modal dismissWithCompletion:^{
NSString *usernameNotFoundFormat = OWSLocalizedString(@"USERNAME_NOT_FOUND_FORMAT",
@"A message indicating that the given username is not a registered signal "
@"account. Embeds "
@"{{username}}");
[OWSActionSheets
showActionSheetWithTitle:
OWSLocalizedString(@"USERNAME_NOT_FOUND_TITLE",
@"A message indicating that the given username was not "
@"registered with signal.")
message:[[NSString alloc]
initWithFormat:usernameNotFoundFormat,
[CommonFormats formatUsername:username]]];
}];
});
}
failure:^(NSError *error) {
if (modal.wasCancelled) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
[modal dismissWithCompletion:^{
[OWSActionSheets showErrorAlertWithMessage:
OWSLocalizedString(@"USERNAME_LOOKUP_ERROR",
@"A message indicating that username lookup failed.")];
}];
});
}];
}];
}
#pragma mark - OWSTableViewControllerDelegate
- (void)tableViewWillBeginDragging
{
[self.searchBar resignFirstResponder];
[self.delegate recipientPickerTableViewWillBeginDragging:self];
}
#pragma mark - ContactsViewHelperObserver
- (void)contactsViewHelperDidUpdateContacts
{
[self updateTableContents];
[self showContactAppropriateViews];
}
#pragma mark - FindByPhoneNumberDelegate
- (void)findByPhoneNumber:(FindByPhoneNumberViewController *)findByPhoneNumber
didSelectAddress:(SignalServiceAddress *)address
{
OWSAssertDebug(address.isValid);
[self tryToSelectRecipient:[PickedRecipient forAddress:address]];
}
#pragma mark - UISearchBarDelegate
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
NSString *eventId = [NSString stringWithFormat:@"Compose Search - %@", searchText];
[BenchManager startEventWithTitle:@"Compose Search" eventId:eventId];
[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)setSearchResults:(nullable ComposeScreenSearchResultSet *)searchResults
{
if (searchResults == nil) {
if (self.searchText.length >= kMinimumSearchLength) {
OWSLogVerbose(@"user has entered text since clearing results. Skipping stale results.");
return;
}
} else {
if (![searchResults.searchText isEqualToString:self.searchText]) {
OWSLogVerbose(@"user has changed text since search started. Skipping stale results.");
return;
}
}
if (![NSObject isNullableObject:_searchResults equalTo:searchResults]) {
OWSLogVerbose(@"showing search results for term: %@", searchResults.searchText);
_searchResults = searchResults;
[self updateSearchPhoneNumbers];
[self updateTableContents];
}
}
- (void)searchTextDidChange
{
NSString *searchText = self.searchText;
if (searchText.length < kMinimumSearchLength) {
self.searchResults = nil;
self.lastSearchText = nil;
return;
}
if ([NSObject isNullableObject:self.lastSearchText equalTo:searchText]) {
return;
}
self.lastSearchText = searchText;
__weak __typeof(self) weakSelf = self;
__block ComposeScreenSearchResultSet *searchResults;
[self.databaseStorage
asyncReadWithBlock:^(SDSAnyReadTransaction *transaction) {
searchResults =
[self.fullTextSearcher searchForComposeScreenWithSearchText:searchText
omitLocalUser:self.shouldHideLocalRecipient
maxResults:FullTextSearcher.kDefaultMaxResults
transaction:transaction];
}
completion:^{
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (![NSObject isNullableObject:strongSelf.lastSearchText equalTo:searchText]) {
// Discard obsolete search results.
return;
}
strongSelf.searchResults = searchResults;
NSString *eventId = [NSString stringWithFormat:@"Compose Search - %@", searchResults.searchText];
[BenchManager completeEventWithEventId:eventId];
}];
}
#pragma mark -
- (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]) {
OWSAssertDebug(countryCode.length > 0);
NSString *callingCode = [PhoneNumberUtil callingCodeFromCountryCode:countryCode];
OWSAssertDebug(callingCode.length > 0);
OWSAssertDebug([callingCode hasPrefix:@"+"]);
OWSAssertDebug(![callingCode isEqualToString:@"+0"]);
map[callingCode] = countryCode;
}
result = [map copy];
});
return result;
}
- (nullable NSString *)callingCodeForPossiblePhoneNumber:(NSString *)phoneNumber
{
OWSAssertDebug([phoneNumber hasPrefix:@"+"]);
for (NSString *callingCode in [self callingCodesToCountryCodeMap].allKeys) {
if ([phoneNumber hasPrefix:callingCode]) {
return callingCode;
}
}
return nil;
}
- (NSString *)searchText
{
NSString *rawText = self.searchBar.text;
return rawText.ows_stripped ?: @"";
}
- (NSArray<NSString *> *)parsePossibleSearchPhoneNumbers
{
NSString *searchText = self.searchText;
if (searchText.length < 8) {
return @[];
}
NSMutableSet<NSString *> *parsedPhoneNumbers = [NSMutableSet new];
for (PhoneNumber *phoneNumber in
[PhoneNumber tryParsePhoneNumbersFromUserSpecifiedText: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
{
NSMutableSet<NSString *> *unknownPhoneNumbers = [NSMutableSet new];
for (NSString *phoneNumber in phoneNumbers) {
if (!
[self.nonContactAccountSet containsObject:[[SignalServiceAddress alloc] initWithPhoneNumber:phoneNumber]]) {
[unknownPhoneNumbers addObject:phoneNumber];
}
}
if ([unknownPhoneNumbers count] < 1) {
return;
}
__weak RecipientPickerViewController *weakSelf = self;
OWSContactDiscoveryTask *discoveryTask = [[OWSContactDiscoveryTask alloc] initWithPhoneNumbers:unknownPhoneNumbers];
[discoveryTask performAtQoS:QOS_CLASS_USER_INITIATED
callbackQueue:dispatch_get_main_queue()
success:^(NSSet<SignalRecipient *> *resultSet) {
[weakSelf updateNonContactAccountSet:[resultSet allObjects]];
}
failure:^(NSError *error) {
// Ignore.
}];
}
- (void)updateNonContactAccountSet:(NSArray<SignalRecipient *> *)recipients
{
BOOL didUpdate = NO;
for (SignalRecipient *recipient in recipients) {
if ([self.nonContactAccountSet containsObject:recipient.address]) {
continue;
}
[self.nonContactAccountSet addObject:recipient.address];
didUpdate = YES;
}
if (didUpdate) {
[self updateTableContents];
}
}
#pragma mark - Theme
- (void)applyTheme
{
OWSAssertIsOnMainThread();
[super applyTheme];
[self.tableViewController applyThemeToViewController:self];
self.searchBar.searchFieldBackgroundColorOverride = Theme.searchFieldElevatedBackgroundColor;
self.tableViewController.tableView.sectionIndexColor = Theme.primaryTextColor;
}
- (void)applyThemeToViewController:(UIViewController *)viewController
{
[self.tableViewController applyThemeToViewController:viewController];
}
- (void)removeThemeFromViewController:(UIViewController *)viewController
{
[self.tableViewController removeThemeFromViewController:viewController];
}
@end
NS_ASSUME_NONNULL_END