Let users share imported files to a thread or contact of their choice.

// FREEBIE
This commit is contained in:
Matthew Chen 2017-04-25 12:56:37 -04:00
parent 3c7574a908
commit 6e36ce97a5
19 changed files with 956 additions and 180 deletions

View File

@ -7,6 +7,9 @@
objects = {
/* Begin PBXBuildFile section */
3400C7931EAF89CD008A8584 /* SendExternalFileViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3400C7911EAF89CD008A8584 /* SendExternalFileViewController.m */; };
3400C7961EAF99F4008A8584 /* SelectThreadViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3400C7951EAF99F4008A8584 /* SelectThreadViewController.m */; };
3400C7991EAFB772008A8584 /* ThreadViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 3400C7981EAFB772008A8584 /* ThreadViewHelper.m */; };
341BB7491DB727EE001E2975 /* JSQMediaItem+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 341BB7481DB727EE001E2975 /* JSQMediaItem+OWS.m */; };
34330A5A1E7875FB00DF2FB9 /* fontawesome-webfont.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 34330A591E7875FB00DF2FB9 /* fontawesome-webfont.ttf */; };
34330A5C1E787A9800DF2FB9 /* dripicons-v2.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 34330A5B1E787A9800DF2FB9 /* dripicons-v2.ttf */; };
@ -344,6 +347,12 @@
/* Begin PBXFileReference section */
1B5E7D6C9007F5E5761D79DD /* libPods-SignalTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SignalTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
3400C7901EAF89CD008A8584 /* SendExternalFileViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SendExternalFileViewController.h; sourceTree = "<group>"; };
3400C7911EAF89CD008A8584 /* SendExternalFileViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SendExternalFileViewController.m; sourceTree = "<group>"; };
3400C7941EAF99F4008A8584 /* SelectThreadViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SelectThreadViewController.h; sourceTree = "<group>"; };
3400C7951EAF99F4008A8584 /* SelectThreadViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SelectThreadViewController.m; sourceTree = "<group>"; };
3400C7971EAFB772008A8584 /* ThreadViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ThreadViewHelper.h; sourceTree = "<group>"; };
3400C7981EAFB772008A8584 /* ThreadViewHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadViewHelper.m; sourceTree = "<group>"; };
341BB7471DB727EE001E2975 /* JSQMediaItem+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "JSQMediaItem+OWS.h"; sourceTree = "<group>"; };
341BB7481DB727EE001E2975 /* JSQMediaItem+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "JSQMediaItem+OWS.m"; sourceTree = "<group>"; };
34330A591E7875FB00DF2FB9 /* fontawesome-webfont.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "fontawesome-webfont.ttf"; sourceTree = "<group>"; };
@ -894,6 +903,10 @@
34B3F8651E8DF1700035BE1A /* PrivacySettingsTableViewController.m */,
34B3F8661E8DF1700035BE1A /* RegistrationViewController.h */,
34B3F8671E8DF1700035BE1A /* RegistrationViewController.m */,
3400C7941EAF99F4008A8584 /* SelectThreadViewController.h */,
3400C7951EAF99F4008A8584 /* SelectThreadViewController.m */,
3400C7901EAF89CD008A8584 /* SendExternalFileViewController.h */,
3400C7911EAF89CD008A8584 /* SendExternalFileViewController.m */,
34B3F8681E8DF1700035BE1A /* SettingsTableViewController.h */,
34B3F8691E8DF1700035BE1A /* SettingsTableViewController.m */,
34B3F86A1E8DF1700035BE1A /* ShowGroupMembersViewController.h */,
@ -903,6 +916,8 @@
34B3F86E1E8DF1700035BE1A /* SignalsNavigationController.m */,
34B3F86F1E8DF1700035BE1A /* SignalsViewController.h */,
34B3F8701E8DF1700035BE1A /* SignalsViewController.m */,
3400C7971EAFB772008A8584 /* ThreadViewHelper.h */,
3400C7981EAFB772008A8584 /* ThreadViewHelper.m */,
34B3F8A01E8EA6040035BE1A /* ViewControllerUtils.h */,
34B3F8A11E8EA6040035BE1A /* ViewControllerUtils.m */,
);
@ -1988,6 +2003,7 @@
B62D53F71A23CCAD009AAF82 /* TSMessageAdapter.m in Sources */,
76EB063C18170B33006006FC /* NumberUtil.m in Sources */,
B6A3EB4B1A423B3800B2236B /* TSPhotoAdapter.m in Sources */,
3400C7961EAF99F4008A8584 /* SelectThreadViewController.m in Sources */,
34B3F88F1E8DF1710035BE1A /* RegistrationViewController.m in Sources */,
34B3F8901E8DF1710035BE1A /* SettingsTableViewController.m in Sources */,
34FD93701E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m in Sources */,
@ -2071,7 +2087,9 @@
451DE9FD1DC1A28200810E42 /* SyncPushTokensJob.swift in Sources */,
45666F761D9BFE00008FE134 /* OWS100RemoveTSRecipientsMigration.m in Sources */,
34B3F89F1E8DF5490035BE1A /* OWSTableViewController.m in Sources */,
3400C7931EAF89CD008A8584 /* SendExternalFileViewController.m in Sources */,
FCC81A981A44558300DFEC7D /* UIDevice+TSHardwareVersion.m in Sources */,
3400C7991EAFB772008A8584 /* ThreadViewHelper.m in Sources */,
76EB054018170B33006006FC /* AppDelegate.m in Sources */,
341BB7491DB727EE001E2975 /* JSQMediaItem+OWS.m in Sources */,
34B3F89C1E8DF3270035BE1A /* BlockListViewController.m in Sources */,

