From 3080cb512bcc81b8fb47221cb1153533f958d3b3 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Sat, 14 Oct 2017 13:20:46 -0400 Subject: [PATCH] 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 {