// // MessagesViewController.m // Signal // // Created by Dylan Bourgeois on 28/10/14. // Copyright (c) 2014 Open Whisper Systems. All rights reserved. // #import "AppDelegate.h" #import "DJWActionSheet+OWS.h" #import "Environment.h" #import "FingerprintViewController.h" #import "FullImageViewController.h" #import "MessagesViewController.h" #import "NSDate+millisecondTimeStamp.h" #import "NewGroupViewController.h" #import "OWSCall.h" #import "OWSCallCollectionViewCell.h" #import "OWSContactsManager.h" #import "OWSConversationSettingsTableViewController.h" #import "OWSDisappearingMessagesJob.h" #import "OWSDisplayedMessageCollectionViewCell.h" #import "OWSErrorMessage.h" #import "OWSExpirableMessageView.h" #import "OWSIncomingMessageCollectionViewCell.h" #import "OWSInfoMessage.h" #import "OWSMessagesBubblesSizeCalculator.h" #import "OWSOutgoingMessageCollectionViewCell.h" #import "PhoneManager.h" #import "PreferencesUtil.h" #import "SignalKeyingStorage.h" #import "TSAttachmentPointer.h" #import "TSCall.h" #import "TSContactThread.h" #import "TSContentAdapters.h" #import "TSDatabaseView.h" #import "TSErrorMessage.h" #import "TSGroupThread.h" #import "TSIncomingMessage.h" #import "TSInfoMessage.h" #import "TSInvalidIdentityKeyErrorMessage.h" #import "TSMessagesManager+attachments.h" #import "TSMessagesManager+sendMessages.h" #import "UIFont+OWS.h" #import "UIUtil.h" #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import @import Photos; #define kYapDatabaseRangeLength 50 #define kYapDatabaseRangeMaxLength 300 #define kYapDatabaseRangeMinLength 20 #define JSQ_TOOLBAR_ICON_HEIGHT 22 #define JSQ_TOOLBAR_ICON_WIDTH 22 #define JSQ_IMAGE_INSET 5 static NSTimeInterval const kTSMessageSentDateShowTimeInterval = 5 * 60; static NSString *const OWSMessagesViewControllerSegueShowFingerprint = @"fingerprintSegue"; static NSString *const OWSMessagesViewControllerSeguePushConversationSettings = @"OWSMessagesViewControllerSeguePushConversationSettings"; NSString *const OWSMessagesViewControllerDidAppearNotification = @"OWSMessagesViewControllerDidAppear"; typedef enum : NSUInteger { kMediaTypePicture, kMediaTypeVideo, } kMediaTypes; @interface MessagesViewController () { UIImage *tappedImage; BOOL isGroupConversation; UIView *_unreadContainer; UIImageView *_unreadBackground; UILabel *_unreadLabel; NSUInteger _unreadCount; } @property TSThread *thread; @property TSMessageAdapter *lastDeliveredMessage; @property (nonatomic, strong) YapDatabaseConnection *editingDatabaseConnection; @property (nonatomic, strong) YapDatabaseConnection *uiDatabaseConnection; @property (nonatomic, strong) YapDatabaseViewMappings *messageMappings; @property (nonatomic, retain) JSQMessagesBubbleImage *outgoingBubbleImageData; @property (nonatomic, retain) JSQMessagesBubbleImage *incomingBubbleImageData; @property (nonatomic, retain) JSQMessagesBubbleImage *currentlyOutgoingBubbleImageData; @property (nonatomic, retain) JSQMessagesBubbleImage *outgoingMessageFailedImageData; @property (nonatomic, strong) NSTimer *audioPlayerPoller; @property (nonatomic, strong) TSVideoAttachmentAdapter *currentMediaAdapter; @property (nonatomic, retain) NSTimer *readTimer; @property (nonatomic, strong) UILabel *navbarTitleLabel; @property (nonatomic, retain) UIButton *attachButton; @property (nonatomic) CGFloat previousCollectionViewFrameWidth; @property NSUInteger page; @property (nonatomic) BOOL composeOnOpen; @property (nonatomic) BOOL peek; @property (nonatomic, readonly) TSStorageManager *storageManager; @property (nonatomic, readonly) OWSContactsManager *contactsManager; @property (nonatomic, readonly) OWSDisappearingMessagesJob *disappearingMessagesJob; @property NSCache *messageAdapterCache; @end @implementation MessagesViewController - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (instancetype)init { self = [super init]; if (!self) { return self; } _contactsManager = [[Environment getCurrent] contactsManager]; _storageManager = [TSStorageManager sharedManager]; _disappearingMessagesJob = [[OWSDisappearingMessagesJob alloc] initWithStorageManager:_storageManager]; return self; } - (instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (!self) { return self; } _contactsManager = [[Environment getCurrent] contactsManager]; _storageManager = [TSStorageManager sharedManager]; _disappearingMessagesJob = [[OWSDisappearingMessagesJob alloc] initWithStorageManager:_storageManager]; return self; } - (void)peekSetup { _peek = YES; [self setComposeOnOpen:NO]; } - (void)popped { _peek = NO; [self hideInputIfNeeded]; } - (void)configureForThread:(TSThread *)thread keyboardOnViewAppearing:(BOOL)keyboardAppearing { _thread = thread; isGroupConversation = [self.thread isKindOfClass:[TSGroupThread class]]; _composeOnOpen = keyboardAppearing; [self.uiDatabaseConnection beginLongLivedReadTransaction]; self.messageMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[ thread.uniqueId ] view:TSMessageDatabaseViewExtensionName]; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { [self.messageMappings updateWithTransaction:transaction]; self.page = 0; [self updateRangeOptionsForPage:self.page]; [self markAllMessagesAsRead]; [self.collectionView reloadData]; }]; [self updateLoadEarlierVisible]; } - (BOOL)userLeftGroup { if (![_thread isKindOfClass:[TSGroupThread class]]) { return NO; } TSGroupThread *groupThread = (TSGroupThread *)self.thread; return ![groupThread.groupModel.groupMemberIds containsObject:[TSAccountManager localNumber]]; } - (void)hideInputIfNeeded { if (_peek) { [self inputToolbar].hidden = YES; [self.inputToolbar endEditing:TRUE]; return; } if (self.userLeftGroup) { [self inputToolbar].hidden = YES; // user has requested they leave the group. further sends disallowed [self.inputToolbar endEditing:TRUE]; } else { [self inputToolbar].hidden = NO; [self loadDraftInCompose]; } } - (void)viewDidLoad { [super viewDidLoad]; [self.navigationController.navigationBar setTranslucent:NO]; self.messageAdapterCache = [[NSCache alloc] init]; _attachButton = [[UIButton alloc] init]; [_attachButton setFrame:CGRectMake(0, 0, JSQ_TOOLBAR_ICON_WIDTH + JSQ_IMAGE_INSET * 2, JSQ_TOOLBAR_ICON_HEIGHT + JSQ_IMAGE_INSET * 2)]; _attachButton.imageEdgeInsets = UIEdgeInsetsMake(JSQ_IMAGE_INSET, JSQ_IMAGE_INSET, JSQ_IMAGE_INSET, JSQ_IMAGE_INSET); [_attachButton setImage:[UIImage imageNamed:@"btnAttachments--blue"] forState:UIControlStateNormal]; [self initializeTextView]; [JSQMessagesCollectionViewCell registerMenuAction:@selector(delete:)]; SEL saveSelector = NSSelectorFromString(@"save:"); [JSQMessagesCollectionViewCell registerMenuAction:saveSelector]; [UIMenuController sharedMenuController].menuItems = @[ [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_SAVE_ACTION", @"Short name for edit menu item to save contents of media message.") action:saveSelector] ]; [self initializeCollectionViewLayout]; [self registerCustomMessageNibs]; self.senderId = ME_MESSAGE_IDENTIFIER; self.senderDisplayName = ME_MESSAGE_IDENTIFIER; [self initializeToolbars]; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; // JSQMVC width is initially 375px on iphone6/ios9 (as specified by the xib), which causes // our initial bubble calculations to be off since they happen before the containing // view is layed out. https://github.com/jessesquires/JSQMessagesViewController/issues/1257 if (CGRectGetWidth(self.collectionView.frame) != self.previousCollectionViewFrameWidth) { // save frame value from next comparison self.previousCollectionViewFrameWidth = CGRectGetWidth(self.collectionView.frame); // invalidate layout [self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; } } - (void)didMoveToParentViewController:(UIViewController *)parent { [self setupTitleLabelGestureRecognizer]; } - (void)registerCustomMessageNibs { [self.collectionView registerNib:[OWSCallCollectionViewCell nib] forCellWithReuseIdentifier:[OWSCallCollectionViewCell cellReuseIdentifier]]; [self.collectionView registerNib:[OWSDisplayedMessageCollectionViewCell nib] forCellWithReuseIdentifier:[OWSDisplayedMessageCollectionViewCell cellReuseIdentifier]]; self.outgoingCellIdentifier = [OWSOutgoingMessageCollectionViewCell cellReuseIdentifier]; [self.collectionView registerNib:[OWSOutgoingMessageCollectionViewCell nib] forCellWithReuseIdentifier:[OWSOutgoingMessageCollectionViewCell cellReuseIdentifier]]; self.outgoingMediaCellIdentifier = [OWSOutgoingMessageCollectionViewCell mediaCellReuseIdentifier]; [self.collectionView registerNib:[OWSOutgoingMessageCollectionViewCell nib] forCellWithReuseIdentifier:[OWSOutgoingMessageCollectionViewCell mediaCellReuseIdentifier]]; self.incomingCellIdentifier = [OWSIncomingMessageCollectionViewCell cellReuseIdentifier]; [self.collectionView registerNib:[OWSIncomingMessageCollectionViewCell nib] forCellWithReuseIdentifier:[OWSIncomingMessageCollectionViewCell cellReuseIdentifier]]; self.incomingMediaCellIdentifier = [OWSIncomingMessageCollectionViewCell mediaCellReuseIdentifier]; [self.collectionView registerNib:[OWSIncomingMessageCollectionViewCell nib] forCellWithReuseIdentifier:[OWSIncomingMessageCollectionViewCell mediaCellReuseIdentifier]]; } - (void)toggleObservers:(BOOL)shouldObserve { if (shouldObserve) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(yapDatabaseModified:) name:YapDatabaseModifiedNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(startReadTimer) name:UIApplicationWillEnterForegroundNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(startExpirationTimerAnimations) name:UIApplicationWillEnterForegroundNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(cancelReadTimer) name:UIApplicationDidEnterBackgroundNotification object:nil]; } else { [[NSNotificationCenter defaultCenter] removeObserver:self name:YapDatabaseModifiedNotification object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationWillEnterForegroundNotification object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidEnterBackgroundNotification object:nil]; } } - (void)initializeTextView { [self.inputToolbar.contentView.textView setFont:[UIFont ows_dynamicTypeBodyFont]]; self.inputToolbar.contentView.leftBarButtonItem = self.attachButton; UILabel *sendLabel = self.inputToolbar.contentView.rightBarButtonItem.titleLabel; // override superclass translations since we support more translations than upstream. sendLabel.text = NSLocalizedString(@"SEND_BUTTON_TITLE", nil); sendLabel.font = [UIFont ows_regularFontWithSize:17.0f]; sendLabel.textColor = [UIColor ows_materialBlueColor]; sendLabel.textAlignment = NSTextAlignmentCenter; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // We need to recheck on every appearance, since the user may have left the group in the settings VC, // or on another device. [self hideInputIfNeeded]; [self toggleObservers:YES]; // Triggering modified notification renders "call notification" when leaving full screen call view [self.thread touch]; // restart any animations that were stopped e.g. while inspecting the contact info screens. [self startExpirationTimerAnimations]; OWSDisappearingMessagesConfiguration *configuration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; [self setBarButtonItemsForDisappearingMessagesConfiguration:configuration]; [self setNavigationTitle]; NSInteger numberOfMessages = (NSInteger)[self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; if (numberOfMessages > 0) { NSIndexPath *lastCellIndexPath = [NSIndexPath indexPathForRow:numberOfMessages - 1 inSection:0]; [self.collectionView scrollToItemAtIndexPath:lastCellIndexPath atScrollPosition:UICollectionViewScrollPositionBottom animated:NO]; } } - (void)startReadTimer { self.readTimer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(markAllMessagesAsRead) userInfo:nil repeats:YES]; } - (void)cancelReadTimer { [self.readTimer invalidate]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; [self dismissKeyBoard]; [self startReadTimer]; // TODO prep this sync one time before view loads so we don't have to repaint. [self updateBackButtonAsync]; [self.inputToolbar.contentView.textView endEditing:YES]; self.inputToolbar.contentView.textView.editable = YES; if (_composeOnOpen && !self.inputToolbar.hidden) { [self popKeyBoard]; } } - (void)updateBackButtonAsync { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSUInteger count = [[TSMessagesManager sharedManager] unreadMessagesCountExcept:self.thread]; dispatch_async(dispatch_get_main_queue(), ^{ if (self) { [self setUnreadCount:count]; } }); }); } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; [self toggleObservers:NO]; [_unreadContainer removeFromSuperview]; _unreadContainer = nil; [_audioPlayerPoller invalidate]; [_audioPlayer stop]; // reset all audio bars to 0 JSQMessagesCollectionView *collectionView = self.collectionView; NSInteger num_bubbles = [self collectionView:collectionView numberOfItemsInSection:0]; for (NSInteger i = 0; i < num_bubbles; i++) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; id message = [self messageAtIndexPath:indexPath]; if (message.messageType == TSIncomingMessageAdapter && message.isMediaMessage && [message isKindOfClass:[TSVideoAttachmentAdapter class]]) { TSVideoAttachmentAdapter *msgMedia = (TSVideoAttachmentAdapter *)message.media; if ([msgMedia isAudio]) { msgMedia.isPaused = NO; msgMedia.isAudioPlaying = NO; [msgMedia setAudioProgressFromFloat:0]; [msgMedia setAudioIconToPlay]; } } } [self cancelReadTimer]; [self saveDraft]; } - (void)startExpirationTimerAnimations { [[NSNotificationCenter defaultCenter] postNotificationName:OWSMessagesViewControllerDidAppearNotification object:nil]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; self.inputToolbar.contentView.textView.editable = NO; } #pragma mark - Initiliazers - (void)setNavigationTitle { NSString *navTitle = self.thread.name; if (isGroupConversation && [navTitle length] == 0) { navTitle = NSLocalizedString(@"NEW_GROUP_DEFAULT_TITLE", @""); } self.title = navTitle; } - (void)setBarButtonItemsForDisappearingMessagesConfiguration: (OWSDisappearingMessagesConfiguration *)disappearingMessagesConfiguration { if (self.userLeftGroup) { self.navigationItem.rightBarButtonItems = @[]; return; } NSMutableArray *barButtons = [NSMutableArray new]; if ([self canCall]) { UIBarButtonItem *callButton = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"btnPhone--white"] style:UIBarButtonItemStylePlain target:self action:@selector(callAction)]; callButton.imageInsets = UIEdgeInsetsMake(0, -10, 0, 10); [barButtons addObject:callButton]; } else if ([self.thread isGroupThread]) { UIBarButtonItem *manageGroupButton = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"contact-options-action"] style:UIBarButtonItemStylePlain target:self action:@selector(didTapManageGroupButton:)]; // Hack to shrink button image manageGroupButton.imageInsets = UIEdgeInsetsMake(10, 20, 10, 0); [barButtons addObject:manageGroupButton]; } if (disappearingMessagesConfiguration.isEnabled) { [barButtons addObject:[[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"ic_timer"] style:UIBarButtonItemStylePlain target:self action:@selector(didTapTimerInNavbar)]]; } self.navigationItem.rightBarButtonItems = [barButtons copy]; } - (void)initializeToolbars { // HACK JSQMessagesViewController doesn't yet support dynamic type in the inputToolbar. // See: https://github.com/jessesquires/JSQMessagesViewController/pull/1169/files [self.inputToolbar.contentView.textView sizeToFit]; self.inputToolbar.preferredDefaultHeight = self.inputToolbar.contentView.textView.frame.size.height + 16; // prevent draft from obscuring message history in case user wants to scroll back to refer to something // while composing a long message. self.inputToolbar.maximumHeight = 300; } - (void)setupTitleLabelGestureRecognizer { // Called on load/unload, but we only want to init once. if (self.navbarTitleLabel) { return; } UILabel *navbarTitleLabel = [self findNavbarTitleLabel]; if (!navbarTitleLabel) { DDLogError(@"%@ Unable to find navbar title label. Skipping gesture recognition", self.tag); return; } self.navbarTitleLabel = navbarTitleLabel; navbarTitleLabel.userInteractionEnabled = YES; navbarTitleLabel.superview.userInteractionEnabled = YES; UITapGestureRecognizer *titleTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapTitle)]; [navbarTitleLabel addGestureRecognizer:titleTapRecognizer]; } - (nullable UILabel *)findNavbarTitleLabel { for (UIView *view in self.navigationController.navigationBar.subviews) { if ([view isKindOfClass:NSClassFromString(@"UINavigationItemView")]) { UIView *navItemView = view; for (UIView *aView in navItemView.subviews) { if ([aView isKindOfClass:[UILabel class]]) { UILabel *label = (UILabel *)aView; if ([label.text isEqualToString:self.title]) { return label; } } } } } return nil; } // Overiding JSQMVC layout defaults - (void)initializeCollectionViewLayout { [self.collectionView.collectionViewLayout setMessageBubbleFont:[UIFont ows_dynamicTypeBodyFont]]; self.collectionView.showsVerticalScrollIndicator = NO; self.collectionView.showsHorizontalScrollIndicator = NO; [self updateLoadEarlierVisible]; self.collectionView.collectionViewLayout.incomingAvatarViewSize = CGSizeZero; self.collectionView.collectionViewLayout.outgoingAvatarViewSize = CGSizeZero; if ([UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPad) { // Narrow the bubbles a bit to create more white space in the messages view // Since we're not using avatars it gets a bit crowded otherwise. self.collectionView.collectionViewLayout.messageBubbleLeftRightMargin = 80.0f; } // Bubbles self.collectionView.collectionViewLayout.bubbleSizeCalculator = [[OWSMessagesBubblesSizeCalculator alloc] init]; JSQMessagesBubbleImageFactory *bubbleFactory = [[JSQMessagesBubbleImageFactory alloc] init]; self.incomingBubbleImageData = [bubbleFactory incomingMessagesBubbleImageWithColor:[UIColor jsq_messageBubbleLightGrayColor]]; self.outgoingBubbleImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor ows_materialBlueColor]]; self.currentlyOutgoingBubbleImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor ows_fadedBlueColor]]; self.outgoingMessageFailedImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor grayColor]]; } #pragma mark - Fingerprints - (void)showFingerprintWithTheirIdentityKey:(NSData *)theirIdentityKey theirSignalId:(NSString *)theirSignalId { OWSFingerprintBuilder *builder = [[OWSFingerprintBuilder alloc] initWithStorageManager:self.storageManager]; OWSFingerprint *fingerprint = [builder fingerprintWithTheirSignalId:self.thread.contactIdentifier theirIdentityKey:theirIdentityKey]; [self markAllMessagesAsRead]; [self performSegueWithIdentifier:OWSMessagesViewControllerSegueShowFingerprint sender:fingerprint]; } #pragma mark - Calls - (SignalRecipient *)signalRecipient { __block SignalRecipient *recipient; [self.editingDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { recipient = [SignalRecipient recipientWithTextSecureIdentifier:[self phoneNumberForThread].toE164 withTransaction:transaction]; }]; return recipient; } - (BOOL)isTextSecureReachable { return isGroupConversation || [self signalRecipient]; } - (PhoneNumber *)phoneNumberForThread { NSString *contactId = [(TSContactThread *)self.thread contactIdentifier]; return [PhoneNumber tryParsePhoneNumberFromUserSpecifiedText:contactId]; } - (void)callAction { if ([self canCall]) { PhoneNumber *number = [self phoneNumberForThread]; Contact *contact = [self.contactsManager latestContactForPhoneNumber:number]; [Environment.phoneManager initiateOutgoingCallToContact:contact atRemoteNumber:number]; } else { DDLogWarn(@"Tried to initiate a call but thread is not callable."); } } - (BOOL)canCall { return !(isGroupConversation || [((TSContactThread *)self.thread).contactIdentifier isEqualToString:[TSAccountManager localNumber]]); } #pragma mark - JSQMessagesViewController method overrides - (void)didPressSendButton:(UIButton *)button withMessageText:(NSString *)text senderId:(NSString *)senderId senderDisplayName:(NSString *)senderDisplayName date:(NSDate *)date { if (text.length > 0) { [JSQSystemSoundPlayer jsq_playMessageSentSound]; TSOutgoingMessage *message; OWSDisappearingMessagesConfiguration *configuration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; if (configuration.isEnabled) { message = [[TSOutgoingMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp] inThread:self.thread messageBody:text attachmentIds:[NSMutableArray new] expiresInSeconds:configuration.durationSeconds]; } else { message = [[TSOutgoingMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp] inThread:self.thread messageBody:text]; } [[TSMessagesManager sharedManager] sendMessage:message inThread:self.thread success:^{ DDLogInfo(@"%@ Successfully sent message.", self.tag); } failure:^{ DDLogWarn(@"%@ Failed to deliver message.", self.tag); }]; [self finishSendingMessage]; } } #pragma mark - UICollectionViewDelegate // Override JSQMVC - (BOOL)collectionView:(JSQMessagesCollectionView *)collectionView shouldShowMenuForItemAtIndexPath:(NSIndexPath *)indexPath { if (indexPath == nil) { DDLogError(@"Aborting shouldShowMenuForItemAtIndexPath because indexPath is nil"); // Not sure why this is nil, but occasionally it is, which crashes. return NO; } // JSQM does some setup in super method [super collectionView:collectionView shouldShowMenuForItemAtIndexPath:indexPath]; // Super method returns false for media methods. We want menu for *all* items return YES; } - (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(nonnull UICollectionViewCell *)cell forItemAtIndexPath:(nonnull NSIndexPath *)indexPath { if ([cell conformsToProtocol:@protocol(OWSExpirableMessageView)]) { id expirableView = (id)cell; [expirableView stopExpirationTimer]; } } #pragma mark - JSQMessages CollectionView DataSource - (id)collectionView:(JSQMessagesCollectionView *)collectionView messageDataForItemAtIndexPath:(NSIndexPath *)indexPath { return [self messageAtIndexPath:indexPath]; } - (id)collectionView:(JSQMessagesCollectionView *)collectionView messageBubbleImageDataForItemAtIndexPath:(NSIndexPath *)indexPath { TSInteraction *message = [self interactionAtIndexPath:indexPath]; if ([message isKindOfClass:[TSOutgoingMessage class]]) { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)message; switch (outgoingMessage.messageState) { case TSOutgoingMessageStateUnsent: return self.outgoingMessageFailedImageData; case TSOutgoingMessageStateAttemptingOut: return self.currentlyOutgoingBubbleImageData; default: return self.outgoingBubbleImageData; } } return self.incomingBubbleImageData; } - (id)collectionView:(JSQMessagesCollectionView *)collectionView avatarImageDataForItemAtIndexPath:(NSIndexPath *)indexPath { return nil; } #pragma mark - UICollectionView DataSource - (UICollectionViewCell *)collectionView:(JSQMessagesCollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { id message = [self messageAtIndexPath:indexPath]; NSParameterAssert(message != nil); JSQMessagesCollectionViewCell *cell; switch (message.messageType) { case TSCallAdapter: { OWSCall *call = (OWSCall *)message; cell = [self loadCallCellForCall:call atIndexPath:indexPath]; } break; case TSInfoMessageAdapter: { OWSInfoMessage *infoMessage = (OWSInfoMessage *)message; cell = [self loadInfoMessageCellForMessage:infoMessage atIndexPath:indexPath]; } break; case TSErrorMessageAdapter: { OWSErrorMessage *errorMessage = (OWSErrorMessage *)message; cell = [self loadErrorMessageCellForMessage:errorMessage atIndexPath:indexPath]; } break; case TSIncomingMessageAdapter: { cell = [self loadIncomingMessageCellForMessage:message atIndexPath:indexPath]; } break; case TSOutgoingMessageAdapter: { cell = [self loadOutgoingCellForMessage:message atIndexPath:indexPath]; } break; default: { DDLogWarn(@"using default cell constructor for message: %@", message); cell = (JSQMessagesCollectionViewCell *)[super collectionView:collectionView cellForItemAtIndexPath:indexPath]; } break; } cell.delegate = collectionView; if (message.shouldStartExpireTimer && [cell conformsToProtocol:@protocol(OWSExpirableMessageView)]) { id expirableView = (id)cell; [expirableView startExpirationTimerWithExpiresAtSeconds:message.expiresAtSeconds initialDurationSeconds:message.expiresInSeconds]; } return cell; } #pragma mark - Loading message cells - (JSQMessagesCollectionViewCell *)loadIncomingMessageCellForMessage:(id)message atIndexPath:(NSIndexPath *)indexPath { JSQMessagesCollectionViewCell *cell = (JSQMessagesCollectionViewCell *)[super collectionView:self.collectionView cellForItemAtIndexPath:indexPath]; // BEGIN HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 [self fixupiOS10EmojiBugForTextView:cell.textView]; // END HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 if (!message.isMediaMessage) { cell.textView.textColor = [UIColor ows_blackColor]; cell.textView.linkTextAttributes = @{ NSForegroundColorAttributeName : cell.textView.textColor, NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle | NSUnderlinePatternSolid) }; } return cell; } - (JSQMessagesCollectionViewCell *)loadOutgoingCellForMessage:(id)message atIndexPath:(NSIndexPath *)indexPath { OWSOutgoingMessageCollectionViewCell *cell = (OWSOutgoingMessageCollectionViewCell *)[super collectionView:self.collectionView cellForItemAtIndexPath:indexPath]; // BEGIN HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 [self fixupiOS10EmojiBugForTextView:cell.textView]; // END HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 if (!message.isMediaMessage) { cell.textView.textColor = [UIColor whiteColor]; cell.textView.linkTextAttributes = @{ NSForegroundColorAttributeName : cell.textView.textColor, NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle | NSUnderlinePatternSolid) }; } return cell; } // BEGIN HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 - (void)fixupiOS10EmojiBugForTextView:(UITextView *)textView { BOOL isIOS10OrGreater = [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){.majorVersion = 10 }]; if (isIOS10OrGreater) { [textView.textStorage enumerateAttribute:NSFontAttributeName inRange:NSMakeRange(0, textView.textStorage.length) options:0 usingBlock:^(id _Nullable value, NSRange range, BOOL *_Nonnull stop) { UIFont *font = (UIFont *)value; if ([font.fontName isEqualToString:@".AppleColorEmojiUI"]) { [textView.textStorage addAttribute:NSFontAttributeName value:[UIFont fontWithName:@"AppleColorEmoji" size:font.pointSize] range:range]; } }]; } } // END HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 - (OWSCallCollectionViewCell *)loadCallCellForCall:(OWSCall *)call atIndexPath:(NSIndexPath *)indexPath { OWSCallCollectionViewCell *callCell = [self.collectionView dequeueReusableCellWithReuseIdentifier:[OWSCallCollectionViewCell cellReuseIdentifier] forIndexPath:indexPath]; NSString *text = call.date != nil ? [call text] : call.senderDisplayName; NSString *allText = call.date != nil ? [text stringByAppendingString:[call dateText]] : text; UIFont *boldFont = [UIFont fontWithName:@"HelveticaNeue-Medium" size:12.0f]; NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:allText attributes:@{ NSFontAttributeName: boldFont }]; if([call date]!=nil) { // Not a group meta message UIFont *regularFont = [UIFont fontWithName:@"HelveticaNeue-Light" size:12.0f]; const NSRange range = NSMakeRange([text length], [[call dateText] length]); [attributedText setAttributes:@{ NSFontAttributeName: regularFont } range:range]; } callCell.textView.text = nil; callCell.textView.attributedText = attributedText; callCell.textView.textAlignment = NSTextAlignmentCenter; callCell.textView.textColor = [UIColor ows_materialBlueColor]; callCell.layer.shouldRasterize = YES; callCell.layer.rasterizationScale = [UIScreen mainScreen].scale; // Disable text selectability. Specifying this in prepareForReuse/awakeFromNib was not sufficient. callCell.textView.userInteractionEnabled = NO; callCell.textView.selectable = NO; return callCell; } - (OWSDisplayedMessageCollectionViewCell *)loadDisplayedMessageCollectionViewCellForIndexPath:(NSIndexPath *)indexPath { OWSDisplayedMessageCollectionViewCell *messageCell = [self.collectionView dequeueReusableCellWithReuseIdentifier:[OWSDisplayedMessageCollectionViewCell cellReuseIdentifier] forIndexPath:indexPath]; messageCell.layer.shouldRasterize = YES; messageCell.layer.rasterizationScale = [UIScreen mainScreen].scale; messageCell.textView.textColor = [UIColor darkGrayColor]; messageCell.cellTopLabel.attributedText = [self.collectionView.dataSource collectionView:self.collectionView attributedTextForCellTopLabelAtIndexPath:indexPath]; return messageCell; } - (OWSDisplayedMessageCollectionViewCell *)loadInfoMessageCellForMessage:(OWSInfoMessage *)infoMessage atIndexPath:(NSIndexPath *)indexPath { OWSDisplayedMessageCollectionViewCell *infoCell = [self loadDisplayedMessageCollectionViewCellForIndexPath:indexPath]; // HACK this will get called when we get a new info message, but there's gotta be a better spot for this. OWSDisappearingMessagesConfiguration *configuration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; [self setBarButtonItemsForDisappearingMessagesConfiguration:configuration]; infoCell.textView.text = [infoMessage text]; // Disable text selectability. Specifying this in prepareForReuse/awakeFromNib was not sufficient. infoCell.textView.userInteractionEnabled = NO; infoCell.textView.selectable = NO; infoCell.messageBubbleContainerView.layer.borderColor = [[UIColor ows_infoMessageBorderColor] CGColor]; if (infoMessage.infoMessageType == TSInfoMessageTypeDisappearingMessagesUpdate) { infoCell.headerImageView.image = [UIImage imageNamed:@"ic_timer"]; infoCell.headerImageView.backgroundColor = [UIColor whiteColor]; // Lighten up the broad stroke header icon to match the perceived color of the border. infoCell.headerImageView.tintColor = [UIColor ows_infoMessageBorderColor]; } else { infoCell.headerImageView.image = [UIImage imageNamed:@"warning_white"]; } return infoCell; } - (OWSDisplayedMessageCollectionViewCell *)loadErrorMessageCellForMessage:(OWSErrorMessage *)errorMessage atIndexPath:(NSIndexPath *)indexPath { OWSDisplayedMessageCollectionViewCell *errorCell = [self loadDisplayedMessageCollectionViewCellForIndexPath:indexPath]; errorCell.textView.text = [errorMessage text]; // Disable text selectability. Specifying this in prepareForReuse/awakeFromNib was not sufficient. errorCell.textView.userInteractionEnabled = NO; errorCell.textView.selectable = NO; errorCell.messageBubbleContainerView.layer.borderColor = [[UIColor ows_errorMessageBorderColor] CGColor]; errorCell.headerImageView.image = [UIImage imageNamed:@"error_white"]; return errorCell; } #pragma mark - Adjusting cell label heights - (CGFloat)collectionView:(JSQMessagesCollectionView *)collectionView layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout heightForCellTopLabelAtIndexPath:(NSIndexPath *)indexPath { if ([self showDateAtIndexPath:indexPath]) { return kJSQMessagesCollectionViewCellLabelHeightDefault; } return 0.0f; } - (BOOL)showDateAtIndexPath:(NSIndexPath *)indexPath { BOOL showDate = NO; if (indexPath.row == 0) { showDate = YES; } else { TSMessageAdapter *currentMessage = [self messageAtIndexPath:indexPath]; TSMessageAdapter *previousMessage = [self messageAtIndexPath:[NSIndexPath indexPathForItem:indexPath.row - 1 inSection:indexPath.section]]; NSTimeInterval timeDifference = [currentMessage.date timeIntervalSinceDate:previousMessage.date]; if (timeDifference > kTSMessageSentDateShowTimeInterval) { showDate = YES; } } return showDate; } - (NSAttributedString *)collectionView:(JSQMessagesCollectionView *)collectionView attributedTextForCellTopLabelAtIndexPath:(NSIndexPath *)indexPath { if ([self showDateAtIndexPath:indexPath]) { TSMessageAdapter *currentMessage = [self messageAtIndexPath:indexPath]; return [[JSQMessagesTimestampFormatter sharedFormatter] attributedTimestampForDate:currentMessage.date]; } return nil; } - (BOOL)shouldShowMessageStatusAtIndexPath:(NSIndexPath *)indexPath { TSMessageAdapter *currentMessage = [self messageAtIndexPath:indexPath]; if (currentMessage.isExpiringMessage) { return YES; } return !![self collectionView:self.collectionView attributedTextForCellBottomLabelAtIndexPath:indexPath]; } - (TSMessageAdapter *)nextOutgoingMessage:(NSIndexPath *)indexPath { TSMessageAdapter *nextMessage = [self messageAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row + 1 inSection:indexPath.section]]; int i = 1; while (indexPath.item + i < [self.collectionView numberOfItemsInSection:indexPath.section] - 1 && ![self isMessageOutgoingAndDelivered:nextMessage]) { i++; nextMessage = [self messageAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row + i inSection:indexPath.section]]; } return nextMessage; } - (BOOL)isMessageOutgoingAndDelivered:(TSMessageAdapter *)message { if (message.messageType == TSOutgoingMessageAdapter) { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)message; if(outgoingMessage.messageState == TSOutgoingMessageStateDelivered) { return YES; } } return NO; } - (NSAttributedString *)collectionView:(JSQMessagesCollectionView *)collectionView attributedTextForCellBottomLabelAtIndexPath:(NSIndexPath *)indexPath { id messageData = [self messageAtIndexPath:indexPath]; if (![messageData isKindOfClass:[TSMessageAdapter class]]) { return nil; } TSMessageAdapter *message = (TSMessageAdapter *)messageData; if (message.messageType == TSOutgoingMessageAdapter) { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)message.interaction; if (outgoingMessage.messageState == TSOutgoingMessageStateUnsent) { NSAttributedString *failedString = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"FAILED_SENDING_TEXT", nil)]; return failedString; } else if ([self isMessageOutgoingAndDelivered:message]) { NSAttributedString *deliveredString = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"DELIVERED_MESSAGE_TEXT", @"")]; // Show when it's the last message in the thread if (indexPath.item == [self.collectionView numberOfItemsInSection:indexPath.section] - 1) { [self updateLastDeliveredMessage:message]; return deliveredString; } // Or when the next message is *not* an outgoing delivered message. TSMessageAdapter *nextMessage = [self nextOutgoingMessage:indexPath]; if (![self isMessageOutgoingAndDelivered:nextMessage]) { [self updateLastDeliveredMessage:message]; return deliveredString; } } } else if (message.messageType == TSIncomingMessageAdapter && [self.thread isKindOfClass:[TSGroupThread class]]) { TSIncomingMessage *incomingMessage = (TSIncomingMessage *)message.interaction; NSString *_Nonnull name = [self.contactsManager nameStringForPhoneIdentifier:incomingMessage.authorId]; NSAttributedString *senderNameString = [[NSAttributedString alloc] initWithString:name]; return senderNameString; } return nil; } - (void)updateLastDeliveredMessage:(TSMessageAdapter *)newLastDeliveredMessage { if (newLastDeliveredMessage.interaction.timestamp > self.lastDeliveredMessage.interaction.timestamp) { TSMessageAdapter *penultimateDeliveredMessage = self.lastDeliveredMessage; self.lastDeliveredMessage = newLastDeliveredMessage; [penultimateDeliveredMessage.interaction touch]; } } - (CGFloat)collectionView:(JSQMessagesCollectionView *)collectionView layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout heightForCellBottomLabelAtIndexPath:(NSIndexPath *)indexPath { if ([self shouldShowMessageStatusAtIndexPath:indexPath]) { return 16.0f; } return 0.0f; } #pragma mark - Actions - (void)showConversationSettings { if (self.userLeftGroup) { DDLogDebug(@"%@ Ignoring request to show conversation settings, since user left group", self.tag); return; } [self performSegueWithIdentifier:OWSMessagesViewControllerSeguePushConversationSettings sender:self]; } - (void)didTapTitle { DDLogDebug(@"%@ Tapped title in navbar", self.tag); [self showConversationSettings]; } - (void)didTapManageGroupButton:(id)sender { DDLogDebug(@"%@ Tapped options menu in navbar", self.tag); [self showConversationSettings]; } - (void)didTapTimerInNavbar { DDLogDebug(@"%@ Tapped timer in navbar", self.tag); [self showConversationSettings]; } - (void)collectionView:(JSQMessagesCollectionView *)collectionView didTapMessageBubbleAtIndexPath:(NSIndexPath *)indexPath { id messageItem = [self messageAtIndexPath:indexPath]; TSInteraction *interaction = [self interactionAtIndexPath:indexPath]; switch (messageItem.messageType) { case TSOutgoingMessageAdapter: { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)messageItem; if (outgoingMessage.messageState == TSOutgoingMessageStateUnsent) { [self handleUnsentMessageTap:(TSOutgoingMessage *)interaction]; } } // No `break` as we want to fall through to capture tapping on media items case TSIncomingMessageAdapter: { BOOL isMediaMessage = [messageItem isMediaMessage]; if (isMediaMessage) { if ([[messageItem media] isKindOfClass:[TSPhotoAdapter class]]) { TSPhotoAdapter *messageMedia = (TSPhotoAdapter *)[messageItem media]; tappedImage = ((UIImageView *)[messageMedia mediaView]).image; if(tappedImage == nil) { DDLogWarn(@"tapped TSPhotoAdapter with nil image"); } else { CGRect convertedRect = [self.collectionView convertRect:[collectionView cellForItemAtIndexPath:indexPath].frame toView:nil]; __block TSAttachment *attachment = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { attachment = [TSAttachment fetchObjectWithUniqueID:messageMedia.attachmentId transaction:transaction]; }]; if ([attachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *attStream = (TSAttachmentStream *)attachment; FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachment:attStream fromRect:convertedRect forInteraction:[self interactionAtIndexPath:indexPath] isAnimated:NO]; [vc presentFromViewController:self.navigationController]; } } } else if ([[messageItem media] isKindOfClass:[TSAnimatedAdapter class]]) { // Show animated image full-screen TSAnimatedAdapter *messageMedia = (TSAnimatedAdapter *)[messageItem media]; tappedImage = ((UIImageView *)[messageMedia mediaView]).image; if(tappedImage == nil) { DDLogWarn(@"tapped TSAnimatedAdapter with nil image"); } else { CGRect convertedRect = [self.collectionView convertRect:[collectionView cellForItemAtIndexPath:indexPath].frame toView:nil]; __block TSAttachment *attachment = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { attachment = [TSAttachment fetchObjectWithUniqueID:messageMedia.attachmentId transaction:transaction]; }]; if ([attachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *attStream = (TSAttachmentStream *)attachment; FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachment:attStream fromRect:convertedRect forInteraction:[self interactionAtIndexPath:indexPath] isAnimated:YES]; [vc presentFromViewController:self.navigationController]; } } } else if ([[messageItem media] isKindOfClass:[TSVideoAttachmentAdapter class]]) { // fileurl disappeared should look up in db as before. will do refactor // full screen, check this setup with a .mov TSVideoAttachmentAdapter *messageMedia = (TSVideoAttachmentAdapter *)[messageItem media]; _currentMediaAdapter = messageMedia; __block TSAttachment *attachment = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { attachment = [TSAttachment fetchObjectWithUniqueID:messageMedia.attachmentId transaction:transaction]; }]; if ([attachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *attStream = (TSAttachmentStream *)attachment; NSFileManager *fileManager = [NSFileManager defaultManager]; if ([messageMedia isVideo]) { if ([fileManager fileExistsAtPath:[attStream.mediaURL path]]) { [self dismissKeyBoard]; _videoPlayer = [[MPMoviePlayerController alloc] initWithContentURL:attStream.mediaURL]; [_videoPlayer prepareToPlay]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(moviePlayBackDidFinish:) name:MPMoviePlayerPlaybackDidFinishNotification object:_videoPlayer]; _videoPlayer.controlStyle = MPMovieControlStyleDefault; _videoPlayer.shouldAutoplay = YES; [self.view addSubview:_videoPlayer.view]; [_videoPlayer setFullscreen:YES animated:YES]; } } else if ([messageMedia isAudio]) { if (messageMedia.isAudioPlaying) { // if you had started playing an audio msg and now you're tapping it to pause messageMedia.isAudioPlaying = NO; [_audioPlayer pause]; messageMedia.isPaused = YES; [_audioPlayerPoller invalidate]; double current = [_audioPlayer currentTime] / [_audioPlayer duration]; [messageMedia setAudioProgressFromFloat:(float)current]; [messageMedia setAudioIconToPlay]; } else { BOOL isResuming = NO; [_audioPlayerPoller invalidate]; // loop through all the other bubbles and set their isPlaying to false NSInteger num_bubbles = [self collectionView:collectionView numberOfItemsInSection:0]; for (NSInteger i = 0; i < num_bubbles; i++) { NSIndexPath *indexPathI = [NSIndexPath indexPathForRow:i inSection:0]; id message = [self messageAtIndexPath:indexPathI]; if (message.messageType == TSIncomingMessageAdapter && message.isMediaMessage) { TSVideoAttachmentAdapter *msgMedia = (TSVideoAttachmentAdapter *)[message media]; if ([msgMedia isAudio]) { if (msgMedia == messageMedia && messageMedia.isPaused) { isResuming = YES; } else { msgMedia.isAudioPlaying = NO; msgMedia.isPaused = NO; [msgMedia setAudioIconToPlay]; [msgMedia setAudioProgressFromFloat:0]; [msgMedia resetAudioDuration]; } } } } if (isResuming) { // if you had paused an audio msg and now you're tapping to resume [_audioPlayer prepareToPlay]; [_audioPlayer play]; [messageMedia setAudioIconToPause]; messageMedia.isAudioPlaying = YES; messageMedia.isPaused = NO; _audioPlayerPoller = [NSTimer scheduledTimerWithTimeInterval:.05 target:self selector:@selector(audioPlayerUpdated:) userInfo:@{ @"adapter" : messageMedia } repeats:YES]; } else { // if you are tapping an audio msg for the first time to play messageMedia.isAudioPlaying = YES; NSError *error; _audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:attStream.mediaURL error:&error]; if (error) { DDLogError(@"error: %@", error); } [_audioPlayer prepareToPlay]; [_audioPlayer play]; [messageMedia setAudioIconToPause]; _audioPlayer.delegate = self; _audioPlayerPoller = [NSTimer scheduledTimerWithTimeInterval:.05 target:self selector:@selector(audioPlayerUpdated:) userInfo:@{ @"adapter" : messageMedia } repeats:YES]; } } } } } } } break; case TSErrorMessageAdapter: [self handleErrorMessageTap:(TSErrorMessage *)interaction]; break; case TSInfoMessageAdapter: [self handleWarningTap:interaction]; break; case TSCallAdapter: break; default: DDLogDebug(@"Unhandled bubble touch for interaction: %@.", interaction); break; } } - (void)handleWarningTap:(TSInteraction *)interaction { if ([interaction isKindOfClass:[TSIncomingMessage class]]) { TSIncomingMessage *message = (TSIncomingMessage *)interaction; for (NSString *attachmentId in message.attachmentIds) { __block TSAttachment *attachment; [self.editingDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; }]; if ([attachment isKindOfClass:[TSAttachmentPointer class]]) { TSAttachmentPointer *pointer = (TSAttachmentPointer *)attachment; // FIXME possible for pointer to get stuck in isDownloading state if app is closed while downloading. // see: https://github.com/WhisperSystems/Signal-iOS/issues/1254 if (!pointer.isDownloading) { [[TSMessagesManager sharedManager] retrieveAttachment:pointer messageId:message.uniqueId]; } } } } } - (void)moviePlayBackDidFinish:(id)sender { DDLogDebug(@"playback finished"); } - (void)collectionView:(JSQMessagesCollectionView *)collectionView header:(JSQMessagesLoadEarlierHeaderView *)headerView didTapLoadEarlierMessagesButton:(UIButton *)sender { if ([self shouldShowLoadEarlierMessages]) { self.page++; } NSInteger item = (NSInteger)[self scrollToItem]; [self updateRangeOptionsForPage:self.page]; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { [self.messageMappings updateWithTransaction:transaction]; }]; [self updateLayoutForEarlierMessagesWithOffset:item]; } - (BOOL)shouldShowLoadEarlierMessages { __block BOOL show = YES; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { show = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId] < [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; }]; return show; } - (NSUInteger)scrollToItem { __block NSUInteger item = kYapDatabaseRangeLength * (self.page + 1) - [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { NSUInteger numberOfVisibleMessages = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; NSUInteger numberOfTotalMessages = [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; NSUInteger numberOfMessagesToLoad = numberOfTotalMessages - numberOfVisibleMessages; BOOL canLoadFullRange = numberOfMessagesToLoad >= kYapDatabaseRangeLength; if (!canLoadFullRange) { item = numberOfMessagesToLoad; } }]; return item == 0 ? item : item - 1; } - (void)updateLoadEarlierVisible { [self setShowLoadEarlierMessagesHeader:[self shouldShowLoadEarlierMessages]]; } - (void)updateLayoutForEarlierMessagesWithOffset:(NSInteger)offset { [self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; [self.collectionView reloadData]; [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:offset inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO]; [self updateLoadEarlierVisible]; } - (void)updateRangeOptionsForPage:(NSUInteger)page { YapDatabaseViewRangeOptions *rangeOptions = [YapDatabaseViewRangeOptions flexibleRangeWithLength:kYapDatabaseRangeLength * (page + 1) offset:0 from:YapDatabaseViewEnd]; rangeOptions.maxLength = kYapDatabaseRangeMaxLength; rangeOptions.minLength = kYapDatabaseRangeMinLength; [self.messageMappings setRangeOptions:rangeOptions forGroup:self.thread.uniqueId]; } #pragma mark Bubble User Actions - (void)handleUnsentMessageTap:(TSOutgoingMessage *)message { [self dismissKeyBoard]; [DJWActionSheet showInView:self.parentViewController.view withTitle:nil cancelButtonTitle:NSLocalizedString(@"TXT_CANCEL_TITLE", @"") destructiveButtonTitle:NSLocalizedString(@"TXT_DELETE_TITLE", @"") otherButtonTitles:@[ NSLocalizedString(@"SEND_AGAIN_BUTTON", @"") ] tapBlock:^(DJWActionSheet *actionSheet, NSInteger tappedButtonIndex) { if (tappedButtonIndex == actionSheet.cancelButtonIndex) { DDLogDebug(@"User Cancelled"); } else if (tappedButtonIndex == actionSheet.destructiveButtonIndex) { [self.editingDatabaseConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [message removeWithTransaction:transaction]; }]; } else { [[TSMessagesManager sharedManager] sendMessage:message inThread:self.thread success:nil failure:nil]; [self finishSendingMessage]; } }]; } - (void)handleErrorMessageTap:(TSErrorMessage *)message { if ([message isKindOfClass:[TSInvalidIdentityKeyErrorMessage class]]) { [self tappedInvalidIdentityKeyErrorMessage:(TSInvalidIdentityKeyErrorMessage *)message]; } } - (void)tappedInvalidIdentityKeyErrorMessage:(TSInvalidIdentityKeyErrorMessage *)errorMessage { NSString *keyOwner = [self.contactsManager nameStringForPhoneIdentifier:errorMessage.theirSignalId]; NSString *titleFormat = NSLocalizedString(@"SAFETY_NUMBERS_ACTIONSHEET_TITLE", @"Action sheet heading"); NSString *titleText = [NSString stringWithFormat:titleFormat, keyOwner]; NSArray *actions = @[ NSLocalizedString(@"SHOW_SAFETY_NUMBER_ACTION", @"Action sheet item"), NSLocalizedString(@"ACCEPT_NEW_IDENTITY_ACTION", @"Action sheet item") ]; [self dismissKeyBoard]; [DJWActionSheet showInView:self.parentViewController.view withTitle:titleText cancelButtonTitle:NSLocalizedString(@"TXT_CANCEL_TITLE", @"") destructiveButtonTitle:nil otherButtonTitles:actions tapBlock:^(DJWActionSheet *actionSheet, NSInteger tappedButtonIndex) { if (tappedButtonIndex == actionSheet.cancelButtonIndex) { DDLogDebug(@"%@ Remote Key Changed actions: Tapped cancel", self.tag); } else { switch (tappedButtonIndex) { case 0: DDLogInfo(@"%@ Remote Key Changed actions: Show fingerprint display", self.tag); [self showFingerprintWithTheirIdentityKey:errorMessage.newIdentityKey theirSignalId:errorMessage.theirSignalId]; break; case 1: DDLogInfo(@"%@ Remote Key Changed actions: Accepted new identity key", self.tag); [errorMessage acceptNewIdentityKey]; break; default: DDLogInfo(@"%@ Remote Key Changed actions: Unhandled button pressed: %d", self.tag, (int)tappedButtonIndex); break; } } }]; } #pragma mark - Navigation - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { if ([segue.identifier isEqualToString:OWSMessagesViewControllerSegueShowFingerprint]) { if (![segue.destinationViewController isKindOfClass:[FingerprintViewController class]]) { DDLogError(@"%@ Expected Fingerprint VC but got: %@", self.tag, segue.destinationViewController); return; } FingerprintViewController *vc = (FingerprintViewController *)segue.destinationViewController; if (![sender isKindOfClass:[OWSFingerprint class]]) { DDLogError(@"%@ Attempting to segue to fingerprint VC without a valid fingerprint: %@", self.tag, sender); return; } OWSFingerprint *fingerprint = (OWSFingerprint *)sender; NSString *contactName = [self.contactsManager nameStringForPhoneIdentifier:fingerprint.theirStableId]; [vc configureWithThread:self.thread fingerprint:fingerprint contactName:contactName]; } else if ([segue.destinationViewController isKindOfClass:[OWSConversationSettingsTableViewController class]]) { OWSConversationSettingsTableViewController *controller = (OWSConversationSettingsTableViewController *)segue.destinationViewController; [controller configureWithThread:self.thread]; } } #pragma mark - UIImagePickerController /* * Presenting UIImagePickerController */ - (void)takePictureOrVideo { if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { DDLogError(@"Camera ImagePicker source not available"); return; } UIImagePickerController *picker = [[UIImagePickerController alloc] init]; picker.sourceType = UIImagePickerControllerSourceTypeCamera; picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; picker.allowsEditing = NO; picker.delegate = self; [self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]]; } - (void)chooseFromLibrary { if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypePhotoLibrary]) { DDLogError(@"PhotoLibrary ImagePicker source not available"); return; } UIImagePickerController *picker = [[UIImagePickerController alloc] init]; picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; picker.delegate = self; picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; [self presentViewController:picker animated:YES completion:[UIUtil modalCompletionBlock]]; } /* * Dismissing UIImagePickerController */ - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { [UIUtil modalCompletionBlock](); [self dismissViewControllerAnimated:YES completion:nil]; } - (void)resetFrame { // fixes bug on frame being off after this selection CGRect frame = [UIScreen mainScreen].applicationFrame; self.view.frame = frame; } /* * Fetching data from UIImagePickerController */ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { [UIUtil modalCompletionBlock](); [self resetFrame]; void (^failedToPickAttachment)(NSError *error) = ^void(NSError *error) { DDLogError(@"failed to pick attachment with error: %@", error); }; NSString *mediaType = info[UIImagePickerControllerMediaType]; if ([mediaType isEqualToString:(__bridge NSString *)kUTTypeMovie]) { // Video picked from library or captured with camera NSURL *videoURL = info[UIImagePickerControllerMediaURL]; [self sendQualityAdjustedAttachment:videoURL]; } else if (picker.sourceType == UIImagePickerControllerSourceTypeCamera) { // Static Image captured from camera UIImage *imageFromCamera = [info[UIImagePickerControllerOriginalImage] normalizedImage]; if (imageFromCamera) { [self sendMessageAttachment:[self qualityAdjustedAttachmentForImage:imageFromCamera] ofType:@"image/jpeg"]; } else { failedToPickAttachment(nil); } } else { // Non-Video image picked from library NSURL *assetURL = info[UIImagePickerControllerReferenceURL]; PHAsset *asset = [[PHAsset fetchAssetsWithALAssetURLs:@[ assetURL ] options:nil] lastObject]; if (!asset) { return failedToPickAttachment(nil); } PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; options.synchronous = YES; // We're only fetching one asset. options.networkAccessAllowed = YES; // iCloud OK options.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; // Don't need quick/dirty version [[PHImageManager defaultManager] requestImageDataForAsset:asset options:options resultHandler:^(NSData *_Nullable imageData, NSString *_Nullable dataUTI, UIImageOrientation orientation, NSDictionary *_Nullable assetInfo) { NSError *assetFetchingError = assetInfo[PHImageErrorKey]; if (assetFetchingError || !imageData) { return failedToPickAttachment(assetFetchingError); } DDLogVerbose( @"Size in bytes: %lu; detected filetype: %@", (unsigned long)imageData.length, dataUTI); if ([dataUTI isEqualToString:(__bridge NSString *)kUTTypeGIF] && imageData.length <= 5 * 1024 * 1024) { DDLogVerbose(@"Sending raw image/gif to retain any animation"); /** * Media Size constraints lifted from Signal-Android * (org/thoughtcrime/securesms/mms/PushMediaConstraints.java) * * GifMaxSize return 5 * MB; * For reference, other media size limits we're not explicitly enforcing: * ImageMaxSize return 420 * KB; * VideoMaxSize return 100 * MB; * getAudioMaxSize 100 * MB; */ [self sendMessageAttachment:imageData ofType:@"image/gif"]; } else { DDLogVerbose(@"Compressing attachment as image/jpeg"); UIImage *pickedImage = [[UIImage alloc] initWithData:imageData]; [self sendMessageAttachment:[self qualityAdjustedAttachmentForImage:pickedImage] ofType:@"image/jpeg"]; } }]; } } - (void)sendMessageAttachment:(NSData *)attachmentData ofType:(NSString *)attachmentType { TSOutgoingMessage *message; OWSDisappearingMessagesConfiguration *configuration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; if (configuration.isEnabled) { message = [[TSOutgoingMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp] inThread:self.thread messageBody:nil attachmentIds:[NSMutableArray new] expiresInSeconds:configuration.durationSeconds]; } else { message = [[TSOutgoingMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp] inThread:self.thread messageBody:nil attachmentIds:[NSMutableArray new]]; } [self dismissViewControllerAnimated:YES completion:^{ DDLogVerbose(@"Sending attachment. Size in bytes: %lu, contentType: %@", (unsigned long)attachmentData.length, attachmentType); [[TSMessagesManager sharedManager] sendAttachment:attachmentData contentType:attachmentType inMessage:message thread:self.thread success:nil failure:nil]; }]; } - (NSURL *)videoTempFolder { NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); NSString *basePath = ([paths count] > 0) ? [paths objectAtIndex:0] : nil; basePath = [basePath stringByAppendingPathComponent:@"videos"]; if (![[NSFileManager defaultManager] fileExistsAtPath:basePath]) { [[NSFileManager defaultManager] createDirectoryAtPath:basePath withIntermediateDirectories:YES attributes:nil error:nil]; } return [NSURL fileURLWithPath:basePath]; } - (void)sendQualityAdjustedAttachment:(NSURL *)movieURL { AVAsset *video = [AVAsset assetWithURL:movieURL]; AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:video presetName:AVAssetExportPresetMediumQuality]; exportSession.shouldOptimizeForNetworkUse = YES; exportSession.outputFileType = AVFileTypeMPEG4; double currentTime = [[NSDate date] timeIntervalSince1970]; NSString *strImageName = [NSString stringWithFormat:@"%f", currentTime]; NSURL *compressedVideoUrl = [[self videoTempFolder] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.mp4", strImageName]]; exportSession.outputURL = compressedVideoUrl; [exportSession exportAsynchronouslyWithCompletionHandler:^{ NSError *error; [self sendMessageAttachment:[NSData dataWithContentsOfURL:compressedVideoUrl] ofType:@"video/mp4"]; [[NSFileManager defaultManager] removeItemAtURL:compressedVideoUrl error:&error]; if (error) { DDLogWarn(@"Failed to remove cached video file: %@", error.debugDescription); } }]; } - (NSData *)qualityAdjustedAttachmentForImage:(UIImage *)image { return UIImageJPEGRepresentation([self adjustedImageSizedForSending:image], [self compressionRate]); } - (UIImage *)adjustedImageSizedForSending:(UIImage *)image { CGFloat correctedWidth; switch ([Environment.preferences imageUploadQuality]) { case TSImageQualityUncropped: return image; case TSImageQualityHigh: correctedWidth = 2048; break; case TSImageQualityMedium: correctedWidth = 1024; break; case TSImageQualityLow: correctedWidth = 512; break; default: break; } return [self imageScaled:image toMaxSize:correctedWidth]; } - (UIImage *)imageScaled:(UIImage *)image toMaxSize:(CGFloat)size { CGFloat scaleFactor; CGFloat aspectRatio = image.size.height / image.size.width; if (aspectRatio > 1) { scaleFactor = size / image.size.width; } else { scaleFactor = size / image.size.height; } CGSize newSize = CGSizeMake(image.size.width * scaleFactor, image.size.height * scaleFactor); UIGraphicsBeginImageContext(newSize); [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; UIImage *updatedImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return updatedImage; } - (CGFloat)compressionRate { switch ([Environment.preferences imageUploadQuality]) { case TSImageQualityUncropped: return 1; case TSImageQualityHigh: return 0.9f; case TSImageQualityMedium: return 0.5f; case TSImageQualityLow: return 0.3f; default: break; } } #pragma mark Storage access - (YapDatabaseConnection *)uiDatabaseConnection { NSAssert([NSThread isMainThread], @"Must access uiDatabaseConnection on main thread!"); if (!_uiDatabaseConnection) { _uiDatabaseConnection = [self.storageManager newDatabaseConnection]; [_uiDatabaseConnection beginLongLivedReadTransaction]; } return _uiDatabaseConnection; } - (YapDatabaseConnection *)editingDatabaseConnection { if (!_editingDatabaseConnection) { _editingDatabaseConnection = [self.storageManager newDatabaseConnection]; } return _editingDatabaseConnection; } - (void)yapDatabaseModified:(NSNotification *)notification { [self updateBackButtonAsync]; if (isGroupConversation) { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { TSGroupThread *gThread = (TSGroupThread *)self.thread; if (gThread.groupModel) { self.thread = [TSGroupThread threadWithGroupModel:gThread.groupModel transaction:transaction]; } }]; } NSArray *notifications = [self.uiDatabaseConnection beginLongLivedReadTransaction]; if (![[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] hasChangesForNotifications:notifications]) { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { [self.messageMappings updateWithTransaction:transaction]; }]; return; } NSArray *messageRowChanges = nil; NSArray *sectionChanges = nil; [[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] getSectionChanges:§ionChanges rowChanges:&messageRowChanges forNotifications:notifications withMappings:self.messageMappings]; __block BOOL scrollToBottom = NO; if ([sectionChanges count] == 0 & [messageRowChanges count] == 0) { return; } [self.collectionView performBatchUpdates:^{ for (YapDatabaseViewRowChange *rowChange in messageRowChanges) { switch (rowChange.type) { case YapDatabaseViewChangeDelete: { [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; YapCollectionKey *collectionKey = rowChange.collectionKey; if (collectionKey.key) { [self.messageAdapterCache removeObjectForKey:collectionKey.key]; } break; } case YapDatabaseViewChangeInsert: { [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; scrollToBottom = YES; break; } case YapDatabaseViewChangeMove: { [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; break; } case YapDatabaseViewChangeUpdate: { YapCollectionKey *collectionKey = rowChange.collectionKey; if (collectionKey.key) { [self.messageAdapterCache removeObjectForKey:collectionKey.key]; } [self.collectionView reloadItemsAtIndexPaths:@[ rowChange.indexPath ]]; scrollToBottom = YES; break; } } } } completion:^(BOOL success) { if (!success) { [self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; [self.collectionView reloadData]; } if (scrollToBottom) { [self scrollToBottomAnimated:YES]; } }]; } #pragma mark - UICollectionView DataSource - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { NSInteger numberOfMessages = (NSInteger)[self.messageMappings numberOfItemsInSection:(NSUInteger)section]; return numberOfMessages; } - (TSInteraction *)interactionAtIndexPath:(NSIndexPath *)indexPath { __block TSInteraction *message = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; NSParameterAssert(viewTransaction != nil); NSParameterAssert(self.messageMappings != nil); NSParameterAssert(indexPath != nil); NSUInteger row = (NSUInteger)indexPath.row; NSUInteger section = (NSUInteger)indexPath.section; NSUInteger numberOfItemsInSection __unused = [self.messageMappings numberOfItemsInSection:section]; NSAssert(row < numberOfItemsInSection, @"Cannot fetch message because row %d is >= numberOfItemsInSection %d", (int)row, (int)numberOfItemsInSection); message = [viewTransaction objectAtRow:row inSection:section withMappings:self.messageMappings]; NSParameterAssert(message != nil); }]; return message; } - (id)messageAtIndexPath:(NSIndexPath *)indexPath { TSInteraction *interaction = [self interactionAtIndexPath:indexPath]; id messageAdapter = [self.messageAdapterCache objectForKey:interaction.uniqueId]; if (!messageAdapter) { messageAdapter = [TSMessageAdapter messageViewDataWithInteraction:interaction inThread:self.thread]; [self.messageAdapterCache setObject:messageAdapter forKey: interaction.uniqueId]; } return messageAdapter; } #pragma mark - Audio - (void)recordAudio { // Define the recorder setting NSArray *pathComponents = [NSArray arrayWithObjects:[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject], [NSString stringWithFormat:@"%lld.m4a", [NSDate ows_millisecondTimeStamp]], nil]; NSURL *outputFileURL = [NSURL fileURLWithPathComponents:pathComponents]; // Setup audio session AVAudioSession *session = [AVAudioSession sharedInstance]; [session setCategory:AVAudioSessionCategoryPlayAndRecord error:nil]; NSMutableDictionary *recordSetting = [[NSMutableDictionary alloc] init]; [recordSetting setValue:[NSNumber numberWithInt:kAudioFormatMPEG4AAC] forKey:AVFormatIDKey]; [recordSetting setValue:[NSNumber numberWithFloat:44100.0] forKey:AVSampleRateKey]; [recordSetting setValue:[NSNumber numberWithInt:2] forKey:AVNumberOfChannelsKey]; // Initiate and prepare the recorder _audioRecorder = [[AVAudioRecorder alloc] initWithURL:outputFileURL settings:recordSetting error:NULL]; _audioRecorder.delegate = self; _audioRecorder.meteringEnabled = YES; [_audioRecorder prepareToRecord]; } - (void)audioPlayerUpdated:(NSTimer *)timer { double current = [_audioPlayer currentTime] / [_audioPlayer duration]; double interval = [_audioPlayer duration] - [_audioPlayer currentTime]; [_currentMediaAdapter setDurationOfAudio:interval]; [_currentMediaAdapter setAudioProgressFromFloat:(float)current]; } - (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { [_audioPlayerPoller invalidate]; [_currentMediaAdapter setAudioProgressFromFloat:0]; [_currentMediaAdapter setDurationOfAudio:_audioPlayer.duration]; [_currentMediaAdapter setAudioIconToPlay]; } - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { if (flag) { [self sendMessageAttachment:[NSData dataWithContentsOfURL:recorder.url] ofType:@"audio/m4a"]; } } #pragma mark Accessory View - (void)didPressAccessoryButton:(UIButton *)sender { [self dismissKeyBoard]; UIView *presenter = self.parentViewController.view; [DJWActionSheet showInView:presenter withTitle:nil cancelButtonTitle:NSLocalizedString(@"TXT_CANCEL_TITLE", @"") destructiveButtonTitle:nil otherButtonTitles:@[ NSLocalizedString(@"TAKE_MEDIA_BUTTON", @""), NSLocalizedString(@"CHOOSE_MEDIA_BUTTON", @"") ] //,@"Record audio"] tapBlock:^(DJWActionSheet *actionSheet, NSInteger tappedButtonIndex) { if (tappedButtonIndex == actionSheet.cancelButtonIndex) { DDLogVerbose(@"User Cancelled"); } else if (tappedButtonIndex == actionSheet.destructiveButtonIndex) { DDLogVerbose(@"Destructive button tapped"); } else { switch (tappedButtonIndex) { case 0: [self takePictureOrVideo]; break; case 1: [self chooseFromLibrary]; break; case 2: [self recordAudio]; break; default: break; } } }]; } - (void)markAllMessagesAsRead { [self.thread markAllAsRead]; // In theory this should be unnecessary as read-status starts expiration // but in practice I've seen messages not have their timer started. [self.disappearingMessagesJob setExpirationsForThread:self.thread]; } - (BOOL)collectionView:(UICollectionView *)collectionView canPerformAction:(SEL)action forItemAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender { TSMessageAdapter *messageAdapter = [self messageAtIndexPath:indexPath]; // HACK make sure method exists before calling since messageAtIndexPath doesn't // always return TSMessageAdapters - it can also return JSQCall! if ([messageAdapter respondsToSelector:@selector(canPerformEditingAction:)]) { return [messageAdapter canPerformEditingAction:action]; } else { return NO; } } - (void)collectionView:(UICollectionView *)collectionView performAction:(SEL)action forItemAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender { [[self messageAtIndexPath:indexPath] performEditingAction:action]; } - (void)updateGroupModelTo:(TSGroupModel *)newGroupModel { __block TSGroupThread *groupThread; __block TSOutgoingMessage *message; [self.editingDatabaseConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { groupThread = [TSGroupThread getOrCreateThreadWithGroupModel:newGroupModel transaction:transaction]; NSString *updateGroupInfo = [groupThread.groupModel getInfoStringAboutUpdateTo:newGroupModel contactsManager:self.contactsManager]; groupThread.groupModel = newGroupModel; [groupThread saveWithTransaction:transaction]; message = [[TSOutgoingMessage alloc] initWithTimestamp:[NSDate ows_millisecondTimeStamp] inThread:groupThread messageBody:@"" attachmentIds:[NSMutableArray new]]; message.groupMetaMessage = TSGroupMessageUpdate; message.customMessage = updateGroupInfo; }]; if (newGroupModel.groupImage != nil) { [[TSMessagesManager sharedManager] sendAttachment:UIImagePNGRepresentation(newGroupModel.groupImage) contentType:OWSMimeTypeImagePng inMessage:message thread:groupThread success:nil failure:nil]; } else { [[TSMessagesManager sharedManager] sendMessage:message inThread:groupThread success:nil failure:nil]; } self.thread = groupThread; } - (IBAction)unwindGroupUpdated:(UIStoryboardSegue *)segue { NewGroupViewController *ngc = [segue sourceViewController]; TSGroupModel *newGroupModel = [ngc groupModel]; NSMutableSet *groupMemberIds = [NSMutableSet setWithArray:newGroupModel.groupMemberIds]; [groupMemberIds addObject:[TSAccountManager localNumber]]; newGroupModel.groupMemberIds = [NSMutableArray arrayWithArray:[groupMemberIds allObjects]]; [self updateGroupModelTo:newGroupModel]; [self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; [self.collectionView reloadData]; } - (void)popKeyBoard { [self.inputToolbar.contentView.textView becomeFirstResponder]; } - (void)dismissKeyBoard { [self.inputToolbar.contentView.textView resignFirstResponder]; } #pragma mark Drafts - (void)loadDraftInCompose { __block NSString *placeholder; [self.editingDatabaseConnection asyncReadWithBlock:^(YapDatabaseReadTransaction *transaction) { placeholder = [_thread currentDraftWithTransaction:transaction]; } completionBlock:^{ dispatch_async(dispatch_get_main_queue(), ^{ [self.inputToolbar.contentView.textView setText:placeholder]; [self textViewDidChange:self.inputToolbar.contentView.textView]; }); }]; } - (void)saveDraft { if (self.inputToolbar.hidden == NO) { __block TSThread *thread = _thread; __block NSString *currentDraft = self.inputToolbar.contentView.textView.text; [self.editingDatabaseConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [thread setDraft:currentDraft transaction:transaction]; }]; } } #pragma mark Unread Badge - (void)setUnreadCount:(NSUInteger)unreadCount { if (_unreadCount != unreadCount) { _unreadCount = unreadCount; if (_unreadCount > 0) { if (_unreadContainer == nil) { static UIImage *backgroundImage = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ UIGraphicsBeginImageContextWithOptions(CGSizeMake(17.0f, 17.0f), false, 0.0f); CGContextRef context = UIGraphicsGetCurrentContext(); CGContextSetFillColorWithColor(context, [UIColor redColor].CGColor); CGContextFillEllipseInRect(context, CGRectMake(0.0f, 0.0f, 17.0f, 17.0f)); backgroundImage = [UIGraphicsGetImageFromCurrentImageContext() stretchableImageWithLeftCapWidth:8 topCapHeight:8]; UIGraphicsEndImageContext(); }); _unreadContainer = [[UIImageView alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 10.0f, 10.0f)]; _unreadContainer.userInteractionEnabled = NO; _unreadContainer.layer.zPosition = 2000; [self.navigationController.navigationBar addSubview:_unreadContainer]; _unreadBackground = [[UIImageView alloc] initWithImage:backgroundImage]; [_unreadContainer addSubview:_unreadBackground]; _unreadLabel = [[UILabel alloc] init]; _unreadLabel.backgroundColor = [UIColor clearColor]; _unreadLabel.textColor = [UIColor whiteColor]; _unreadLabel.font = [UIFont systemFontOfSize:12]; [_unreadContainer addSubview:_unreadLabel]; } _unreadContainer.hidden = false; _unreadLabel.text = [NSString stringWithFormat:@"%lu", (unsigned long)unreadCount]; [_unreadLabel sizeToFit]; CGPoint offset = CGPointMake(17.0f, 2.0f); _unreadBackground.frame = CGRectMake(offset.x, offset.y, MAX(_unreadLabel.frame.size.width + 8.0f, 17.0f), 17.0f); _unreadLabel.frame = CGRectMake(offset.x + (CGFloat)floor( (2.0 * (_unreadBackground.frame.size.width - _unreadLabel.frame.size.width) / 2.0f) / 2.0f), offset.y + 1.0f, _unreadLabel.frame.size.width, _unreadLabel.frame.size.height); } else if (_unreadContainer != nil) { _unreadContainer.hidden = true; } } } #pragma mark 3D Touch Preview Actions - (NSArray> *)previewActionItems { return @[]; } #pragma mark - Logging + (NSString *)tag { return [NSString stringWithFormat:@"[%@]", self.class]; } - (NSString *)tag { return self.class.tag; } @end