View File

@ -119,146 +119,11 @@
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Signal File</string>
<key>CFBundleTypeIconFiles</key>
<array>
<string>Icon</string>
<string>Icon@2x</string>
<string>Icon@3x</string>
</array>
<key>LSItemContentTypes</key>
<array>
<string>public.item</string>
<string>public.content</string>
<string>public.composite-content</string>
<string>public.message</string>
<string>public.contact</string>
<string>public.archive</string>
<string>public.disk-image</string>
<string>public.content</string>
<string>public.data</string>
<string>public.directory</string>
<string>com.apple.resolvable</string>
<string>public.symlink</string>
<string>public.executable</string>
<string>com.apple.mount-point</string>
<string>com.apple.alias-file</string>
<string>com.apple.alias-record</string>
<string>com.apple.bookmark</string>
<string>public.url</string>
<string>public.file-url</string>
<string>public.text</string>
<string>public.plain-text</string>
<string>public.utf8-plain-text</string>
<string>public.utf16-external-plain-text</string>
<string>public.utf16-plain-text</string>
<string>public.delimited-values-text</string>
<string>public.comma-separated-values-text</string>
<string>public.tab-separated-values-text</string>
<string>public.utf8-tab-separated-values-text</string>
<string>public.rtf</string>
<string>public.html</string>
<string>public.xml</string>
<string>public.source-code</string>
<string>public.assembly-source</string>
<string>public.c-source</string>
<string>public.objective-c-source</string>
<string>public.swift-source</string>
<string>public.c-plus-plus-source</string>
<string>public.objective-c-plus-plus-source</string>
<string>public.c-header</string>
<string>public.c-plus-plus-header</string>
<string>com.sun.java-source</string>
<string>public.script</string>
<string>com.apple.applescript.text</string>
<string>com.apple.applescript.script</string>
<string>com.apple.applescript.script-bundle</string>
<string>com.netscape.javascript-source</string>
<string>public.shell-script</string>
<string>public.perl-script</string>
<string>public.python-script</string>
<string>public.ruby-script</string>
<string>public.php-script</string>
<string>public.json</string>
<string>com.apple.property-list</string>
<string>com.apple.xml-property-list</string>
<string>com.apple.binary-property-list</string>
<string>com.adobe.pdf</string>
<string>com.apple.rtfd</string>
<string>com.apple.flat-rtfd</string>
<string>com.apple.txn.text-multimedia-data</string>
<string>com.apple.webarchive</string>
<string>public.image</string>
<string>public.jpeg</string>
<string>public.jpeg-2000</string>
<string>public.tiff</string>
<string>com.apple.pict</string>
<string>com.compuserve.gif</string>
<string>public.png</string>
<string>com.apple.quicktime-image</string>
<string>com.apple.icns</string>
<string>com.microsoft.bmp</string>
<string>com.microsoft.ico</string>
<string>public.camera-raw-image</string>
<string>public.svg-image</string>
<string>com.apple.live-photo</string>
<string>public.audiovisual-content</string>
<string>public.movie</string>
<string>public.video</string>
<string>public.audio</string>
<string>com.apple.quicktime-movie</string>
<string>public.mpeg</string>
<string>public.mpeg-2-video</string>
<string>public.mpeg-2-transport-stream</string>
<string>public.mp3</string>
<string>public.mpeg-4</string>
<string>public.mpeg-4-audio</string>
<string>com.apple.protected-mpeg-4-audio</string>
<string>com.apple.protected-mpeg-4-video</string>
<string>public.avi</string>
<string>public.aiff-audio</string>
<string>com.microsoft.waveform-audio</string>
<string>public.midi-audio</string>
<string>public.playlist</string>
<string>public.m3u-playlist</string>
<string>public.folder</string>
<string>public.volume</string>
<string>com.apple.package</string>
<string>com.apple.bundle</string>
<string>com.apple.plugin</string>
<string>com.apple.metadata-importer</string>
<string>com.apple.quicklook-generator</string>
<string>com.apple.xpc-service</string>
<string>com.apple.framework</string>
<string>com.apple.application</string>
<string>com.apple.application-bundle</string>
<string>com.apple.application-file</string>
<string>public.unix-executable</string>
<string>com.microsoft.windows-executable</string>
<string>com.sun.java-class</string>
<string>com.sun.java-archive</string>
<string>com.apple.systempreference.prefpane</string>
<string>org.gnu.gnu-zip-archive</string>
<string>public.bzip2-archive</string>
<string>public.zip-archive</string>
<string>public.spreadsheet</string>
<string>public.presentation</string>
<string>public.database</string>
<string>public.vcard</string>
<string>public.to-do-item</string>
<string>public.calendar-event</string>
<string>public.email-message</string>
<string>com.apple.internet-location</string>
<string>com.apple.ink.inktext</string>
<string>public.font</string>
<string>public.bookmark</string>
<string>public.3d-content</string>
<string>com.rsa.pkcs-12</string>
<string>public.x509-certificate</string>
<string>org.idpf.epub-container</string>
<string>public.log</string>
<string>com.adobe.pdf</string>
<string>public.pdf</string>
</array>
<key>LSHandlerRank</key>
<string>Alternate</string>

View File

