// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "ConversationViewController.h" #import "AppDelegate.h" #import #import "ConversationCollectionView.h" #import "ConversationInputTextView.h" #import "ConversationInputToolbar.h" #import "ConversationScrollButton.h" #import "ConversationViewCell.h" #import "ConversationViewItem.h" #import "ConversationViewLayout.h" #import "ConversationViewModel.h" #import "DateUtil.h" #import #import "OWSAudioPlayer.h" #import "OWSConversationSettingsViewController.h" #import "OWSConversationSettingsViewDelegate.h" #import "OWSDisappearingMessagesJob.h" #import "OWSMath.h" #import "OWSMessageCell.h" #import "OWSSystemMessageCell.h" #import #import "Session-Swift.h" #import #import "TSAttachmentPointer.h" #import "TSContactThread.h" #import "TSDatabaseView.h" #import "TSErrorMessage.h" #import "TSGroupThread.h" #import "TSIncomingMessage.h" #import "TSInfoMessage.h" #import #import "UIFont+OWS.h" #import "UIViewController+Permissions.h" #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import @import Photos; NS_ASSUME_NONNULL_BEGIN static const CGFloat kLoadMoreHeaderHeight = 60.f; static const CGFloat kToastInset = 10; typedef enum : NSUInteger { kMediaTypePicture, kMediaTypeVideo, } kMediaTypes; typedef enum : NSUInteger { kScrollContinuityBottom = 0, kScrollContinuityTop, } ScrollContinuity; #pragma mark - @interface ConversationViewController () @property (nonatomic) TSThread *thread; @property (nonatomic, readonly) ConversationViewModel *conversationViewModel; @property (nonatomic, readonly) OWSAudioActivity *recordVoiceNoteAudioActivity; @property (nonatomic, readonly) NSTimeInterval viewControllerCreatedAt; @property (nonatomic, readonly) ConversationInputToolbar *inputToolbar; @property (nonatomic, readonly) ConversationCollectionView *collectionView; @property (nonatomic, readonly) UIProgressView *progressIndicatorView; @property (nonatomic, readonly) ConversationViewLayout *layout; @property (nonatomic, readonly) ConversationStyle *conversationStyle; @property (nonatomic, nullable) AVAudioRecorder *audioRecorder; @property (nonatomic, nullable) NSTimer *audioTimer; @property (nonatomic, nullable) OWSAudioPlayer *audioAttachmentPlayer; @property (nonatomic, nullable) NSUUID *voiceMessageUUID; @property (nonatomic, nullable) NSTimer *readTimer; @property (nonatomic) NSCache *cellMediaCache; @property (nonatomic) LKConversationTitleView *headerView; @property (nonatomic, nullable) UIView *bannerView; @property (nonatomic, nullable) UIView *restoreSessionBannerView; @property (nonatomic, nullable) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration; // Back Button Unread Count @property (nonatomic, readonly) UIView *backButtonUnreadCountView; @property (nonatomic, readonly) UILabel *backButtonUnreadCountLabel; @property (nonatomic, readonly) NSUInteger backButtonUnreadCount; @property (nonatomic) ConversationViewAction actionOnOpen; @property (nonatomic) BOOL peek; @property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper; @property (nonatomic) BOOL userHasScrolled; @property (nonatomic, nullable) NSDate *lastMessageSentDate; @property (nonatomic, nullable) UIBarButtonItem *customBackButton; @property (nonatomic) BOOL showLoadMoreHeader; @property (nonatomic) UILabel *loadMoreHeader; @property (nonatomic) uint64_t lastVisibleSortId; @property (nonatomic) BOOL isUserScrolling; @property (nonatomic) NSLayoutConstraint *scrollDownButtonButtomConstraint; @property (nonatomic) ConversationScrollButton *scrollDownButton; @property (nonatomic) BOOL isViewCompletelyAppeared; @property (nonatomic) BOOL isViewVisible; @property (nonatomic) BOOL shouldAnimateKeyboardChanges; @property (nonatomic) BOOL viewHasEverAppeared; @property (nonatomic) BOOL hasUnreadMessages; @property (nonatomic) BOOL isPickingMediaAsDocument; @property (nonatomic, nullable) NSNumber *viewHorizonTimestamp; @property (nonatomic) NSTimer *reloadTimer; @property (nonatomic, nullable) NSDate *lastReloadDate; @property (nonatomic) CGFloat scrollDistanceToBottomSnapshot; @property (nonatomic, nullable) NSNumber *lastKnownDistanceFromBottom; @property (nonatomic) ScrollContinuity scrollContinuity; @property (nonatomic, nullable) NSTimer *autoLoadMoreTimer; @property (nonatomic, readonly) ConversationSearchController *searchController; @property (nonatomic, nullable) NSString *lastSearchedText; @property (nonatomic) BOOL isShowingSearchUI; @property (nonatomic, nullable) MenuActionsViewController *menuActionsViewController; @property (nonatomic) CGFloat extraContentInsetPadding; @property (nonatomic) CGFloat contentInsetBottom; // Mentions @property (nonatomic) NSInteger currentMentionStartIndex; @property (nonatomic) NSMutableArray *mentions; @property (nonatomic) NSString *oldText; // Status bar updating /// Used to avoid duplicate status bar updates. @property (nonatomic) NSMutableSet *handledMessageTimestamps; @end #pragma mark - @implementation ConversationViewController - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder { OWSFailDebug(@"Do not instantiate this view from coder"); self = [super initWithCoder:aDecoder]; if (!self) { return self; } [self commonInit]; return self; } - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; if (!self) { return self; } [self commonInit]; return self; } - (void)commonInit { _viewControllerCreatedAt = CACurrentMediaTime(); NSString *audioActivityDescription = [NSString stringWithFormat:@"%@ voice note", self.logTag]; _recordVoiceNoteAudioActivity = [[OWSAudioActivity alloc] initWithAudioDescription:audioActivityDescription behavior:OWSAudioBehavior_PlayAndRecord]; self.scrollContinuity = kScrollContinuityBottom; _currentMentionStartIndex = -1; _mentions = [NSMutableArray new]; _oldText = @""; } #pragma mark - Dependencies - (OWSSessionResetJobQueue *)sessionResetJobQueue { return AppEnvironment.shared.sessionResetJobQueue; } - (OWSAudioSession *)audioSession { return Environment.shared.audioSession; } - (OWSBlockingManager *)blockingManager { return [OWSBlockingManager sharedManager]; } - (OWSPrimaryStorage *)primaryStorage { return SSKEnvironment.shared.primaryStorage; } - (id)typingIndicators { return SSKEnvironment.shared.typingIndicators; } - (TSAccountManager *)tsAccountManager { OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); return SSKEnvironment.shared.tsAccountManager; } - (OWSNotificationPresenter *)notificationPresenter { return AppEnvironment.shared.notificationPresenter; } #pragma mark - - (void)addNotificationListeners { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(blockListDidChange:) name:kNSNotificationName_BlockListDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowManagerCallDidChange:) name:OWSWindowManagerCallDidChangeNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(identityStateDidChange:) name:kNSNotificationName_IdentityStateDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didChangePreferredContentSize:) name:UIContentSizeCategoryDidChangeNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationWillEnterForeground:) name:OWSApplicationWillEnterForegroundNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidEnterBackground:) name:OWSApplicationDidEnterBackgroundNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationWillResignActive:) name:OWSApplicationWillResignActiveNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidBecomeActive:) name:OWSApplicationDidBecomeActiveNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(cancelReadTimer) name:OWSApplicationDidEnterBackgroundNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(otherUsersProfileDidChange:) name:kNSNotificationName_OtherUsersProfileDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(profileWhitelistDidChange:) name:kNSNotificationName_ProfileWhitelistDidChange object:nil]; // Keyboard events. [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidShow:) name:UIKeyboardDidShowNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidHide:) name:UIKeyboardDidHideNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillChangeFrame:) name:UIKeyboardWillChangeFrameNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidChangeFrame:) name:UIKeyboardDidChangeFrameNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleThreadSessionRestoreDevicesChangedNotifiaction:) name:NSNotification.threadSessionRestoreDevicesChanged object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleGroupThreadUpdatedNotification:) name:NSNotification.groupThreadUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleEncryptingMessageNotification:) name:NSNotification.encryptingMessage object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleCalculatingMessagePoWNotification:) name:NSNotification.calculatingMessagePoW object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMessageSendingNotification:) name:NSNotification.messageSending object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMessageSentNotification:) name:NSNotification.messageSent object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMessageSendingFailedNotification:) name:NSNotification.messageSendingFailed object:nil]; } - (BOOL)isGroupConversation { OWSAssertDebug(self.thread); return self.thread.isGroupThread; } - (void)otherUsersProfileDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; OWSAssertDebug(recipientId.length > 0); if (recipientId.length > 0 && [self.thread.recipientIdentifiers containsObject:recipientId]) { if (self.isGroupConversation) { // Reload all cells if this is a group conversation, // since we may need to update the sender names on the messages. [self resetContentAndLayout]; } } } - (void)profileWhitelistDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); // If profile whitelist just changed, we may want to hide a profile whitelist offer. NSString *_Nullable recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; NSData *_Nullable groupId = notification.userInfo[kNSNotificationKey_ProfileGroupId]; if (recipientId.length > 0 && [self.thread.recipientIdentifiers containsObject:recipientId]) { [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; } else if (groupId.length > 0 && self.thread.isGroupThread) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; if ([groupThread.groupModel.groupId isEqualToData:groupId]) { [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; [self ensureBannerState]; } } } - (void)blockListDidChange:(id)notification { OWSAssertIsOnMainThread(); [self ensureBannerState]; } - (void)identityStateDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); [self ensureBannerState]; } - (void)handleGroupThreadUpdatedNotification:(NSNotification *)notification { OWSAssertIsOnMainThread(); // Check thread NSString *threadID = (NSString *)notification.object; if (![threadID isEqualToString:self.thread.uniqueId]) { return; } // Ensure thread instance is up to date [self.thread reload]; // Update UI [self hideInputIfNeeded]; [self.collectionView.collectionViewLayout invalidateLayout]; for (id item in self.viewItems) { [item clearCachedLayoutState]; } [self.conversationViewModel reloadViewItems]; [self.collectionView reloadData]; } - (void)handleThreadSessionRestoreDevicesChangedNotifiaction:(NSNotification *)notification { // Check thread NSString *threadID = (NSString *)notification.object; if (![threadID isEqualToString:self.thread.uniqueId]) { return; } // Ensure thread instance is up to date [self.thread reload]; // Update UI [self updateSessionRestoreBanner]; } - (void)peekSetup { _peek = YES; self.actionOnOpen = ConversationViewActionNone; } - (void)popped { _peek = NO; [self hideInputIfNeeded]; } - (void)configureForThread:(TSThread *)thread action:(ConversationViewAction)action focusMessageId:(nullable NSString *)focusMessageId { OWSAssertDebug(thread); OWSLogInfo(@"configureForThread."); _thread = thread; self.actionOnOpen = action; _cellMediaCache = [NSCache new]; // Cache the cell media for ~24 cells. self.cellMediaCache.countLimit = 24; _conversationStyle = [[ConversationStyle alloc] initWithThread:thread]; _conversationViewModel = [[ConversationViewModel alloc] initWithThread:thread focusMessageIdOnOpen:focusMessageId delegate:self]; _searchController = [[ConversationSearchController alloc] initWithThread:thread]; _searchController.delegate = self; self.reloadTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f target:self selector:@selector(reloadTimerDidFire) userInfo:nil repeats:YES]; [LKMentionsManager populateUserPublicKeyCacheIfNeededFor:thread.uniqueId in:nil]; } - (void)dealloc { [self.reloadTimer invalidate]; [self.autoLoadMoreTimer invalidate]; } - (void)reloadTimerDidFire { OWSAssertIsOnMainThread(); if (self.isUserScrolling || !self.isViewCompletelyAppeared || !self.isViewVisible || !CurrentAppContext().isAppForegroundAndActive || !self.viewHasEverAppeared || OWSWindowManager.sharedManager.isPresentingMenuActions) { return; } NSDate *now = [NSDate new]; if (self.lastReloadDate) { NSTimeInterval timeSinceLastReload = [now timeIntervalSinceDate:self.lastReloadDate]; const NSTimeInterval kReloadFrequency = 60.f; if (timeSinceLastReload < kReloadFrequency) { return; } } OWSLogVerbose(@"reloading conversation view contents."); [self resetContentAndLayout]; } - (BOOL)userLeftGroup { if (![_thread isKindOfClass:[TSGroupThread class]]) { return NO; } TSGroupThread *groupThread = (TSGroupThread *)self.thread; return !groupThread.isCurrentUserMemberInGroup; } - (void)hideInputIfNeeded { if (_peek) { self.inputToolbar.hidden = YES; [self dismissKeyBoard]; return; } if ([self.thread isKindOfClass:TSGroupThread.class] && !((TSGroupThread *)self.thread).isOpenGroup && !((TSGroupThread *)self.thread).usesSharedSenderKeys) { self.inputToolbar.hidden = YES; } else if (self.userLeftGroup) { self.inputToolbar.hidden = YES; // user has requested they leave the group. further sends disallowed [self dismissKeyBoard]; } else { self.inputToolbar.hidden = NO; } } - (void)viewDidLoad { [super viewDidLoad]; [self createContents]; [self createConversationScrollButtons]; [self createHeaderViews]; if (@available(iOS 11, *)) { // We use the default back button from home view, which animates nicely with interactive transitions like the // interactive pop gesture and the "slide left" for info. } else { // On iOS9/10 the default back button is too wide, so we use a custom back button. This doesn't animate nicely // with interactive transitions, but has the appropriate width. [self createBackButton]; } [self addNotificationListeners]; [self loadDraftInCompose]; [self applyTheme]; [self.conversationViewModel viewDidLoad]; [LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:nil customBackButton:YES]; self.collectionView.backgroundColor = UIColor.clearColor; UIBarButtonItem *settingsButton = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"Gear"] style:UIBarButtonItemStylePlain target:self action:@selector(showConversationSettings)]; settingsButton.tintColor = LKColors.text; settingsButton.accessibilityLabel = @"Conversation settings button"; settingsButton.isAccessibilityElement = YES; self.navigationItem.rightBarButtonItem = settingsButton; if (self.thread.isGroupThread) { TSGroupThread *thread = (TSGroupThread *)self.thread; if (!thread.isOpenGroup) { return; } SNOpenGroup *publicChat = [LKStorage.shared getOpenGroupForThreadID:thread.uniqueId]; [SNOpenGroupAPI getInfoForChannelWithID:publicChat.channel onServer:publicChat.server] .thenOn(dispatch_get_main_queue(), ^(id userCount) { [self.headerView updateSubtitleForCurrentStatus]; }); } if ([self.thread isKindOfClass:TSContactThread.class]) { [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [SSKEnvironment.shared.profileManager ensureProfileCachedForContactWithID:self.thread.contactIdentifier with:transaction]; }]; } } - (void)createContents { OWSAssertDebug(self.conversationStyle); _layout = [[ConversationViewLayout alloc] initWithConversationStyle:self.conversationStyle]; self.conversationStyle.viewWidth = self.view.width; self.layout.delegate = self; // We use the root view bounds as the initial frame for the collection // view so that its contents can be laid out immediately. // // TODO: To avoid relayout, it'd be better to take into account safeAreaInsets, // but they're not yet set when this method is called. _collectionView = [[ConversationCollectionView alloc] initWithFrame:self.view.bounds collectionViewLayout:self.layout]; self.collectionView.layoutDelegate = self; self.collectionView.delegate = self; self.collectionView.dataSource = self; self.collectionView.showsVerticalScrollIndicator = YES; self.collectionView.showsHorizontalScrollIndicator = NO; self.collectionView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; if (@available(iOS 10, *)) { // To minimize time to initial apearance, we initially disable prefetching, but then // re-enable it once the view has appeared. self.collectionView.prefetchingEnabled = NO; } [self.view addSubview:self.collectionView]; [self.collectionView autoPinEdgeToSuperviewEdge:ALEdgeTop]; [self.collectionView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; [self.collectionView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading]; [self.collectionView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; _progressIndicatorView = [UIProgressView new]; [self.progressIndicatorView autoSetDimension:ALDimensionHeight toSize:LKValues.progressBarThickness]; self.progressIndicatorView.progressViewStyle = UIProgressViewStyleBar; self.progressIndicatorView.progressTintColor = LKColors.accent; self.progressIndicatorView.trackTintColor = UIColor.clearColor; self.progressIndicatorView.alpha = 0; [self.view addSubview:self.progressIndicatorView]; [self.progressIndicatorView autoPinEdgeToSuperviewEdge:ALEdgeTop]; [self.progressIndicatorView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading]; [self.progressIndicatorView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; [self.collectionView applyScrollViewInsetsFix]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _collectionView); _inputToolbar = [[ConversationInputToolbar alloc] initWithConversationStyle:self.conversationStyle]; self.inputToolbar.inputToolbarDelegate = self; self.inputToolbar.inputTextViewDelegate = self; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _inputToolbar); self.loadMoreHeader = [UILabel new]; self.loadMoreHeader.text = NSLocalizedString(@"CONVERSATION_VIEW_LOADING_MORE_MESSAGES", @"Indicates that the app is loading more messages in this conversation."); self.loadMoreHeader.textColor = [LKColors.text colorWithAlphaComponent:0.8]; self.loadMoreHeader.textAlignment = NSTextAlignmentCenter; self.loadMoreHeader.font = [UIFont boldSystemFontOfSize:LKValues.verySmallFontSize]; [self.collectionView addSubview:self.loadMoreHeader]; [self.loadMoreHeader autoPinWidthToWidthOfView:self.view]; [self.loadMoreHeader autoPinEdgeToSuperviewEdge:ALEdgeTop]; [self.loadMoreHeader autoSetDimension:ALDimensionHeight toSize:kLoadMoreHeaderHeight]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _loadMoreHeader); [self registerCellClasses]; [self updateShowLoadMoreHeader]; } - (BOOL)becomeFirstResponder { OWSLogDebug(@""); return [super becomeFirstResponder]; } - (BOOL)resignFirstResponder { OWSLogDebug(@""); return [super resignFirstResponder]; } - (BOOL)canBecomeFirstResponder { return YES; } - (nullable UIView *)inputAccessoryView { if (self.isShowingSearchUI) { return self.searchController.resultsBar; } else { return self.inputToolbar; } } - (void)registerCellClasses { [self.collectionView registerClass:[OWSSystemMessageCell class] forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSTypingIndicatorCell class] forCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSMessageCell class] forCellWithReuseIdentifier:[OWSMessageCell cellReuseIdentifier]]; } - (void)applicationWillEnterForeground:(NSNotification *)notification { [self startReadTimer]; [self updateCellsVisible]; } - (void)applicationDidEnterBackground:(NSNotification *)notification { [self updateCellsVisible]; [self.cellMediaCache removeAllObjects]; } - (void)applicationWillResignActive:(NSNotification *)notification { [self cancelVoiceMemo]; self.isUserScrolling = NO; [self saveDraft]; [self markVisibleMessagesAsRead]; [self.cellMediaCache removeAllObjects]; [self cancelReadTimer]; [self dismissPresentedViewControllerIfNecessary]; } - (void)applicationDidBecomeActive:(NSNotification *)notification { [self startReadTimer]; [self resetContentAndLayout]; } - (void)dismissPresentedViewControllerIfNecessary { UIViewController *_Nullable presentedViewController = self.presentedViewController; if (!presentedViewController) { OWSLogDebug(@"presentedViewController was nil"); return; } if ([presentedViewController isKindOfClass:[UIAlertController class]]) { OWSLogDebug(@"dismissing presentedViewController: %@", presentedViewController); [self dismissViewControllerAnimated:NO completion:nil]; return; } if ([presentedViewController isKindOfClass:[UIImagePickerController class]]) { OWSLogDebug(@"dismissing presentedViewController: %@", presentedViewController); [self dismissViewControllerAnimated:NO completion:nil]; return; } } - (void)viewWillAppear:(BOOL)animated { OWSLogDebug(@"viewWillAppear"); [self ensureBannerState]; [self updateSessionRestoreBanner]; [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.isViewVisible = YES; [self updateDisappearingMessagesConfiguration]; [self updateBarButtonItems]; [self resetContentAndLayout]; // We want to set the initial scroll state the first time we enter the view. if (!self.viewHasEverAppeared) { [self scrollToDefaultPosition:NO]; } else if (self.menuActionsViewController != nil) { [self scrollToMenuActionInteraction:NO]; } [self updateLastVisibleSortId]; if (!self.viewHasEverAppeared) { NSTimeInterval appearenceDuration = CACurrentMediaTime() - self.viewControllerCreatedAt; OWSLogVerbose(@"First viewWillAppear took: %.2fms", appearenceDuration * 1000); } [self updateInputBarLayout]; } - (NSArray> *)viewItems { return self.conversationViewModel.viewState.viewItems; } - (ThreadDynamicInteractions *)dynamicInteractions { return self.conversationViewModel.dynamicInteractions; } - (NSIndexPath *_Nullable)indexPathOfUnreadMessagesIndicator { NSNumber *_Nullable unreadIndicatorIndex = self.conversationViewModel.viewState.unreadIndicatorIndex; if (unreadIndicatorIndex == nil) { return nil; } return [NSIndexPath indexPathForRow:unreadIndicatorIndex.integerValue inSection:0]; } - (NSIndexPath *_Nullable)indexPathOfMessageOnOpen { OWSAssertDebug(self.conversationViewModel.focusMessageIdOnOpen); OWSAssertDebug(self.dynamicInteractions.focusMessagePosition); if (!self.dynamicInteractions.focusMessagePosition) { // This might happen if the focus message has disappeared // before this view could appear. OWSFailDebug(@"focus message has unknown position."); return nil; } NSUInteger focusMessagePosition = self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue; if (focusMessagePosition >= self.viewItems.count) { // This might happen if the focus message is outside the maximum // valid load window size for this view. OWSFailDebug(@"focus message has invalid position."); return nil; } NSInteger row = (NSInteger)((self.viewItems.count - 1) - focusMessagePosition); return [NSIndexPath indexPathForRow:row inSection:0]; } - (void)scrollToDefaultPosition:(BOOL)isAnimated { if (self.isUserScrolling) { return; } NSIndexPath *_Nullable indexPath = nil; if (self.conversationViewModel.focusMessageIdOnOpen) { indexPath = [self indexPathOfMessageOnOpen]; } if (!indexPath) { indexPath = [self indexPathOfUnreadMessagesIndicator]; } if (indexPath) { if (indexPath.section == 0 && indexPath.row == 0) { [self.collectionView setContentOffset:CGPointZero animated:isAnimated]; } else { [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionTop animated:isAnimated]; } } else { [self scrollToBottomAnimated:isAnimated]; } } - (void)scrollToUnreadIndicatorAnimated { if (self.isUserScrolling) { return; } NSIndexPath *_Nullable indexPath = [self indexPathOfUnreadMessagesIndicator]; if (indexPath) { if (indexPath.section == 0 && indexPath.row == 0) { [self.collectionView setContentOffset:CGPointZero animated:YES]; } else { [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionTop animated:YES]; } } } - (void)resetContentAndLayout { self.scrollContinuity = kScrollContinuityBottom; // Avoid layout corrupt issues and out-of-date message subtitles. self.lastReloadDate = [NSDate new]; [self.conversationViewModel viewDidResetContentAndLayout]; [self.collectionView.collectionViewLayout invalidateLayout]; [self.collectionView reloadSections:[NSIndexSet indexSetWithIndex:0]]; if (self.viewHasEverAppeared) { // Try to update the lastKnownDistanceFromBottom; the content size may have changed. [self updateLastKnownDistanceFromBottom]; } } - (void)setUserHasScrolled:(BOOL)userHasScrolled { _userHasScrolled = userHasScrolled; [self ensureBannerState]; } - (void)updateSessionRestoreBanner { // BOOL isContactThread = [self.thread isKindOfClass:[TSContactThread class]]; // BOOL shouldDetachBanner = !isContactThread; // if (isContactThread) { // TSContactThread *thread = (TSContactThread *)self.thread; // if (thread.sessionRestoreDevices.count > 0) { // if (self.restoreSessionBannerView == nil) { // LKSessionRestorationView *bannerView = [[LKSessionRestorationView alloc] initWithThread:thread]; // [self.view addSubview:bannerView]; // [bannerView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:LKValues.mediumSpacing]; // [bannerView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:LKValues.largeSpacing]; // [bannerView autoPinEdgeToSuperviewEdge:ALEdgeRight withInset:LKValues.mediumSpacing]; // [self.view layoutSubviews]; // self.restoreSessionBannerView = bannerView; // [bannerView setOnRestore:^{ // [self restoreSession]; // }]; // [bannerView setOnDismiss:^{ // [thread removeAllSessionRestoreDevicesWithTransaction:nil]; // }]; // } // } else { // shouldDetachBanner = true; // } // } // if (shouldDetachBanner && self.restoreSessionBannerView != nil) { // [self.restoreSessionBannerView removeFromSuperview]; // self.restoreSessionBannerView = nil; // } } - (void)ensureBannerState { // This method should be called rarely, so it's simplest to discard and // rebuild the indicator view every time. [self.bannerView removeFromSuperview]; self.bannerView = nil; if (self.userHasScrolled) { return; } NSString *blockStateMessage = nil; if ([self isBlockedConversation]) { if (self.isGroupConversation) { /* blockStateMessage = NSLocalizedString( @"MESSAGES_VIEW_GROUP_BLOCKED", @"Indicates that this group conversation has been blocked."); */ } else { blockStateMessage = NSLocalizedString( @"MESSAGES_VIEW_CONTACT_BLOCKED", @"Indicates that this 1:1 conversation has been blocked."); } } if (blockStateMessage) { [self createBannerWithTitle:blockStateMessage bannerColor:LKColors.destructive tapSelector:@selector(blockBannerViewWasTapped:)]; return; } } - (void)createBannerWithTitle:(NSString *)title bannerColor:(UIColor *)bannerColor tapSelector:(SEL)tapSelector { OWSAssertDebug(title.length > 0); OWSAssertDebug(bannerColor); UIView *bannerView = [UIView containerView]; bannerView.backgroundColor = bannerColor; bannerView.layer.cornerRadius = 2.5f; // Use a shadow to "pop" the indicator above the other views. bannerView.layer.shadowColor = [UIColor blackColor].CGColor; bannerView.layer.shadowOffset = CGSizeMake(2, 3); bannerView.layer.shadowRadius = 2.f; bannerView.layer.shadowOpacity = 0.35f; UILabel *label = [UILabel new]; label.font = [UIFont ows_mediumFontWithSize:14.f]; label.text = title; label.textColor = [UIColor whiteColor]; label.numberOfLines = 0; label.lineBreakMode = NSLineBreakByWordWrapping; label.textAlignment = NSTextAlignmentCenter; UIImage *closeIcon = [UIImage imageNamed:@"banner_close"]; UIImageView *closeButton = [[UIImageView alloc] initWithImage:closeIcon]; [bannerView addSubview:closeButton]; const CGFloat kBannerCloseButtonPadding = 8.f; [closeButton autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:kBannerCloseButtonPadding]; [closeButton autoPinTrailingToSuperviewMarginWithInset:kBannerCloseButtonPadding]; [closeButton autoSetDimension:ALDimensionWidth toSize:closeIcon.size.width]; [closeButton autoSetDimension:ALDimensionHeight toSize:closeIcon.size.height]; [bannerView addSubview:label]; [label autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:5]; [label autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:5]; const CGFloat kBannerHPadding = 15.f; [label autoPinLeadingToSuperviewMarginWithInset:kBannerHPadding]; const CGFloat kBannerHSpacing = 10.f; [closeButton autoPinLeadingToTrailingEdgeOfView:label offset:kBannerHSpacing]; [bannerView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:tapSelector]]; bannerView.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"banner_close"); [self.view addSubview:bannerView]; [bannerView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:self.view withOffset:10.0f]; [bannerView autoHCenterInSuperview]; CGFloat labelDesiredWidth = [label sizeThatFits:CGSizeZero].width; CGFloat bannerDesiredWidth = (labelDesiredWidth + kBannerHPadding + kBannerHSpacing + closeIcon.size.width + kBannerCloseButtonPadding); const CGFloat kMinBannerHMargin = 20.f; if (bannerDesiredWidth + kMinBannerHMargin * 2.f >= self.view.width) { [bannerView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading withInset:kMinBannerHMargin]; [bannerView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing withInset:kMinBannerHMargin]; } [self.view layoutSubviews]; self.bannerView = bannerView; } - (void)blockBannerViewWasTapped:(UIGestureRecognizer *)sender { if (sender.state != UIGestureRecognizerStateRecognized) { return; } if ([self isBlockedConversation]) { // If this a blocked conversation, offer to unblock. [self showUnblockConversationUI:nil]; } } - (void)restoreSession { // if (![self.thread isKindOfClass:TSContactThread.class]) { return; } // TSContactThread *thread = (TSContactThread *)self.thread; // __weak ConversationViewController *weakSelf = self; // dispatch_async(dispatch_get_main_queue(), ^{ // [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { // [thread addSessionRestoreDevice:thread.contactIdentifier transaction:transaction]; // [LKSessionManagementProtocol startSessionResetInThread:thread transaction:transaction]; // }]; // [weakSelf updateSessionRestoreBanner]; // }); } - (void)showUnblockConversationUI:(nullable BlockActionCompletionBlock)completionBlock { self.userHasScrolled = NO; [UIView setAnimationsEnabled:NO]; [BlockListUIUtils showUnblockThreadActionSheet:self.thread fromViewController:self blockingManager:self.blockingManager completionBlock:completionBlock]; [UIView setAnimationsEnabled:YES]; } - (BOOL)isBlockedConversation { return [self.blockingManager isThreadBlocked:self.thread]; } - (int)blockedGroupMemberCount { OWSAssertDebug(self.isGroupConversation); OWSAssertDebug([self.thread isKindOfClass:[TSGroupThread class]]); TSGroupThread *groupThread = (TSGroupThread *)self.thread; int blockedMemberCount = 0; NSArray *blockedPhoneNumbers = [self.blockingManager blockedPhoneNumbers]; for (NSString *contactIdentifier in groupThread.groupModel.groupMemberIds) { if ([blockedPhoneNumbers containsObject:contactIdentifier]) { blockedMemberCount++; } } return blockedMemberCount; } - (void)startReadTimer { [self.readTimer invalidate]; self.readTimer = [NSTimer weakScheduledTimerWithTimeInterval:3.f target:self selector:@selector(readTimerDidFire) userInfo:nil repeats:YES]; } - (void)readTimerDidFire { [self markVisibleMessagesAsRead]; } - (void)cancelReadTimer { [self.readTimer invalidate]; self.readTimer = nil; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // We don't present incoming message notifications for the presented // conversation. But there's a narrow window *while* the conversationVC // is being presented where a message notification for the not-quite-yet // presented conversation can be shown. If that happens, dismiss it as soon // as we enter the conversation. [self.notificationPresenter cancelNotificationsWithThreadId:self.thread.uniqueId]; // recover status bar when returning from PhotoPicker, which is dark (uses light status bar) [self setNeedsStatusBarAppearanceUpdate]; [self markVisibleMessagesAsRead]; [self startReadTimer]; [self autoLoadMoreIfNecessary]; if (!self.viewHasEverAppeared) { // To minimize time to initial apearance, we initially disable prefetching, but then // re-enable it once the view has appeared. if (@available(iOS 10, *)) { self.collectionView.prefetchingEnabled = YES; } } self.conversationViewModel.focusMessageIdOnOpen = nil; self.isViewCompletelyAppeared = YES; self.viewHasEverAppeared = YES; self.shouldAnimateKeyboardChanges = YES; // HACK: Because the inputToolbar is the inputAccessoryView, we make some special considertations WRT it's firstResponder status. // // When a view controller is presented, it is first responder. However if we resign first responder // and the view re-appears, without being presented, the inputToolbar can become invisible. // e.g. specifically works around the scenario: // - Present this VC // - Longpress on a message to show edit menu, which entails making the pressed view the first responder. // - Begin presenting another view, e.g. swipe-left for details or swipe-right to go back, but quit part way, so that you remain on the conversation view // - toolbar will be not be visible unless we reaquire first responder. if (!self.isFirstResponder) { // We don't have to worry about the input toolbar being visible if the inputToolbar.textView is first responder // In fact doing so would unnecessarily dismiss the keyboard which is probably not desirable and at least // a distracting animation. BOOL shouldBecomeFirstResponder = NO; if (self.isShowingSearchUI) { shouldBecomeFirstResponder = !self.searchController.uiSearchController.searchBar.isFirstResponder; } else { shouldBecomeFirstResponder = !self.inputToolbar.isInputTextViewFirstResponder; } if (shouldBecomeFirstResponder) { OWSLogDebug(@"reclaiming first responder to ensure toolbar is shown."); [self becomeFirstResponder]; } } switch (self.actionOnOpen) { case ConversationViewActionNone: break; case ConversationViewActionCompose: [self popKeyBoard]; break; } // Clear the "on open" state after the view has been presented. self.actionOnOpen = ConversationViewActionNone; [self updateInputBarLayout]; [self ensureScrollDownButton]; if ([self.thread isKindOfClass:TSGroupThread.class] && !((TSGroupThread *)self.thread).isOpenGroup && !((TSGroupThread *)self.thread).usesSharedSenderKeys) { UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Session" message:@"Legacy closed groups are no longer supported. Please create a new group to continue." preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; [self presentViewController:alert animated:YES completion:nil]; } } // `viewWillDisappear` is called whenever the view *starts* to disappear, // but, as is the case with the "pan left for message details view" gesture, // this can be canceled. As such, we shouldn't tear down anything expensive // until `viewDidDisappear`. - (void)viewWillDisappear:(BOOL)animated { OWSLogDebug(@""); [super viewWillDisappear:animated]; self.isViewCompletelyAppeared = NO; [self dismissMenuActions]; } - (void)viewDidDisappear:(BOOL)animated { OWSLogDebug(@""); [super viewDidDisappear:animated]; self.userHasScrolled = NO; self.isViewVisible = NO; self.shouldAnimateKeyboardChanges = NO; [self.audioAttachmentPlayer stop]; self.audioAttachmentPlayer = nil; [self cancelReadTimer]; [self saveDraft]; [self markVisibleMessagesAsRead]; [self cancelVoiceMemo]; [self.cellMediaCache removeAllObjects]; self.isUserScrolling = NO; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; // We resize the inputToolbar whenever it's text is modified, including when setting saved draft-text. // However it's possible this draft-text is set before the inputToolbar (an inputAccessoryView) is mounted // in the view hierarchy. Since it's not in the view hierarchy, it hasn't been laid out and has no width, // which is used to determine height. // So here we unsure the proper height once we know everything's been layed out. [self.inputToolbar ensureTextViewHeight]; } #pragma mark - Initiliazers - (void)createHeaderViews { LKConversationTitleView *headerView = [[LKConversationTitleView alloc] initWithThread:self.thread]; self.headerView = headerView; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, headerView); self.navigationItem.titleView = headerView; if (@available(iOS 11, *)) { // Do nothing, we use autolayout/intrinsic content size to grow } else { // Request "full width" title; the navigation bar will truncate this // to fit between the left and right buttons. CGSize screenSize = [UIScreen mainScreen].bounds.size; CGRect headerFrame = CGRectMake(0, 0, screenSize.width, 44); headerView.frame = headerFrame; } } - (CGFloat)unreadCountViewDiameter { return 16; } - (void)createBackButton { UIBarButtonItem *backItem = [self createOWSBackButton]; self.customBackButton = backItem; if (backItem.customView) { // This method gets called multiple times, so it's important we re-layout the unread badge // with respect to the new backItem. [backItem.customView addSubview:_backButtonUnreadCountView]; // TODO: The back button assets are assymetrical. There are strong reasons // to use spacing in the assets to manipulate the size and positioning of // bar button items, but it means we'll probably need separate RTL and LTR // flavors of these assets. [_backButtonUnreadCountView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:-6]; [_backButtonUnreadCountView autoPinLeadingToSuperviewMarginWithInset:1]; [_backButtonUnreadCountView autoSetDimension:ALDimensionHeight toSize:self.unreadCountViewDiameter]; // We set a min width, but we will also pin to our subview label, so we can grow to accommodate multiple digits. [_backButtonUnreadCountView autoSetDimension:ALDimensionWidth toSize:self.unreadCountViewDiameter relation:NSLayoutRelationGreaterThanOrEqual]; [_backButtonUnreadCountView addSubview:_backButtonUnreadCountLabel]; [_backButtonUnreadCountLabel autoPinWidthToSuperviewWithMargin:4]; [_backButtonUnreadCountLabel autoPinHeightToSuperview]; } self.navigationItem.leftBarButtonItem = backItem; } - (void)windowManagerCallDidChange:(NSNotification *)notification { [self updateBarButtonItems]; } - (void)updateBarButtonItems { return; // Loki: Re-enable later? self.navigationItem.hidesBackButton = NO; if (self.customBackButton) { self.navigationItem.leftBarButtonItem = self.customBackButton; } if (self.userLeftGroup) { self.navigationItem.rightBarButtonItems = @[]; return; } if (self.isShowingSearchUI) { self.navigationItem.rightBarButtonItems = @[]; self.navigationItem.leftBarButtonItem = nil; self.navigationItem.hidesBackButton = YES; return; } const CGFloat kBarButtonSize = 44; NSMutableArray *barButtons = [NSMutableArray new]; if (self.disappearingMessagesConfiguration.isEnabled) { DisappearingTimerConfigurationView *timerView = [[DisappearingTimerConfigurationView alloc] initWithDurationSeconds:self.disappearingMessagesConfiguration.durationSeconds]; timerView.delegate = self; timerView.tintColor = Theme.navbarIconColor; // As of iOS11, we can size barButton item custom views with autoLayout. // Before that, though we can still use autoLayout *within* the customView, // setting the view's size with constraints causes the customView to be temporarily // laid out with a misplaced origin. if (@available(iOS 11.0, *)) { [timerView autoSetDimensionsToSize:CGSizeMake(36, 44)]; } else { timerView.frame = CGRectMake(0, 0, 36, 44); } [barButtons addObject:[[UIBarButtonItem alloc] initWithCustomView:timerView accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"timer")]]; } self.navigationItem.rightBarButtonItems = [barButtons copy]; } #pragma mark - Dynamic Text /** Called whenever the user manually changes the dynamic type options inside Settings. @param notification NSNotification with the dynamic type change information. */ - (void)didChangePreferredContentSize:(NSNotification *)notification { OWSLogInfo(@"didChangePreferredContentSize"); [self resetForSizeOrOrientationChange]; [self.inputToolbar updateFontSizes]; } #pragma mark - Actions - (void)showConversationSettings { [self showConversationSettingsAndShowVerification:NO]; } - (void)showConversationSettingsAndShowVerification:(BOOL)showVerification { OWSConversationSettingsViewController *settingsVC = [OWSConversationSettingsViewController new]; settingsVC.conversationSettingsViewDelegate = self; [settingsVC configureWithThread:self.thread uiDatabaseConnection:self.uiDatabaseConnection]; settingsVC.showVerificationOnAppear = showVerification; [self.navigationController pushViewController:settingsVC animated:YES]; } #pragma mark - DisappearingTimerConfigurationViewDelegate - (void)disappearingTimerConfigurationViewWasTapped:(DisappearingTimerConfigurationView *)disappearingTimerView { OWSLogDebug(@"Tapped timer in navbar"); [self showConversationSettings]; } #pragma mark - Load More - (void)autoLoadMoreIfNecessary { BOOL isMainAppAndActive = CurrentAppContext().isMainAppAndActive; if (self.isUserScrolling || !self.isViewVisible || !isMainAppAndActive) { return; } if (!self.showLoadMoreHeader) { return; } CGSize screenSize = UIScreen.mainScreen.bounds.size; CGFloat loadMoreThreshold = MAX(screenSize.width, screenSize.height); if (self.collectionView.contentOffset.y < loadMoreThreshold) { [self.conversationViewModel loadAnotherPageOfMessages]; } } - (void)updateShowLoadMoreHeader { OWSAssertDebug(self.conversationViewModel); self.showLoadMoreHeader = self.conversationViewModel.canLoadMoreItems; } - (void)setShowLoadMoreHeader:(BOOL)showLoadMoreHeader { BOOL valueChanged = _showLoadMoreHeader != showLoadMoreHeader; _showLoadMoreHeader = showLoadMoreHeader; self.loadMoreHeader.hidden = !showLoadMoreHeader; self.loadMoreHeader.userInteractionEnabled = showLoadMoreHeader; if (valueChanged) { [self resetContentAndLayout]; } } - (void)updateDisappearingMessagesConfiguration { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { self.disappearingMessagesConfiguration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId transaction:transaction]; }]; } - (void)setDisappearingMessagesConfiguration: (nullable OWSDisappearingMessagesConfiguration *)disappearingMessagesConfiguration { if (_disappearingMessagesConfiguration.isEnabled == disappearingMessagesConfiguration.isEnabled && _disappearingMessagesConfiguration.durationSeconds == disappearingMessagesConfiguration.durationSeconds) { return; } _disappearingMessagesConfiguration = disappearingMessagesConfiguration; [self updateBarButtonItems]; } #pragma mark Bubble User Actions - (void)handleFailedDownloadTapForMessage:(TSMessage *)message { // Do nothing } - (void)handleUnsentMessageTap:(TSOutgoingMessage *)tsMessage { UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:tsMessage.mostRecentFailureText message:nil preferredStyle:UIAlertControllerStyleActionSheet]; [actionSheet addAction:[OWSAlerts cancelAction]]; UIAlertAction *deleteMessageAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"TXT_DELETE_TITLE", @"") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *action) { [self remove:tsMessage]; }]; [actionSheet addAction:deleteMessageAction]; UIAlertAction *resendMessageAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"SEND_AGAIN_BUTTON", @"") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_again") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { SNVisibleMessage *message = [SNVisibleMessage from:tsMessage]; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { NSMutableArray *attachments = @[].mutableCopy; for (NSString *attachmentID in tsMessage.attachmentIds) { TSAttachmentStream *stream = [TSAttachmentStream fetchObjectWithUniqueID:attachmentID transaction:transaction]; if (![stream isKindOfClass:TSAttachmentStream.class]) { continue; } [attachments addObject:stream]; } [SNMessageSender prep:attachments forMessage:message usingTransaction: transaction]; [SNMessageSender send:message inThread:self.thread usingTransaction:transaction]; }]; }]; [actionSheet addAction:resendMessageAction]; [self dismissKeyBoard]; [self presentAlert:actionSheet]; } - (void)remove:(TSOutgoingMessage *)message { [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [message removeWithTransaction:transaction]; [LKStorage.shared cancelPendingMessageSendJobIfNeededForMessage:message.timestamp using:transaction]; }]; } - (void)tappedCorruptedMessage:(TSErrorMessage *)message { NSString *alertMessage = [NSString stringWithFormat:NSLocalizedString(@"CORRUPTED_SESSION_DESCRIPTION", @"ActionSheet title"), self.thread.name]; UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:alertMessage preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[OWSAlerts cancelAction]]; UIAlertAction *resetSessionAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"FINGERPRINT_SHRED_KEYMATERIAL_BUTTON", @"") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"reset_session") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { if (![self.thread isKindOfClass:[TSContactThread class]]) { // Corrupt Message errors only appear in contact threads. OWSLogError(@"Unexpected request to reset session in group thread. Refusing"); return; } TSContactThread *contactThread = (TSContactThread *)self.thread; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { [self.sessionResetJobQueue addContactThread:contactThread transaction:transaction]; }]; }]; [alert addAction:resetSessionAction]; [self dismissKeyBoard]; [self presentAlert:alert]; } #pragma mark - MessageActionsDelegate - (void)messageActionsShowDetailsForItem:(id)conversationViewItem { [self showDetailViewForViewItem:conversationViewItem]; } - (void)report:(id)conversationViewItem { UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Report?" message:@"If the message is found to violate the Session Public Chat code of conduct it will be removed." preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { uint64_t messageID = 0; if ([conversationViewItem.interaction isKindOfClass:TSMessage.class]) { messageID = ((TSMessage *)conversationViewItem.interaction).openGroupServerMessageID; } [SNOpenGroupAPI reportMessageWithID:messageID inChannel:1 onServer:@"https://chat.getsession.org"]; }]]; [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleDefault handler:nil]]; [self presentViewController:alert animated:YES completion:nil]; } - (void)messageActionsReplyToItem:(id)conversationViewItem { [self populateReplyForViewItem:conversationViewItem]; } - (void)copyPublicKeyFor:(id)conversationViewItem { UIPasteboard.generalPasteboard.string = ((TSIncomingMessage *)conversationViewItem.interaction).authorId; } #pragma mark - MessageDetailViewDelegate - (void)detailViewMessageWasDeleted:(MessageDetailViewController *)messageDetailViewController { OWSLogInfo(@""); [self.navigationController popToViewController:self animated:YES]; } #pragma mark - LongTextViewDelegate - (void)longTextViewMessageWasDeleted:(LongTextViewController *)longTextViewController { OWSLogInfo(@""); [self.navigationController popToViewController:self animated:YES]; } #pragma mark - MenuActionsViewControllerDelegate - (void)menuActionsWillPresent:(MenuActionsViewController *)menuActionsViewController { OWSLogVerbose(@""); // While the menu actions are presented, temporarily use extra content // inset padding so that interactions near the top or bottom of the // collection view can be scrolled anywhere within the viewport. // // e.g. In a new conversation, there might be only a single message // which we might want to scroll to the bottom of the screen to // pin above the menu actions popup. CGSize mainScreenSize = UIScreen.mainScreen.bounds.size; self.extraContentInsetPadding = MAX(mainScreenSize.width, mainScreenSize.height); UIEdgeInsets contentInset = self.collectionView.contentInset; contentInset.top += self.extraContentInsetPadding; contentInset.bottom += self.extraContentInsetPadding; self.collectionView.contentInset = contentInset; self.menuActionsViewController = menuActionsViewController; } - (void)menuActionsIsPresenting:(MenuActionsViewController *)menuActionsViewController { OWSLogVerbose(@""); // Changes made in this "is presenting" callback are animated by the caller. [self scrollToMenuActionInteraction:NO]; } - (void)menuActionsDidPresent:(MenuActionsViewController *)menuActionsViewController { OWSLogVerbose(@""); [self scrollToMenuActionInteraction:NO]; } - (void)menuActionsIsDismissing:(MenuActionsViewController *)menuActionsViewController { OWSLogVerbose(@""); // Changes made in this "is dismissing" callback are animated by the caller. [self clearMenuActionsState]; } - (void)menuActionsDidDismiss:(MenuActionsViewController *)menuActionsViewController { OWSLogVerbose(@""); [self dismissMenuActions]; } - (void)dismissMenuActions { OWSLogVerbose(@""); [self clearMenuActionsState]; [[OWSWindowManager sharedManager] hideMenuActionsWindow]; } - (void)clearMenuActionsState { OWSLogVerbose(@""); if (self.menuActionsViewController == nil) { return; } UIEdgeInsets contentInset = self.collectionView.contentInset; contentInset.top -= self.extraContentInsetPadding; contentInset.bottom -= self.extraContentInsetPadding; self.collectionView.contentInset = contentInset; self.menuActionsViewController = nil; self.extraContentInsetPadding = 0; } - (void)scrollToMenuActionInteractionIfNecessary { if (self.menuActionsViewController != nil) { [self scrollToMenuActionInteraction:NO]; } } - (void)scrollToMenuActionInteraction:(BOOL)animated { OWSAssertDebug(self.menuActionsViewController); NSValue *_Nullable contentOffset = [self contentOffsetForMenuActionInteraction]; if (contentOffset == nil) { // OWSFailDebug(@"Missing contentOffset."); return; } [self.collectionView setContentOffset:contentOffset.CGPointValue animated:animated]; } - (nullable NSValue *)contentOffsetForMenuActionInteraction { OWSAssertDebug(self.menuActionsViewController); NSString *_Nullable menuActionInteractionId = self.menuActionsViewController.focusedInteraction.uniqueId; if (menuActionInteractionId == nil) { OWSFailDebug(@"Missing menu action interaction."); return nil; } CGPoint modalTopWindow = [self.menuActionsViewController.focusUI convertPoint:CGPointZero toView:nil]; CGPoint modalTopLocal = [self.view convertPoint:modalTopWindow fromView:nil]; CGPoint offset = modalTopLocal; CGFloat focusTop = offset.y - self.menuActionsViewController.vSpacing; NSNumber *_Nullable interactionIndex = self.conversationViewModel.viewState.interactionIndexMap[menuActionInteractionId]; if (interactionIndex == nil) { // This is expected if the menu action interaction is being deleted. return nil; } NSIndexPath *indexPath = [NSIndexPath indexPathForRow:interactionIndex.integerValue inSection:0]; UICollectionViewLayoutAttributes *_Nullable layoutAttributes = [self.layout layoutAttributesForItemAtIndexPath:indexPath]; if (layoutAttributes == nil) { // OWSFailDebug(@"Missing layoutAttributes."); return nil; } CGRect cellFrame = layoutAttributes.frame; return [NSValue valueWithCGPoint:CGPointMake(0, CGRectGetMaxY(cellFrame) - focusTop)]; } - (void)dismissMenuActionsIfNecessary { if (self.shouldDismissMenuActions) { [self dismissMenuActions]; } } - (BOOL)shouldDismissMenuActions { if (!OWSWindowManager.sharedManager.isPresentingMenuActions) { return NO; } NSString *_Nullable menuActionInteractionId = self.menuActionsViewController.focusedInteraction.uniqueId; if (menuActionInteractionId == nil) { return NO; } // Check whether there is still a view item for this interaction. return (self.conversationViewModel.viewState.interactionIndexMap[menuActionInteractionId] == nil); } #pragma mark - ConversationViewCellDelegate - (void)conversationCell:(ConversationViewCell *)cell shouldAllowReply:(BOOL)shouldAllowReply didLongpressMediaViewItem:(id)viewItem { NSArray *messageActions = [ConversationViewItemActions mediaActionsWithConversationViewItem:viewItem shouldAllowReply:shouldAllowReply delegate:self]; [self presentMessageActions:messageActions withFocusedCell:cell]; } - (void)conversationCell:(ConversationViewCell *)cell shouldAllowReply:(BOOL)shouldAllowReply didLongpressTextViewItem:(id)viewItem { NSArray *messageActions = [ConversationViewItemActions textActionsWithConversationViewItem:viewItem shouldAllowReply:shouldAllowReply delegate:self]; [self presentMessageActions:messageActions withFocusedCell:cell]; } - (void)conversationCell:(ConversationViewCell *)cell shouldAllowReply:(BOOL)shouldAllowReply didLongpressQuoteViewItem:(id)viewItem { NSArray *messageActions = [ConversationViewItemActions quotedMessageActionsWithConversationViewItem:viewItem shouldAllowReply:shouldAllowReply delegate:self]; [self presentMessageActions:messageActions withFocusedCell:cell]; } - (void)conversationCell:(ConversationViewCell *)cell didLongpressSystemMessageViewItem:(id)viewItem { NSArray *messageActions = [ConversationViewItemActions infoMessageActionsWithConversationViewItem:viewItem delegate:self]; [self presentMessageActions:messageActions withFocusedCell:cell]; } - (void)presentMessageActions:(NSArray *)messageActions withFocusedCell:(ConversationViewCell *)cell { MenuActionsViewController *menuActionsViewController = [[MenuActionsViewController alloc] initWithFocusedInteraction:cell.viewItem.interaction focusedView:cell actions:messageActions]; menuActionsViewController.delegate = self; [[OWSWindowManager sharedManager] showMenuActionsWindow:menuActionsViewController]; } #pragma mark - OWSMessageBubbleViewDelegate - (void)didTapImageViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream imageView:(UIView *)imageView { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); OWSAssertDebug(imageView); [self dismissKeyBoard]; // In case we were presenting edit menu, we need to become first responder before presenting another VC // else UIKit won't restore first responder status to us when the presented VC is dismissed. if (!self.isFirstResponder) { [self becomeFirstResponder]; } MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread options:MediaGalleryOptionSliderEnabled | MediaGalleryOptionShowAllMediaButton]; [mediaGallery presentDetailViewFromViewController:self mediaAttachment:attachmentStream replacingView:imageView]; } - (void)didTapVideoViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream imageView:(UIImageView *)imageView { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); [self dismissKeyBoard]; // In case we were presenting edit menu, we need to become first responder before presenting another VC // else UIKit won't restore first responder status to us when the presented VC is dismissed. if (!self.isFirstResponder) { [self becomeFirstResponder]; } MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread options:MediaGalleryOptionSliderEnabled | MediaGalleryOptionShowAllMediaButton]; [mediaGallery presentDetailViewFromViewController:self mediaAttachment:attachmentStream replacingView:imageView]; } - (void)didTapAudioViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); NSFileManager *fileManager = [NSFileManager defaultManager]; if (![fileManager fileExistsAtPath:attachmentStream.originalFilePath]) { OWSFailDebug(@"Missing audio file: %@", attachmentStream.originalMediaURL); } [self dismissKeyBoard]; if (self.audioAttachmentPlayer) { // Is this player associated with this media adapter? if (self.audioAttachmentPlayer.owner == viewItem) { // Tap to pause & unpause. [self.audioAttachmentPlayer togglePlayState]; return; } [self.audioAttachmentPlayer stop]; self.audioAttachmentPlayer = nil; } self.audioAttachmentPlayer = [[OWSAudioPlayer alloc] initWithMediaUrl:attachmentStream.originalMediaURL audioBehavior:OWSAudioBehavior_AudioMessagePlayback delegate:viewItem]; // Associate the player with this media adapter. self.audioAttachmentPlayer.owner = viewItem; [self.audioAttachmentPlayer play]; [self.audioAttachmentPlayer setCurrentTime:viewItem.audioProgressSeconds]; } - (void)didPanAudioViewItemToCurrentTime:(NSTimeInterval)currentTime { [self.audioAttachmentPlayer setCurrentTime:currentTime]; } - (void)didTapTruncatedTextMessage:(id)conversationItem { OWSAssertIsOnMainThread(); OWSAssertDebug(conversationItem); OWSAssertDebug([conversationItem.interaction isKindOfClass:[TSMessage class]]); LongTextViewController *viewController = [[LongTextViewController alloc] initWithViewItem:conversationItem]; viewController.delegate = self; [self.navigationController pushViewController:viewController animated:YES]; } - (void)didTapFailedIncomingAttachment:(id)viewItem { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); // Restart failed downloads TSMessage *message = (TSMessage *)viewItem.interaction; [self handleFailedDownloadTapForMessage:message]; } - (void)didTapFailedOutgoingMessage:(TSOutgoingMessage *)message { OWSAssertIsOnMainThread(); OWSAssertDebug(message); [self handleUnsentMessageTap:message]; } - (void)didTapConversationItem:(id)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer { // Do nothing } - (void)didTapConversationItem:(id)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(quotedReply); OWSAssertDebug(quotedReply.timestamp > 0); OWSAssertDebug(quotedReply.authorId.length > 0); NSIndexPath *_Nullable indexPath = [self.conversationViewModel ensureLoadWindowContainsQuotedReply:quotedReply]; if (!indexPath) { [self presentRemotelySourcedQuotedReplyToast]; return; } [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionTop animated:YES]; // TODO: Highlight the quoted message? } - (void)didTapConversationItem:(id)viewItem linkPreview:(OWSLinkPreview *)linkPreview { OWSAssertIsOnMainThread(); NSURL *_Nullable url = [NSURL URLWithString:linkPreview.urlString]; if (!url) { OWSFailDebug(@"Invalid link preview URL."); return; } [UIApplication.sharedApplication openURL:url]; } - (void)showDetailViewForViewItem:(id)conversationItem { OWSAssertIsOnMainThread(); OWSAssertDebug(conversationItem); OWSAssertDebug([conversationItem.interaction isKindOfClass:[TSMessage class]]); TSMessage *message = (TSMessage *)conversationItem.interaction; MessageDetailViewController *detailVC = [[MessageDetailViewController alloc] initWithViewItem:conversationItem message:message thread:self.thread mode:MessageMetadataViewModeFocusOnMetadata]; detailVC.delegate = self; [self.navigationController pushViewController:detailVC animated:YES]; } - (void)populateReplyForViewItem:(id)conversationItem { OWSLogDebug(@"user did tap reply"); __block OWSQuotedReplyModel *quotedReply; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { quotedReply = [OWSQuotedReplyModel quotedReplyForSendingWithConversationViewItem:conversationItem threadId:conversationItem.interaction.uniqueThreadId transaction:transaction]; }]; if (![quotedReply isKindOfClass:[OWSQuotedReplyModel class]]) { OWSFailDebug(@"unexpected quotedMessage: %@", quotedReply.class); return; } self.inputToolbar.quotedReply = quotedReply; [self.inputToolbar beginEditingTextMessage]; } #pragma mark - ContactEditingDelegate - (void)didFinishEditingContact { OWSLogDebug(@""); [self dismissViewControllerAnimated:NO completion:nil]; } #pragma mark - CNContactViewControllerDelegate - (void)contactViewController:(CNContactViewController *)viewController didCompleteWithContact:(nullable CNContact *)contact { if (contact) { // Saving normally returns you to the "Show Contact" view // which we're not interested in, so we skip it here. There is // an unfortunate blip of the "Show Contact" view on slower devices. OWSLogDebug(@"completed editing contact."); [self dismissViewControllerAnimated:NO completion:nil]; } else { OWSLogDebug(@"canceled editing contact."); [self dismissViewControllerAnimated:YES completion:nil]; } } #pragma mark - ContactsViewHelperDelegate - (void)contactsViewHelperDidUpdateContacts { [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; } - (void)createConversationScrollButtons { self.scrollDownButton = [[ConversationScrollButton alloc] initWithIconText:@"\uf107"]; [self.scrollDownButton addTarget:self action:@selector(scrollDownButtonTapped) forControlEvents:UIControlEventTouchUpInside]; [self.view addSubview:self.scrollDownButton]; [self.scrollDownButton autoSetDimension:ALDimensionWidth toSize:ConversationScrollButton.buttonSize]; [self.scrollDownButton autoSetDimension:ALDimensionHeight toSize:ConversationScrollButton.buttonSize]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _scrollDownButton); // The "scroll down" button layout tracks the content inset of the collection view, // so pin to the edge of the collection view. self.scrollDownButtonButtomConstraint = [self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.view]; [self.scrollDownButton autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; [self updateScrollDownButtonLayout]; } - (void)updateScrollDownButtonLayout { CGFloat inset = -(self.collectionView.contentInset.bottom + self.bottomLayoutGuide.length); self.scrollDownButtonButtomConstraint.constant = inset; [self.scrollDownButton.superview setNeedsLayout]; } - (void)setHasUnreadMessages:(BOOL)hasUnreadMessages { if (_hasUnreadMessages == hasUnreadMessages) { return; } _hasUnreadMessages = hasUnreadMessages; self.scrollDownButton.hasUnreadMessages = hasUnreadMessages; [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; } - (void)scrollDownButtonTapped { NSIndexPath *indexPathOfUnreadMessagesIndicator = [self indexPathOfUnreadMessagesIndicator]; if (indexPathOfUnreadMessagesIndicator != nil) { NSInteger unreadRow = indexPathOfUnreadMessagesIndicator.row; BOOL isScrolledAboveUnreadIndicator = YES; NSArray *visibleIndices = self.collectionView.indexPathsForVisibleItems; for (NSIndexPath *indexPath in visibleIndices) { if (indexPath.row > unreadRow) { isScrolledAboveUnreadIndicator = NO; break; } } if (isScrolledAboveUnreadIndicator) { // Only scroll as far as the unread indicator if we're scrolled above the unread indicator. [[self collectionView] scrollToItemAtIndexPath:indexPathOfUnreadMessagesIndicator atScrollPosition:UICollectionViewScrollPositionTop animated:YES]; return; } } [self scrollToBottomAnimated:YES]; } - (void)ensureScrollDownButton { OWSAssertIsOnMainThread(); BOOL shouldShowScrollDownButton = NO; CGFloat scrollSpaceToBottom = (self.safeContentHeight + self.collectionView.contentInset.bottom - (self.collectionView.contentOffset.y + self.collectionView.frame.size.height)); CGFloat pageHeight = (self.collectionView.frame.size.height - (self.collectionView.contentInset.top + self.collectionView.contentInset.bottom)); // Show "scroll down" button if user is scrolled up at least // one page. BOOL isScrolledUp = scrollSpaceToBottom > pageHeight * 1.f; if (self.viewItems.count > 0) { id lastViewItem = [self.viewItems lastObject]; OWSAssertDebug(lastViewItem); if (lastViewItem.interaction.sortId > self.lastVisibleSortId) { shouldShowScrollDownButton = YES; } else if (isScrolledUp) { shouldShowScrollDownButton = YES; } } self.scrollDownButton.hidden = !shouldShowScrollDownButton; } #pragma mark - Attachment Picking: Documents - (void)showAttachmentDocumentPickerMenu { NSString *allItems = (__bridge NSString *)kUTTypeItem; NSArray *documentTypes = @[ allItems ]; // UIDocumentPickerModeImport copies to a temp file within our container. // It uses more memory than "open" but lets us avoid working with security scoped URLs. UIDocumentPickerMode pickerMode = UIDocumentPickerModeImport; // TODO: UIDocumentMenuViewController is deprecated; we should use UIDocumentPickerViewController // instead. UIDocumentMenuViewController *menuController = [[UIDocumentMenuViewController alloc] initWithDocumentTypes:documentTypes inMode:pickerMode]; menuController.delegate = self; UIImage *takeMediaImage = [UIImage imageNamed:@"actionsheet_camera_black"]; OWSAssertDebug(takeMediaImage); [menuController addOptionWithTitle:NSLocalizedString( @"MEDIA_FROM_LIBRARY_BUTTON", @"media picker option to choose from library") image:takeMediaImage order:UIDocumentMenuOrderFirst handler:^{ [self chooseFromLibraryAsDocument]; }]; [self dismissKeyBoard]; [self presentViewController:menuController animated:YES completion:nil]; } #pragma mark - Attachment Picking: GIFs - (void)showGIFMetadataWarning { NSString *title = NSLocalizedString(@"Search GIFs?", @""); NSString *message = NSLocalizedString(@"You will not have full metadata protection when sending GIFs.", @""); UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self showGifPicker]; }]]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleDefault handler:nil]]; [self presentViewController:alert animated:YES completion:nil]; } - (void)showGifPicker { GifPickerViewController *view = [[GifPickerViewController alloc] initWithThread:self.thread]; view.delegate = self; OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:view]; [self dismissKeyBoard]; [self presentViewController:navigationController animated:YES completion:nil]; } #pragma mark GifPickerViewControllerDelegate - (void)gifPickerDidSelectWithAttachment:(SignalAttachment *)attachment { OWSAssertDebug(attachment); [self showApprovalDialogForAttachment:attachment]; [self.conversationViewModel ensureDynamicInteractionsAndUpdateIfNecessary:YES]; } - (void)messageWasSent:(TSOutgoingMessage *)message { OWSAssertIsOnMainThread(); OWSAssertDebug(message); self.lastMessageSentDate = [NSDate new]; [self.conversationViewModel clearUnreadMessagesIndicator]; self.inputToolbar.quotedReply = nil; if (!Environment.shared.preferences.hasSentAMessage) { [Environment.shared.preferences setHasSentAMessage:YES]; } if ([Environment.shared.preferences soundInForeground]) { SystemSoundID soundId = [OWSSounds systemSoundIDForSound:OWSSound_MessageSent quiet:YES]; AudioServicesPlaySystemSound(soundId); } [self.typingIndicators didSendOutgoingMessageInThread:self.thread]; } #pragma mark UIDocumentMenuDelegate - (void)documentMenu:(UIDocumentMenuViewController *)documentMenu didPickDocumentPicker:(UIDocumentPickerViewController *)documentPicker { documentPicker.delegate = self; [self dismissKeyBoard]; [self presentViewController:documentPicker animated:YES completion:nil]; } #pragma mark UIDocumentPickerDelegate - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url { OWSLogDebug(@"Picked document at url: %@", url); NSString *type; NSError *typeError; [url getResourceValue:&type forKey:NSURLTypeIdentifierKey error:&typeError]; if (typeError) { OWSFailDebug(@"Determining type of picked document at url: %@ failed with error: %@", url, typeError); } if (!type) { OWSFailDebug(@"falling back to default filetype for picked document at url: %@", url); type = (__bridge NSString *)kUTTypeData; } NSNumber *isDirectory; NSError *isDirectoryError; [url getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:&isDirectoryError]; if (isDirectoryError) { OWSFailDebug(@"Determining if picked document was a directory failed with error: %@", isDirectoryError); } else if ([isDirectory boolValue]) { OWSLogInfo(@"User picked directory."); dispatch_async(dispatch_get_main_queue(), ^{ [OWSAlerts showAlertWithTitle: NSLocalizedString(@"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE", @"Alert title when picking a document fails because user picked a directory/bundle") message: NSLocalizedString(@"ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY", @"Alert body when picking a document fails because user picked a directory/bundle")]; }); return; } NSString *filename = url.lastPathComponent; if (!filename) { OWSFailDebug(@"Unable to determine filename"); filename = NSLocalizedString( @"ATTACHMENT_DEFAULT_FILENAME", @"Generic filename for an attachment with no known name"); } OWSAssertDebug(type); OWSAssertDebug(filename); DataSource *_Nullable dataSource = [DataSourcePath dataSourceWithURL:url shouldDeleteOnDeallocation:NO]; if (!dataSource) { OWSFailDebug(@"attachment data was unexpectedly empty for picked document"); dispatch_async(dispatch_get_main_queue(), ^{ [OWSAlerts showAlertWithTitle:NSLocalizedString(@"ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE", @"Alert title when picking a document fails for an unknown reason")]; }); return; } [dataSource setSourceFilename:filename]; // Although we want to be able to send higher quality attachments through the document picker // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) if ([SignalAttachment isInvalidVideoWithDataSource:dataSource dataUTI:type]) { [self showApprovalDialogAfterProcessingVideoURL:url filename:filename]; return; } // "Document picker" attachments _SHOULD NOT_ be resized, if possible. SignalAttachment *attachment = [SignalAttachment attachmentWithDataSource:dataSource dataUTI:type imageQuality:TSImageQualityOriginal]; [self showApprovalDialogForAttachment:attachment]; } #pragma mark - UIImagePickerController /* * Presenting UIImagePickerController */ - (void)takePictureOrVideo { [self ows_askForCameraPermissions:^(BOOL cameraGranted) { if (!cameraGranted) { OWSLogWarn(@"camera permission denied."); return; } [self ows_askForMicrophonePermissions:^(BOOL micGranted) { if (!micGranted) { OWSLogWarn(@"proceeding, though mic permission denied."); // We can still continue without mic permissions, but any captured video will // be silent. } UIViewController *pickerModal; if (SSKFeatureFlags.useCustomPhotoCapture) { SendMediaNavigationController *navController = [SendMediaNavigationController showingCameraFirst]; navController.sendMediaNavDelegate = self; pickerModal = navController; } else { UIImagePickerController *picker = [OWSImagePickerController new]; pickerModal = picker; picker.sourceType = UIImagePickerControllerSourceTypeCamera; picker.mediaTypes = @[ (__bridge NSString *)kUTTypeImage, (__bridge NSString *)kUTTypeMovie ]; picker.allowsEditing = NO; picker.delegate = self; } OWSAssertDebug(pickerModal); [self dismissKeyBoard]; pickerModal.modalPresentationStyle = UIModalPresentationFullScreen; [self presentViewController:pickerModal animated:YES completion:nil]; }]; }]; } - (void)chooseFromLibraryAsDocument { OWSAssertIsOnMainThread(); [self chooseFromLibraryAsDocument:YES]; } - (void)chooseFromLibraryAsMedia { OWSAssertIsOnMainThread(); [self chooseFromLibraryAsDocument:NO]; } - (void)chooseFromLibraryAsDocument:(BOOL)shouldTreatAsDocument { OWSAssertIsOnMainThread(); self.isPickingMediaAsDocument = shouldTreatAsDocument; [self ows_askForMediaLibraryPermissions:^(BOOL granted) { if (!granted) { OWSLogWarn(@"Media Library permission denied."); return; } SendMediaNavigationController *pickerModal = [SendMediaNavigationController showingMediaLibraryFirst]; pickerModal.sendMediaNavDelegate = self; [self dismissKeyBoard]; pickerModal.modalPresentationStyle = UIModalPresentationFullScreen; [self presentViewController:pickerModal animated:YES completion:nil]; }]; } /* * Dismissing UIImagePickerController */ - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { [self dismissViewControllerAnimated:YES completion:nil]; } - (void)resetFrame { // fixes bug on frame being off after this selection CGRect frame = [UIScreen mainScreen].bounds; self.view.frame = frame; } #pragma mark - SendMediaNavDelegate - (void)sendMediaNavDidCancel:(SendMediaNavigationController *)sendMediaNavigationController { [self dismissViewControllerAnimated:YES completion:^{ if (!self.isFirstResponder) { [self becomeFirstResponder]; } }]; } - (void)sendMediaNav:(SendMediaNavigationController *)sendMediaNavigationController didApproveAttachments:(NSArray *)attachments messageText:(nullable NSString *)messageText { [self tryToSendAttachments:attachments messageText:messageText]; [self.inputToolbar clearTextMessageAnimated:NO]; [self resetMentions]; // we want to already be at the bottom when the user returns, rather than have to watch // the new message scroll into view. [self scrollToBottomAnimated:NO]; [self dismissViewControllerAnimated:YES completion:^{ if (!self.isFirstResponder) { [self becomeFirstResponder]; } if (@available(iOS 10, *)) { // do nothing } else { [self reloadInputViews]; } }]; } - (nullable NSString *)sendMediaNavInitialMessageText:(SendMediaNavigationController *)sendMediaNavigationController { return self.inputToolbar.messageText; } - (void)sendMediaNav:(SendMediaNavigationController *)sendMediaNavigationController didChangeMessageText:(nullable NSString *)messageText { [self.inputToolbar setMessageText:messageText animated:NO]; } #pragma mark - UIImagePickerControllerDelegate /* * Fetching data from UIImagePickerController */ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { [self resetFrame]; NSURL *referenceURL = [info valueForKey:UIImagePickerControllerReferenceURL]; if (!referenceURL) { OWSLogVerbose(@"Could not retrieve reference URL for picked asset"); [self imagePickerController:picker didFinishPickingMediaWithInfo:info filename:nil]; return; } ALAssetsLibraryAssetForURLResultBlock resultblock = ^(ALAsset *imageAsset) { ALAssetRepresentation *imageRep = [imageAsset defaultRepresentation]; NSString *filename = [imageRep filename]; [self imagePickerController:picker didFinishPickingMediaWithInfo:info filename:filename]; }; ALAssetsLibrary *assetslibrary = [[ALAssetsLibrary alloc] init]; [assetslibrary assetForURL:referenceURL resultBlock:resultblock failureBlock:^(NSError *error) { OWSCFailDebug(@"Error retrieving filename for asset: %@", error); }]; } - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info filename:(NSString *_Nullable)filename { OWSAssertIsOnMainThread(); void (^failedToPickAttachment)(NSError *error) = ^void(NSError *error) { OWSLogError(@"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 dismissViewControllerAnimated:YES completion:^{ [self showApprovalDialogAfterProcessingVideoURL:videoURL filename:filename]; }]; } else if (picker.sourceType == UIImagePickerControllerSourceTypeCamera) { // Static Image captured from camera UIImage *imageFromCamera = [info[UIImagePickerControllerOriginalImage] normalizedImage]; [self dismissViewControllerAnimated:YES completion:^{ OWSAssertIsOnMainThread(); if (imageFromCamera) { // "Camera" attachments _SHOULD_ be resized, if possible. SignalAttachment *attachment = [SignalAttachment imageAttachmentWithImage:imageFromCamera dataUTI:(NSString *)kUTTypeJPEG filename:filename imageQuality:TSImageQualityCompact]; if (!attachment || [attachment hasError]) { OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; failedToPickAttachment(nil); } else { [self showApprovalDialogForAttachment:attachment]; } } else { failedToPickAttachment(nil); } }]; } else { // Non-Video image picked from library OWSFailDebug( @"Only use UIImagePicker for camera/video capture. Picking media from UIImagePicker is not supported. "); // To avoid re-encoding GIF and PNG's as JPEG we have to get the raw data of // the selected item vs. using the UIImagePickerControllerOriginalImage NSURL *assetURL = info[UIImagePickerControllerReferenceURL]; PHAsset *asset = [[PHAsset fetchAssetsWithALAssetURLs:@[ assetURL ] options:nil] lastObject]; if (!asset) { return failedToPickAttachment(nil); } // Images chosen from the "attach document" UI should be sent as originals; // images chosen from the "attach media" UI should be resized to "medium" size; TSImageQuality imageQuality = (self.isPickingMediaAsDocument ? TSImageQualityOriginal : TSImageQualityMedium); 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); } OWSAssertIsOnMainThread(); DataSource *_Nullable dataSource = [DataSourceValue dataSourceWithData:imageData utiType:dataUTI]; [dataSource setSourceFilename:filename]; SignalAttachment *attachment = [SignalAttachment attachmentWithDataSource:dataSource dataUTI:dataUTI imageQuality:imageQuality]; [self dismissViewControllerAnimated:YES completion:^{ OWSAssertIsOnMainThread(); if (!attachment || [attachment hasError]) { OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; failedToPickAttachment(nil); } else { [self showApprovalDialogForAttachment:attachment]; } }]; }]; } } - (void)showApprovalDialogAfterProcessingVideoURL:(NSURL *)movieURL filename:(nullable NSString *)filename { OWSAssertIsOnMainThread(); [ModalActivityIndicatorViewController presentFromViewController:self canCancel:YES backgroundBlock:^(ModalActivityIndicatorViewController *modalActivityIndicator) { DataSource *dataSource = [DataSourcePath dataSourceWithURL:movieURL shouldDeleteOnDeallocation:NO]; dataSource.sourceFilename = filename; VideoCompressionResult *compressionResult = [SignalAttachment compressVideoAsMp4WithDataSource:dataSource dataUTI:(NSString *)kUTTypeMPEG4]; [compressionResult.attachmentPromise.then(^(SignalAttachment *attachment) { OWSAssertIsOnMainThread(); OWSAssertDebug([attachment isKindOfClass:[SignalAttachment class]]); if (modalActivityIndicator.wasCancelled) { return; } [modalActivityIndicator dismissWithCompletion:^{ if (!attachment || [attachment hasError]) { OWSLogError(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; } else { [self showApprovalDialogForAttachment:attachment]; } }]; }) retainUntilComplete]; }]; } #pragma mark - Storage access - (YapDatabaseConnection *)uiDatabaseConnection { return OWSPrimaryStorage.sharedManager.uiDatabaseConnection; } - (YapDatabaseConnection *)editingDatabaseConnection { return OWSPrimaryStorage.sharedManager.dbReadWriteConnection; } #pragma mark - Audio - (void)requestRecordingVoiceMemo { OWSAssertIsOnMainThread(); NSUUID *voiceMessageUUID = [NSUUID UUID]; self.voiceMessageUUID = voiceMessageUUID; __weak typeof(self) weakSelf = self; [self ows_askForMicrophonePermissions:^(BOOL granted) { __strong typeof(self) strongSelf = weakSelf; if (!strongSelf) { return; } if (strongSelf.voiceMessageUUID != voiceMessageUUID) { // This voice message recording has been cancelled // before recording could begin. return; } if (granted) { [strongSelf startRecordingVoiceMemo]; } else { OWSLogInfo(@"we do not have recording permission."); [strongSelf cancelVoiceMemo]; [OWSAlerts showNoMicrophonePermissionAlert]; } }]; } - (void)startRecordingVoiceMemo { OWSAssertIsOnMainThread(); OWSLogInfo(@"startRecordingVoiceMemo"); // Cancel any ongoing audio playback. [self.audioAttachmentPlayer stop]; self.audioAttachmentPlayer = nil; NSString *temporaryDirectory = OWSTemporaryDirectory(); NSString *filename = [NSString stringWithFormat:@"%lld.m4a", [NSDate ows_millisecondTimeStamp]]; NSString *filepath = [temporaryDirectory stringByAppendingPathComponent:filename]; NSURL *fileURL = [NSURL fileURLWithPath:filepath]; // Setup audio session BOOL configuredAudio = [self.audioSession startAudioActivity:self.recordVoiceNoteAudioActivity]; if (!configuredAudio) { OWSFailDebug(@"Couldn't configure audio session"); [self cancelVoiceMemo]; return; } NSError *error; // Initiate and prepare the recorder self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:fileURL settings:@{ AVFormatIDKey : @(kAudioFormatMPEG4AAC), AVSampleRateKey : @(44100), AVNumberOfChannelsKey : @(2), AVEncoderBitRateKey : @(128 * 1024), } error:&error]; __weak ConversationViewController *weakSelf = self; self.audioTimer = [NSTimer scheduledTimerWithTimeInterval:60 repeats:NO block:^(NSTimer *timer) { [[weakSelf inputToolbar] hideVoiceMemoUI:YES]; [weakSelf endRecordingVoiceMemo]; }]; if (error) { OWSFailDebug(@"Couldn't create audioRecorder: %@", error); [self cancelVoiceMemo]; return; } self.audioRecorder.meteringEnabled = YES; if (![self.audioRecorder prepareToRecord]) { OWSFailDebug(@"audioRecorder couldn't prepareToRecord."); [self cancelVoiceMemo]; return; } if (![self.audioRecorder record]) { OWSFailDebug(@"audioRecorder couldn't record."); [self cancelVoiceMemo]; return; } } - (void)endRecordingVoiceMemo { OWSAssertIsOnMainThread(); OWSLogInfo(@"endRecordingVoiceMemo"); [self.audioTimer invalidate]; self.voiceMessageUUID = nil; if (!self.audioRecorder) { // No voice message recording is in progress. // We may be cancelling before the recording could begin. OWSLogError(@"Missing audioRecorder"); return; } NSTimeInterval durationSeconds = self.audioRecorder.currentTime; [self stopRecording]; const NSTimeInterval kMinimumRecordingTimeSeconds = 1.f; if (durationSeconds < kMinimumRecordingTimeSeconds) { OWSLogInfo(@"Discarding voice message; too short."); self.audioRecorder = nil; [self dismissKeyBoard]; [OWSAlerts showAlertWithTitle: NSLocalizedString(@"VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", @"Title for the alert indicating the 'voice message' needs to be held to be held down to record.") message:NSLocalizedString(@"VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", @"Message for the alert indicating the 'voice message' needs to be held to be held " @"down to record.")]; return; } DataSource *_Nullable dataSource = [DataSourcePath dataSourceWithURL:self.audioRecorder.url shouldDeleteOnDeallocation:YES]; self.audioRecorder = nil; if (!dataSource) { OWSFailDebug(@"Couldn't load audioRecorder data"); self.audioRecorder = nil; return; } NSString *filename = [NSLocalizedString(@"VOICE_MESSAGE_FILE_NAME", @"Filename for voice messages.") stringByAppendingPathExtension:@"m4a"]; [dataSource setSourceFilename:filename]; SignalAttachment *attachment = [SignalAttachment voiceMessageAttachmentWithDataSource:dataSource dataUTI:(NSString *)kUTTypeMPEG4Audio]; OWSLogVerbose(@"voice memo duration: %f, file size: %zd", durationSeconds, [dataSource dataLength]); if (!attachment || [attachment hasError]) { OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; } else { [self tryToSendAttachments:@[ attachment ] messageText:nil]; } } - (void)stopRecording { [self.audioRecorder stop]; [self.audioSession endAudioActivity:self.recordVoiceNoteAudioActivity]; } - (void)cancelRecordingVoiceMemo { OWSAssertIsOnMainThread(); OWSLogDebug(@"cancelRecordingVoiceMemo"); [self.audioTimer invalidate]; [self stopRecording]; self.audioRecorder = nil; self.voiceMessageUUID = nil; } - (void)setAudioRecorder:(nullable AVAudioRecorder *)audioRecorder { // Prevent device from sleeping while recording a voice message. if (audioRecorder) { [DeviceSleepManager.sharedInstance addBlockWithBlockObject:audioRecorder]; } else if (_audioRecorder) { [DeviceSleepManager.sharedInstance removeBlockWithBlockObject:_audioRecorder]; } _audioRecorder = audioRecorder; } #pragma mark Accessory View - (void)attachmentButtonPressed { [self dismissKeyBoard]; __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { [self showUnblockConversationUI:^(BOOL isBlocked) { if (!isBlocked) { [weakSelf attachmentButtonPressed]; } }]; return; } UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; [actionSheet addAction:[OWSAlerts cancelAction]]; UIAlertAction *takeMediaAction = [UIAlertAction actionWithTitle:NSLocalizedString( @"MEDIA_FROM_CAMERA_BUTTON", @"media picker option to take photo or video") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_camera") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self takePictureOrVideo]; }]; UIImage *takeMediaImage = [UIImage imageNamed:@"actionsheet_camera_black"]; OWSAssertDebug(takeMediaImage); [takeMediaAction setValue:takeMediaImage forKey:@"image"]; [actionSheet addAction:takeMediaAction]; UIAlertAction *chooseMediaAction = [UIAlertAction actionWithTitle:NSLocalizedString( @"MEDIA_FROM_LIBRARY_BUTTON", @"media picker option to choose from library") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_choose_media") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self chooseFromLibraryAsMedia]; }]; UIImage *chooseMediaImage = [UIImage imageNamed:@"actionsheet_camera_roll_black"]; OWSAssertDebug(chooseMediaImage); [chooseMediaAction setValue:chooseMediaImage forKey:@"image"]; [actionSheet addAction:chooseMediaAction]; UIAlertAction *gifAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"SELECT_GIF_BUTTON", @"Label for 'select GIF to attach' action sheet button") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_gif") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults; BOOL hasSeenGIFMetadataWarning = [userDefaults boolForKey:@"hasSeenGIFMetadataWarning"]; if (!hasSeenGIFMetadataWarning) { [self showGIFMetadataWarning]; [userDefaults setBool:YES forKey:@"hasSeenGIFMetadataWarning"]; } else { [self showGifPicker]; } }]; UIImage *gifImage = [UIImage imageNamed:@"actionsheet_gif_black"]; OWSAssertDebug(gifImage); [gifAction setValue:gifImage forKey:@"image"]; [actionSheet addAction:gifAction]; UIAlertAction *chooseDocumentAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"MEDIA_FROM_DOCUMENT_PICKER_BUTTON", @"action sheet button title when choosing attachment type") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_document") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self showAttachmentDocumentPickerMenu]; }]; UIImage *chooseDocumentImage = [UIImage imageNamed:@"actionsheet_document_black"]; OWSAssertDebug(chooseDocumentImage); [chooseDocumentAction setValue:chooseDocumentImage forKey:@"image"]; [actionSheet addAction:chooseDocumentAction]; /* if (kIsSendingContactSharesEnabled) { UIAlertAction *chooseContactAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"ATTACHMENT_MENU_CONTACT_BUTTON", @"attachment menu option to send contact") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_contact") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self chooseContactForSending]; }]; UIImage *chooseContactImage = [UIImage imageNamed:@"actionsheet_contact"]; OWSAssertDebug(takeMediaImage); [chooseContactAction setValue:chooseContactImage forKey:@"image"]; [actionSheet addAction:chooseContactAction]; } */ [self dismissKeyBoard]; [self presentAlert:actionSheet]; } - (nullable NSIndexPath *)lastVisibleIndexPath { NSIndexPath *_Nullable lastVisibleIndexPath = nil; for (NSIndexPath *indexPath in [self.collectionView indexPathsForVisibleItems]) { if (!lastVisibleIndexPath || indexPath.row > lastVisibleIndexPath.row) { lastVisibleIndexPath = indexPath; } } if (lastVisibleIndexPath && lastVisibleIndexPath.row >= (NSInteger)self.viewItems.count) { return (self.viewItems.count > 0 ? [NSIndexPath indexPathForRow:(NSInteger)self.viewItems.count - 1 inSection:0] : nil); } return lastVisibleIndexPath; } - (nullable id)lastVisibleViewItem { NSIndexPath *_Nullable lastVisibleIndexPath = [self lastVisibleIndexPath]; if (!lastVisibleIndexPath) { return nil; } return [self viewItemForIndex:lastVisibleIndexPath.row]; } // In the case where we explicitly scroll to bottom, we want to synchronously // update the UI to reflect that, since the "mark as read" logic is asynchronous // and won't update the UI state immediately. - (void)didScrollToBottom { id _Nullable lastVisibleViewItem = [self.viewItems lastObject]; if (lastVisibleViewItem) { uint64_t lastVisibleSortId = lastVisibleViewItem.interaction.sortId; self.lastVisibleSortId = MAX(self.lastVisibleSortId, lastVisibleSortId); } self.scrollDownButton.hidden = YES; self.hasUnreadMessages = NO; if (lastVisibleViewItem != NULL && self.lastVisibleSortId > 0) { [OWSReadReceiptManager.sharedManager markAsReadLocallyBeforeSortId:self.lastVisibleSortId thread:self.thread]; } } - (void)updateLastVisibleSortId { id _Nullable lastVisibleViewItem = [self lastVisibleViewItem]; if (lastVisibleViewItem) { uint64_t lastVisibleSortId = lastVisibleViewItem.interaction.sortId; self.lastVisibleSortId = MAX(self.lastVisibleSortId, lastVisibleSortId); } [self ensureScrollDownButton]; __block NSUInteger numberOfUnreadMessages; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { numberOfUnreadMessages = [[transaction ext:TSUnreadDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; }]; self.hasUnreadMessages = numberOfUnreadMessages > 0; } - (void)markVisibleMessagesAsRead { if (self.presentedViewController) { OWSLogInfo(@"Not marking messages as read; another view is presented."); return; } if (OWSWindowManager.sharedManager.shouldShowCallView) { OWSLogInfo(@"Not marking messages as read; call view is presented."); return; } if (self.navigationController.topViewController != self) { OWSLogInfo(@"Not marking messages as read; another view is pushed."); return; } [self updateLastVisibleSortId]; uint64_t lastVisibleSortId = self.lastVisibleSortId; if (lastVisibleSortId == 0) { // No visible messages yet. New Thread. return; } [OWSReadReceiptManager.sharedManager markAsReadLocallyBeforeSortId:self.lastVisibleSortId thread:self.thread]; } - (void)updateGroupModelTo:(TSGroupModel *)newGroupModel successCompletion:(void (^_Nullable)(void))successCompletion { } - (void)popKeyBoard { [self.inputToolbar beginEditingTextMessage]; } - (void)dismissKeyBoard { [self.inputToolbar endEditingTextMessage]; } #pragma mark Drafts - (void)loadDraftInCompose { OWSAssertIsOnMainThread(); __block NSString *draft; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { draft = [self.thread currentDraftWithTransaction:transaction]; }]; [self.inputToolbar setMessageText:draft animated:NO]; } - (void)saveDraft { if (!self.inputToolbar.hidden) { __block TSThread *thread = _thread; __block NSString *currentDraft = [self.inputToolbar messageText]; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [thread setDraft:currentDraft transaction:transaction]; }]; } } #pragma mark 3D Touch Preview Actions - (NSArray> *)previewActionItems { return @[]; } #ifdef USE_DEBUG_UI - (void)navigationTitleLongPressed:(UIGestureRecognizer *)gestureRecognizer { if (gestureRecognizer.state == UIGestureRecognizerStateBegan) { [DebugUITableViewController presentDebugUIForThread:self.thread fromViewController:self]; } } #endif #pragma mark - ConversationInputTextViewDelegate - (void)textViewDidChange:(UITextView *)textView { // Prepare NSString *newText = textView.text; // Typing indicators if (newText.length > 0) { [self.typingIndicators didStartTypingOutgoingInputInThread:self.thread]; } // Mentions BOOL isBackspace = newText.length < self.oldText.length; if (isBackspace) { self.currentMentionStartIndex = -1; [self.inputToolbar hideMentionCandidateSelectionView]; NSArray *mentionsToRemove = [self.mentions filtered:^BOOL(LKMention *mention) { return ![mention isContainedIn:newText]; }]; [self.mentions removeObjectsInArray:mentionsToRemove]; } if (newText.length > 0) { NSUInteger lastCharacterIndex = newText.length - 1; unichar lastCharacter = [newText characterAtIndex:lastCharacterIndex]; // Check if there is a whitespace before '@' or the '@' is the first character unichar secondToLastCharacter = ' '; if (lastCharacterIndex > 0) { secondToLastCharacter = [newText characterAtIndex:lastCharacterIndex - 1]; } if (lastCharacter == '@' && [NSCharacterSet.whitespaceAndNewlineCharacterSet characterIsMember:secondToLastCharacter]) { NSArray *mentionCandidates = [LKMentionsManager getMentionCandidatesFor:@"" in:self.thread.uniqueId]; self.currentMentionStartIndex = (NSInteger)lastCharacterIndex; [self.inputToolbar showMentionCandidateSelectionViewFor:mentionCandidates in:self.thread]; } else if ([NSCharacterSet.whitespaceAndNewlineCharacterSet characterIsMember:lastCharacter]) { self.currentMentionStartIndex = -1; [self.inputToolbar hideMentionCandidateSelectionView]; } else { if (self.currentMentionStartIndex != -1) { NSString *query = [newText substringFromIndex:(NSUInteger)self.currentMentionStartIndex + 1]; // + 1 to get rid of the @ NSArray *mentionCandidates = [LKMentionsManager getMentionCandidatesFor:query in:self.thread.uniqueId]; [self.inputToolbar showMentionCandidateSelectionViewFor:mentionCandidates in:self.thread]; } } } self.oldText = newText; } - (void)handleMentionCandidateSelected:(LKMention *)mentionCandidate from:(LKMentionCandidateSelectionView *)mentionCandidateSelectionView { NSUInteger mentionStartIndex = (NSUInteger)self.currentMentionStartIndex; [self.mentions addObject:mentionCandidate]; NSString *oldText = self.inputToolbar.messageText; NSString *newText = [oldText stringByReplacingCharactersInRange:NSMakeRange(mentionStartIndex, oldText.length - mentionStartIndex) withString:[NSString stringWithFormat:@"@%@ ", mentionCandidate.displayName]]; [self.inputToolbar setMessageText:newText animated:NO]; self.currentMentionStartIndex = -1; [self.inputToolbar hideMentionCandidateSelectionView]; self.oldText = newText; } - (NSString *)getSendText { NSString *result = self.inputToolbar.messageText; for (LKMention *mention in self.mentions) { NSRange range = [result rangeOfString:[NSString stringWithFormat:@"@%@", mention.displayName]]; result = [result stringByReplacingCharactersInRange:range withString:[[NSString alloc] initWithFormat:@"@%@", mention.publicKey]]; } return result; } - (void)resetMentions { self.oldText = @""; self.currentMentionStartIndex = -1; self.mentions = @[].mutableCopy; } - (void)inputTextViewSendMessagePressed { [self sendButtonPressed]; } - (void)didPasteAttachment:(SignalAttachment *_Nullable)attachment { OWSLogError(@""); [self showApprovalDialogForAttachment:attachment]; } - (void)showApprovalDialogForAttachment:(SignalAttachment *_Nullable)attachment { if (attachment == nil) { OWSFailDebug(@"attachment was unexpectedly nil"); [self showErrorAlertForAttachment:nil]; return; } [self showApprovalDialogForAttachments:@[ attachment ]]; } - (void)showApprovalDialogForAttachments:(NSArray *)attachments { OWSNavigationController *modal = [AttachmentApprovalViewController wrappedInNavControllerWithAttachments:attachments approvalDelegate:self]; [self presentViewController:modal animated:YES completion:nil]; } - (void)tryToSendAttachments:(NSArray *)attachments messageText:(NSString *_Nullable)messageText { DispatchMainThreadSafe(^{ __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { [self showUnblockConversationUI:^(BOOL isBlocked) { if (!isBlocked) { [weakSelf tryToSendAttachments:attachments messageText:messageText]; } }]; return; } for (SignalAttachment *attachment in attachments) { if ([attachment hasError]) { OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; return; } } SNVisibleMessage *message = [SNVisibleMessage new]; message.text = messageText; message.sentTimestamp = [NSDate millisecondTimestamp]; TSThread *thread = self.thread; TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread]; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [tsMessage saveWithTransaction:transaction]; } completion:^{ [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [SNMessageSender send:message withAttachments:attachments inThread:thread usingTransaction:transaction]; }]; }]; [self messageWasSent:tsMessage]; }); } - (void)keyboardWillShow:(NSNotification *)notification { [self handleKeyboardNotification:notification]; } - (void)keyboardDidShow:(NSNotification *)notification { [self handleKeyboardNotification:notification]; } - (void)keyboardWillHide:(NSNotification *)notification { [self handleKeyboardNotification:notification]; } - (void)keyboardDidHide:(NSNotification *)notification { [self handleKeyboardNotification:notification]; } - (void)keyboardWillChangeFrame:(NSNotification *)notification { [self handleKeyboardNotification:notification]; } - (void)keyboardDidChangeFrame:(NSNotification *)notification { [self handleKeyboardNotification:notification]; } - (void)handleKeyboardNotification:(NSNotification *)notification { OWSAssertIsOnMainThread(); NSDictionary *userInfo = [notification userInfo]; NSValue *_Nullable keyboardBeginFrameValue = userInfo[UIKeyboardFrameBeginUserInfoKey]; if (!keyboardBeginFrameValue) { OWSFailDebug(@"Missing keyboard begin frame"); return; } NSValue *_Nullable keyboardEndFrameValue = userInfo[UIKeyboardFrameEndUserInfoKey]; if (!keyboardEndFrameValue) { OWSFailDebug(@"Missing keyboard end frame"); return; } CGRect keyboardEndFrame = [keyboardEndFrameValue CGRectValue]; CGRect keyboardEndFrameConverted = [self.view convertRect:keyboardEndFrame fromView:nil]; UIEdgeInsets oldInsets = self.collectionView.contentInset; UIEdgeInsets newInsets = oldInsets; // Measures how far the keyboard "intrudes" into the collection view's content region. // Indicates how large the bottom content inset should be in order to avoid the keyboard // from hiding the conversation content. // // NOTE: we can ignore the "bottomLayoutGuide" (i.e. the notch); this will be accounted // for by the "adjustedContentInset". CGFloat keyboardContentOverlap = MAX(0, self.view.height - self.bottomLayoutGuide.length - keyboardEndFrameConverted.origin.y); // For the sake of continuity, we want to maintain the same contentInsetBottom when the // the keyboard/input accessory are hidden, e.g. during dismissal animations, when // presenting popups like the attachment picker, etc. // // Therefore, we only zero out the contentInsetBottom if the inputAccessoryView is nil. if (self.inputAccessoryView == nil || keyboardContentOverlap > 0) { self.contentInsetBottom = keyboardContentOverlap; } else if (!CurrentAppContext().isAppForegroundAndActive) { // If app is not active, we'll dismiss the keyboard // so only reserve enough space for the input accessory // view. Otherwise, the content will animate into place // when the app returns from the background. // // NOTE: There are two separate cases. If the keyboard is // dismissed, the inputAccessoryView grows to allow // space for the notch. In this case, we need to // subtract bottomLayoutGuide. However, if the // keyboard is presented we don't want to do that. // I don't see a simple, safe way to distinguish // these two cases. Therefore, I'm _always_ // subtracting bottomLayoutGuide. This will cause // a slight animation when returning to the app // but it will "match" the presentation animation // of the input accessory. self.contentInsetBottom = MAX(0, self.inputAccessoryView.height - self.bottomLayoutGuide.length); } newInsets.top = 0 + self.extraContentInsetPadding; newInsets.bottom = self.contentInsetBottom + self.extraContentInsetPadding; BOOL wasScrolledToBottom = [self isScrolledToBottom]; void (^adjustInsets)(void) = ^(void) { if (!UIEdgeInsetsEqualToEdgeInsets(self.collectionView.contentInset, newInsets)) { self.collectionView.contentInset = newInsets; } self.collectionView.scrollIndicatorInsets = newInsets; // Note there is a bug in iOS11.2 which where switching to the emoji keyboard // does not fire a UIKeyboardFrameWillChange notification. In that case, the scroll // down button gets mostly obscured by the keyboard. // RADAR: #36297652 [self updateScrollDownButtonLayout]; // Update the layout of the scroll down button immediately. // This change might be animated by the keyboard notification. [self.scrollDownButton.superview layoutIfNeeded]; // Adjust content offset to prevent the presented keyboard from obscuring content. if (!self.viewHasEverAppeared) { [self scrollToDefaultPosition:NO]; } else if (wasScrolledToBottom) { // If we were scrolled to the bottom, don't do any fancy math. Just stay at the bottom. [self scrollToBottomAnimated:NO]; } else if (self.isViewCompletelyAppeared) { // If we were scrolled away from the bottom, shift the content in lockstep with the // keyboard, up to the limits of the content bounds. CGFloat insetChange = newInsets.bottom - oldInsets.bottom; CGFloat oldYOffset = self.collectionView.contentOffset.y; CGFloat newYOffset = CGFloatClamp(oldYOffset + insetChange, 0, self.safeContentHeight); CGPoint newOffset = CGPointMake(0, newYOffset); // If the user is dismissing the keyboard via interactive scrolling, any additional conset offset feels // redundant, so we only adjust content offset when *presenting* the keyboard (i.e. when insetChange > 0). if (insetChange > 0 && newYOffset > keyboardEndFrame.origin.y) { [self.collectionView setContentOffset:newOffset animated:NO]; } } }; if (self.shouldAnimateKeyboardChanges && CurrentAppContext().isAppForegroundAndActive) { adjustInsets(); } else { // Even though we are scrolling without explicitly animating, the notification seems to occur within the context // of a system animation, which is desirable when the view is visible, because the user sees the content rise // in sync with the keyboard. However, when the view hasn't yet been presented, the animation conflicts and the // result is that initial load causes the collection cells to visably "animate" to their final position once the // view appears. [UIView performWithoutAnimation:adjustInsets]; } } - (void)applyTheme { OWSAssertIsOnMainThread(); // make sure toolbar extends below iPhoneX home button. self.view.backgroundColor = Theme.toolbarBackgroundColor; self.collectionView.backgroundColor = Theme.backgroundColor; } #pragma mark - AttachmentApprovalViewControllerDelegate - (void)attachmentApproval:(AttachmentApprovalViewController *)attachmentApproval didApproveAttachments:(NSArray *)attachments messageText:(NSString *_Nullable)messageText { for (SignalAttachment *attachment in attachments) { if ([attachment hasError]) { OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; return; } } [self tryToSendAttachments:attachments messageText:messageText]; [self.inputToolbar clearTextMessageAnimated:NO]; [self resetMentions]; [self dismissViewControllerAnimated:YES completion:nil]; // We always want to scroll to the bottom of the conversation after the local user // sends a message. Normally, this is taken care of in yapDatabaseModified:, but // we don't listen to db modifications when this view isn't visible, i.e. when the // attachment approval view is presented. [self scrollToBottomAnimated:NO]; } - (void)attachmentApprovalDidCancel:(AttachmentApprovalViewController *)attachmentApproval { [self dismissViewControllerAnimated:YES completion:nil]; } - (void)attachmentApproval:(AttachmentApprovalViewController *)attachmentApproval didChangeMessageText:(nullable NSString *)newMessageText { [self.inputToolbar setMessageText:newMessageText animated:NO]; } #pragma mark - - (void)showErrorAlertForAttachment:(SignalAttachment *_Nullable)attachment { OWSAssertDebug(attachment == nil || [attachment hasError]); NSString *errorMessage = (attachment ? [attachment localizedErrorDescription] : [SignalAttachment missingDataErrorMessage]); OWSLogError(@": %@", errorMessage); [OWSAlerts showAlertWithTitle:NSLocalizedString( @"ATTACHMENT_ERROR_ALERT_TITLE", @"The title of the 'attachment error' alert.") message:errorMessage]; } - (CGFloat)safeContentHeight { // Don't use self.collectionView.contentSize.height as the collection view's // content size might not be set yet. // // We can safely call prepareLayout to ensure the layout state is up-to-date // since our layout uses a dirty flag internally to debounce redundant work. [self.layout prepareLayout]; return [self.collectionView.collectionViewLayout collectionViewContentSize].height; } - (void)scrollToBottomAnimated:(BOOL)animated { OWSAssertIsOnMainThread(); if (self.isUserScrolling) { return; } // Ensure the view is fully layed out before we try to scroll to the bottom, since // we use the collectionView bounds to determine where the "bottom" is. [self.view layoutIfNeeded]; const CGFloat topInset = ^{ if (@available(iOS 11, *)) { return -self.collectionView.adjustedContentInset.top; } else { return -self.collectionView.contentInset.top; } }(); const CGFloat bottomInset = ^{ if (@available(iOS 11, *)) { return -self.collectionView.adjustedContentInset.bottom; } else { return -self.collectionView.contentInset.bottom; } }(); const CGFloat firstContentPageTop = topInset; const CGFloat collectionViewUnobscuredHeight = self.collectionView.bounds.size.height + bottomInset; const CGFloat lastContentPageTop = self.safeContentHeight - collectionViewUnobscuredHeight; CGFloat dstY = MAX(firstContentPageTop, lastContentPageTop); [self.collectionView setContentOffset:CGPointMake(0, dstY) animated:animated]; [self didScrollToBottom]; } - (void)scrollToFirstUnreadMessage:(BOOL)isAnimated { [self scrollToDefaultPosition:isAnimated]; } #pragma mark - UIScrollViewDelegate - (void)updateLastKnownDistanceFromBottom { // Never update the lastKnownDistanceFromBottom, // if we're presenting the menu actions which // temporarily meddles with the content insets. if (!OWSWindowManager.sharedManager.isPresentingMenuActions) { self.lastKnownDistanceFromBottom = @(self.safeDistanceFromBottom); } } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { // Constantly try to update the lastKnownDistanceFromBottom. [self updateLastKnownDistanceFromBottom]; [self updateLastVisibleSortId]; [self.autoLoadMoreTimer invalidate]; self.autoLoadMoreTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.1f target:self selector:@selector(autoLoadMoreTimerDidFire) userInfo:nil repeats:NO]; } - (void)autoLoadMoreTimerDidFire { [self autoLoadMoreIfNecessary]; } - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { self.userHasScrolled = YES; self.isUserScrolling = YES; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { self.isUserScrolling = NO; } #pragma mark - OWSConversationSettingsViewDelegate - (void)resendGroupUpdateForErrorMessage:(TSErrorMessage *)message { OWSAssertIsOnMainThread(); OWSAssertDebug([_thread isKindOfClass:[TSGroupThread class]]); OWSAssertDebug(message); TSGroupThread *groupThread = (TSGroupThread *)self.thread; TSGroupModel *groupModel = groupThread.groupModel; [self updateGroupModelTo:groupModel successCompletion:^{ OWSLogInfo(@"Group updated, removing group creation error."); [message remove]; }]; } - (void)conversationColorWasUpdated { [self.conversationStyle updateProperties]; [self resetContentAndLayout]; } - (void)groupWasUpdated:(TSGroupModel *)groupModel { OWSAssertDebug(groupModel); NSMutableSet *groupMemberIds = [NSMutableSet setWithArray:groupModel.groupMemberIds]; [groupMemberIds addObject:self.tsAccountManager.localNumber]; groupModel.groupMemberIds = [NSMutableArray arrayWithArray:[groupMemberIds allObjects]]; [self updateGroupModelTo:groupModel successCompletion:nil]; } - (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock { if (self.presentedViewController) { [self.presentedViewController dismissViewControllerAnimated:YES completion:^{ [self.navigationController popToViewController:self animated:YES completion:completionBlock]; }]; } else { [self.navigationController popToViewController:self animated:YES completion:completionBlock]; } } #pragma mark - Conversation Search - (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController { [self showSearchUI]; [self popAllConversationSettingsViewsWithCompletion:^{ // This delay is unfortunate, but without it, self.searchController.uiSearchController.searchBar // isn't yet ready to become first responder. Presumably we're still mid transition. // A hardcorded constant like this isn't great because it's either too slow, making our users // wait, or too fast, and fails to wait long enough to be ready to become first responder. // Luckily in this case the stakes aren't catastrophic. In the case that we're too aggressive // the user will just have to manually tap into the search field before typing. // Leaving this assert in as proof that we're not ready to become first responder yet. // If this assert fails, *great* maybe we can get rid of this delay. OWSAssertDebug(![self.searchController.uiSearchController.searchBar canBecomeFirstResponder]); dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self.searchController.uiSearchController.searchBar becomeFirstResponder]; }); }]; } - (void)showSearchUI { self.isShowingSearchUI = YES; UISearchBar *searchBar = self.searchController.uiSearchController.searchBar; searchBar.searchBarStyle = UISearchBarStyleMinimal; searchBar.barStyle = UIBarStyleBlack; searchBar.tintColor = LKColors.accent; UIImage *searchImage = [[UIImage imageNamed:@"searchbar_search"] asTintedImageWithColor:LKColors.searchBarPlaceholder]; [searchBar setImage:searchImage forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; UIImage *clearImage = [[UIImage imageNamed:@"searchbar_clear"] asTintedImageWithColor:LKColors.searchBarPlaceholder]; [searchBar setImage:clearImage forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; UITextField *searchTextField; if (@available(iOS 13, *)) { searchTextField = searchBar.searchTextField; } else { searchTextField = (UITextField *)[searchBar valueForKey:@"_searchField"]; } searchTextField.backgroundColor = LKColors.searchBarBackground; searchTextField.textColor = LKColors.text; searchTextField.attributedPlaceholder = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"Search", @"") attributes:@{ NSForegroundColorAttributeName : LKColors.searchBarPlaceholder }]; searchBar.keyboardAppearance = LKAppModeUtilities.isLightMode ? UIKeyboardAppearanceDefault : UIKeyboardAppearanceDark; [searchBar setPositionAdjustment:UIOffsetMake(4, 0) forSearchBarIcon:UISearchBarIconSearch]; [searchBar setSearchTextPositionAdjustment:UIOffsetMake(2, 0)]; [searchBar setPositionAdjustment:UIOffsetMake(-4, 0) forSearchBarIcon:UISearchBarIconClear]; // Note: setting a searchBar as the titleView causes UIKit to render the navBar // *slightly* taller (44pt -> 56pt) self.navigationItem.titleView = searchBar; [self updateBarButtonItems]; // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. // // Details: // // When the search UI is activated, both the SearchField and the ConversationVC // have the resultsBar as their inputAccessoryView. // // So when the SearchField is first responder, the ResultsBar is shown on top of the keyboard. // When the ConversationVC is first responder, the ResultsBar is shown at the bottom of the // screen. // // When the user swipes to dismiss the keyboard, trying to see more of the content while // searching, we want the ResultsBar to stay at the bottom of the screen - that is, we // want the ConversationVC to becomeFirstResponder. // // If the SearchField were a subview of ConversationVC.view, this would all be automatic, // as first responder status is percolated up the responder chain via `nextResponder`, which // basically travereses each superView, until you're at a rootView, at which point the next // responder is the ViewController which controls that View. // // However, because SearchField lives in the Navbar, it's "controlled" by the // NavigationController, not the ConversationVC. // // So here we stub the next responder on the navBar so that when the searchBar resigns // first responder, the ConversationVC will be in it's responder chain - keeeping the // ResultsBar on the bottom of the screen after dismissing the keyboard. if (![self.navigationController.navigationBar isKindOfClass:[OWSNavigationBar class]]) { OWSFailDebug(@"unexpected navigationController: %@", self.navigationController); return; } OWSNavigationBar *navBar = (OWSNavigationBar *)self.navigationController.navigationBar; navBar.stubbedNextResponder = self; } - (void)hideSearchUI { self.isShowingSearchUI = NO; self.navigationItem.titleView = self.headerView; [self updateBarButtonItems]; if (![self.navigationController.navigationBar isKindOfClass:[OWSNavigationBar class]]) { OWSFailDebug(@"unexpected navigationController: %@", self.navigationController); return; } OWSNavigationBar *navBar = (OWSNavigationBar *)self.navigationController.navigationBar; OWSAssertDebug(navBar.stubbedNextResponder == self); navBar.stubbedNextResponder = nil; // restore first responder to VC [self becomeFirstResponder]; if (@available(iOS 10, *)) { [self reloadInputViews]; } else { // We want to change the inputAccessoryView from SearchResults -> MessageInput // reloading too soon on an old iOS9 device caused the inputAccessoryView to go from // SearchResults -> MessageInput -> SearchResults dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self reloadInputViews]; }); } } #pragma mark ConversationSearchControllerDelegate - (void)didDismissSearchController:(UISearchController *)searchController { OWSLogVerbose(@""); OWSAssertIsOnMainThread(); [self hideSearchUI]; } - (void)conversationSearchController:(ConversationSearchController *)conversationSearchController didUpdateSearchResults:(nullable ConversationScreenSearchResultSet *)conversationScreenSearchResultSet { OWSAssertIsOnMainThread(); OWSLogInfo(@"conversationScreenSearchResultSet: %@", conversationScreenSearchResultSet.debugDescription); self.lastSearchedText = conversationScreenSearchResultSet.searchText; [UIView performWithoutAnimation:^{ [self.collectionView reloadItemsAtIndexPaths:self.collectionView.indexPathsForVisibleItems]; }]; if (conversationScreenSearchResultSet) { [BenchManager completeEventWithEventId:self.lastSearchedText]; } } - (void)conversationSearchController:(ConversationSearchController *)conversationSearchController didSelectMessageId:(NSString *)messageId { OWSLogDebug(@"messageId: %@", messageId); [self scrollToInteractionId:messageId]; [BenchManager completeEventWithEventId:[NSString stringWithFormat:@"Conversation Search Nav: %@", messageId]]; } - (void)scrollToInteractionId:(NSString *)interactionId { NSIndexPath *_Nullable indexPath = [self.conversationViewModel ensureLoadWindowContainsInteractionId:interactionId]; if (!indexPath) { OWSFailDebug(@"unable to find indexPath"); return; } [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES]; } #pragma mark - ConversationViewLayoutDelegate - (NSArray> *)layoutItems { return self.viewItems; } - (CGFloat)layoutHeaderHeight { return (self.showLoadMoreHeader ? kLoadMoreHeaderHeight : 0.f); } #pragma mark - ConversationInputToolbarDelegate - (void)sendButtonPressed { [BenchManager startEventWithTitle:@"Send Message" eventId:@"message-send"]; [BenchManager startEventWithTitle:@"Send Message milestone: clearTextMessageAnimated completed" eventId:@"fromSendUntil_clearTextMessageAnimated"]; [BenchManager startEventWithTitle:@"Send Message milestone: toggleDefaultKeyboard completed" eventId:@"fromSendUntil_toggleDefaultKeyboard"]; [self.inputToolbar hideMentionCandidateSelectionView]; [self tryToSendTextMessage:[self getSendText] updateKeyboardState:YES]; } - (void)tryToSendTextMessage:(NSString *)text updateKeyboardState:(BOOL)updateKeyboardState { OWSAssertIsOnMainThread(); __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { [self showUnblockConversationUI:^(BOOL isBlocked) { if (!isBlocked) { [weakSelf tryToSendTextMessage:text updateKeyboardState:NO]; } }]; return; } text = [text ows_stripped]; if (text.length < 1) { return; } SNVisibleMessage *message = [SNVisibleMessage new]; message.sentTimestamp = [NSDate millisecondTimestamp]; message.text = text; message.quote = [SNQuote from:self.inputToolbar.quotedReply]; OWSLinkPreviewDraft *linkPreviewDraft = self.inputToolbar.linkPreviewDraft; TSThread *thread = self.thread; TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread]; [self.conversationViewModel appendUnsavedOutgoingTextMessage:tsMessage]; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { message.linkPreview = [SNLinkPreview from:linkPreviewDraft using:transaction]; } completion:^{ // Completes on the main queue tsMessage.linkPreview = [OWSLinkPreview from:message.linkPreview]; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [tsMessage saveWithTransaction:transaction]; }]; [LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [SNMessageSender send:message withAttachments:@[] inThread:thread usingTransaction:transaction]; [thread setDraft:@"" transaction:transaction]; }]; [self messageWasSent:tsMessage]; [self.inputToolbar clearTextMessageAnimated:YES]; [self resetMentions]; dispatch_async(dispatch_get_main_queue(), ^{ [[weakSelf inputToolbar] toggleDefaultKeyboard]; }); }]; } - (void)voiceMemoGestureDidStart { OWSAssertIsOnMainThread(); OWSLogInfo(@"voiceMemoGestureDidStart"); const CGFloat kIgnoreMessageSendDoubleTapDurationSeconds = 2.f; if (self.lastMessageSentDate && [[NSDate new] timeIntervalSinceDate:self.lastMessageSentDate] < kIgnoreMessageSendDoubleTapDurationSeconds) { // If users double-taps the message send button, the second tap can look like a // very short voice message gesture. We want to ignore such gestures. [self.inputToolbar cancelVoiceMemoIfNecessary]; [self.inputToolbar hideVoiceMemoUI:NO]; [self cancelRecordingVoiceMemo]; return; } [self.inputToolbar showVoiceMemoUI]; AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); [self requestRecordingVoiceMemo]; } - (void)voiceMemoGestureDidComplete { OWSAssertIsOnMainThread(); OWSLogInfo(@""); [self.inputToolbar hideVoiceMemoUI:YES]; [self endRecordingVoiceMemo]; AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); } - (void)voiceMemoGestureDidLock { OWSAssertIsOnMainThread(); OWSLogInfo(@""); [self.inputToolbar lockVoiceMemoUI]; } - (void)voiceMemoGestureDidCancel { OWSAssertIsOnMainThread(); OWSLogInfo(@"voiceMemoGestureDidCancel"); [self.inputToolbar hideVoiceMemoUI:NO]; [self cancelRecordingVoiceMemo]; AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); } - (void)voiceMemoGestureDidUpdateCancelWithRatioComplete:(CGFloat)cancelAlpha { OWSAssertIsOnMainThread(); [self.inputToolbar setVoiceMemoUICancelAlpha:cancelAlpha]; } - (void)cancelVoiceMemo { OWSAssertIsOnMainThread(); [self.inputToolbar cancelVoiceMemoIfNecessary]; [self.inputToolbar hideVoiceMemoUI:NO]; [self cancelRecordingVoiceMemo]; } #pragma mark - Database Observation - (void)setIsUserScrolling:(BOOL)isUserScrolling { _isUserScrolling = isUserScrolling; [self autoLoadMoreIfNecessary]; } - (void)setIsViewVisible:(BOOL)isViewVisible { _isViewVisible = isViewVisible; [self updateCellsVisible]; } - (void)updateCellsVisible { BOOL isAppInBackground = CurrentAppContext().isInBackground; BOOL isCellVisible = self.isViewVisible && !isAppInBackground; for (ConversationViewCell *cell in self.collectionView.visibleCells) { cell.isCellVisible = isCellVisible; } } - (nullable NSIndexPath *)firstIndexPathAtViewHorizonTimestamp { if (!self.viewHorizonTimestamp) { return nil; } if (self.viewItems.count < 1) { return nil; } uint64_t viewHorizonTimestamp = self.viewHorizonTimestamp.unsignedLongLongValue; // Binary search for the first view item whose timestamp >= the "view horizon" timestamp. // We want to move "left" rightward, discarding interactions before this cutoff. // We want to move "right" leftward, discarding all-but-the-first interaction after this cutoff. // In the end, if we converge on an item _after_ this cutoff, it's the one we want. // If we converge on an item _before_ this cutoff, there was no interaction that fit our criteria. NSUInteger left = 0, right = self.viewItems.count - 1; while (left != right) { OWSAssertDebug(left < right); NSUInteger mid = (left + right) / 2; OWSAssertDebug(left <= mid); OWSAssertDebug(mid < right); id viewItem = self.viewItems[mid]; if (viewItem.interaction.timestampForUI >= viewHorizonTimestamp) { right = mid; } else { // This is an optimization; it also ensures that we converge. left = mid + 1; } } OWSAssertDebug(left == right); id viewItem = self.viewItems[left]; if (viewItem.interaction.timestampForUI >= viewHorizonTimestamp) { OWSLogInfo(@"firstIndexPathAtViewHorizonTimestamp: %zd / %zd", left, self.viewItems.count); return [NSIndexPath indexPathForRow:(NSInteger) left inSection:0]; } else { OWSLogInfo(@"firstIndexPathAtViewHorizonTimestamp: none / %zd", self.viewItems.count); return nil; } } #pragma mark - ConversationCollectionViewDelegate - (void)collectionViewWillChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize { OWSAssertIsOnMainThread(); } - (void)collectionViewDidChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize { OWSAssertIsOnMainThread(); if (oldSize.width != newSize.width) { [self resetForSizeOrOrientationChange]; } [self updateLastVisibleSortId]; } #pragma mark - View Items - (nullable id)viewItemForIndex:(NSInteger)index { if (index < 0 || index >= (NSInteger)self.viewItems.count) { OWSFailDebug(@"Invalid view item index: %lu", (unsigned long)index); return nil; } return self.viewItems[(NSUInteger)index]; } #pragma mark - UICollectionViewDataSource - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return (NSInteger)self.viewItems.count; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { id _Nullable viewItem = [self viewItemForIndex:indexPath.row]; ConversationViewCell *cell = [viewItem dequeueCellForCollectionView:self.collectionView indexPath:indexPath]; if (!cell) { OWSFailDebug(@"Could not dequeue cell."); return cell; } cell.viewItem = viewItem; cell.delegate = self; if ([cell isKindOfClass:[OWSMessageCell class]]) { OWSMessageCell *messageCell = (OWSMessageCell *)cell; messageCell.messageBubbleView.delegate = self; } cell.conversationStyle = self.conversationStyle; [cell loadForDisplay]; // TODO: Confirm with nancy if this will work. NSString *cellName = [NSString stringWithFormat:@"interaction.%@", NSUUID.UUID.UUIDString]; cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, cellName); return cell; } #pragma mark - UICollectionViewDelegate - (void)collectionView:(UICollectionView *)collectionView willDisplayCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath { OWSAssertDebug([cell isKindOfClass:[ConversationViewCell class]]); ConversationViewCell *conversationViewCell = (ConversationViewCell *)cell; conversationViewCell.isCellVisible = YES; } - (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(nonnull UICollectionViewCell *)cell forItemAtIndexPath:(nonnull NSIndexPath *)indexPath { OWSAssertDebug([cell isKindOfClass:[ConversationViewCell class]]); ConversationViewCell *conversationViewCell = (ConversationViewCell *)cell; conversationViewCell.isCellVisible = NO; } // We use this hook to ensure scroll state continuity. As the collection // view's content size changes, we want to keep the same cells in view. - (CGPoint)collectionView:(UICollectionView *)collectionView targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset { if (@available(iOS 13, *)) { } else { if (self.menuActionsViewController != nil) { NSValue *_Nullable contentOffset = [self contentOffsetForMenuActionInteraction]; if (contentOffset != nil) { return contentOffset.CGPointValue; } } } if (self.scrollContinuity == kScrollContinuityBottom && self.lastKnownDistanceFromBottom) { NSValue *_Nullable contentOffset = [self contentOffsetForLastKnownDistanceFromBottom:self.lastKnownDistanceFromBottom.floatValue]; if (contentOffset) { proposedContentOffset = contentOffset.CGPointValue; } } return proposedContentOffset; } // We use this hook to ensure scroll state continuity. As the collection // view's content size changes, we want to keep the same cells in view. - (nullable NSValue *)contentOffsetForLastKnownDistanceFromBottom:(CGFloat)lastKnownDistanceFromBottom { // Adjust the content offset to reflect the "last known" distance // from the bottom of the content. CGFloat contentOffsetYBottom = self.maxContentOffsetY; CGFloat contentOffsetY = contentOffsetYBottom - MAX(0, lastKnownDistanceFromBottom); CGFloat minContentOffsetY; if (@available(iOS 11, *)) { minContentOffsetY = -self.collectionView.safeAreaInsets.top; } else { minContentOffsetY = 0.f; } contentOffsetY = MAX(minContentOffsetY, contentOffsetY); return [NSValue valueWithCGPoint:CGPointMake(0, contentOffsetY)]; } #pragma mark - Scroll State - (BOOL)isScrolledToBottom { CGFloat distanceFromBottom = self.safeDistanceFromBottom; const CGFloat kIsAtBottomTolerancePts = 5; BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts; return isScrolledToBottom; } - (CGFloat)safeDistanceFromBottom { // This is a bit subtle. // // The _wrong_ way to determine if we're scrolled to the bottom is to // measure whether the collection view's content is "near" the bottom edge // of the collection view. This is wrong because the collection view // might not have enough content to fill the collection view's bounds // _under certain conditions_ (e.g. with the keyboard dismissed). // // What we're really interested in is something a bit more subtle: // "Is the scroll view scrolled down as far as it can, "at rest". // // To determine that, we find the appropriate "content offset y" if // the scroll view were scrolled down as far as possible. IFF the // actual "content offset y" is "near" that value, we return YES. CGFloat maxContentOffsetY = self.maxContentOffsetY; CGFloat distanceFromBottom = maxContentOffsetY - self.collectionView.contentOffset.y; return distanceFromBottom; } - (CGFloat)maxContentOffsetY { CGFloat contentHeight = self.safeContentHeight; UIEdgeInsets adjustedContentInset; if (@available(iOS 11, *)) { adjustedContentInset = self.collectionView.adjustedContentInset; } else { adjustedContentInset = self.collectionView.contentInset; } // Note the usage of MAX() to handle the case where there isn't enough // content to fill the collection view at its current size. CGFloat maxContentOffsetY = contentHeight + adjustedContentInset.bottom - self.collectionView.bounds.size.height; return maxContentOffsetY; } #pragma mark - Toast - (void)presentMissingQuotedReplyToast { OWSLogInfo(@""); NSString *toastText = NSLocalizedString(@"QUOTED_REPLY_ORIGINAL_MESSAGE_DELETED", @"Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of " @"the message was since deleted."); ToastController *toastController = [[ToastController alloc] initWithText:toastText]; CGFloat bottomInset = kToastInset + self.collectionView.contentInset.bottom + self.view.layoutMargins.bottom; [toastController presentToastViewFromBottomOfView:self.view inset:bottomInset]; } - (void)presentRemotelySourcedQuotedReplyToast { OWSLogInfo(@""); NSString *toastText = NSLocalizedString(@"QUOTED_REPLY_ORIGINAL_MESSAGE_REMOTELY_SOURCED", @"Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of " @"the message didn't exist when the quote was received."); ToastController *toastController = [[ToastController alloc] initWithText:toastText]; CGFloat bottomInset = kToastInset + self.collectionView.contentInset.bottom + self.view.layoutMargins.bottom; [toastController presentToastViewFromBottomOfView:self.view inset:bottomInset]; } #pragma mark - - (void)presentViewController:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^__nullable)(void))completion { // Ensure that we are first responder before presenting other views. // This ensures that the input toolbar will be restored after the // presented view is dismissed. if (![self isFirstResponder]) { [self becomeFirstResponder]; } [super presentViewController:viewController animated:animated completion:completion]; } #pragma mark - ConversationViewModelDelegate - (void)conversationViewModelWillUpdate { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); // HACK to work around radar #28167779 // "UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout" // more: https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue // This was our #2 crash, and much exacerbated by the refactoring somewhere between 2.6.2.0-2.6.3.8 // // NOTE: It's critical we do this before beginLongLivedReadTransaction. // We want to relayout our contents using the old message mappings and // view items before they are updated. [self.collectionView layoutIfNeeded]; // ENDHACK to work around radar #28167779 } - (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate { OWSAssertIsOnMainThread(); OWSAssertDebug(conversationUpdate); OWSAssertDebug(self.conversationViewModel); if (!self.viewLoaded) { // It's safe to ignore updates before the view loads; // viewWillAppear will call resetContentAndLayout. return; } [self dismissMenuActionsIfNecessary]; if (self.isGroupConversation) { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { [self.thread reloadWithTransaction:transaction]; }]; } [self updateDisappearingMessagesConfiguration]; if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Minor) { return; } else if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Reload) { [self resetContentAndLayout]; [self updateLastVisibleSortId]; return; } OWSAssertDebug(conversationUpdate.conversationUpdateType == ConversationUpdateType_Diff); OWSAssertDebug(conversationUpdate.updateItems); // We want to auto-scroll to the bottom of the conversation // if the user is inserting new interactions. __block BOOL scrollToBottom = NO; self.scrollContinuity = ([self isScrolledToBottom] ? kScrollContinuityBottom : kScrollContinuityTop); void (^batchUpdates)(void) = ^{ OWSAssertIsOnMainThread(); const NSUInteger section = 0; BOOL hasInserted = NO, hasUpdated = NO; for (ConversationUpdateItem *updateItem in conversationUpdate.updateItems) { switch (updateItem.updateItemType) { case ConversationUpdateItemType_Delete: { // Always perform deletes before inserts and updates. OWSAssertDebug(!hasInserted && !hasUpdated); [self.collectionView deleteItemsAtIndexPaths:@[ [NSIndexPath indexPathForRow:(NSInteger)updateItem.oldIndex inSection:section] ]]; break; } case ConversationUpdateItemType_Insert: { // Always perform inserts before updates. OWSAssertDebug(!hasUpdated); [self.collectionView insertItemsAtIndexPaths:@[ [NSIndexPath indexPathForRow:(NSInteger)updateItem.newIndex inSection:section] ]]; hasInserted = YES; id viewItem = updateItem.viewItem; OWSAssertDebug(viewItem); if ([viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; if (!outgoingMessage.isFromLinkedDevice) { scrollToBottom = YES; } } break; } case ConversationUpdateItemType_Update: { [self.collectionView reloadItemsAtIndexPaths:@[ [NSIndexPath indexPathForRow:(NSInteger)updateItem.oldIndex inSection:section] ]]; hasUpdated = YES; break; } } } }; BOOL shouldAnimateUpdates = conversationUpdate.shouldAnimateUpdates; void (^batchUpdatesCompletion)(BOOL) = ^(BOOL finished) { OWSAssertIsOnMainThread(); if (!finished) { OWSLogInfo(@"performBatchUpdates did not finish"); } [self updateLastVisibleSortId]; if (scrollToBottom) { [self scrollToBottomAnimated:NO]; } // Try to update the lastKnownDistanceFromBottom; the content size may have changed. [self updateLastKnownDistanceFromBottom]; }; @try { if (shouldAnimateUpdates) { [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; } else { // HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a // UIKit Crash like: // // *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:], // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760 // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While // preparing update a visible view at {length = 2, path = 0 - 142} // wasn't found in the current data model and was not in an update animation. This is an internal // error.' // // I'm unclear if this is a bug in UIKit, or if we're doing something crazy in // ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the // conversation. See `DebugUIMessages#thrashCellsInThread:` [UIView animateWithDuration:0.0 animations:^{ [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; if (scrollToBottom) { [self scrollToBottomAnimated:NO]; } [BenchManager completeEventWithEventId:@"message-send"]; }]; } } @catch (NSException *exception) { OWSFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", exception.description, exception.name, exception.reason, exception.userInfo); for (ConversationUpdateItem *updateItem in conversationUpdate.updateItems) { switch (updateItem.updateItemType) { case ConversationUpdateItemType_Delete: OWSLogWarn(@"ConversationUpdateItemType_Delete class: %@, itemId: %@, oldIndex: %lu, " @"newIndex: %lu", [updateItem.viewItem class], updateItem.viewItem.itemId, (unsigned long)updateItem.oldIndex, (unsigned long)updateItem.newIndex); break; case ConversationUpdateItemType_Insert: OWSLogWarn(@"ConversationUpdateItemType_Insert class: %@, itemId: %@, oldIndex: %lu, " @"newIndex: %lu", [updateItem.viewItem class], updateItem.viewItem.itemId, (unsigned long)updateItem.oldIndex, (unsigned long)updateItem.newIndex); break; case ConversationUpdateItemType_Update: OWSLogWarn(@"ConversationUpdateItemType_Update class: %@, itemId: %@, oldIndex: %lu, " @"newIndex: %lu", [updateItem.viewItem class], updateItem.viewItem.itemId, (unsigned long)updateItem.oldIndex, (unsigned long)updateItem.newIndex); break; } } @throw exception; } self.lastReloadDate = [NSDate new]; } - (void)conversationViewModelWillLoadMoreItems { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); // We want to restore the current scroll state after we update the range, update // the dynamic interactions and re-layout. Here we take a "before" snapshot. self.scrollDistanceToBottomSnapshot = self.safeContentHeight - self.collectionView.contentOffset.y; } - (void)conversationViewModelDidLoadMoreItems { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); [self.layout prepareLayout]; self.collectionView.contentOffset = CGPointMake(0, self.safeContentHeight - self.scrollDistanceToBottomSnapshot); } - (void)conversationViewModelDidLoadPrevPage { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); [self scrollToUnreadIndicatorAnimated]; } - (void)conversationViewModelRangeDidChange { OWSAssertIsOnMainThread(); if (!self.conversationViewModel) { return; } [self updateShowLoadMoreHeader]; } - (void)conversationViewModelDidReset { OWSAssertIsOnMainThread(); // Scroll to bottom to get view back to a known good state. [self scrollToBottomAnimated:NO]; } #pragma mark - Orientation - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { OWSAssertIsOnMainThread(); [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; // The "message actions" window tries to pin the message // in the content of this view. It's easier to dismiss the // "message actions" window when the device changes orientation // than to try to ensure this works in that case. if (OWSWindowManager.sharedManager.isPresentingMenuActions) { [self dismissMenuActions]; } // Snapshot the "last visible row". NSIndexPath *_Nullable lastVisibleIndexPath = self.lastVisibleIndexPath; __weak ConversationViewController *weakSelf = self; [coordinator animateAlongsideTransition:^(id context) { if (lastVisibleIndexPath) { [self.collectionView scrollToItemAtIndexPath:lastVisibleIndexPath atScrollPosition:UICollectionViewScrollPositionBottom animated:NO]; } } completion:^(id context) { ConversationViewController *strongSelf = weakSelf; if (!strongSelf) { return; } // When transition animation is complete, update layout to reflect // new size. [strongSelf resetForSizeOrOrientationChange]; [strongSelf updateInputBarLayout]; if (self.menuActionsViewController != nil) { [self scrollToMenuActionInteraction:NO]; } else if (lastVisibleIndexPath) { [strongSelf.collectionView scrollToItemAtIndexPath:lastVisibleIndexPath atScrollPosition:UICollectionViewScrollPositionBottom animated:NO]; } }]; } - (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; [self ensureBannerState]; [self updateBarButtonItems]; } - (void)resetForSizeOrOrientationChange { self.scrollContinuity = kScrollContinuityBottom; self.conversationStyle.viewWidth = self.collectionView.width; // Evacuate cached cell sizes. for (id viewItem in self.viewItems) { [viewItem clearCachedLayoutState]; } [self.collectionView.collectionViewLayout invalidateLayout]; [self.collectionView reloadData]; if (self.viewHasEverAppeared) { // Try to update the lastKnownDistanceFromBottom; the content size may have changed. [self updateLastKnownDistanceFromBottom]; } [self updateInputBarLayout]; } - (void)viewSafeAreaInsetsDidChange { [super viewSafeAreaInsetsDidChange]; [self updateInputBarLayout]; } - (void)updateInputBarLayout { UIEdgeInsets safeAreaInsets = UIEdgeInsetsZero; if (@available(iOS 11, *)) { safeAreaInsets = self.view.safeAreaInsets; } [self.inputToolbar updateLayoutWithSafeAreaInsets:safeAreaInsets]; // Scroll button layout depends on input toolbar size. [self updateScrollDownButtonLayout]; } - (void)handleEncryptingMessageNotification:(NSNotification *)notification { NSNumber *timestamp = (NSNumber *)notification.object; [self setProgressIfNeededTo:0.25f forMessageWithTimestamp:timestamp]; } - (void)handleCalculatingMessagePoWNotification:(NSNotification *)notification { NSNumber *timestamp = (NSNumber *)notification.object; [self setProgressIfNeededTo:0.50f forMessageWithTimestamp:timestamp]; } - (void)handleMessageSendingNotification:(NSNotification *)notification { NSNumber *timestamp = (NSNumber *)notification.object; [self setProgressIfNeededTo:0.75f forMessageWithTimestamp:timestamp]; } - (void)handleMessageSentNotification:(NSNotification *)notification { NSNumber *timestamp = (NSNumber *)notification.object; [self setProgressIfNeededTo:1.0f forMessageWithTimestamp:timestamp]; [self.handledMessageTimestamps addObject:timestamp]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void) { [self hideProgressIndicatorViewForMessageWithTimestamp:timestamp]; }); } - (void)handleMessageSendingFailedNotification:(NSNotification *)notification { NSNumber *timestamp = (NSNumber *)notification.object; self.progressIndicatorView.progressTintColor = LKColors.destructive; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void) { [self hideProgressIndicatorViewForMessageWithTimestamp:timestamp]; }); } - (void)setProgressIfNeededTo:(float)progress forMessageWithTimestamp:(NSNumber *)timestamp { if ([self.handledMessageTimestamps containsObject:timestamp]) { return; } dispatch_async(dispatch_get_main_queue(), ^{ __block TSInteraction *targetInteraction; [LKStorage readWithBlock:^(YapDatabaseReadTransaction *transaction) { [self.thread enumerateInteractionsWithTransaction:transaction usingBlock:^(TSInteraction *interaction, YapDatabaseReadTransaction *t) { if (interaction.timestampForUI == timestamp.unsignedLongLongValue) { targetInteraction = interaction; } }]; }]; if (targetInteraction == nil || targetInteraction.interactionType != OWSInteractionType_OutgoingMessage) { return; } NSString *hexEncodedPublicKey = targetInteraction.thread.contactIdentifier; if (hexEncodedPublicKey == nil) { return; } if (progress <= self.progressIndicatorView.progress) { return; } self.progressIndicatorView.alpha = 1; [self.progressIndicatorView setProgress:progress animated:YES]; }); } - (void)hideProgressIndicatorViewForMessageWithTimestamp:(NSNumber *)timestamp { __block TSInteraction *targetInteraction; [self.thread enumerateInteractionsUsingBlock:^(TSInteraction *interaction) { if (interaction.timestampForUI == timestamp.unsignedLongLongValue) { targetInteraction = interaction; } }]; if (targetInteraction == nil || targetInteraction.interactionType != OWSInteractionType_OutgoingMessage) { return; } dispatch_async(dispatch_get_main_queue(), ^{ [UIView animateWithDuration:0.25 animations:^{ self.progressIndicatorView.alpha = 0; } completion:^(BOOL finished) { [self.progressIndicatorView setProgress:0.0f]; self.progressIndicatorView.progressTintColor = LKColors.accent; }]; }); } @end NS_ASSUME_NONNULL_END