From 3080cb512bcc81b8fb47221cb1153533f958d3b3 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Sat, 14 Oct 2017 13:20:46 -0400 Subject: [PATCH 1/4] Compose View: collation index and group search - Include table index for contacts - Fix extra spacing in OWS table view - Separate search results into contact/invite sections - Include groups in search results when composing new message - Compose Screen search matches on group member names // FREEBIE --- Signal.xcodeproj/project.pbxproj | 21 + Signal/src/UserInterface/Strings.swift | 4 + .../src/ViewControllers/ContactsPicker.swift | 1 + .../ConversationViewController.m | 2 +- .../src/ViewControllers/InboxTableViewCell.m | 2 +- .../NewContactThreadViewController.m | 374 ++++++++++++++---- .../ViewControllers/NewGroupViewController.m | 2 +- .../OWSConversationSettingsViewController.m | 2 +- .../ViewControllers/OWSTableViewController.h | 3 + .../ViewControllers/OWSTableViewController.m | 45 ++- Signal/src/contact/OWSContactsManager.h | 5 + Signal/src/contact/OWSContactsManager.m | 18 + Signal/src/environment/Environment.h | 1 + Signal/src/environment/Environment.m | 7 +- Signal/src/environment/NotificationsManager.m | 2 +- Signal/src/util/Searcher.swift | 45 +++ Signal/src/views/ContactTableViewCell.h | 2 + Signal/src/views/ContactTableViewCell.m | 5 +- Signal/src/views/GroupTableViewCell.swift | 69 ++++ Signal/test/util/SearcherTest.swift | 60 +++ .../translations/en.lproj/Localizable.strings | 9 + SignalServiceKit/src/Messages/TSGroupModel.h | 2 +- SignalServiceKit/src/Messages/TSGroupModel.m | 2 +- 23 files changed, 586 insertions(+), 97 deletions(-) create mode 100644 Signal/src/util/Searcher.swift create mode 100644 Signal/src/views/GroupTableViewCell.swift create mode 100644 Signal/test/util/SearcherTest.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 3b8f1042b..2b2768216 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -155,6 +155,11 @@ 452ECA4D1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; }; 452ECA4E1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; }; 4531C9C41DD8E6D800F08304 /* JSQMessagesCollectionViewCell+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 4531C9C31DD8E6D800F08304 /* JSQMessagesCollectionViewCell+OWS.m */; }; + 45360B8D1F9521F800FA666C /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; }; + 45360B8E1F9521F800FA666C /* Searcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8C1F9521F800FA666C /* Searcher.swift */; }; + 45360B901F9527DA00FA666C /* SearcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45360B8F1F9527DA00FA666C /* SearcherTest.swift */; }; + 45360B911F952AA900FA666C /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; }; + 45360B921F952AB400FA666C /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C04D7F1F6195E6004308B3 /* OWSFlatButton.swift */; }; 45387B041E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.m in Sources */ = {isa = PBXBuildFile; fileRef = 45387B031E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.m */; }; 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; }; 4539B5871F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; }; @@ -209,6 +214,8 @@ 458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 458E38361D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m */; }; 458E383A1D6699FA0094BD24 /* OWSDeviceProvisioningURLParserTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 458E38391D6699FA0094BD24 /* OWSDeviceProvisioningURLParserTest.m */; }; 459311FC1D75C948008DD4F0 /* OWSDeviceTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 459311FB1D75C948008DD4F0 /* OWSDeviceTableViewCell.m */; }; + 45A663C51F92EC760027B59E /* GroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */; }; + 45A663C61F92EC760027B59E /* GroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */; }; 45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A6DAD51EBBF85500893231 /* ReminderView.swift */; }; 45A6DAD71EBBF85500893231 /* ReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A6DAD51EBBF85500893231 /* ReminderView.swift */; }; 45AE48511E0732D6004D96C2 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AE48501E0732D6004D96C2 /* TurnServerInfo.swift */; }; @@ -624,6 +631,8 @@ 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MessageFetcherJob.swift; path = Jobs/MessageFetcherJob.swift; sourceTree = ""; }; 4531C9C21DD8E6D800F08304 /* JSQMessagesCollectionViewCell+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "JSQMessagesCollectionViewCell+OWS.h"; sourceTree = ""; }; 4531C9C31DD8E6D800F08304 /* JSQMessagesCollectionViewCell+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "JSQMessagesCollectionViewCell+OWS.m"; sourceTree = ""; }; + 45360B8C1F9521F800FA666C /* Searcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Searcher.swift; sourceTree = ""; }; + 45360B8F1F9527DA00FA666C /* SearcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearcherTest.swift; sourceTree = ""; }; 45387B021E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWS102MoveLoggingPreferenceToUserDefaults.h; path = Migrations/OWS102MoveLoggingPreferenceToUserDefaults.h; sourceTree = ""; }; 45387B031E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWS102MoveLoggingPreferenceToUserDefaults.m; path = Migrations/OWS102MoveLoggingPreferenceToUserDefaults.m; sourceTree = ""; }; 4539B5851F79348F007141FF /* PushRegistrationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushRegistrationManager.swift; sourceTree = ""; }; @@ -684,6 +693,7 @@ 459311FB1D75C948008DD4F0 /* OWSDeviceTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSDeviceTableViewCell.m; sourceTree = ""; }; 4597E94E1D8313C100040CDE /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = translations/sq.lproj/Localizable.strings; sourceTree = ""; }; 4597E94F1D8313CB00040CDE /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = translations/bg.lproj/Localizable.strings; sourceTree = ""; }; + 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTableViewCell.swift; sourceTree = ""; }; 45A6DAD51EBBF85500893231 /* ReminderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReminderView.swift; sourceTree = ""; }; 45AE48501E0732D6004D96C2 /* TurnServerInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TurnServerInfo.swift; sourceTree = ""; }; 45B201741DAECBFD00C461E0 /* Signal-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Signal-Bridging-Header.h"; sourceTree = ""; }; @@ -1477,6 +1487,7 @@ 76EB04FB18170B33006006FC /* Util.h */, 45F170D51E315310003FC1F2 /* Weak.swift */, 45F170CB1E310E22003FC1F2 /* WeakTimer.swift */, + 45360B8C1F9521F800FA666C /* Searcher.swift */, ); path = util; sourceTree = ""; @@ -1489,6 +1500,7 @@ 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */, 45F3AEB51DFDE7900080CE33 /* AvatarImageView.swift */, 451764291DE939FD00EDB8B9 /* ContactCell.swift */, + 45A663C41F92EC760027B59E /* GroupTableViewCell.swift */, 451764281DE939FD00EDB8B9 /* ContactCell.xib */, 76EB052E18170B33006006FC /* ContactTableViewCell.h */, 76EB052F18170B33006006FC /* ContactTableViewCell.m */, @@ -1672,6 +1684,7 @@ B660F6B41C29868000687D6E /* UtilTest.m */, 45666F571D9B2880008FE134 /* OWSScrubbingLogFormatterTest.m */, 455AC69D1F4F8B0300134004 /* ImageCacheTest.swift */, + 45360B8F1F9527DA00FA666C /* SearcherTest.swift */, ); path = util; sourceTree = ""; @@ -2355,6 +2368,7 @@ 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */, 45F170BB1E2FC5D3003FC1F2 /* CallAudioService.swift in Sources */, 34B3F8711E8DF1700035BE1A /* AboutTableViewController.m in Sources */, + 45360B8D1F9521F800FA666C /* Searcher.swift in Sources */, 34B3F88D1E8DF1700035BE1A /* OWSQRCodeScanningViewController.m in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 34B3F8811E8DF1700035BE1A /* LockInteractionController.m in Sources */, @@ -2403,6 +2417,8 @@ 76EB058818170B33006006FC /* OWSPreferences.m in Sources */, 34330A611E788EA900DF2FB9 /* AttachmentUploadView.m in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, + 34D1F0B01F867BFC0066283D /* OWSSystemMessageCell.m in Sources */, + 45A663C51F92EC760027B59E /* GroupTableViewCell.swift in Sources */, 34B3F87D1E8DF1700035BE1A /* FullImageViewController.m in Sources */, 45666F7B1D9C0533008FE134 /* OWSDatabaseMigration.m in Sources */, B90418E6183E9DD40038554A /* DateUtil.m in Sources */, @@ -2453,7 +2469,9 @@ 45AE48521E0732D6004D96C2 /* TurnServerInfo.swift in Sources */, 450873C41D9D5149006B54F2 /* OWSExpirationTimerView.m in Sources */, 453D28BB1D332DB100D523F0 /* OWSMessagesBubblesSizeCalculator.m in Sources */, + 45360B901F9527DA00FA666C /* SearcherTest.swift in Sources */, B660F7561C29988E00687D6E /* PushManager.m in Sources */, + 45360B911F952AA900FA666C /* MarqueeLabel.swift in Sources */, 45FBC5D21DF8592E00E9B410 /* SignalCall.swift in Sources */, 451A13B21E13DED2000A50FD /* CallNotificationsAdapter.swift in Sources */, 45C681B81D305A580050903A /* OWSCall.m in Sources */, @@ -2467,7 +2485,9 @@ B660F7721C29988E00687D6E /* AppStoreRating.m in Sources */, B660F7751C29988E00687D6E /* UIColor+OWS.m in Sources */, B660F7761C29988E00687D6E /* UIFont+OWS.m in Sources */, + 45360B8E1F9521F800FA666C /* Searcher.swift in Sources */, B660F7771C29988E00687D6E /* UIImage+OWS.m in Sources */, + 45360B921F952AB400FA666C /* OWSFlatButton.swift in Sources */, 954AEE6A1DF33E01002E5410 /* ContactsPickerTest.swift in Sources */, 455AC69C1F4F79E500134004 /* ImageCache.swift in Sources */, 4556FA691F54AA9500AF40DD /* DebugUIProfile.swift in Sources */, @@ -2491,6 +2511,7 @@ 45E7A6A81E71CA7E00D44FB5 /* DisplayableTextFilterTest.swift in Sources */, 451DA3CB1F148AAD008E2423 /* CallViewController.swift in Sources */, 456F6E201E2411A000FD2210 /* CallService.swift in Sources */, + 45A663C61F92EC760027B59E /* GroupTableViewCell.swift in Sources */, 45E615171E8C59100018AD52 /* DisplayableTextFilter.swift in Sources */, B660F6DF1C29868000687D6E /* QueueTest.m in Sources */, B660F6BB1C29868000687D6E /* OWSContactsManagerTest.m in Sources */, diff --git a/Signal/src/UserInterface/Strings.swift b/Signal/src/UserInterface/Strings.swift index 2d683d53e..e62c9d8e4 100644 --- a/Signal/src/UserInterface/Strings.swift +++ b/Signal/src/UserInterface/Strings.swift @@ -14,6 +14,10 @@ import Foundation } +@objc class MessageStrings: NSObject { + static let newGroupDefaultTitle = NSLocalizedString("NEW_GROUP_DEFAULT_TITLE", comment: "Used in place of the group name when a group has not yet been named.") +} + @objc class CallStrings: NSObject { static let callStatusFormat = NSLocalizedString("CALL_STATUS_FORMAT", comment: "embeds {{Call Status}} in call screen label. For ongoing calls, {{Call Status}} is a seconds timer like 01:23, otherwise {{Call Status}} is a short text like 'Ringing', 'Busy', or 'Failed Call'") diff --git a/Signal/src/ViewControllers/ContactsPicker.swift b/Signal/src/ViewControllers/ContactsPicker.swift index 29278653b..3ae37a706 100644 --- a/Signal/src/ViewControllers/ContactsPicker.swift +++ b/Signal/src/ViewControllers/ContactsPicker.swift @@ -289,6 +289,7 @@ open class ContactsPicker: OWSViewController, UITableViewDelegate, UITableViewDa return nil } + // Don't show empty sections if dataSource[section].count > 0 { guard section < collation.sectionTitles.count else { return nil diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index d647a542a..4981cf8b5 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -1251,7 +1251,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { NSAttributedString *name; if (self.thread.isGroupThread) { if (self.thread.name.length == 0) { - name = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @"")]; + name = [[NSAttributedString alloc] initWithString:[MessageStrings newGroupDefaultTitle]]; } else { name = [[NSAttributedString alloc] initWithString:self.thread.name]; } diff --git a/Signal/src/ViewControllers/InboxTableViewCell.m b/Signal/src/ViewControllers/InboxTableViewCell.m index 4312ccb9e..693dfa43d 100644 --- a/Signal/src/ViewControllers/InboxTableViewCell.m +++ b/Signal/src/ViewControllers/InboxTableViewCell.m @@ -327,7 +327,7 @@ const NSUInteger kAvatarViewDiameter = 52; NSAttributedString *name; if (thread.isGroupThread) { if (thread.name.length == 0) { - name = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @"")]; + name = [[NSAttributedString alloc] initWithString:[MessageStrings newGroupDefaultTitle]]; } else { name = [[NSAttributedString alloc] initWithString:thread.name]; } diff --git a/Signal/src/ViewControllers/NewContactThreadViewController.m b/Signal/src/ViewControllers/NewContactThreadViewController.m index e348f9581..3663ca490 100644 --- a/Signal/src/ViewControllers/NewContactThreadViewController.m +++ b/Signal/src/ViewControllers/NewContactThreadViewController.m @@ -22,6 +22,22 @@ NS_ASSUME_NONNULL_BEGIN +@interface SignalAccount (Collation) + +- (NSString *)stringForCollation; + +@end + +@implementation SignalAccount (Collation) + +- (NSString *)stringForCollation +{ + OWSContactsManager *contactsManager = [Environment getCurrent].contactsManager; + return [contactsManager comparableNameForSignalAccount:self]; +} + +@end + @interface NewContactThreadViewController () 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) { + // Offset the collation section to account for the noncollated sections. + NSInteger sectionIndex = [self.collation sectionForSectionIndexTitleAtIndex:index] + noncollatedSections; + if (sectionIndex < 0) { + // Sentinal in case we change our section ordering in a surprising way. + OWSFail(@"Unexpected negative section index"); + return 0; + } + if (sectionIndex >= (NSInteger)contents.sections.count) { + // Sentinal in case we change our section ordering in a surprising way. + OWSFail(@"Unexpectedly large index"); + return 0; + } + + return sectionIndex; + }; + contents.sectionIndexTitlesForTableViewBlock = ^NSArray *_Nonnull + { + return self.collation.sectionTitles; + }; + } + + self.tableViewController.contents = contents; +} + +- (NSArray *)collatedContactsSections +{ + if (self.contactsViewHelper.signalAccounts.count < 1) { + // No Contacts + OWSTableSection *contactsSection = [OWSTableSection new]; + + if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized + && self.contactsViewHelper.hasUpdatedContactsAtLeastOnce) { + + [contactsSection + addItem:[OWSTableItem + softCenterLabelItemWithText:NSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_CONTACTS", + @"A label that indicates the user has no Signal contacts.") + customRowHeight:self.actionCellHeight]]; + } + + return @[ contactsSection ]; + } + __weak NewContactThreadViewController *weakSelf = self; + + NSMutableArray *contactSections = [NSMutableArray new]; + + NSMutableArray *> *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 *signalAccounts = collatedSignalAccounts[i]; + NSMutableArray *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 configureWithSignalAccount:signalAccount contactsManager:self.contactsViewHelper.contactsManager]; + + return cell; + } + customRowHeight:[ContactTableViewCell rowHeight] + 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 *)contactsSectionsForSearch +{ + __weak NewContactThreadViewController *weakSelf = self; + + NSMutableArray *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 *searchPhoneNumbers = [self parsePossibleSearchPhoneNumbers]; @@ -334,7 +478,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssert(phoneNumber.length > 0); if ([self.nonContactAccountSet containsObject:phoneNumber]) { - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ + [phoneNumbersSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ ContactTableViewCell *cell = [ContactTableViewCell new]; BOOL isBlocked = [helper isRecipientIdBlocked:phoneNumber]; if (isBlocked) { @@ -351,32 +495,42 @@ NS_ASSUME_NONNULL_BEGIN return cell; } - customRowHeight:[ContactTableViewCell rowHeight] - actionBlock:^{ - [weakSelf newConversationWith:phoneNumber]; - }]]; + customRowHeight:[ContactTableViewCell rowHeight] + 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 receipient's phone number."), phoneNumber]; - [section addItem:[OWSTableItem disclosureItemWithText:text - customRowHeight:kActionCellHeight - actionBlock:^{ - [weakSelf sendTextToPhoneNumber:phoneNumber]; - }]]; + [phoneNumbersSection addItem:[OWSTableItem disclosureItemWithText:text + customRowHeight:self.actionCellHeight + actionBlock:^{ + [weakSelf sendTextToPhoneNumber:phoneNumber]; + }]]; } } + if (searchPhoneNumbers.count > 0) { + [sections addObject:phoneNumbersSection]; + } - // Contacts, possibly filtered with the search text. + // Contacts, filtered with the search text. NSArray *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; } - [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ + [contactsSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ ContactTableViewCell *cell = [ContactTableViewCell new]; BOOL isBlocked = [helper isRecipientIdBlocked:signalAccount.recipientId]; if (isBlocked) { @@ -388,76 +542,124 @@ NS_ASSUME_NONNULL_BEGIN return cell; } - customRowHeight:[ContactTableViewCell rowHeight] - actionBlock:^{ - [weakSelf newConversationWith:signalAccount.recipientId]; - }]]; + customRowHeight:[ContactTableViewCell rowHeight] + actionBlock:^{ + [weakSelf newConversationWithRecipientId:signalAccount.recipientId]; + }]]; + } + if (filteredSignalAccounts.count > 0) { + [sections addObject:contactsSection]; } - BOOL hasSearchText = [self.searchBar text].length > 0; - BOOL hasSearchResults = filteredSignalAccounts.count > 0; - - // Invitation offers for non-signal contacts - if (hasSearchText) { - for (Contact *contact in [helper nonSignalContactsMatchingSearchString:[self.searchBar text]]) { - 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 receipient's phone number."), - displayName]; - [section addItem:[OWSTableItem disclosureItemWithText:text - customRowHeight:kActionCellHeight - actionBlock:^{ - [weakSelf sendTextToPhoneNumber:phoneNumber.toE164]; - }]]; + // 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 *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:[ContactTableViewCell rowHeight] + actionBlock:^{ + [weakSelf newConversationWithThread:thread]; + }]]; + } + if (filteredGroupThreads.count > 0) { + [sections addObject:groupSection]; } - if (!hasSearchText && helper.signalAccounts.count < 1) { - // No Contacts - - if (self.contactsViewHelper.contactsManager.isSystemContactsAuthorized - && self.contactsViewHelper.hasUpdatedContactsAtLeastOnce) { - - [section - addItem:[OWSTableItem - softCenterLabelItemWithText:NSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_CONTACTS", - @"A label that indicates the user has no Signal contacts.") - customRowHeight:kActionCellHeight]]; + // 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 *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 receipient's phone number."), + displayName]; + [inviteeSection addItem:[OWSTableItem disclosureItemWithText:text + customRowHeight:self.actionCellHeight + actionBlock:^{ + [weakSelf sendTextToPhoneNumber:phoneNumber.toE164]; + }]]; + } + if (invitees.count > 0) { + [sections addObject:inviteeSection]; } - if (hasSearchText && !hasSearchResults) { - // No Search Results - [section addItem:[OWSTableItem softCenterLabelItemWithText: - NSLocalizedString(@"SETTINGS_BLOCK_LIST_NO_SEARCH_RESULTS", - @"A label that indicates the user's search has no matching results.") - customRowHeight:kActionCellHeight]]; + 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:self.actionCellHeight]]; + + [sections addObject:noResultsSection]; } - [contents addSection:section]; - - self.tableViewController.contents = contents; + return [sections copy]; } - (NSArray *)filteredSignalAccounts { - NSString *searchString = [self.searchBar text]; + NSString *searchString = self.searchBar.text; ContactsViewHelper *helper = self.contactsViewHelper; return [helper signalAccountsMatchingSearchString:searchString]; } +- (NSArray *)filteredGroupThreads +{ + AnySearcher *searcher = [[AnySearcher alloc] initWithIndexer:^NSString * _Nonnull(id _Nonnull obj) { + if (![obj isKindOfClass:[TSGroupThread class]]) { + OWSFail(@"unexpected item in searcher"); + return @""; + } + TSGroupThread *groupThread = (TSGroupThread *)obj; + NSString *groupName = groupThread.groupModel.groupName; + NSMutableString *groupMemberNames = [NSMutableString new]; + for (NSString *recipientId in groupThread.groupModel.groupMemberIds) { + NSString *contactName = [self.contactsViewHelper.contactsManager displayNameForPhoneIdentifier:recipientId]; + [groupMemberNames appendFormat:@" %@", contactName]; + } + + return [NSString stringWithFormat:@"%@ %@", groupName, groupMemberNames]; + }]; + + NSMutableArray *matchingThreads = [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; + if ([searcher item:groupThread doesMatchQuery:self.searchBar.text]) { + [matchingThreads addObject:groupThread]; + } + }]; + + return [matchingThreads copy]; +} + #pragma mark - No Contacts Mode - (void)hideBackgroundView @@ -619,13 +821,19 @@ NS_ASSUME_NONNULL_BEGIN [self dismissViewControllerAnimated:YES completion:nil]; } -- (void)newConversationWith:(NSString *)recipientId +- (void)newConversationWithRecipientId:(NSString *)recipientId { OWSAssert(recipientId.length > 0); + TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactId:recipientId]; + [self newConversationWithThread:thread]; +} +- (void)newConversationWithThread:(TSThread *)thread +{ + OWSAssert(thread != nil); [self dismissViewControllerAnimated:YES completion:^() { - [Environment presentConversationForRecipientId:recipientId withCompose:YES]; + [Environment presentConversationForThread:thread withCompose:YES]; }]; } @@ -662,7 +870,7 @@ NS_ASSUME_NONNULL_BEGIN { OWSAssert(recipientId.length > 0); - [self newConversationWith:recipientId]; + [self newConversationWithRecipientId:recipientId]; } #pragma mark - UISearchBarDelegate diff --git a/Signal/src/ViewControllers/NewGroupViewController.m b/Signal/src/ViewControllers/NewGroupViewController.m index 317d5e2e4..dc1fae3ff 100644 --- a/Signal/src/ViewControllers/NewGroupViewController.m +++ b/Signal/src/ViewControllers/NewGroupViewController.m @@ -98,7 +98,7 @@ const NSUInteger kNewGroupViewControllerAvatarWidth = 68; { [super loadView]; - self.title = NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @"The navbar title for the 'new group' view."); + self.title = [MessageStrings newGroupDefaultTitle]; self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"NEW_GROUP_CREATE_BUTTON", @"The title for the 'create group' button.") diff --git a/Signal/src/ViewControllers/OWSConversationSettingsViewController.m b/Signal/src/ViewControllers/OWSConversationSettingsViewController.m index 64f864aaf..f26c9edb3 100644 --- a/Signal/src/ViewControllers/OWSConversationSettingsViewController.m +++ b/Signal/src/ViewControllers/OWSConversationSettingsViewController.m @@ -126,7 +126,7 @@ NS_ASSUME_NONNULL_BEGIN threadName = [PhoneNumber bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber:self.thread.contactIdentifier]; } else if (threadName.length == 0 && [self isGroupThread]) { - threadName = NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @""); + threadName = [MessageStrings newGroupDefaultTitle]; } return threadName; } diff --git a/Signal/src/ViewControllers/OWSTableViewController.h b/Signal/src/ViewControllers/OWSTableViewController.h index d3fd989a5..629158ced 100644 --- a/Signal/src/ViewControllers/OWSTableViewController.h +++ b/Signal/src/ViewControllers/OWSTableViewController.h @@ -14,7 +14,10 @@ extern const CGFloat kOWSTable_DefaultCellHeight; @interface OWSTableContents : NSObject @property (nonatomic) NSString *title; +@property (nonatomic, nullable) NSInteger (^sectionForSectionIndexTitleBlock)(NSString *title, NSInteger index); +@property (nonatomic, nullable) NSArray * (^sectionIndexTitlesForTableViewBlock)(void); +@property (nonatomic, readonly) NSArray *sections; - (void)addSection:(OWSTableSection *)section; @end diff --git a/Signal/src/ViewControllers/OWSTableViewController.m b/Signal/src/ViewControllers/OWSTableViewController.m index d76d39aed..565b95cb5 100644 --- a/Signal/src/ViewControllers/OWSTableViewController.m +++ b/Signal/src/ViewControllers/OWSTableViewController.m @@ -509,19 +509,36 @@ NSString * const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier"; - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)sectionIndex { OWSTableSection *section = [self sectionForIndex:sectionIndex]; - if (section && section.customHeaderHeight) { + + if (!section) { + OWSFail(@"Section index out of bounds."); + return 0; + } + + if (section.customHeaderHeight) { return [section.customHeaderHeight floatValue]; + } else if (section.headerTitle.length > 0) { + return UITableViewAutomaticDimension; + } else { + return 0; } - return UITableViewAutomaticDimension; } - (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)sectionIndex { OWSTableSection *section = [self sectionForIndex:sectionIndex]; - if (section && section.customFooterHeight) { + if (!section) { + OWSFail(@"Section index out of bounds."); + return 0; + } + + if (section.customFooterHeight) { return [section.customFooterHeight floatValue]; + } else if (section.footerTitle.length > 0) { + return UITableViewAutomaticDimension; + } else { + return 0; } - return UITableViewAutomaticDimension; } // Called before the user changes the selection. Return a new indexPath, or nil, to change the proposed selection. @@ -545,6 +562,26 @@ NSString * const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier"; } } +#pragma mark Index + +- (NSInteger)tableView:(UITableView *)tableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index +{ + if (self.contents.sectionForSectionIndexTitleBlock) { + return self.contents.sectionForSectionIndexTitleBlock(title, index); + } else { + return 0; + } +} + +- (nullable NSArray *)sectionIndexTitlesForTableView:(UITableView *)tableView +{ + if (self.contents.sectionIndexTitlesForTableViewBlock) { + return self.contents.sectionIndexTitlesForTableViewBlock(); + } else { + return 0; + } +} + #pragma mark - Logging + (NSString *)tag diff --git a/Signal/src/contact/OWSContactsManager.h b/Signal/src/contact/OWSContactsManager.h index 26ead6b21..76e4fa4a8 100644 --- a/Signal/src/contact/OWSContactsManager.h +++ b/Signal/src/contact/OWSContactsManager.h @@ -80,6 +80,11 @@ extern NSString *const OWSContactsManagerSignalAccountsDidChangeNotification; - (NSString *)displayNameForPhoneIdentifier:(nullable NSString *)identifier; - (NSString *)displayNameForSignalAccount:(SignalAccount *)signalAccount; +/** + * Used for sorting, respects system contacts name sort order preference. + */ +- (NSString *)comparableNameForSignalAccount:(SignalAccount *)signalAccount; + // Generally we prefer the formattedProfileName over the raw profileName so as to // distinguish a profile name apart from a name pulled from the system's contacts. // This helps clarify when the remote person chooses a potentially confusing profile name. diff --git a/Signal/src/contact/OWSContactsManager.m b/Signal/src/contact/OWSContactsManager.m index 75d187caa..0e3b279a7 100644 --- a/Signal/src/contact/OWSContactsManager.m +++ b/Signal/src/contact/OWSContactsManager.m @@ -709,6 +709,24 @@ NSString *const kTSStorageManager_lastKnownContactRecipientIds = @"lastKnownCont return image; } +- (NSString *)comparableNameForSignalAccount:(SignalAccount *)signalAccount +{ + NSString *_Nullable name; + if (signalAccount.contact) { + if (ABPersonGetSortOrdering() == kABPersonSortByFirstName) { + name = signalAccount.contact.comparableNameFirstLast; + } else { + name = signalAccount.contact.comparableNameLastFirst; + } + } + + if (name.length < 1) { + name = signalAccount.recipientId; + } + + return name; +} + #pragma mark - Logging + (NSString *)tag diff --git a/Signal/src/environment/Environment.h b/Signal/src/environment/Environment.h index 966c7174f..7009f0200 100644 --- a/Signal/src/environment/Environment.h +++ b/Signal/src/environment/Environment.h @@ -67,5 +67,6 @@ + (void)callRecipientId:(NSString *)recipientId; + (void)presentConversationForThreadId:(NSString *)threadId; + (void)presentConversationForThread:(TSThread *)thread; ++ (void)presentConversationForThread:(TSThread *)thread withCompose:(BOOL)compose; @end diff --git a/Signal/src/environment/Environment.m b/Signal/src/environment/Environment.m index 683b0d9c9..1ef1b1028 100644 --- a/Signal/src/environment/Environment.m +++ b/Signal/src/environment/Environment.m @@ -247,7 +247,12 @@ static Environment *environment = nil; + (void)presentConversationForThread:(TSThread *)thread { - [self presentConversationForThread:thread keyboardOnViewAppearing:YES callOnViewAppearing:NO]; + [self presentConversationForThread:thread withCompose:YES]; +} + ++ (void)presentConversationForThread:(TSThread *)thread withCompose:(BOOL)compose +{ + [self presentConversationForThread:thread keyboardOnViewAppearing:compose callOnViewAppearing:NO]; } + (void)presentConversationForThread:(TSThread *)thread diff --git a/Signal/src/environment/NotificationsManager.m b/Signal/src/environment/NotificationsManager.m index df7ddfc7e..f6ab2a7a8 100644 --- a/Signal/src/environment/NotificationsManager.m +++ b/Signal/src/environment/NotificationsManager.m @@ -274,7 +274,7 @@ NSString *const kNotificationsManagerNewMesssageSoundName = @"NewMessage.aifc"; NSString *senderName = [contactsManager displayNameForPhoneIdentifier:message.authorId]; NSString *groupName = [thread.name stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; if (groupName.length < 1) { - groupName = NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @""); + groupName = [MessageStrings newGroupDefaultTitle]; } if ([UIApplication sharedApplication].applicationState != UIApplicationStateActive && messageDescription) { diff --git a/Signal/src/util/Searcher.swift b/Signal/src/util/Searcher.swift new file mode 100644 index 000000000..0cd260025 --- /dev/null +++ b/Signal/src/util/Searcher.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import Foundation + +// ObjC compatible searcher +@objc class AnySearcher: NSObject { + private let searcher: Searcher + + public init(indexer: @escaping (AnyObject) -> String ) { + searcher = Searcher(indexer: indexer) + super.init() + } + + @objc(item:doesMatchQuery:) + public func matches(item: AnyObject, query: String) -> Bool { + return searcher.matches(item: item, query: query) + } +} + +class Searcher { + + private let indexer: (T) -> String + + public init(indexer: @escaping (T) -> String) { + self.indexer = indexer + } + + public func matches(item: T, query: String) -> Bool { + let itemString = normalize(string: indexer(item)) + + return stem(string: query).map { queryStem in + return itemString.contains(queryStem) + }.reduce(true) { $0 && $1 } + } + + private func stem(string: String) -> [String] { + return normalize(string: string).components(separatedBy: .whitespaces) + } + + private func normalize(string: String) -> String { + return string.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Signal/src/views/ContactTableViewCell.h b/Signal/src/views/ContactTableViewCell.h index 4f3da3c30..73442b7fb 100644 --- a/Signal/src/views/ContactTableViewCell.h +++ b/Signal/src/views/ContactTableViewCell.h @@ -14,6 +14,8 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const kContactsTable_CellReuseIdentifier; +extern const NSUInteger kContactTableViewCellAvatarSize; +extern const CGFloat kContactTableViewCellAvatarTextMargin; @class OWSContactsManager; @class SignalAccount; diff --git a/Signal/src/views/ContactTableViewCell.m b/Signal/src/views/ContactTableViewCell.m index 46061cf3f..c28d8eee2 100644 --- a/Signal/src/views/ContactTableViewCell.m +++ b/Signal/src/views/ContactTableViewCell.m @@ -18,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN NSString *const kContactsTable_CellReuseIdentifier = @"kContactsTable_CellReuseIdentifier"; const NSUInteger kContactTableViewCellAvatarSize = 40; +const CGFloat kContactTableViewCellAvatarTextMargin = 12; @interface ContactTableViewCell () @@ -107,7 +108,7 @@ const NSUInteger kContactTableViewCellAvatarSize = 40; [_subtitle autoPinEdgeToSuperviewEdge:ALEdgeBottom]; [_nameContainerView autoVCenterInSuperview]; - [_nameContainerView autoPinLeadingToTrailingOfView:_avatarView margin:12.f]; + [_nameContainerView autoPinLeadingToTrailingOfView:_avatarView margin:kContactTableViewCellAvatarTextMargin]; [_nameContainerView autoPinTrailingToSuperview]; // Force layout, since imageView isn't being initally rendered on App Store optimized build. @@ -158,7 +159,7 @@ const NSUInteger kContactTableViewCellAvatarSize = 40; NSString *threadName = thread.name; if (threadName.length == 0 && [thread isKindOfClass:[TSGroupThread class]]) { - threadName = NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @""); + threadName = [MessageStrings newGroupDefaultTitle]; } NSAttributedString *attributedText = [[NSAttributedString alloc] diff --git a/Signal/src/views/GroupTableViewCell.swift b/Signal/src/views/GroupTableViewCell.swift new file mode 100644 index 000000000..c8a939ed8 --- /dev/null +++ b/Signal/src/views/GroupTableViewCell.swift @@ -0,0 +1,69 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import UIKit + +@objc class GroupTableViewCell: UITableViewCell { + + let TAG = "[GroupTableViewCell]" + + private let avatarView = AvatarImageView() + private let nameLabel = UILabel() + private let subtitleLabel = UILabel() + + init() { + super.init(style: .default, reuseIdentifier: TAG) + + self.contentView.addSubview(avatarView) + + let textContainer = UIView.container() + textContainer.addSubview(nameLabel) + textContainer.addSubview(subtitleLabel) + self.contentView.addSubview(textContainer) + + // Font config + nameLabel.font = UIFont.ows_dynamicTypeBody() + subtitleLabel.font = UIFont.ows_footnote() + subtitleLabel.textColor = UIColor.ows_darkGray() + + // Listen to notifications... + // TODO avatar, group name change, group membership change, group member name change + + // Layout + + nameLabel.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .bottom) + subtitleLabel.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .top) + subtitleLabel.autoPinEdge(.top, to: .bottom, of: nameLabel) + + avatarView.autoPinLeadingToSuperview() + avatarView.autoVCenterInSuperview() + avatarView.autoSetDimension(.width, toSize: CGFloat(kContactTableViewCellAvatarSize)) + avatarView.autoPinToSquareAspectRatio() + + textContainer.autoPinEdge(.leading, to: .trailing, of: avatarView, withOffset: kContactTableViewCellAvatarTextMargin) + textContainer.autoPinEdge(toSuperviewEdge: .trailing) + textContainer.autoVCenterInSuperview() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func configure(thread: TSGroupThread, contactsManager: OWSContactsManager) { + if let groupName = thread.groupModel.groupName, !groupName.isEmpty { + self.nameLabel.text = groupName + } else { + self.nameLabel.text = MessageStrings.newGroupDefaultTitle + } + + let groupMemberIds: [String] = thread.groupModel.groupMemberIds + let groupMemberNames = groupMemberIds.map { (recipientId: String) in + contactsManager.displayName(forPhoneIdentifier: recipientId) + }.joined(separator: ", ") + self.subtitleLabel.text = groupMemberNames + + self.avatarView.image = OWSAvatarBuilder.buildImage(thread: thread, diameter: kContactTableViewCellAvatarSize, contactsManager: contactsManager) + } + +} diff --git a/Signal/test/util/SearcherTest.swift b/Signal/test/util/SearcherTest.swift new file mode 100644 index 000000000..0be0cdb44 --- /dev/null +++ b/Signal/test/util/SearcherTest.swift @@ -0,0 +1,60 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import XCTest + +class SearcherTest: XCTestCase { + + struct TestCharacter { + let name: String + let description: String + } + + let smerdyakov = TestCharacter(name: "Pavel Fyodorovich Smerdyakov", description: "A rusty hue in the sky") + let stinkingLizaveta = TestCharacter(name: "Stinking Lizaveta", description: "object of pity") + let regularLizaveta = TestCharacter(name: "Lizaveta", description: "") + + let indexer = { (character: TestCharacter) in + return "\(character.name) \(character.description)" + } + + var searcher: Searcher { + return Searcher(indexer: indexer) + } + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + func testSimple() { + XCTAssert(searcher.matches(item: smerdyakov, query: "Pavel")) + XCTAssert(searcher.matches(item: smerdyakov, query: "pavel")) + XCTAssertFalse(searcher.matches(item: smerdyakov, query: "asdf")) + XCTAssertFalse(searcher.matches(item: smerdyakov, query: "")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Pity")) + } + + func testRepeats() { + XCTAssert(searcher.matches(item: smerdyakov, query: "pavel pavel")) + XCTAssertFalse(searcher.matches(item: smerdyakov, query: "pavelpavel")) + } + + func testSplitWords() { + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Lizaveta")) + XCTAssert(searcher.matches(item: regularLizaveta, query: "Lizaveta")) + + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Stinking Lizaveta")) + XCTAssertFalse(searcher.matches(item: regularLizaveta, query: "Stinking Lizaveta")) + + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Lizaveta Stinking")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Lizaveta St")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: " Lizaveta St ")) + } +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index f40ce005b..902a3a17c 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -256,6 +256,15 @@ /* Activity Sheet label */ "COMPARE_SAFETY_NUMBER_ACTION" = "Compare with Clipboard"; +/* Table section header for contact listing when composing a new message */ +"COMPOSE_MESSAGE_CONTACT_SECTION_TITLE" = "Contacts"; + +/* Table section header for group listing when composing a new message */ +"COMPOSE_MESSAGE_GROUP_SECTION_TITLE" = "Groups"; + +/* Table section header for invite listing when composing a new message */ +"COMPOSE_MESSAGE_INVITE_SECTION_TITLE" = "Invite"; + /* Multiline label explaining why compose-screen contact picker is empty. */ "COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION" = "To see which of your contacts are Signal users, allow contacts access in your system settings."; diff --git a/SignalServiceKit/src/Messages/TSGroupModel.h b/SignalServiceKit/src/Messages/TSGroupModel.h index e9653f447..84bf3ef31 100644 --- a/SignalServiceKit/src/Messages/TSGroupModel.h +++ b/SignalServiceKit/src/Messages/TSGroupModel.h @@ -7,7 +7,7 @@ @interface TSGroupModel : TSYapDatabaseObject -@property (nonatomic, strong) NSMutableArray *groupMemberIds; +@property (nonatomic, strong) NSArray *groupMemberIds; @property (nonatomic, strong) NSString *groupName; @property (nonatomic, strong) NSData *groupId; diff --git a/SignalServiceKit/src/Messages/TSGroupModel.m b/SignalServiceKit/src/Messages/TSGroupModel.m index 5bf0fa0d3..2844bd3fe 100644 --- a/SignalServiceKit/src/Messages/TSGroupModel.m +++ b/SignalServiceKit/src/Messages/TSGroupModel.m @@ -9,7 +9,7 @@ #if TARGET_OS_IOS - (instancetype)initWithTitle:(NSString *)title - memberIds:(NSMutableArray *)memberIds + memberIds:(NSArray *)memberIds image:(UIImage *)image groupId:(NSData *)groupId { From 6a65ee6def7b091a9975c78cfcddb1719197fd1f Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Sat, 14 Oct 2017 11:41:54 -0400 Subject: [PATCH 2/4] Pull to refresh on homeview fetches messages. This is useful when you're using censorship circumvention and unable to receive push notifications. // FREEBIE --- Signal/src/AppDelegate.m | 7 +- Signal/src/Jobs/MessageFetcherJob.swift | 64 ++++++++++--------- .../src/ViewControllers/HomeViewController.m | 18 +++++- Signal/src/network/PushManager.m | 4 +- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/Signal/src/AppDelegate.m b/Signal/src/AppDelegate.m index 98ce74c6f..9a1e9459b 100644 --- a/Signal/src/AppDelegate.m +++ b/Signal/src/AppDelegate.m @@ -833,11 +833,12 @@ static NSString *const kURLHostVerifyPrefix = @"verify"; // Fetch messages as soon as possible after launching. In particular, when // launching from the background, without this, we end up waiting some extra // seconds before receiving an actionable push notification. - [[Environment getCurrent].messageFetcherJob runAsync]; + __unused AnyPromise *messagePromise = [[Environment getCurrent].messageFetcherJob run]; // This should happen at any launch, background or foreground. - __unused AnyPromise *promise = [OWSSyncPushTokensJob runWithAccountManager:[Environment getCurrent].accountManager - preferences:[Environment preferences]]; + __unused AnyPromise *pushTokenpromise = + [OWSSyncPushTokensJob runWithAccountManager:[Environment getCurrent].accountManager + preferences:[Environment preferences]]; } [DeviceSleepManager.sharedInstance removeBlockWithBlockObject:self]; diff --git a/Signal/src/Jobs/MessageFetcherJob.swift b/Signal/src/Jobs/MessageFetcherJob.swift index a63696def..fe16808e6 100644 --- a/Signal/src/Jobs/MessageFetcherJob.swift +++ b/Signal/src/Jobs/MessageFetcherJob.swift @@ -8,15 +8,14 @@ import PromiseKit @objc(OWSMessageFetcherJob) class MessageFetcherJob: NSObject { - let TAG = "[MessageFetcherJob]" - var timer: Timer? + private let TAG = "[MessageFetcherJob]" - // MARK: injected dependencies - let networkManager: TSNetworkManager - let messageReceiver: OWSMessageReceiver - let signalService: OWSSignalService + private var timer: Timer? - var runPromises = [Double: Promise]() + // MARK: injected dependencies + private let networkManager: TSNetworkManager + private let messageReceiver: OWSMessageReceiver + private let signalService: OWSSignalService init(messageReceiver: OWSMessageReceiver, networkManager: TSNetworkManager, signalService: OWSSignalService) { self.messageReceiver = messageReceiver @@ -24,53 +23,58 @@ class MessageFetcherJob: NSObject { self.signalService = signalService } - func runAsync() { - Logger.debug("\(TAG) \(#function)") - guard signalService.isCensorshipCircumventionActive else { + public func run() -> Promise { + Logger.debug("\(TAG) in \(#function)") + + guard signalService.isCensorshipCircumventionActive else { Logger.debug("\(self.TAG) delegating message fetching to SocketManager since we're using normal transport.") TSSocketManager.requestSocketOpen() - return + return Promise(value: ()) } - Logger.info("\(TAG) using fallback message fetching.") + Logger.info("\(TAG) fetching messages via REST.") - let promiseId = NSDate().timeIntervalSince1970 - Logger.debug("\(self.TAG) starting promise: \(promiseId)") - let runPromise = self.fetchUndeliveredMessages().then { (envelopes: [OWSSignalServiceProtosEnvelope], more: Bool) -> Void in + let promise = self.fetchUndeliveredMessages().then { (envelopes: [OWSSignalServiceProtosEnvelope], more: Bool) -> Promise in for envelope in envelopes { Logger.info("\(self.TAG) received envelope.") self.messageReceiver.handleReceivedEnvelope(envelope) self.acknowledgeDelivery(envelope: envelope) } + if more { - Logger.info("\(self.TAG) more messages, so recursing.") - // recurse - self.runAsync() + Logger.info("\(self.TAG) fetching more messages.") + return self.run() + } else { + // All finished + return Promise(value: ()) } - }.always { - Logger.debug("\(self.TAG) cleaning up promise: \(promiseId)") - self.runPromises[promiseId] = nil } - // maintain reference to make sure it's not de-alloced prematurely. - runPromises[promiseId] = runPromise + promise.retainUntilComplete() + + return promise + } + + @objc func run() -> AnyPromise { + return AnyPromise(run()) } // use in DEBUG or wherever you can't receive push notifications to poll for messages. // Do not use in production. - func startRunLoop(timeInterval: Double) { + public func startRunLoop(timeInterval: Double) { Logger.error("\(TAG) Starting message fetch polling. This should not be used in production.") timer = WeakTimer.scheduledTimer(timeInterval: timeInterval, target: self, userInfo: nil, repeats: true) {[weak self] _ in - self?.runAsync() + let _: Promise? = self?.run() + return } } - func stopRunLoop() { + public func stopRunLoop() { timer?.invalidate() timer = nil } - func parseMessagesResponse(responseObject: Any?) -> (envelopes: [OWSSignalServiceProtosEnvelope], more: Bool)? { + private func parseMessagesResponse(responseObject: Any?) -> (envelopes: [OWSSignalServiceProtosEnvelope], more: Bool)? { guard let responseObject = responseObject else { Logger.error("\(self.TAG) response object was surpringly nil") return nil @@ -103,7 +107,7 @@ class MessageFetcherJob: NSObject { ) } - func buildEnvelope(messageDict: [String: Any]) -> OWSSignalServiceProtosEnvelope? { + private func buildEnvelope(messageDict: [String: Any]) -> OWSSignalServiceProtosEnvelope? { let builder = OWSSignalServiceProtosEnvelopeBuilder() guard let typeInt = messageDict["type"] as? Int32 else { @@ -156,7 +160,7 @@ class MessageFetcherJob: NSObject { return builder.build() } - func fetchUndeliveredMessages() -> Promise<(envelopes: [OWSSignalServiceProtosEnvelope], more: Bool)> { + private func fetchUndeliveredMessages() -> Promise<(envelopes: [OWSSignalServiceProtosEnvelope], more: Bool)> { return Promise { fulfill, reject in let messagesRequest = OWSGetMessagesRequest() @@ -181,7 +185,7 @@ class MessageFetcherJob: NSObject { } } - func acknowledgeDelivery(envelope: OWSSignalServiceProtosEnvelope) { + private func acknowledgeDelivery(envelope: OWSSignalServiceProtosEnvelope) { let request = OWSAcknowledgeMessageDeliveryRequest(source: envelope.source, timestamp: envelope.timestamp) self.networkManager.makeRequest(request, success: { (_: URLSessionDataTask?, _: Any?) -> Void in diff --git a/Signal/src/ViewControllers/HomeViewController.m b/Signal/src/ViewControllers/HomeViewController.m index f30e923aa..d4c33e35c 100644 --- a/Signal/src/ViewControllers/HomeViewController.m +++ b/Signal/src/ViewControllers/HomeViewController.m @@ -216,6 +216,13 @@ typedef NS_ENUM(NSInteger, CellState) { kArchiveState, kInboxState }; [emptyBoxLabel autoPinToTopLayoutGuideOfViewController:self withInset:0]; [emptyBoxLabel autoPinToBottomLayoutGuideOfViewController:self withInset:0]; + UIRefreshControl *pullToRefreshView = [UIRefreshControl new]; + pullToRefreshView.tintColor = [UIColor grayColor]; + [pullToRefreshView addTarget:self + action:@selector(pullToRefreshPerformed:) + forControlEvents:UIControlEventValueChanged]; + [self.tableView insertSubview:pullToRefreshView atIndex:0]; + [self updateReminderViews]; } @@ -596,6 +603,16 @@ typedef NS_ENUM(NSInteger, CellState) { kArchiveState, kInboxState }; return InboxTableViewCell.rowHeight; } +- (void)pullToRefreshPerformed:(UIRefreshControl *)refreshControl +{ + OWSAssert([NSThread isMainThread]); + DDLogInfo(@"%@ beggining refreshing.", self.tag); + [[Environment getCurrent].messageFetcherJob run].always(^{ + DDLogInfo(@"%@ ending refreshing.", self.tag); + [refreshControl endRefreshing]; + }); +} + #pragma mark Table Swipe to Delete - (void)tableView:(UITableView *)tableView @@ -605,7 +622,6 @@ typedef NS_ENUM(NSInteger, CellState) { kArchiveState, kInboxState }; return; } - - (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewRowAction *deleteAction = diff --git a/Signal/src/network/PushManager.m b/Signal/src/network/PushManager.m index 309599b2d..4b8bce455 100644 --- a/Signal/src/network/PushManager.m +++ b/Signal/src/network/PushManager.m @@ -101,11 +101,11 @@ NSString *const Signal_Message_MarkAsRead_Identifier = @"Signal_Message_MarkAsRe { DDLogInfo(@"%@ received remote notification", self.tag); - [self.messageFetcherJob runAsync]; + [self.messageFetcherJob run]; } - (void)applicationDidBecomeActive { - [self.messageFetcherJob runAsync]; + [self.messageFetcherJob run]; } /** From 038ca0d6a99620a7772f97206799f807b6705626 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Sat, 14 Oct 2017 12:18:10 -0400 Subject: [PATCH 3/4] Fix invite via SMS in search - whispersystems.org -> signal.org - nav color was wrong since iOS10 - update search results after clearing post SMS invite // FREEBIE --- Signal/src/ViewControllers/InviteFlow.swift | 40 ++++++++++--------- .../NewContactThreadViewController.m | 18 ++++----- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Signal/src/ViewControllers/InviteFlow.swift b/Signal/src/ViewControllers/InviteFlow.swift index 50eb09603..aacf720ae 100644 --- a/Signal/src/ViewControllers/InviteFlow.swift +++ b/Signal/src/ViewControllers/InviteFlow.swift @@ -16,7 +16,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos let TAG = "[ShareActions]" let installUrl = "https://signal.org/install/" - let homepageUrl = "https://whispersystems.org" + let homepageUrl = "https://signal.org" let actionSheetController: UIAlertController let presentingViewController: UIViewController @@ -98,7 +98,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos switch inviteChannel { case .message: let phoneNumbers: [String] = contacts.map { $0.userTextPhoneNumbers.first }.filter { $0 != nil }.map { $0! } - sendSMSTo(phoneNumbers: phoneNumbers) + dismissAndSendSMSTo(phoneNumbers: phoneNumbers) case .mail: let recipients: [String] = contacts.map { $0.emails.first }.filter { $0 != nil }.map { $0! } sendMailTo(emails: recipients) @@ -144,26 +144,30 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos } } - func sendSMSTo(phoneNumbers: [String]) { + public func dismissAndSendSMSTo(phoneNumbers: [String]) { self.presentingViewController.dismiss(animated: true) { - if #available(iOS 10.0, *) { - // iOS10 message compose view doesn't respect some system appearence attributes. - // Specifically, the title is white, but the navbar is gray. - // So, we have to set system appearence before init'ing the message compose view controller in order - // to make its colors legible. - // Then we have to be sure to set it back in the ComposeViewControllerDelegate callback. - UIUtil.applyDefaultSystemAppearence() - } - let messageComposeViewController = MFMessageComposeViewController() - messageComposeViewController.messageComposeDelegate = self - messageComposeViewController.recipients = phoneNumbers - - let inviteText = NSLocalizedString("SMS_INVITE_BODY", comment:"body sent to contacts when inviting to Install Signal") - messageComposeViewController.body = inviteText.appending(" \(self.installUrl)") - self.presentingViewController.navigationController?.present(messageComposeViewController, animated:true) + self.sendSMSTo(phoneNumbers: phoneNumbers) } } + public func sendSMSTo(phoneNumbers: [String]) { + if #available(iOS 10.0, *) { + // iOS10 message compose view doesn't respect some system appearence attributes. + // Specifically, the title is white, but the navbar is gray. + // So, we have to set system appearence before init'ing the message compose view controller in order + // to make its colors legible. + // Then we have to be sure to set it back in the ComposeViewControllerDelegate callback. + UIUtil.applyDefaultSystemAppearence() + } + let messageComposeViewController = MFMessageComposeViewController() + messageComposeViewController.messageComposeDelegate = self + messageComposeViewController.recipients = phoneNumbers + + let inviteText = NSLocalizedString("SMS_INVITE_BODY", comment:"body sent to contacts when inviting to Install Signal") + messageComposeViewController.body = inviteText.appending(" \(self.installUrl)") + self.presentingViewController.navigationController?.present(messageComposeViewController, animated:true) + } + // MARK: MessageComposeViewControllerDelegate func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) { diff --git a/Signal/src/ViewControllers/NewContactThreadViewController.m b/Signal/src/ViewControllers/NewContactThreadViewController.m index 3663ca490..8bc1fa110 100644 --- a/Signal/src/ViewControllers/NewContactThreadViewController.m +++ b/Signal/src/ViewControllers/NewContactThreadViewController.m @@ -722,6 +722,11 @@ NS_ASSUME_NONNULL_BEGIN - (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) { @@ -739,18 +744,8 @@ NS_ASSUME_NONNULL_BEGIN style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self.searchBar resignFirstResponder]; - if ([MFMessageComposeViewController canSendText]) { - MFMessageComposeViewController *picker = [[MFMessageComposeViewController alloc] init]; - picker.messageComposeDelegate = self; - - picker.recipients = @[ - phoneNumber, - ]; - picker.body = [NSLocalizedString(@"SMS_INVITE_BODY", @"") - stringByAppendingString: - @" https://itunes.apple.com/us/app/signal-private-messenger/id874139669?mt=8"]; - [self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]]; + [inviteFlow sendSMSToPhoneNumbers:@[ phoneNumber ]]; } else { [OWSAlerts showAlertWithTitle:NSLocalizedString(@"ALERT_ERROR_TITLE", @"") message:NSLocalizedString(@"UNSUPPORTED_FEATURE_ERROR", @"")]; @@ -760,6 +755,7 @@ NS_ASSUME_NONNULL_BEGIN [alertController addAction:[OWSAlerts cancelAction]]; [alertController addAction:okAction]; self.searchBar.text = @""; + [self searchTextDidChange]; // must dismiss search controller before presenting alert. if ([self presentedViewController]) { From 9ae4a26eb1029df608615f31a2cec9225d3daf68 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 13 Oct 2017 18:06:06 -0400 Subject: [PATCH 4/4] Message details shows entire message When viewing a longer message, to avoid the risk of the user not being aware they can scroll down, we scroll to the last couple lines of the message, ensuring they can see the meta-data. // FREEBIE --- .../MessageMetadataViewController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Signal/src/ViewControllers/MessageMetadataViewController.swift b/Signal/src/ViewControllers/MessageMetadataViewController.swift index b258cfa67..597dddb0a 100644 --- a/Signal/src/ViewControllers/MessageMetadataViewController.swift +++ b/Signal/src/ViewControllers/MessageMetadataViewController.swift @@ -25,6 +25,7 @@ class MessageMetadataViewController: OWSViewController { let databaseConnection: YapDatabaseConnection let bubbleFactory = OWSMessagesBubbleImageFactory() + var bubbleView: UIView? var message: TSMessage @@ -69,6 +70,15 @@ class MessageMetadataViewController: OWSViewController { createViews() + self.view.layoutIfNeeded() + if let bubbleView = self.bubbleView { + let showAtLeast: CGFloat = 50 + let middleCenter = CGPoint(x: bubbleView.frame.origin.x + bubbleView.frame.width / 2, + y: bubbleView.frame.origin.y + bubbleView.frame.height - showAtLeast) + let offset = bubbleView.superview!.convert(middleCenter, to: scrollView) + self.scrollView!.setContentOffset(offset, animated: false) + } + NotificationCenter.default.addObserver(self, selector:#selector(yapDatabaseModified), name:NSNotification.Name.YapDatabaseModified, @@ -283,8 +293,7 @@ class MessageMetadataViewController: OWSViewController { bodyLabel.textColor = isIncoming ? UIColor.black : UIColor.white bodyLabel.font = UIFont.ows_regularFont(withSize:16) bodyLabel.text = messageBody - // Only show the first N lines. - bodyLabel.numberOfLines = 10 + bodyLabel.numberOfLines = 0 bodyLabel.lineBreakMode = .byWordWrapping let bubbleImageData = isIncoming ? bubbleFactory.incoming : bubbleFactory.outgoing @@ -293,6 +302,7 @@ class MessageMetadataViewController: OWSViewController { let trailingMargin: CGFloat = isIncoming ? 10 : 15 let bubbleView = UIImageView(image: bubbleImageData.messageBubbleImage) + self.bubbleView = bubbleView bubbleView.layer.cornerRadius = 10 bubbleView.addSubview(bodyLabel)