@ -15,6 +15,7 @@
#import "PropertyListPreferences.h"
#import "PushManager.h"
#import "Release.h"
#import "SendExternalFileViewController.h"
#import "Signal-Swift.h"
#import "TSMessagesManager.h"
#import "TSSocketManager.h"
@ -294,6 +295,22 @@ static NSString *const kURLHostVerifyPrefix = @"verify";
return NO;
}
DDLogInfo(@"Application opened with URL: %@", url);
[[TSAccountManager sharedInstance]
ifRegistered:YES
runAsync:^{
dispatch_async(dispatch_get_main_queue(), ^{
SendExternalFileViewController *viewController = [SendExternalFileViewController new];
viewController.attachment = attachment;
UINavigationController *navigationController =
[[UINavigationController alloc] initWithRootViewController:viewController];
[[[Environment getCurrent] signalsViewController]
presentTopLevelModalViewController:navigationController
animateDismissal:NO
animatePresentation:YES];
});
}];
return YES;
} else {
DDLogWarn(@"Application opened with an unknown URL scheme: %@", url.scheme);

View File

@ -7,6 +7,7 @@
NS_ASSUME_NONNULL_BEGIN
@class Contact;
@class OWSContactsManager;
typedef enum : NSUInteger { kArchiveState = 0, kInboxState = 1 } CellState;
@ -20,13 +21,23 @@ typedef enum : NSUInteger { kArchiveState = 0, kInboxState = 1 } CellState;
@property (nonatomic) IBOutlet UIView *contentContainerView;
@property (nonatomic) IBOutlet UIView *messageCounter;
@property (nonatomic) NSString *threadId;
@property (nonatomic) NSString *contactId;
+ (instancetype)inboxTableViewCell;
+ (CGFloat)rowHeight;
- (void)configureWithThread:(TSThread *)thread
contactsManager:(OWSContactsManager *)contactsManager
blockedPhoneNumberSet:(NSSet<NSString *> *)blockedPhoneNumberSet;
// This method is used to present _possible_ threads - threads
// that will be created if this cell is selected.
- (void)configureWithContact:(Contact *)contact
recipientId:(NSString *)recipientId
contactsManager:(OWSContactsManager *)contactsManager
isBlocked:(BOOL)isBlocked;
- (void)animateDisappear;
@end

View File

@ -5,6 +5,7 @@
#import "InboxTableViewCell.h"
#import "Environment.h"
#import "OWSAvatarBuilder.h"
#import "OWSContactAvatarBuilder.h"
#import "PropertyListPreferences.h"
#import "Signal-Swift.h"
#import "TSContactThread.h"
@ -25,12 +26,14 @@ NS_ASSUME_NONNULL_BEGIN
@interface InboxTableViewCell ()
@property NSUInteger unreadMessages;
@property UIView *messagesBadge;
@property UILabel *unreadLabel;
@property (nonatomic) NSUInteger unreadMessages;
@property (nonatomic) UIView *messagesBadge;
@property (nonatomic) UILabel *unreadLabel;
@end
#pragma mark -
@implementation InboxTableViewCell
+ (instancetype)inboxTableViewCell {
@ -41,6 +44,11 @@ NS_ASSUME_NONNULL_BEGIN
return cell;
}
+ (CGFloat)rowHeight
{
return 72.f;
}
- (void)initializeLayout {
self.selectionStyle = UITableViewCellSelectionStyleDefault;
}
@ -130,6 +138,52 @@ NS_ASSUME_NONNULL_BEGIN
});
}
- (void)configureWithContact:(Contact *)contact
recipientId:(NSString *)recipientId
contactsManager:(OWSContactsManager *)contactsManager
isBlocked:(BOOL)isBlocked
{
OWSAssert([NSThread isMainThread]);
OWSAssert(contact);
OWSAssert(recipientId.length > 0);
OWSAssert(contactsManager);
NSString *name = contact.fullName;
self.threadId = recipientId;
NSMutableAttributedString *snippetText = [NSMutableAttributedString new];
if (isBlocked) {
// If thread is blocked, don't show a snippet or mute status.
[snippetText
appendAttributedString:[[NSAttributedString alloc]
initWithString:NSLocalizedString(@"HOME_VIEW_BLOCKED_CONTACT_CONVERSATION",
@"A label for conversations with blocked users.")
attributes:@{
NSFontAttributeName : [UIFont ows_mediumFontWithSize:12],
NSForegroundColorAttributeName : [UIColor ows_blackColor],
}]];
}
self.nameLabel.text = name;
self.snippetLabel.attributedText = snippetText;
self.contactPictureView.image = [UIImage imageNamed:@"empty-group-avatar"];
[UIUtil applyRoundedBorderToImageView:_contactPictureView];
self.separatorInset = UIEdgeInsetsMake(0, _contactPictureView.frame.size.width * 1.5f, 0, 0);
[self updateCellForUnreadMessage];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
UIImage *avatar = [[[OWSContactAvatarBuilder alloc] initWithContactId:recipientId
name:contact.fullName
contactsManager:contactsManager] build];
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.threadId isEqualToString:recipientId]) {
self.contactPictureView.image = avatar;
}
});
});
}
- (void)updateCellForUnreadMessage {
_nameLabel.font = [UIFont ows_boldFontWithSize:14.0f];
_nameLabel.textColor = [UIColor ows_blackColor];

View File

@ -113,24 +113,10 @@ typedef enum : NSUInteger {
return YES;
}
- (BOOL)pasteBoardHasText
{
if ([UIPasteboard generalPasteboard].numberOfItems < 1) {
return NO;
}
NSIndexSet *itemSet = [NSIndexSet indexSetWithIndex:0];
NSSet<NSString *> *utiTypes =
[NSSet setWithArray:[[UIPasteboard generalPasteboard] pasteboardTypesForItemSet:itemSet][0]];
return ([utiTypes containsObject:(NSString *)kUTTypeText] || [utiTypes containsObject:(NSString *)kUTTypePlainText]
||
[utiTypes containsObject:(NSString *)kUTTypeUTF8PlainText] ||
[utiTypes containsObject:(NSString *)kUTTypeUTF16PlainText]);
}
- (BOOL)pasteBoardHasPossibleAttachment {
// We don't want to load/convert images more than once so we
// only do a cursory validation pass at this time.
return ([SignalAttachment pasteboardHasPossibleAttachment] && ![self pasteBoardHasText]);
return ([SignalAttachment pasteboardHasPossibleAttachment] && ![SignalAttachment pasteBoardHasText]);
}
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {

View File

@ -35,6 +35,8 @@ extern const CGFloat kOWSTable_DefaultCellHeight;
- (void)addItem:(OWSTableItem *)item;
- (NSUInteger)itemCount;
@end
#pragma mark -
@ -69,8 +71,18 @@ typedef UITableViewCell *_Nonnull (^OWSTableCustomCellBlock)();
#pragma mark -
@protocol OWSTableViewControllerDelegate <NSObject>
- (void)tableViewDidScroll;
@end
#pragma mark -
@interface OWSTableViewController : UIViewController
@property (nonatomic, weak) id<OWSTableViewControllerDelegate> delegate;
@property (nonatomic) OWSTableContents *contents;
@property (nonatomic, readonly) UITableView *tableView;

View File

@ -71,6 +71,11 @@ const CGFloat kOWSTable_DefaultCellHeight = 45.f;
[_items addObject:item];
}
- (NSUInteger)itemCount
{
return _items.count;
}
@end
#pragma mark -
@ -171,19 +176,15 @@ NSString * const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier";
@implementation OWSTableViewController
- (void)viewDidLoad
{
[super viewDidLoad];
[self.navigationController.navigationBar setTranslucent:NO];
}
- (void)loadView
{
[super loadView];
OWSAssert(self.contents);
self.title = self.contents.title;
if (self.contents.title.length > 0) {
self.title = self.contents.title;
}
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleGrouped];
self.tableView.delegate = self;
@ -196,6 +197,20 @@ NSString * const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier";
[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kOWSTableCellIdentifier];
}
- (void)viewDidLoad
{
[super viewDidLoad];
[self.navigationController.navigationBar setTranslucent:NO];
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero];
}
- (OWSTableSection *)sectionForIndex:(NSInteger)sectionIndex
{
OWSAssert(self.contents);
@ -217,20 +232,27 @@ NSString * const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier";
return item;
}
- (void)setContents:(OWSTableContents *)contents
{
OWSAssert(contents);
_contents = contents;
[self.tableView reloadData];
}
#pragma mark - Table view data source
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
OWSAssert(self.contents);
OWSAssert(self.contents.sections.count > 0);
return (NSInteger) self.contents.sections.count;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)sectionIndex
{
OWSTableSection *section = [self sectionForIndex:sectionIndex];
OWSAssert(section.items.count > 0);
OWSAssert(section.items);
return (NSInteger) section.items.count;
}
@ -356,6 +378,13 @@ NSString * const kOWSTableCellIdentifier = @"kOWSTableCellIdentifier";
[self dismissViewControllerAnimated:YES completion:nil];
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
[self.delegate tableViewDidScroll];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,21 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
@class TSThread;
@protocol SelectThreadViewControllerDelegate <NSObject>
- (void)threadWasSelected:(TSThread *)thread;
- (BOOL)canSelectBlockedContact;
@end
#pragma mark -
@interface SelectThreadViewController : UIViewController
@property (nonatomic, weak) id<SelectThreadViewControllerDelegate> delegate;
@end

View File

@ -0,0 +1,428 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "SelectThreadViewController.h"
#import "BlockListUIUtils.h"
#import "ContactTableViewCell.h"
#import "Environment.h"
#import "InboxTableViewCell.h"
#import "OWSContactsManager.h"
#import "OWSContactsSearcher.h"
#import "OWSTableViewController.h"
#import "ThreadViewHelper.h"
#import "UIColor+OWS.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import <SignalServiceKit/OWSBlockingManager.h>
#import <SignalServiceKit/TSAccountManager.h>
#import <SignalServiceKit/TSContactThread.h>
#import <SignalServiceKit/TSThread.h>
NS_ASSUME_NONNULL_BEGIN
@interface SelectThreadViewController () <OWSTableViewControllerDelegate, ThreadViewHelperDelegate, UISearchBarDelegate>
@property (nonatomic, readonly) OWSBlockingManager *blockingManager;
@property (nonatomic) NSSet<NSString *> *blockedPhoneNumberSet;
@property (nonatomic, readonly) OWSContactsManager *contactsManager;
@property (nonatomic) NSArray<Contact *> *contacts;
@property (nonatomic, readonly) ThreadViewHelper *threadViewHelper;
@property (nonatomic, readonly) OWSTableViewController *tableViewController;
@property (nonatomic, readonly) UISearchBar *searchBar;
@end
#pragma mark -
@implementation SelectThreadViewController
- (void)loadView
{
[super loadView];
self.navigationItem.leftBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop
target:self
action:@selector(dismissPressed:)];
self.view.backgroundColor = [UIColor whiteColor];
_blockingManager = [OWSBlockingManager sharedManager];
_blockedPhoneNumberSet = [NSSet setWithArray:[_blockingManager blockedPhoneNumbers]];
_contactsManager = [Environment getCurrent].contactsManager;
self.contacts = [self filteredContacts];
_threadViewHelper = [ThreadViewHelper new];
_threadViewHelper.delegate = self;
[self createViews];
[self addNotificationListeners];
[self updateTableContents];
}
- (void)viewDidLoad
{
[super viewDidLoad];
[self.navigationController.navigationBar setTranslucent:NO];
}
- (void)addNotificationListeners
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(blockedPhoneNumbersDidChange:)
name:kNSNotificationName_BlockedPhoneNumbersDidChange
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(signalRecipientsDidChange:)
name:OWSContactsManagerSignalRecipientsDidChangeNotification
object:nil];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)createViews
{
// Table
_tableViewController = [OWSTableViewController new];
_tableViewController.delegate = self;
_tableViewController.contents = [OWSTableContents new];
[self.view addSubview:self.tableViewController.view];
[_tableViewController.view autoPinWidthToSuperview];
[_tableViewController.view autoPinToTopLayoutGuideOfViewController:self withInset:0];
[_tableViewController.view autoPinEdgeToSuperviewEdge:ALEdgeBottom];
// Search
UISearchBar *searchBar = [UISearchBar new];
_searchBar = searchBar;
searchBar.searchBarStyle = UISearchBarStyleProminent;
searchBar.delegate = self;
searchBar.placeholder = NSLocalizedString(@"SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT", @"");
[searchBar sizeToFit];
_tableViewController.tableView.tableHeaderView = searchBar;
}
#pragma mark - UISearchBarDelegate
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
[self updateTableContents];
}
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar
{
[self updateTableContents];
}
- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar
{
[self updateTableContents];
}
- (void)searchBarResultsListButtonClicked:(UISearchBar *)searchBar
{
[self updateTableContents];
}
- (void)searchBar:(UISearchBar *)searchBar selectedScopeButtonIndexDidChange:(NSInteger)selectedScope
{
[self updateTableContents];
}
#pragma mark - Actions
- (void)updateTableContents
{
__weak SelectThreadViewController *weakSelf = self;
OWSTableContents *contents = [OWSTableContents new];
OWSTableSection *section = [OWSTableSection new];
// Threads
for (TSThread *thread in [self filteredThreadsWithSearchText]) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
SelectThreadViewController *strongSelf = weakSelf;
if (!strongSelf) {
return (InboxTableViewCell *)nil;
}
InboxTableViewCell *cell = [InboxTableViewCell inboxTableViewCell];
[cell configureWithThread:thread
contactsManager:strongSelf.contactsManager
blockedPhoneNumberSet:strongSelf.blockedPhoneNumberSet];
return cell;
}
customRowHeight:[InboxTableViewCell rowHeight]
actionBlock:^{
[weakSelf.delegate threadWasSelected:thread];
}]];
}
// Contacts
NSArray<Contact *> *filteredContacts = [self filteredContactsWithSearchText];
for (Contact *contact in filteredContacts) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
SelectThreadViewController *strongSelf = weakSelf;
if (!strongSelf) {
return (InboxTableViewCell *)nil;
}
// To be consistent with the threads (above), we use InboxTableViewCell
// instead of ContactTableViewCell to present contacts.
InboxTableViewCell *cell = [InboxTableViewCell inboxTableViewCell];
// TODO: Use ContactAccount.
NSString *recipientId = contact.textSecureIdentifiers.firstObject;
BOOL isBlocked = [strongSelf isContactBlocked:contact];
[cell configureWithContact:contact
recipientId:recipientId
contactsManager:strongSelf.contactsManager
isBlocked:isBlocked];
return cell;
}
customRowHeight:[InboxTableViewCell rowHeight]
actionBlock:^{
[weakSelf contactWasSelected:contact];
}]];
}
if (section.itemCount < 1) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
UITableViewCell *cell = [UITableViewCell new];
cell.textLabel.text = NSLocalizedString(
@"SETTINGS_BLOCK_LIST_NO_CONTACTS", @"A label that indicates the user has no Signal contacts.");
cell.textLabel.font = [UIFont ows_regularFontWithSize:15.f];
cell.textLabel.textColor = [UIColor colorWithWhite:0.5f alpha:1.f];
cell.textLabel.textAlignment = NSTextAlignmentCenter;
return cell;
}
actionBlock:nil]];
}
[contents addSection:section];
self.tableViewController.contents = contents;
}
- (void)contactWasSelected:(Contact *)contact
{
OWSAssert(contact);
OWSAssert(self.delegate);
// TODO: Use ContactAccount.
NSString *recipientId = contact.textSecureIdentifiers.firstObject;
if ([self isRecipientIdBlocked:recipientId] &&
![self.delegate canSelectBlockedContact]) {
__weak SelectThreadViewController *weakSelf = self;
[BlockListUIUtils showUnblockContactActionSheet:contact
fromViewController:self
blockingManager:self.blockingManager
contactsManager:self.contactsManager
completionBlock:^(BOOL isBlocked) {
if (!isBlocked) {
[weakSelf contactWasSelected:contact];
}
}];
return;
}
__block TSThread *thread = nil;
[[TSStorageManager sharedManager].dbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
thread = [TSContactThread getOrCreateThreadWithContactId:recipientId transaction:transaction];
}];
OWSAssert(thread);
[self.delegate threadWasSelected:thread];
}
#pragma mark - Filter
- (NSArray<TSThread *> *)filteredThreadsWithSearchText
{
NSArray<TSThread *> *threads = self.threadViewHelper.threads;
NSString *searchTerm =
[[self.searchBar text] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([searchTerm isEqualToString:@""]) {
return threads;
}
NSString *formattedNumber = [PhoneNumber removeFormattingCharacters:searchTerm];
NSMutableArray *result = [NSMutableArray new];
for (TSThread *thread in threads) {
if ([thread.name containsString:searchTerm]) {
[result addObject:thread];
} else if ([thread isKindOfClass:[TSContactThread class]]) {
TSContactThread *contactThread = (TSContactThread *)thread;
if (formattedNumber.length > 0 && [contactThread.contactIdentifier containsString:formattedNumber]) {
[result addObject:thread];
}
}
}
return result;
}
// TODO: Move this to contacts view helper.
- (NSArray<Contact *> *)filteredContactsWithSearchText
{
// We don't want to show a 1:1 thread with Alice and Alice's contact,
// so we de-duplicate by recipientId.
NSArray<TSThread *> *threads = self.threadViewHelper.threads;
NSMutableSet *contactIdsToIgnore = [NSMutableSet new];
for (TSThread *thread in threads) {
if ([thread isKindOfClass:[TSContactThread class]]) {
TSContactThread *contactThread = (TSContactThread *)thread;
[contactIdsToIgnore addObject:contactThread.contactIdentifier];
}
}
NSString *searchString = [self.searchBar text];
NSArray *nonRedundantContacts =
[self.contacts filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(Contact *contact,
NSDictionary<NSString *, id> *_Nullable bindings) {
return ![contactIdsToIgnore containsObject:contact.textSecureIdentifiers.firstObject];
}]];
// TODO: Move this to contacts view helper.
OWSContactsSearcher *contactsSearcher = [[OWSContactsSearcher alloc] initWithContacts:nonRedundantContacts];
NSArray<Contact *> *filteredContacts = [contactsSearcher filterWithString:searchString];
return filteredContacts;
}
#pragma mark - Contacts and Blocking
- (void)blockedPhoneNumbersDidChange:(id)notification
{
dispatch_async(dispatch_get_main_queue(), ^{
_blockedPhoneNumberSet = [NSSet setWithArray:[_blockingManager blockedPhoneNumbers]];
[self updateContacts];
});
}
- (void)signalRecipientsDidChange:(NSNotification *)notification
{
dispatch_async(dispatch_get_main_queue(), ^{
[self updateContacts];
});
}
- (void)updateContacts
{
OWSAssert([NSThread isMainThread]);
self.contacts = [self filteredContacts];
[self updateTableContents];
}
- (BOOL)isContactBlocked:(Contact *)contact
{
if (contact.parsedPhoneNumbers.count < 1) {
// Hide contacts without any valid phone numbers.
return NO;
}
for (PhoneNumber *phoneNumber in contact.parsedPhoneNumbers) {
if ([_blockedPhoneNumberSet containsObject:phoneNumber.toE164]) {
return YES;
}
}
return NO;
}
- (BOOL)isRecipientIdBlocked:(NSString *)recipientId
{
OWSAssert(recipientId.length > 0);
return [_blockedPhoneNumberSet containsObject:recipientId];
}
- (BOOL)isContactHidden:(Contact *)contact
{
if (contact.parsedPhoneNumbers.count < 1) {
// Hide contacts without any valid phone numbers.
return YES;
}
if ([self isCurrentUserContact:contact]) {
return YES;
}
return NO;
}
- (BOOL)isCurrentUserContact:(Contact *)contact
{
for (PhoneNumber *phoneNumber in contact.parsedPhoneNumbers) {
if ([[phoneNumber toE164] isEqualToString:[TSAccountManager localNumber]]) {
return YES;
}
}
return NO;
}
- (NSArray<Contact *> *_Nonnull)filteredContacts
{
NSMutableArray<Contact *> *result = [NSMutableArray new];
for (Contact *contact in self.contactsManager.signalContacts) {
if (![self isContactHidden:contact]) {
[result addObject:contact];
}
}
return [result copy];
}
#pragma mark - Events
- (void)dismissPressed:(id)sender
{
[self.searchBar resignFirstResponder];
[self dismissViewControllerAnimated:YES completion:nil];
}
#pragma mark - OWSTableViewControllerDelegate
- (void)tableViewDidScroll
{
[self.searchBar resignFirstResponder];
}
#pragma mark - ThreadViewHelperDelegate
- (void)threadListDidChange
{
[self updateTableContents];
}
#pragma mark - Logging
+ (NSString *)tag
{
return [NSString stringWithFormat:@"[%@]", self.class];
}
- (NSString *)tag
{
return self.class.tag;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,13 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "SelectThreadViewController.h"
@class SignalAttachment;
@interface SendExternalFileViewController : SelectThreadViewController
@property (nonatomic) SignalAttachment *attachment;
@end

View File

@ -0,0 +1,66 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "SendExternalFileViewController.h"
#import "Environment.h"
#import "Signal-Swift.h"
#import "ThreadUtil.h"
#import <SignalServiceKit/OWSMessageSender.h>
#import <SignalServiceKit/TSThread.h>
@interface SendExternalFileViewController () <SelectThreadViewControllerDelegate>
@property (nonatomic, readonly) OWSMessageSender *messageSender;
@end
@implementation SendExternalFileViewController
- (void)loadView
{
[super loadView];
self.delegate = self;
_messageSender = [Environment getCurrent].messageSender;
self.title = NSLocalizedString(@"SEND_EXTERNAL_FILE_VIEW_TITLE", @"Title for the 'send external file' view.");
}
#pragma mark - SelectThreadViewControllerDelegate
- (void)threadWasSelected:(TSThread *)thread
{
OWSAssert(self.attachment);
OWSAssert(thread);
// We should have a valid filename.
OWSAssert(self.attachment.filename.length > 0);
NSString *fileExtension = [self.attachment.filename pathExtension].lowercaseString;
OWSAssert(fileExtension.length > 0);
NSSet<NSString *> *textExtensions = [NSSet setWithArray:@[
@"txt",
@"url",
]];
NSString *text = nil;
if ([textExtensions containsObject:fileExtension]) {
text = [[NSString alloc] initWithData:self.attachment.data encoding:NSUTF8StringEncoding];
OWSAssert(text);
}
if (text) {
[ThreadUtil sendMessageWithText:text inThread:thread messageSender:self.messageSender];
} else {
[ThreadUtil sendMessageWithAttachment:self.attachment inThread:thread messageSender:self.messageSender];
}
[Environment messageThreadId:thread.uniqueId];
}
- (BOOL)canSelectBlockedContact
{
return NO;
}
@end

View File

@ -204,17 +204,16 @@ class SignalAttachment: NSObject {
// Returns the file extension for this attachment or nil if no file extension
// can be identified.
var fileExtension: String? {
if dataUTI == SignalAttachment.kOversizeTextAttachmentUTI ||
dataUTI == SignalAttachment.kUnknownTestAttachmentUTI {
assertionFailure()
if dataUTI == SignalAttachment.kOversizeTextAttachmentUTI {
return "txt"
}
if dataUTI == SignalAttachment.kUnknownTestAttachmentUTI {
return "unknown"
}
guard let fileExtension = MIMETypeUtil.fileExtension(forUTIType:dataUTI) else {
return nil
}
guard let fileExtension = UTTypeCopyPreferredTagWithClass(dataUTI as CFString,
kUTTagClassFilenameExtension) else {
return nil
}
return fileExtension.takeRetainedValue() as String
return fileExtension
}
// Returns the set of UTIs that correspond to valid _input_ image formats
@ -250,6 +249,16 @@ class SignalAttachment: NSObject {
return MIMETypeUtil.supportedAudioUTITypes()
}
public class var textUTISet: Set<String> {
return [
kUTTypeText as String,
kUTTypePlainText as String,
kUTTypeUTF8PlainText as String,
kUTTypeUTF16PlainText as String,
kUTTypeURL as String,
]
}
public var isImage: Bool {
return SignalAttachment.outputImageUTISet.contains(dataUTI)
}
@ -266,10 +275,26 @@ class SignalAttachment: NSObject {
return SignalAttachment.audioUTISet.contains(dataUTI)
}
public var isText: Bool {
return SignalAttachment.textUTISet.contains(dataUTI)
}
public class func pasteboardHasPossibleAttachment() -> Bool {
return UIPasteboard.general.numberOfItems > 0
}
public class func pasteBoardHasText() -> Bool {
if UIPasteboard.general.numberOfItems < 1 {
return false
}
let itemSet = IndexSet(integer:0)
guard let pasteboardUTITypes = UIPasteboard.general.types(forItemSet:itemSet) else {
return false
}
let pasteboardUTISet = Set<String>(pasteboardUTITypes[0])
return pasteboardUTISet.intersection(textUTISet).count > 0
}
// Returns an attachment from the pasteboard, or nil if no attachment
// can be found.
//

View File

@ -20,4 +20,11 @@
- (NSNumber *)updateInboxCountLabel;
- (void)composeNew;
- (void)presentTopLevelModalViewController:(UIViewController *)viewController
animateDismissal:(BOOL)animateDismissal
animatePresentation:(BOOL)animatePresentation;
- (void)pushTopLevelViewController:(UIViewController *)viewController
animateDismissal:(BOOL)animateDismissal
animatePresentation:(BOOL)animatePresentation;
@end

View File

@ -528,7 +528,6 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS
[tableView deselectRowAtIndexPath:indexPath animated:YES];
}
- (void)presentThread:(TSThread *)thread
keyboardOnViewAppearing:(BOOL)keyboardOnViewAppearing
callOnViewAppearing:(BOOL)callOnViewAppearing
@ -549,6 +548,67 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS
});
}
- (void)presentTopLevelModalViewController:(UIViewController *)viewController
animateDismissal:(BOOL)animateDismissal
animatePresentation:(BOOL)animatePresentation
{
OWSAssert([NSThread isMainThread]);
OWSAssert(viewController);
[self presentViewControllerWithBlock:^{
[self presentViewController:viewController animated:animatePresentation completion:nil];
}
animateDismissal:animateDismissal];
}
- (void)pushTopLevelViewController:(UIViewController *)viewController
animateDismissal:(BOOL)animateDismissal
animatePresentation:(BOOL)animatePresentation
{
OWSAssert([NSThread isMainThread]);
OWSAssert(viewController);
[self presentViewControllerWithBlock:^{
[self.navigationController pushViewController:viewController animated:animatePresentation];
}
animateDismissal:animateDismissal];
}
- (void)presentViewControllerWithBlock:(void (^)())presentationBlock animateDismissal:(BOOL)animateDismissal
{
OWSAssert([NSThread isMainThread]);
OWSAssert(presentationBlock);
// Presenting a "top level" view controller has three steps:
//
// First, dismiss any presented modal.
// Second, pop to the root view controller if necessary.
// Third present the new view controller using presentationBlock.
// Define a block to perform the second step.
void (^dismissNavigationBlock)() = ^{
if (self.navigationController.viewControllers.lastObject != self) {
[CATransaction begin];
[CATransaction setCompletionBlock:^{
presentationBlock();
}];
[self.navigationController popToViewController:self animated:animateDismissal];
[CATransaction commit];
} else {
presentationBlock();
}
};
// Perform the first step.
if (self.presentedViewController) {
[self.presentedViewController dismissViewControllerAnimated:animateDismissal completion:dismissNavigationBlock];
} else {
dismissNavigationBlock();
}
}
#pragma mark - Navigation
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {

View File

@ -0,0 +1,22 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
@protocol ThreadViewHelperDelegate <NSObject>
- (void)threadListDidChange;
@end
#pragma mark -
@class TSThread;
// A helper class
@interface ThreadViewHelper : NSObject
@property (nonatomic, weak) id<ThreadViewHelperDelegate> delegate;
@property (nonatomic, readonly) NSMutableArray<TSThread *> *threads;
@end

View File

@ -0,0 +1,136 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "ThreadViewHelper.h"
#import <SignalServiceKit/TSDatabaseView.h>
#import <SignalServiceKit/TSStorageManager.h>
#import <SignalServiceKit/TSThread.h>
#import <YapDatabase/YapDatabaseConnection.h>
#import <YapDatabase/YapDatabaseViewChange.h>
#import <YapDatabase/YapDatabaseViewConnection.h>
@interface ThreadViewHelper ()
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
@property (nonatomic) YapDatabaseViewMappings *threadMappings;
@end
@implementation ThreadViewHelper
- (instancetype)init
{
self = [super init];
if (!self) {
return self;
}
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(yapDatabaseModified:)
name:TSUIDatabaseConnectionDidUpdateNotification
object:nil];
[self initializeMapping];
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)initializeMapping
{
OWSAssert([NSThread isMainThread]);
NSString *grouping = TSInboxGroup;
self.threadMappings =
[[YapDatabaseViewMappings alloc] initWithGroups:@[ grouping ] view:TSThreadDatabaseViewExtensionName];
[self.threadMappings setIsReversed:YES forGroup:grouping];
__weak ThreadViewHelper *weakSelf = self;
[self.uiDatabaseConnection asyncReadWithBlock:^(YapDatabaseReadTransaction *transaction) {
[self.threadMappings updateWithTransaction:transaction];
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf updateThreads];
[weakSelf.delegate threadListDidChange];
});
}];
}
#pragma mark - Database
- (YapDatabaseConnection *)uiDatabaseConnection
{
NSAssert([NSThread isMainThread], @"Must access uiDatabaseConnection on main thread!");
if (!_uiDatabaseConnection) {
YapDatabase *database = TSStorageManager.sharedManager.database;
_uiDatabaseConnection = [database newConnection];
[_uiDatabaseConnection beginLongLivedReadTransaction];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(yapDatabaseModified:)
name:YapDatabaseModifiedNotification
object:database];
}
return _uiDatabaseConnection;
}
- (void)yapDatabaseModified:(NSNotification *)notification
{
OWSAssert([NSThread isMainThread]);
NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction];
NSArray *sectionChanges = nil;
NSArray *rowChanges = nil;
[[self.uiDatabaseConnection ext:TSThreadDatabaseViewExtensionName] getSectionChanges:&sectionChanges
rowChanges:&rowChanges
forNotifications:notifications
withMappings:self.threadMappings];
if (sectionChanges.count == 0 && rowChanges.count == 0) {
// Ignore irrelevant modifications.
return;
}
[self updateThreads];
[self.delegate threadListDidChange];
}
- (void)updateThreads
{
OWSAssert([NSThread isMainThread]);
NSMutableArray<TSThread *> *threads = [NSMutableArray new];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSUInteger numberOfSections = [self.threadMappings numberOfSections];
OWSAssert(numberOfSections == 1);
for (NSUInteger section = 0; section < numberOfSections; section++) {
NSUInteger numberOfItems = [self.threadMappings numberOfItemsInSection:section];
for (NSUInteger item = 0; item < numberOfItems; item++) {
TSThread *thread = [[transaction extension:TSThreadDatabaseViewExtensionName]
objectAtIndexPath:[NSIndexPath indexPathForItem:(NSInteger)item inSection:(NSInteger)section]
withMappings:self.threadMappings];
[threads addObject:thread];
}
}
}];
_threads = threads;
}
#pragma mark - Logging
+ (NSString *)tag
{
return [NSString stringWithFormat:@"[%@]", self.class];
}
- (NSString *)tag
{
return self.class.tag;
}
@end

View File

@ -1,9 +1,5 @@
//
// OWSContactsSearcher.m
// Signal
//
// Created by Michael Kirk on 6/27/16.
// Copyright © 2016 Open Whisper Systems. All rights reserved.
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "OWSContactsSearcher.h"
@ -34,6 +30,7 @@
NSString *formattedNumber = [PhoneNumber removeFormattingCharacters:searchTerm];
// TODO: This assumes there's a single search term.
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(fullName contains[c] %@) OR (ANY parsedPhoneNumbers.toE164 contains[c] %@)", searchTerm, formattedNumber];
return [self.contacts filteredArrayUsingPredicate:predicate];

View File

@ -699,7 +699,7 @@
/* No comment provided by engineer. */
"NEW_GROUP_DEFAULT_TITLE" = "New Group";
/* No comment provided by engineer. */
/* Placeholder text for group name field */
"NEW_GROUP_NAMEGROUP_REQUEST_DEFAULT" = "Name this group chat";
/* No comment provided by engineer. */
@ -910,12 +910,21 @@
/* No comment provided by engineer. */
"SECURE_SESSION_RESET" = "Secure session was reset.";
/* A label for the 'select contact' mode vs. 'select thread' mode. */
"SELECT_CONTACT_MODE_LABEL" = "Contacts";
/* A label for the 'select thread' mode vs. 'select contact' mode. */
"SELECT_THREAD_MODE_LABEL" = "Conversations";
/* No comment provided by engineer. */
"SEND_AGAIN_BUTTON" = "Send Again";
/* No comment provided by engineer. */
"SEND_BUTTON_TITLE" = "Send";
/* Title for the 'send external file' view. */
"SEND_EXTERNAL_FILE_VIEW_TITLE" = "Send File";
/* Alert body after invite failed */
"SEND_INVITE_FAILURE" = "Sending invite failed, please try again later.";