// // Copyright (c) 2020 Open Whisper Systems. All rights reserved. // #import "ConversationViewController.h" #import "AppDelegate.h" #import "BlockListUIUtils.h" #import "BlockListViewController.h" #import "ContactsViewHelper.h" #import "ConversationCollectionView.h" #import "ConversationInputToolbar.h" #import "ConversationScrollButton.h" #import "ConversationViewCell.h" #import "ConversationViewItem.h" #import "ConversationViewLayout.h" #import "ConversationViewModel.h" #import "DateUtil.h" #import "DebugUITableViewController.h" #import "FingerprintViewController.h" #import "OWSAudioPlayer.h" #import "OWSDisappearingMessagesJob.h" #import "OWSMath.h" #import "OWSMessageCell.h" #import "OWSMessageStickerView.h" #import "OWSMessageViewOnceView.h" #import "OWSSystemMessageCell.h" #import "Signal-Swift.h" #import "TSAttachmentPointer.h" #import "TSCall.h" #import "TSContactThread.h" #import "TSErrorMessage.h" #import "TSGroupThread.h" #import "TSIncomingMessage.h" #import "TSInfoMessage.h" #import "TSInvalidIdentityKeyErrorMessage.h" #import "UIFont+OWS.h" #import "UIViewController+Permissions.h" #import "ViewControllerUtils.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 #import #import @import SafariServices; NS_ASSUME_NONNULL_BEGIN static const CGFloat kToastInset = 10; typedef enum : NSUInteger { kMediaTypePicture, kMediaTypeVideo, } kMediaTypes; typedef enum : NSUInteger { kScrollContinuityBottom = 0, kScrollContinuityTop, } ScrollContinuity; #pragma mark - @interface ConversationViewController () @property (nonatomic, readonly) ConversationViewModel *conversationViewModel; @property (nonatomic, readonly) OWSAudioActivity *recordVoiceNoteAudioActivity; @property (nonatomic, readonly) UIView *bottomBar; @property (nonatomic, nullable) NSLayoutConstraint *bottomBarBottomConstraint; @property (nonatomic, readonly) InputAccessoryViewPlaceholder *inputAccessoryPlaceholder; @property (nonatomic) BOOL isDismissingInteractively; @property (nonatomic, readonly) ConversationInputToolbar *inputToolbar; @property (nonatomic, readonly) ConversationCollectionView *collectionView; @property (nonatomic, readonly) ConversationViewLayout *layout; @property (nonatomic, readonly) ConversationStyle *conversationStyle; @property (nonatomic, nullable) AVAudioRecorder *audioRecorder; @property (nonatomic, nullable) OWSAudioPlayer *audioAttachmentPlayer; @property (nonatomic, nullable) NSUUID *voiceMessageUUID; @property (nonatomic, nullable) NSTimer *readTimer; @property (nonatomic) BOOL isMarkingAsRead; @property (nonatomic) NSCache *cellMediaCache; @property (nonatomic) ConversationHeaderView *headerView; @property (nonatomic, nullable) UIView *bannerView; @property (nonatomic) ConversationViewAction actionOnOpen; @property (nonatomic, getter=isInPreviewPlatter) BOOL inPreviewPlatter; @property (nonatomic) BOOL userHasScrolled; @property (nonatomic, nullable) NSDate *lastMessageSentDate; @property (nonatomic, readonly) BOOL showLoadOlderHeader; @property (nonatomic, readonly) BOOL showLoadNewerHeader; @property (nonatomic) uint64_t lastSortIdMarkedRead; @property (nonatomic) BOOL isUserScrolling; @property (nonatomic) BOOL isWaitingForDeceleration; @property (nonatomic, nullable) ConversationScrollState *scrollStateBeforeLoadingMore; @property (nonatomic) ConversationScrollButton *scrollDownButton; @property (nonatomic) BOOL isHidingScrollDownButton; @property (nonatomic) ConversationScrollButton *scrollToNextMentionButton; @property (nonatomic) BOOL isHidingScrollToNextMentionButton; @property (nonatomic) BOOL isViewCompletelyAppeared; @property (nonatomic) BOOL isViewVisible; @property (nonatomic) BOOL shouldAnimateKeyboardChanges; @property (nonatomic) BOOL viewHasEverAppeared; @property (nonatomic) BOOL hasViewWillAppearOccurred; @property (nonatomic) NSUInteger unreadMessageCount; @property (nonatomic, nullable) NSArray *unreadMentionMessages; @property (nonatomic, nullable) NSNumber *viewHorizonTimestamp; @property (nonatomic) ContactShareViewHelper *contactShareViewHelper; @property (nonatomic) NSTimer *reloadTimer; @property (nonatomic, nullable) NSDate *lastReloadDate; @property (nonatomic, nullable) NSNumber *lastKnownDistanceFromBottom; @property (nonatomic) ScrollContinuity scrollContinuity; @property (nonatomic, nullable) NSTimer *scrollUpdateTimer; @property (nonatomic, readonly) ConversationSearchController *searchController; @property (nonatomic, nullable) NSString *lastSearchedText; @property (nonatomic, nullable) UIView *requestView; @property (nonatomic) UITapGestureRecognizer *tapGestureRecognizer; @property (nonatomic, nullable) MessageActionsViewController *messageActionsViewController; @property (nonatomic) CGFloat messageActionsExtraContentInsetPadding; @property (nonatomic) CGPoint messageActionsOriginalContentOffset; @property (nonatomic) CGFloat messageActionsOriginalFocusY; @property (nonatomic, nullable, weak) ReactionsDetailSheet *reactionsDetailSheet; @property (nonatomic) ConversationUIMode uiMode; @property (nonatomic) MessageActionsToolbar *selectionToolbar; @property (nonatomic, readonly) SelectionHighlightView *selectionHighlightView; @property (nonatomic) NSDictionary> *selectedItems; @property (nonatomic) UIPanGestureRecognizer *panGestureRecognizer; @property (nonatomic) UILongPressGestureRecognizer *longPressGestureRecognizer; @property (nonatomic) DebouncedEvent *otherUsersProfileDidChangeEvent; @end #pragma mark - @implementation ConversationViewController - (instancetype)initWithThreadViewModel:(ThreadViewModel *)threadViewModel action:(ConversationViewAction)action focusMessageId:(nullable NSString *)focusMessageId { self = [super init]; // If we're not scrolling to a specific message AND we don't have // any unread messages, try and focus on the last visible interaction if (focusMessageId == nil && !threadViewModel.hasUnreadMessages) { focusMessageId = threadViewModel.lastVisibleInteraction.uniqueId; } [self.contactsViewHelper addObserver:self]; _contactShareViewHelper = [[ContactShareViewHelper alloc] initWithContactsManager:self.contactsManager]; _contactShareViewHelper.delegate = self; NSString *audioActivityDescription = [NSString stringWithFormat:@"%@ voice note", self.logTag]; _recordVoiceNoteAudioActivity = [[OWSAudioActivity alloc] initWithAudioDescription:audioActivityDescription behavior:OWSAudioBehavior_PlayAndRecord]; self.scrollContinuity = kScrollContinuityBottom; _inputAccessoryPlaceholder = [InputAccessoryViewPlaceholder new]; self.inputAccessoryPlaceholder.delegate = self; _threadViewModel = threadViewModel; self.actionOnOpen = action; _cellMediaCache = [NSCache new]; // Cache the cell media for ~24 cells. self.cellMediaCache.countLimit = 24; _conversationStyle = [[ConversationStyle alloc] initWithThread:self.thread]; _selectedItems = @{}; _conversationViewModel = [[ConversationViewModel alloc] initWithThread:self.thread focusMessageIdOnOpen:focusMessageId delegate:self]; _searchController = [[ConversationSearchController alloc] initWithThread:self.thread]; _searchController.delegate = self; // because the search bar view is hosted in the navigation bar, it's not in the CVC's responder // chain, and thus won't inherit our inputAccessoryView, so we manually set it here. OWSAssertDebug(self.inputAccessoryPlaceholder != nil); _searchController.uiSearchController.searchBar.inputAccessoryView = self.inputAccessoryPlaceholder; self.reloadTimer = [NSTimer weakTimerWithTimeInterval:1.f target:self selector:@selector(reloadTimerDidFire) userInfo:nil repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:self.reloadTimer forMode:NSRunLoopCommonModes]; [self updateV2GroupIfNecessary]; _longPressGestureRecognizer = [UILongPressGestureRecognizer new]; _panGestureRecognizer = [UIPanGestureRecognizer new]; __weak ConversationViewController *weakSelf = self; _otherUsersProfileDidChangeEvent = [[DebouncedEvent alloc] initWithMaxFrequencySeconds:1.0 onQueue:dispatch_get_main_queue() notifyBlock:^{ // Reload all cells if this is a group conversation, // since we may need to update the sender names on the messages. [weakSelf resetContentAndLayoutWithSneakyTransaction]; }]; return self; } #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:kNSNotificationNameIdentityStateDidChange 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:kNSNotificationNameOtherUsersProfileDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(profileWhitelistDidChange:) name:kNSNotificationNameProfileWhitelistDidChange object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(themeDidChange:) name:ThemeDidChangeNotification object:nil]; } - (BOOL)isGroupConversation { OWSAssertDebug(self.thread); return self.thread.isGroupThread; } - (TSThread *)thread { OWSAssertDebug(self.threadViewModel); return self.threadViewModel.threadRecord; } - (OWSDisappearingMessagesConfiguration *)disappearingMessagesConfiguration { OWSAssertDebug(self.threadViewModel); return self.threadViewModel.disappearingMessagesConfiguration; } - (void)otherUsersProfileDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); SignalServiceAddress *address = notification.userInfo[kNSNotificationKey_ProfileAddress]; OWSAssertDebug(address.isValid); if (address.isValid && [self.thread.recipientAddresses containsObject:address]) { if ([self.thread isKindOfClass:[TSContactThread class]]) { // update title with profile name [self updateNavigationTitle]; } // Reload all cells if this is a group conversation, // since we may need to update the sender names on the messages. // Use a DebounceEvent to de-bounce. if (self.isGroupConversation) { [self.otherUsersProfileDidChangeEvent requestNotify]; } } } - (void)profileWhitelistDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); // If profile whitelist just changed, we may want to hide a profile whitelist offer. SignalServiceAddress *_Nullable address = notification.userInfo[kNSNotificationKey_ProfileAddress]; NSData *_Nullable groupId = notification.userInfo[kNSNotificationKey_ProfileGroupId]; if (address.isValid && [self.thread.recipientAddresses containsObject:address]) { [self ensureBannerState]; [self showMessageRequestDialogIfRequired]; } else if (groupId.length > 0 && self.thread.isGroupThread) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; if ([groupThread.groupModel.groupId isEqualToData:groupId]) { [self ensureBannerState]; [self showMessageRequestDialogIfRequired]; } } } - (void)blockListDidChange:(id)notification { OWSAssertIsOnMainThread(); [self ensureBannerState]; } - (void)identityStateDidChange:(NSNotification *)notification { OWSAssertIsOnMainThread(); [self updateNavigationBarSubtitleLabel]; [self ensureBannerState]; } - (void)themeDidChange:(NSNotification *)notification { [self applyTheme]; } - (void)setInPreviewPlatter:(BOOL)inPreviewPlatter { if (_inPreviewPlatter != inPreviewPlatter) { _inPreviewPlatter = inPreviewPlatter; [self configureScrollDownButtons]; } } - (void)peekSetup { [self setInPreviewPlatter:YES]; self.actionOnOpen = ConversationViewActionNone; } - (void)popped { [self setInPreviewPlatter:NO]; [self updateInputVisibility]; } - (void)updateV2GroupIfNecessary { if (!self.thread.isGroupV2Thread) { return; } TSGroupThread *groupThread = (TSGroupThread *)self.thread; // Try to update the v2 group to latest from the service. // This will help keep us in sync if we've missed any group updates, etc. [self.groupV2UpdatesObjc tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithThrottling:groupThread]; } - (void)dealloc { [self.reloadTimer invalidate]; [self.scrollUpdateTimer invalidate]; } - (void)reloadTimerDidFire { OWSAssertIsOnMainThread(); if (self.isUserScrolling || !self.isViewCompletelyAppeared || !self.isViewVisible || !CurrentAppContext().isAppForegroundAndActive || !self.viewHasEverAppeared || self.isPresentingMessageActions) { 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 resetContentAndLayoutWithSneakyTransaction]; } - (BOOL)userLeftGroup { if (![self.thread isKindOfClass:[TSGroupThread class]]) { return NO; } TSGroupThread *groupThread = (TSGroupThread *)self.thread; return !groupThread.isLocalUserFullMember; } - (BOOL)isLocalUserRequestingMember { if ([self.thread isKindOfClass:[TSGroupThread class]]) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; return groupThread.isLocalUserRequestingMember; } else { return NO; } } - (void)updateInputVisibility { if ([self isInPreviewPlatter]) { self.inputToolbar.hidden = YES; [self dismissKeyBoard]; return; } 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 createGestureRecognizers]; [self registerCellClasses]; [self createConversationScrollButtons]; [self createHeaderViews]; [self updateLeftBarItem]; [self addNotificationListeners]; [self applyTheme]; [self.conversationViewModel viewDidLoad]; } - (void)createContents { OWSAssertDebug(self.conversationStyle); _layout = [[ConversationViewLayout alloc] initWithConversationStyle:self.conversationStyle]; self.conversationStyle.viewWidth = floor(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; self.collectionView.allowsMultipleSelection = YES; // 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]; [self.collectionView applyScrollViewInsetsFix]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _collectionView); self.tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(dismissKeyBoard)]; [self.collectionView addGestureRecognizer:self.tapGestureRecognizer]; _bottomBar = [UIView containerView]; [self.view addSubview:self.bottomBar]; self.bottomBarBottomConstraint = [self.bottomBar autoPinEdgeToSuperviewEdge:ALEdgeBottom]; [self.bottomBar autoPinWidthToSuperview]; _selectionToolbar = [self buildSelectionToolbar]; _selectionHighlightView = [SelectionHighlightView new]; self.selectionHighlightView.userInteractionEnabled = NO; [self.collectionView addSubview:self.selectionHighlightView]; // Selection Highlight View Layout: // // We want the highlight view to have the same frame as the collectionView // but [selectionHighlightView autoPinEdgesToSuperviewEdges] undesirably // affects the size of the collection view. To witness this, you can longpress // on an item and see the collectionView offsets change. Pinning to just the // top left and the same height/width achieves the desired results without // the negative side effects. [self.selectionHighlightView autoPinEdgeToSuperviewEdge:ALEdgeTop]; [self.selectionHighlightView autoPinEdgeToSuperviewEdge:ALEdgeLeading]; [self.selectionHighlightView autoMatchDimension:ALDimensionWidth toDimension:ALDimensionWidth ofView:self.collectionView]; [self.selectionHighlightView autoMatchDimension:ALDimensionHeight toDimension:ALDimensionHeight ofView:self.collectionView]; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { [self updateShowLoadMoreHeadersWithTransaction:transaction]; }]; } - (BOOL)canBecomeFirstResponder { return YES; } - (BOOL)becomeFirstResponder { BOOL result = [super becomeFirstResponder]; // If we become the first responder, it means that the // input toolbar is not the first responder. As such, // we should clear out the desired keyboard since an // interactive dismissal may have just occured and we // need to update the UI to reflect that fact. We don't // actually ever want to be the first responder, so resign // immediately. We just want to know when the responder // state of our children changed and that information is // conveniently bubbled up the responder chain. if (result) { [self resignFirstResponder]; [self.inputToolbar clearDesiredKeyboard]; } return result; } - (nullable UIView *)inputAccessoryView { return self.inputAccessoryPlaceholder; } - (nullable NSString *)textInputContextIdentifier { return self.thread.uniqueId; } - (void)registerCellClasses { [self.collectionView registerClass:[OWSSystemMessageCell class] forCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSTypingIndicatorCell class] forCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSThreadDetailsCell class] forCellWithReuseIdentifier:[OWSThreadDetailsCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSUnreadIndicatorCell class] forCellWithReuseIdentifier:[OWSUnreadIndicatorCell cellReuseIdentifier]]; [self.collectionView registerClass:[OWSDateHeaderCell class] forCellWithReuseIdentifier:[OWSDateHeaderCell cellReuseIdentifier]]; [self.collectionView registerClass:LoadMoreMessagesView.class forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:LoadMoreMessagesView.reuseIdentifier]; [self.collectionView registerClass:LoadMoreMessagesView.class forSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:LoadMoreMessagesView.reuseIdentifier]; for (NSString *cellReuseIdentifier in OWSMessageCell.allCellReuseIdentifiers) { [self.collectionView registerClass:[OWSMessageCell class] forCellWithReuseIdentifier: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.isWaitingForDeceleration = NO; [self saveDraft]; [self markVisibleMessagesAsRead]; [self.cellMediaCache removeAllObjects]; [self cancelReadTimer]; [self dismissPresentedViewControllerIfNecessary]; [self saveLastVisibleSortIdAndOnScreenPercentage]; [self dismissKeyBoard]; } - (void)applicationDidBecomeActive:(NSNotification *)notification { [self startReadTimer]; } - (void)dismissPresentedViewControllerIfNecessary { UIViewController *_Nullable presentedViewController = self.presentedViewController; if (!presentedViewController) { OWSLogDebug(@"presentedViewController was nil"); return; } if ([presentedViewController isKindOfClass:[ActionSheetController class]] || [presentedViewController isKindOfClass:[UIAlertController class]]) { OWSLogDebug(@"dismissing presentedViewController: %@", presentedViewController); [self dismissViewControllerAnimated:NO completion:nil]; return; } } - (void)viewWillAppear:(BOOL)animated { OWSLogDebug(@"viewWillAppear"); [self ensureBannerState]; [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 updateInputVisibility]; self.isViewVisible = YES; self.hasViewWillAppearOccurred = YES; // We should have already requested contact access at this point, so this should be a no-op // unless it ever becomes possible to load this VC without going via the ConversationListViewController. [self.contactsManager requestSystemContactsOnce]; [self updateBarButtonItems]; [self updateNavigationTitle]; [self resetContentAndLayoutWithSneakyTransaction]; // One-time work performed the first time we enter the view. if (!self.viewHasEverAppeared) { [self loadDraftInCompose]; [self scrollToDefaultPositionAnimated:NO]; } if (!self.viewHasEverAppeared) { [BenchManager completeEventWithEventId:[NSString stringWithFormat:@"presenting-conversation-%@", self.thread.uniqueId]]; } [self reloadBottomBar]; [self updateInputToolbarLayout]; // There are cases where we don't have a navigation controller, such as if we got here through 3d touch. // Make sure we only register the gesture interaction if it actually exists. This helps the swipe back // gesture work reliably without conflict with scrolling. if (self.navigationController) { [self.collectionView.panGestureRecognizer requireGestureRecognizerToFail:self.navigationController.interactivePopGestureRecognizer]; } [self showMessageRequestDialogIfRequired]; } - (void)resetContentAndLayoutWithSneakyTransaction { [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { [self resetContentAndLayoutWithTransaction:transaction]; }]; } - (void)resetContentAndLayoutWithTransaction:(SDSAnyReadTransaction *)transaction { self.scrollContinuity = kScrollContinuityBottom; // Avoid layout corrupt issues and out-of-date message subtitles. self.lastReloadDate = [NSDate new]; [self.conversationViewModel viewDidResetContentAndLayoutWithTransaction:transaction]; [self reloadData]; if (self.viewHasEverAppeared) { // Try to update the lastKnownDistanceFromBottom; the content size may have changed. [self updateLastKnownDistanceFromBottom]; } if (self.isShowingSelectionUI) { [self maintainSelectionAfterMappingChange]; [self updateSelectionHighlight]; } } - (void)setUserHasScrolled:(BOOL)userHasScrolled { _userHasScrolled = userHasScrolled; [self ensureBannerState]; } // Returns a collection of the group members who are "no longer verified". - (NSArray *)noLongerVerifiedAddresses { NSMutableArray *result = [NSMutableArray new]; for (SignalServiceAddress *address in self.thread.recipientAddresses) { if ([[OWSIdentityManager shared] verificationStateForAddress:address] == OWSVerificationStateNoLongerVerified) { [result addObject:address]; } } return [result copy]; } - (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; } NSMutableArray *banners = [NSMutableArray new]; NSArray *noLongerVerifiedAddresses = [self noLongerVerifiedAddresses]; if (noLongerVerifiedAddresses.count > 0) { NSString *message; if (noLongerVerifiedAddresses.count > 1) { message = NSLocalizedString(@"MESSAGES_VIEW_N_MEMBERS_NO_LONGER_VERIFIED", @"Indicates that more than one member of this group conversation is no longer verified."); } else { SignalServiceAddress *address = [noLongerVerifiedAddresses firstObject]; NSString *displayName = [self.contactsManager displayNameForAddress:address]; NSString *format = (self.isGroupConversation ? NSLocalizedString(@"MESSAGES_VIEW_1_MEMBER_NO_LONGER_VERIFIED_FORMAT", @"Indicates that one member of this group conversation is no longer " @"verified. Embeds {{user's name or phone number}}.") : NSLocalizedString(@"MESSAGES_VIEW_CONTACT_NO_LONGER_VERIFIED_FORMAT", @"Indicates that this 1:1 conversation is no longer verified. Embeds " @"{{user's name or phone number}}.")); message = [NSString stringWithFormat:format, displayName]; } [banners addObject:[self createBannerWithTitle:message bannerColor:UIColor.ows_accentRedColor tapSelector:@selector(noLongerVerifiedBannerViewWasTapped:)]]; } NSString *blockStateMessage = nil; if (self.isGroupConversation) { int blockedGroupMemberCount = [self blockedGroupMemberCount]; if (blockedGroupMemberCount == 1) { blockStateMessage = NSLocalizedString(@"MESSAGES_VIEW_GROUP_1_MEMBER_BLOCKED", @"Indicates that a single member of this group has been blocked."); } else if (blockedGroupMemberCount > 1) { blockStateMessage = [NSString stringWithFormat:NSLocalizedString(@"MESSAGES_VIEW_GROUP_N_MEMBERS_BLOCKED_FORMAT", @"Indicates that some members of this group has been blocked. Embeds " @"{{the number of blocked users in this group}}."), [OWSFormat formatInt:blockedGroupMemberCount]]; } } if (blockStateMessage) { [banners addObject:[self createBannerWithTitle:blockStateMessage bannerColor:UIColor.ows_accentRedColor tapSelector:@selector(blockBannerViewWasTapped:)]]; } NSUInteger pendingMemberRequestCount = self.pendingMemberRequestCount; if (pendingMemberRequestCount > 0 && self.canApprovePendingMemberRequests) { [banners addObject:[self createPendingJoinReuqestBannerWithCount:pendingMemberRequestCount]]; } if (banners.count < 1) { return; } UIStackView *bannerView = [[UIStackView alloc] initWithArrangedSubviews:banners]; bannerView.axis = UILayoutConstraintAxisVertical; bannerView.alignment = UIStackViewAlignmentFill; [self.view addSubview:bannerView]; [bannerView autoPinToTopLayoutGuideOfViewController:self withInset:0]; [bannerView autoPinEdgeToSuperviewEdge:ALEdgeLeading]; [bannerView autoPinEdgeToSuperviewEdge:ALEdgeTrailing]; UIView *bannerShadow = [UIView new]; bannerShadow.backgroundColor = Theme.backgroundColor; // Use a shadow to "pop" the indicator above the other views. bannerShadow.layer.shadowColor = [UIColor blackColor].CGColor; bannerShadow.layer.shadowOffset = CGSizeMake(0, 4); bannerShadow.layer.shadowRadius = 4.f; bannerShadow.layer.shadowOpacity = 0.15f; [bannerShadow addRedBorder]; [bannerView addSubview:bannerShadow]; [bannerShadow autoPinEdgesToSuperviewEdges]; [bannerView sendSubviewToBack:bannerShadow]; [self.view layoutSubviews]; self.bannerView = bannerView; } - (NSUInteger)pendingMemberRequestCount { if ([self.thread isKindOfClass:[TSGroupThread class]]) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; return groupThread.groupMembership.requestingMembers.count; } else { return 0; } } - (BOOL)canApprovePendingMemberRequests { if ([self.thread isKindOfClass:[TSGroupThread class]]) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; return groupThread.isLocalUserFullMemberAndAdministrator; } else { return NO; } } - (UIView *)createBannerWithTitle:(NSString *)title bannerColor:(UIColor *)bannerColor tapSelector:(SEL)tapSelector { OWSAssertDebug(title.length > 0); OWSAssertDebug(bannerColor); UIView *bannerView = [UIView containerView]; bannerView.backgroundColor = bannerColor; UILabel *label = [self buildBannerLabel:title]; 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"); return bannerView; } - (UILabel *)buildBannerLabel:(NSString *)title { UILabel *label = [UILabel new]; label.font = [UIFont ows_dynamicTypeSubheadlineClampedFont].ows_semibold; label.text = title; label.textColor = [UIColor whiteColor]; label.numberOfLines = 0; label.lineBreakMode = NSLineBreakByWordWrapping; return label; } - (UIView *)createPendingJoinReuqestBannerWithCount:(NSUInteger)pendingMemberRequestCount { OWSAssertDebug(pendingMemberRequestCount > 0); NSString *format = NSLocalizedString(@"PENDING_GROUP_MEMBERS_REQUEST_BANNER_FORMAT", @"Format for banner indicating that there are pending member requests to join the group. Embeds {{ the number " @"of pending member requests }}."); NSString *title = [NSString stringWithFormat:format, [OWSFormat formatUInt:pendingMemberRequestCount]]; UILabel *label = [self buildBannerLabel:title]; label.font = [UIFont ows_dynamicTypeSubheadlineClampedFont]; __weak ConversationViewController *weakSelf = self; OWSButton *dismissButton = [[OWSButton alloc] initWithTitle:CommonStrings.dismissButton block:^{ [weakSelf hidePendingMemberRequests]; }]; dismissButton.titleLabel.font = [UIFont ows_dynamicTypeSubheadlineClampedFont].ows_semibold; NSString *viewRequestsLabel = NSLocalizedString(@"PENDING_GROUP_MEMBERS_REQUEST_BANNER_VIEW_REQUESTS", @"Label for the 'view requests' button in the pending member requests banner."); OWSButton *viewRequestsButton = [[OWSButton alloc] initWithTitle:viewRequestsLabel block:^{ [weakSelf viewMemberRequests]; }]; viewRequestsButton.titleLabel.font = [UIFont ows_dynamicTypeSubheadlineClampedFont].ows_semibold; UIStackView *buttonRow = [[UIStackView alloc] initWithArrangedSubviews:@[ [UIView hStretchingSpacer], dismissButton, viewRequestsButton, ]]; buttonRow.axis = UILayoutConstraintAxisHorizontal; buttonRow.spacing = 24; UIStackView *bannerView = [[UIStackView alloc] initWithArrangedSubviews:@[ label, buttonRow, ]]; bannerView.axis = UILayoutConstraintAxisVertical; bannerView.alignment = UIStackViewAlignmentFill; bannerView.spacing = 10; bannerView.layoutMargins = UIEdgeInsetsMake(14, 16, 14, 16); [bannerView setLayoutMarginsRelativeArrangement:YES]; [bannerView addBackgroundViewWithBackgroundColor:UIColor.ows_accentBlueColor]; bannerView.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"pending_group_request_banner"); return bannerView; } - (void)hidePendingMemberRequests { } - (void)viewMemberRequests { [self showConversationSettingsAndShowMemberRequests]; } - (void)blockBannerViewWasTapped:(UIGestureRecognizer *)sender { if (sender.state != UIGestureRecognizerStateRecognized) { return; } if ([self isBlockedConversation]) { // If this a blocked conversation, offer to unblock. [self showUnblockConversationUI:nil]; } else if (self.isGroupConversation) { // If this a group conversation with at least one blocked member, // Show the block list view. int blockedGroupMemberCount = [self blockedGroupMemberCount]; if (blockedGroupMemberCount > 0) { BlockListViewController *vc = [[BlockListViewController alloc] init]; [self.navigationController pushViewController:vc animated:YES]; } } } - (void)noLongerVerifiedBannerViewWasTapped:(UIGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateRecognized) { NSArray *noLongerVerifiedAddresses = [self noLongerVerifiedAddresses]; if (noLongerVerifiedAddresses.count < 1) { return; } BOOL hasMultiple = noLongerVerifiedAddresses.count > 1; ActionSheetController *actionSheet = [[ActionSheetController alloc] initWithTitle:nil message:nil]; __weak ConversationViewController *weakSelf = self; ActionSheetAction *verifyAction = [[ActionSheetAction alloc] initWithTitle:(hasMultiple ? NSLocalizedString(@"VERIFY_PRIVACY_MULTIPLE", @"Label for button or row which allows users to verify the safety " @"numbers of multiple users.") : NSLocalizedString(@"VERIFY_PRIVACY", @"Label for button or row which allows users to verify the safety " @"number of another user.")) style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { [weakSelf showNoLongerVerifiedUI]; }]; [actionSheet addAction:verifyAction]; ActionSheetAction *dismissAction = [[ActionSheetAction alloc] initWithTitle:CommonStrings.dismissButton accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"dismiss") style:ActionSheetActionStyleCancel handler:^(ActionSheetAction *action) { [weakSelf resetVerificationStateToDefault]; }]; [actionSheet addAction:dismissAction]; [self dismissKeyBoard]; [self presentActionSheet:actionSheet]; } } - (void)resetVerificationStateToDefault { OWSAssertIsOnMainThread(); NSArray *noLongerVerifiedAddresses = [self noLongerVerifiedAddresses]; for (SignalServiceAddress *address in noLongerVerifiedAddresses) { OWSAssertDebug(address.isValid); OWSRecipientIdentity *_Nullable recipientIdentity = [[OWSIdentityManager shared] recipientIdentityForAddress:address]; OWSAssertDebug(recipientIdentity); NSData *identityKey = recipientIdentity.identityKey; OWSAssertDebug(identityKey.length > 0); if (identityKey.length < 1) { continue; } [OWSIdentityManager.shared setVerificationState:OWSVerificationStateDefault identityKey:identityKey address:address isUserInitiatedChange:YES]; } } - (void)showUnblockConversationUI:(nullable BlockActionCompletionBlock)completionBlock { self.userHasScrolled = NO; // To avoid "noisy" animations (hiding the keyboard before showing // the action sheet, re-showing it after), hide the keyboard before // showing the "unblock" action sheet. // // Unblocking is a rare interaction, so it's okay to leave the keyboard // hidden. [self dismissKeyBoard]; [BlockListUIUtils showUnblockThreadActionSheet:self.thread fromViewController:self completionBlock:completionBlock]; } - (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; for (SignalServiceAddress *address in groupThread.groupModel.groupMembers) { if ([self.blockingManager isAddressBlocked:address]) { blockedMemberCount++; } } return blockedMemberCount; } - (void)startReadTimer { [self.readTimer invalidate]; self.readTimer = [NSTimer weakTimerWithTimeInterval:0.1f target:self selector:@selector(readTimerDidFire) userInfo:nil repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:self.readTimer forMode:NSRunLoopCommonModes]; } - (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.bulkProfileFetch fetchProfilesWithThread:self.thread]; [self markVisibleMessagesAsRead]; [self startReadTimer]; [self updateNavigationBarSubtitleLabel]; [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. self.collectionView.prefetchingEnabled = YES; } self.conversationViewModel.focusMessageIdOnOpen = nil; self.isViewCompletelyAppeared = YES; self.viewHasEverAppeared = YES; self.shouldAnimateKeyboardChanges = YES; switch (self.actionOnOpen) { case ConversationViewActionNone: break; case ConversationViewActionCompose: // Don't pop the keyboard if we have a pending message request, since // the user can't currently send a message until acting on this if (!self.requestView) { [self popKeyBoard]; } // When we programmatically pop the keyboard here, // the scroll position gets into a weird state and // content is hidden behind the keyboard so we restore // it to the default position. [self scrollToDefaultPositionAnimated:YES]; break; case ConversationViewActionAudioCall: [self startAudioCall]; break; case ConversationViewActionVideoCall: [self startVideoCall]; break; } // Clear the "on open" state after the view has been presented. self.actionOnOpen = ConversationViewActionNone; [self updateInputToolbarLayout]; [self configureScrollDownButtons]; [self.inputToolbar viewDidAppear]; } // `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 dismissMessageActionsAnimated:NO]; [self dismissReactionsDetailSheetAnimated:NO]; } - (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.inputToolbar clearDesiredKeyboard]; self.isUserScrolling = NO; self.isWaitingForDeceleration = 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)updateNavigationTitle { NSString *_Nullable name; NSAttributedString *_Nullable attributedName; UIImage *_Nullable icon; if ([self.thread isKindOfClass:[TSContactThread class]]) { TSContactThread *thread = (TSContactThread *)self.thread; OWSAssertDebug(thread.contactAddress); if (thread.isNoteToSelf) { name = MessageStrings.noteToSelf; } else { name = [self.contactsManager displayNameForAddress:thread.contactAddress]; } // If the user is in the system contacts, show a badge if ([self.contactsManager hasSignalAccountForAddress:thread.contactAddress]) { icon = [[UIImage imageNamed:@"contact-outline-16"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; } } else if ([self.thread isKindOfClass:TSGroupThread.class]) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; name = groupThread.groupNameOrDefault; } else { OWSFailDebug(@"failure: unexpected thread: %@", self.thread); } self.title = nil; self.headerView.titleIcon = icon; if (name && !attributedName) { attributedName = [[NSAttributedString alloc] initWithString:name attributes:@{ NSForegroundColorAttributeName : Theme.primaryTextColor }]; } if ([attributedName isEqual:self.headerView.attributedTitle]) { return; } self.headerView.attributedTitle = attributedName; } - (void)createHeaderViews { ConversationHeaderView *headerView = [[ConversationHeaderView alloc] initWithThread:self.thread]; headerView.accessibilityLabel = NSLocalizedString(@"CONVERSATION_SETTINGS", "title for conversation settings screen"); self.headerView = headerView; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, headerView); headerView.delegate = self; self.navigationItem.titleView = headerView; #ifdef USE_DEBUG_UI [headerView addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(navigationTitleLongPressed:)]]; #endif [self updateNavigationBarSubtitleLabel]; } - (CGFloat)unreadCountViewDiameter { return 16; } - (void)updateLeftBarItem { // No left button when the view is not collapsed, there's nowhere to go. if (!self.conversationSplitViewController.isCollapsed) { self.navigationItem.leftBarButtonItem = nil; return; } // Otherwise, show the back button. // We use the default back button from conversation list, which animates nicely with interactive transitions // like the interactive pop gesture and the "slide left" for info. self.navigationItem.leftBarButtonItem = nil; } - (void)windowManagerCallDidChange:(NSNotification *)notification { [self updateBarButtonItems]; } - (void)updateBarButtonItems { // Don't include "Back" text on view controllers pushed above us, just use the arrow. [self.navigationItem setBackBarButtonItem:[[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil]]; self.navigationItem.hidesBackButton = NO; self.navigationItem.leftBarButtonItem = nil; switch (self.uiMode) { case ConversationUIMode_Search: { if (self.userLeftGroup) { self.navigationItem.rightBarButtonItems = @[]; return; } if (@available(iOS 13.0, *)) { OWSAssertDebug(self.navigationItem.searchController != nil); } else { self.navigationItem.rightBarButtonItems = @[]; self.navigationItem.leftBarButtonItem = nil; self.navigationItem.hidesBackButton = YES; } return; } case ConversationUIMode_Selection: { self.navigationItem.rightBarButtonItems = @[ self.cancelSelectionBarButtonItem ]; self.navigationItem.leftBarButtonItem = self.deleteAllBarButtonItem; self.navigationItem.hidesBackButton = YES; return; } case ConversationUIMode_Normal: { if (self.userLeftGroup) { self.navigationItem.rightBarButtonItems = @[]; return; } NSMutableArray *barButtons = [NSMutableArray new]; if ([self canCall]) { UIBarButtonItem *audioCallButton = [[UIBarButtonItem alloc] initWithImage:[Theme iconImage:ThemeIconAudioCall] style:UIBarButtonItemStylePlain target:self action:@selector(startAudioCall)]; audioCallButton.enabled = !OWSWindowManager.shared.hasCall; audioCallButton.accessibilityLabel = NSLocalizedString(@"AUDIO_CALL_LABEL", "Accessibility label for placing an audio call"); [barButtons addObject:audioCallButton]; UIBarButtonItem *videoCallButton = [[UIBarButtonItem alloc] initWithImage:[Theme iconImage:ThemeIconVideoCall] style:UIBarButtonItemStylePlain target:self action:@selector(startVideoCall)]; videoCallButton.enabled = !OWSWindowManager.shared.hasCall; videoCallButton.accessibilityLabel = NSLocalizedString(@"VIDEO_CALL_LABEL", "Accessibility label for placing a video call"); [barButtons addObject:videoCallButton]; } self.navigationItem.rightBarButtonItems = [barButtons copy]; return; } } } - (void)updateNavigationBarSubtitleLabel { BOOL hasCompactHeader = self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact; if (hasCompactHeader) { self.headerView.attributedSubtitle = nil; return; } NSMutableAttributedString *subtitleText = [NSMutableAttributedString new]; UIFont *subtitleFont = self.headerView.subtitleFont; NSDictionary *attributes = @{ NSFontAttributeName : subtitleFont, NSForegroundColorAttributeName : [Theme.navbarTitleColor colorWithAlphaComponent:(CGFloat)0.9], }; NSString *hairSpace = @"\u200a"; NSString *thinSpace = @"\u2009"; NSString *iconSpacer = UIDevice.currentDevice.isNarrowerThanIPhone6 ? hairSpace : thinSpace; NSString *betweenItemSpacer = UIDevice.currentDevice.isNarrowerThanIPhone6 ? @" " : @" "; BOOL isMuted = self.thread.isMuted; BOOL hasTimer = self.disappearingMessagesConfiguration.isEnabled; BOOL isVerified = self.thread.recipientAddresses.count > 0; for (SignalServiceAddress *address in self.thread.recipientAddresses) { if ([[OWSIdentityManager shared] verificationStateForAddress:address] != OWSVerificationStateVerified) { isVerified = NO; break; } } if (isMuted) { [subtitleText appendTemplatedImageNamed:@"bell-disabled-outline-24" font:subtitleFont]; if (!isVerified) { [subtitleText append:iconSpacer attributes:attributes]; [subtitleText append:NSLocalizedString(@"MUTED_BADGE", @"Badge indicating that the user is muted.") attributes:attributes]; } } if (hasTimer) { if (isMuted) { [subtitleText append:betweenItemSpacer attributes:attributes]; } [subtitleText appendTemplatedImageNamed:@"timer-outline-16" font:subtitleFont]; [subtitleText append:iconSpacer attributes:attributes]; [subtitleText append:[NSString formatDurationSeconds:self.disappearingMessagesConfiguration.durationSeconds useShortFormat:YES] attributes:attributes]; } if (isVerified) { if (hasTimer || isMuted) { [subtitleText append:betweenItemSpacer attributes:attributes]; } [subtitleText appendTemplatedImageNamed:@"check-12" font:subtitleFont]; [subtitleText append:iconSpacer attributes:attributes]; [subtitleText append:NSLocalizedString( @"PRIVACY_IDENTITY_IS_VERIFIED_BADGE", @"Badge indicating that the user is verified.") attributes:attributes]; } self.headerView.attributedSubtitle = subtitleText; } #pragma mark - Identity /** * Shows confirmation dialog if at least one of the recipient id's is not confirmed. * * returns YES if an alert was shown * NO if there were no unconfirmed identities */ - (BOOL)showSafetyNumberConfirmationIfNecessaryWithConfirmationText:(NSString *)confirmationText completion:(void (^)(BOOL didConfirmIdentity))completionHandler { return [SafetyNumberConfirmationSheet presentIfNecessaryWithAddresses:self.thread.recipientAddresses confirmationText:confirmationText completion:completionHandler]; } - (void)showFingerprintWithAddress:(SignalServiceAddress *)address { // Ensure keyboard isn't hiding the "safety numbers changed" interaction when we // return from FingerprintViewController. [self dismissKeyBoard]; [FingerprintViewController presentFromViewController:self address:address]; } #pragma mark - Calls - (void)startAudioCall { [self callWithVideo:NO]; } - (void)startVideoCall { [self callWithVideo:YES]; } - (void)callWithVideo:(BOOL)isVideo { if (![self.thread isKindOfClass:[TSContactThread class]]) { OWSFailDebug(@"unexpected thread: %@", self.thread); return; } TSContactThread *contactThread = (TSContactThread *)self.thread; if (![self canCall]) { OWSLogWarn(@"Tried to initiate a call but thread is not callable."); return; } __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { [self showUnblockConversationUI:^(BOOL isBlocked) { if (!isBlocked) { [weakSelf callWithVideo:isVideo]; } }]; return; } BOOL didShowSNAlert = [self showSafetyNumberConfirmationIfNecessaryWithConfirmationText:[CallStrings confirmAndCallButtonTitle] completion:^(BOOL didConfirmIdentity) { if (didConfirmIdentity) { [weakSelf callWithVideo:isVideo]; } }]; if (didShowSNAlert) { return; } // We initiated a call, so if there was a pending message request we should accept it. [ThreadUtil addThreadToProfileWhitelistIfEmptyOrPendingRequestWithSneakyTransaction:self.thread]; [self.outboundCallInitiator initiateCallWithAddress:contactThread.contactAddress isVideo:isVideo]; } - (BOOL)canCall { if (!self.thread.isLocalUserFullMemberOfThread) { return NO; } if (![self.thread isKindOfClass:[TSContactThread class]]) { return NO; } TSContactThread *contactThread = (TSContactThread *)self.thread; if (contactThread.isNoteToSelf) { return NO; } if (self.threadViewModel.hasPendingMessageRequest) { return NO; } return YES; } #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)showNoLongerVerifiedUI { NSArray *noLongerVerifiedAddresses = [self noLongerVerifiedAddresses]; if (noLongerVerifiedAddresses.count > 1) { [self showConversationSettingsAndShowVerification]; } else if (noLongerVerifiedAddresses.count == 1) { // Pick one in an arbitrary but deterministic manner. SignalServiceAddress *address = noLongerVerifiedAddresses.lastObject; [self showFingerprintWithAddress:address]; } } - (void)showConversationSettings { [self showConversationSettingsWithMode:ConversationSettingsPresentationModeDefault]; } - (void)showConversationSettingsAndShowAllMedia { [self showConversationSettingsWithMode:ConversationSettingsPresentationModeShowAllMedia]; } - (void)showConversationSettingsAndShowVerification { [self showConversationSettingsWithMode:ConversationSettingsPresentationModeShowVerification]; } - (void)showConversationSettingsAndShowMemberRequests { [self showConversationSettingsWithMode:ConversationSettingsPresentationModeShowMemberRequests]; } - (void)showConversationSettingsWithMode:(ConversationSettingsPresentationMode)mode { NSMutableArray *viewControllers = [self.viewControllersUpToSelf mutableCopy]; ConversationSettingsViewController *settingsView = [[ConversationSettingsViewController alloc] initWithThreadViewModel:self.threadViewModel]; settingsView.conversationSettingsViewDelegate = self; [viewControllers addObject:settingsView]; switch (mode) { case ConversationSettingsPresentationModeDefault: break; case ConversationSettingsPresentationModeShowVerification: settingsView.showVerificationOnAppear = YES; break; case ConversationSettingsPresentationModeShowMemberRequests: { UIViewController *_Nullable view = [settingsView buildMemberRequestsAndInvitesView]; if (view != nil) { [viewControllers addObject:view]; } break; } case ConversationSettingsPresentationModeShowAllMedia: [viewControllers addObject:[[MediaTileViewController alloc] initWithThread:self.thread]]; break; } [self.navigationController setViewControllers:viewControllers animated:YES]; } - (NSArray *)viewControllersUpToSelf { OWSAssertIsOnMainThread(); OWSAssertDebug(self.navigationController); if (self.navigationController.topViewController == self) { return self.navigationController.viewControllers; } NSArray *viewControllers = self.navigationController.viewControllers; NSUInteger index = [viewControllers indexOfObject:self]; if (index == NSNotFound) { OWSFailDebug(@"Unexpectedly missing from view hierarhy"); return viewControllers; } return [viewControllers subarrayWithRange:NSMakeRange(0, index + 1)]; } #pragma mark - Load More - (void)autoLoadMoreIfNecessary { BOOL isMainAppAndActive = CurrentAppContext().isMainAppAndActive; if (self.isUserScrolling || self.isWaitingForDeceleration || !self.isViewVisible || !isMainAppAndActive) { return; } if (!self.showLoadOlderHeader && !self.showLoadNewerHeader) { return; } [self.navigationController.view layoutIfNeeded]; CGSize navControllerSize = self.navigationController.view.frame.size; CGFloat loadThreshold = MAX(navControllerSize.width, navControllerSize.height) * 3; BOOL closeToTop = self.collectionView.contentOffset.y < loadThreshold; if (self.showLoadOlderHeader && closeToTop) { [BenchManager benchWithTitle:@"loading older interactions" block:^{ [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { [self.conversationViewModel appendOlderItemsWithTransaction:transaction]; }]; }]; } CGFloat distanceFromBottom = self.collectionView.contentSize.height - self.collectionView.bounds.size.height - self.collectionView.contentOffset.y; BOOL closeToBottom = distanceFromBottom < loadThreshold; if (self.showLoadNewerHeader && closeToBottom) { [BenchManager benchWithTitle:@"loading newer interactions" block:^{ [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { [self.conversationViewModel appendNewerItemsWithTransaction:transaction]; }]; }]; } } - (void)resetShowLoadMore { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); _showLoadOlderHeader = self.conversationViewModel.canLoadOlderItems; _showLoadNewerHeader = self.conversationViewModel.canLoadNewerItems; } - (void)updateShowLoadMoreHeadersWithTransaction:(SDSAnyReadTransaction *)transaction { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); BOOL valueChanged = NO; { BOOL newValue = self.conversationViewModel.canLoadOlderItems; valueChanged = _showLoadOlderHeader != newValue; _showLoadOlderHeader = newValue; } { BOOL newValue = self.conversationViewModel.canLoadNewerItems; valueChanged = valueChanged || (_showLoadNewerHeader != newValue); _showLoadNewerHeader = newValue; } if (valueChanged) { [self resetContentAndLayoutWithTransaction:transaction]; } } #pragma mark Bubble User Actions - (void)handleFailedDownloadTapForMessage:(TSMessage *)message { OWSAssert(message); [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { NSArray *attachments = [message allAttachmentsWithTransaction:transaction.unwrapGrdbRead]; [self.attachmentDownloads downloadAttachmentsForMessage:message bypassPendingMessageRequest:NO attachments:attachments success:^(NSArray *attachmentStreams) { OWSLogInfo(@"Successfully redownloaded attachment in thread: %@", message.threadWithSneakyTransaction); } failure:^(NSError *error) { OWSLogWarn(@"Failed to redownload message with error: %@", error); }]; }]; } - (void)resendFailedOutgoingMessage:(TSOutgoingMessage *)message { TSOutgoingMessage *messageToSend; // If the message was remotely deleted, resend a *delete* message // rather than the message itself. if (message.wasRemotelyDeleted) { messageToSend = [[TSOutgoingDeleteMessage alloc] initWithThread:self.thread message:message]; } else { messageToSend = message; } NSArray *recipientsWithChangedSafetyNumber = [message failedRecipientAddressesWithErrorCode:OWSErrorCodeUntrustedIdentity]; if (recipientsWithChangedSafetyNumber.count > 0) { // Show special safety number change dialog SafetyNumberConfirmationSheet *sheet = [[SafetyNumberConfirmationSheet alloc] initWithAddressesToConfirm:recipientsWithChangedSafetyNumber confirmationText:MessageStrings.sendButton completionHandler:^(BOOL didConfirm) { if (didConfirm) { DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [self.messageSenderJobQueue addMessage:messageToSend.asPreparer transaction:transaction]; }); } }]; [self presentViewController:sheet animated:YES completion:nil]; return; } ActionSheetController *actionSheet = [[ActionSheetController alloc] initWithTitle:nil message:message.mostRecentFailureText]; [actionSheet addAction:[OWSActionSheets cancelAction]]; ActionSheetAction *deleteMessageAction = [[ActionSheetAction alloc] initWithTitle:CommonStrings.deleteForMeButton style:ActionSheetActionStyleDestructive handler:^(ActionSheetAction *action) { DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [message anyRemoveWithTransaction:transaction]; }); }]; [actionSheet addAction:deleteMessageAction]; ActionSheetAction *resendMessageAction = [[ActionSheetAction alloc] initWithTitle:NSLocalizedString(@"SEND_AGAIN_BUTTON", @"") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"send_again") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [self.messageSenderJobQueue addMessage:messageToSend.asPreparer transaction:transaction]; }); }]; [actionSheet addAction:resendMessageAction]; [self dismissKeyBoard]; [self presentActionSheet:actionSheet]; } - (void)tappedNonBlockingIdentityChangeForAddress:(nullable SignalServiceAddress *)address { if (address == nil) { if (self.thread.isGroupThread) { // Before 2.13 we didn't track the recipient id in the identity change error. OWSLogWarn(@"Ignoring tap on legacy nonblocking identity change since it has no signal id"); return; } else { TSContactThread *thread = (TSContactThread *)self.thread; OWSLogInfo(@"Assuming tap on legacy nonblocking identity change corresponds to current contact thread: %@", thread.contactAddress); address = thread.contactAddress; } } [self showFingerprintWithAddress:address]; } - (void)tappedCorruptedMessage:(TSErrorMessage *)message { __block NSString *threadName; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { threadName = [self.contactsManager displayNameForThread:self.thread transaction:transaction]; }]; NSString *alertMessage = [NSString stringWithFormat:NSLocalizedString(@"CORRUPTED_SESSION_DESCRIPTION", @"ActionSheet title"), threadName]; ActionSheetController *alert = [[ActionSheetController alloc] initWithTitle:nil message:alertMessage]; [alert addAction:[OWSActionSheets cancelAction]]; ActionSheetAction *resetSessionAction = [[ActionSheetAction alloc] initWithTitle:NSLocalizedString(@"FINGERPRINT_SHRED_KEYMATERIAL_BUTTON", @"") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"reset_session") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *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; DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [self.sessionResetJobQueue addContactThread:contactThread transaction:transaction]; }); }]; [alert addAction:resetSessionAction]; [self dismissKeyBoard]; [self presentActionSheet:alert]; } - (void)tappedInvalidIdentityKeyErrorMessage:(TSInvalidIdentityKeyErrorMessage *)errorMessage { NSString *keyOwner = [self.contactsManager displayNameForAddress:errorMessage.theirSignalAddress]; NSString *titleFormat = NSLocalizedString(@"SAFETY_NUMBERS_ACTIONSHEET_TITLE", @"Action sheet heading"); NSString *titleText = [NSString stringWithFormat:titleFormat, keyOwner]; ActionSheetController *actionSheet = [[ActionSheetController alloc] initWithTitle:titleText message:nil]; [actionSheet addAction:[OWSActionSheets cancelAction]]; ActionSheetAction *showSafteyNumberAction = [[ActionSheetAction alloc] initWithTitle:NSLocalizedString(@"SHOW_SAFETY_NUMBER_ACTION", @"Action sheet item") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"show_safety_number") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { OWSLogInfo(@"Remote Key Changed actions: Show fingerprint display"); [self showFingerprintWithAddress:errorMessage.theirSignalAddress]; }]; [actionSheet addAction:showSafteyNumberAction]; ActionSheetAction *acceptSafetyNumberAction = [[ActionSheetAction alloc] initWithTitle:NSLocalizedString(@"ACCEPT_NEW_IDENTITY_ACTION", @"Action sheet item") accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"accept_safety_number") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { OWSLogInfo(@"Remote Key Changed actions: Accepted new identity key"); // DEPRECATED: we're no longer creating these incoming SN error's per message, // but there will be some legacy ones in the wild, behind which await // as-of-yet-undecrypted messages #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" if ([errorMessage isKindOfClass:[TSInvalidIdentityKeyReceivingErrorMessage class]]) { // Deliberately crash if the user fails to explicitly accept the new identity // key. In practice we haven't been creating these messages in over a year. [errorMessage throws_acceptNewIdentityKey]; #pragma clang diagnostic pop } }]; [actionSheet addAction:acceptSafetyNumberAction]; [self dismissKeyBoard]; [self presentActionSheet:actionSheet]; } - (void)handleCallTap:(TSCall *)call { OWSAssertDebug(call); if (![self.thread isKindOfClass:[TSContactThread class]]) { OWSFailDebug(@"unexpected thread: %@", self.thread); return; } TSContactThread *contactThread = (TSContactThread *)self.thread; NSString *displayName = [self.contactsManager displayNameForAddress:contactThread.contactAddress]; ActionSheetController *alert = [[ActionSheetController alloc] initWithTitle:[CallStrings callBackAlertTitle] message:[NSString stringWithFormat:[CallStrings callBackAlertMessageFormat], displayName]]; __weak ConversationViewController *weakSelf = self; ActionSheetAction *callAction = [[ActionSheetAction alloc] initWithTitle:[CallStrings callBackAlertCallButton] accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"call_back") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { switch (call.offerType) { case TSRecentCallOfferTypeAudio: [weakSelf startAudioCall]; break; case TSRecentCallOfferTypeVideo: [weakSelf startVideoCall]; break; } }]; [alert addAction:callAction]; [alert addAction:[OWSActionSheets cancelAction]]; [self.inputToolbar clearDesiredKeyboard]; [self dismissKeyBoard]; [self presentActionSheet:alert]; } - (void)updateSystemContactWithAddress:(SignalServiceAddress *)address withNewNameComponents:(NSPersonNameComponents *)newNameComponents { if (!self.contactsManager.supportsContactEditing) { OWSFailDebug(@"Contact editing unexpectedly unsupported"); return; } CNContactViewController *contactViewController = [self.contactsViewHelper contactViewControllerForAddress:address editImmediately:YES addToExistingCnContact:nil updatedNameComponents:newNameComponents]; contactViewController.delegate = self; [self.navigationController pushViewController:contactViewController animated:YES]; } #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 - - (void)presentMessageActions:(NSArray *)messageActions withFocusedCell:(ConversationViewCell *)cell { MessageActionsViewController *messageActionsViewController = [[MessageActionsViewController alloc] initWithFocusedViewItem:cell.viewItem focusedView:cell actions:messageActions]; messageActionsViewController.delegate = self; self.messageActionsViewController = messageActionsViewController; [self setupMessageActionsStateForCell:cell]; [messageActionsViewController presentOnWindow:self.view.window prepareConstraints:^{ // In order to ensure the bottom bar remains above the keyboard, we pin it // to our bottom bar which follows the inputAccessoryView [messageActionsViewController.bottomBar autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.bottomBar]; // We only want the message actions to show up over the detail view, in // the case where we are expanded. So match its edges to our nav controller. [messageActionsViewController.view autoPinToEdgesOfView:self.navigationController.view]; } animateAlongside:^{ self.bottomBar.alpha = 0; } completion:nil]; } - (void)updateMessageActionsStateForCell:(ConversationViewCell *)cell { // While presenting message actions, cache the original content offset. // This allows us to restore the user to their original scroll position // when they dismiss the menu. self.messageActionsOriginalContentOffset = self.collectionView.contentOffset; self.messageActionsOriginalFocusY = [self.view convertPoint:cell.frame.origin fromView:self.collectionView].y; } - (void)setupMessageActionsStateForCell:(ConversationViewCell *)cell { [self updateMessageActionsStateForCell:cell]; // 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. // This allows us to keep the message position constant even when // messages dissappear above / below the focused message to the point // that we have less than one screen worth of content. CGSize navControllerSize = self.navigationController.view.frame.size; self.messageActionsExtraContentInsetPadding = MAX(navControllerSize.width, navControllerSize.height); UIEdgeInsets contentInset = self.collectionView.contentInset; contentInset.top += self.messageActionsExtraContentInsetPadding; contentInset.bottom += self.messageActionsExtraContentInsetPadding; self.collectionView.contentInset = contentInset; } - (void)clearMessageActionsState { self.bottomBar.alpha = 1; UIEdgeInsets contentInset = self.collectionView.contentInset; contentInset.top -= self.messageActionsExtraContentInsetPadding; contentInset.bottom -= self.messageActionsExtraContentInsetPadding; self.collectionView.contentInset = contentInset; self.collectionView.contentOffset = self.messageActionsOriginalContentOffset; self.messageActionsOriginalContentOffset = CGPointZero; self.messageActionsExtraContentInsetPadding = 0; self.messageActionsViewController = nil; } - (BOOL)isPresentingMessageActions { return self.messageActionsViewController != nil; } - (void)dismissMessageActionsAnimated:(BOOL)animated { [self dismissMessageActionsAnimated:animated completion:^ { }]; } - (void)dismissMessageActionsAnimated:(BOOL)animated completion:(void (^)(void))completion { OWSLogVerbose(@""); if (!self.isPresentingMessageActions) { return; } if (animated) { [self.messageActionsViewController dismissAndAnimateAlongside:^{ self.bottomBar.alpha = 1; } completion:^{ [self clearMessageActionsState]; completion(); }]; } else { [self.messageActionsViewController dismissWithoutAnimating]; [self clearMessageActionsState]; completion(); } } - (void)dismissMessageActionsIfNecessary { if (self.shouldDismissMessageActions) { [self dismissMessageActionsAnimated:YES]; } } - (BOOL)shouldDismissMessageActions { if (!self.isPresentingMessageActions) { return NO; } NSString *_Nullable messageActionInteractionId = self.messageActionsViewController.focusedInteraction.uniqueId; if (messageActionInteractionId == nil) { return NO; } // Check whether there is still a view item for this interaction. return (self.conversationViewModel.viewState.interactionIndexMap[messageActionInteractionId] == nil); } - (nullable NSValue *)contentOffsetForMessageActionInteraction { OWSAssertDebug(self.messageActionsViewController); NSString *_Nullable messageActionInteractionId = self.messageActionsViewController.focusedInteraction.uniqueId; if (messageActionInteractionId == nil) { OWSFailDebug(@"Missing message action interaction."); return nil; } NSNumber *_Nullable interactionIndex = self.conversationViewModel.viewState.interactionIndexMap[messageActionInteractionId]; 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, cellFrame.origin.y - self.messageActionsOriginalFocusY)]; } #pragma mark - ConversationViewCellDelegate - (BOOL)conversationCell:(ConversationViewCell *)cell shouldAllowReplyForItem:(nonnull id)viewItem { if (!self.thread.isLocalUserFullMemberOfThread) { return NO; } if (self.threadViewModel.hasPendingMessageRequest) { return NO; } if ([viewItem.interaction isKindOfClass:[TSMessage class]]) { TSMessage *message = (TSMessage *)viewItem.interaction; if (message.wasRemotelyDeleted) { return NO; } } if (viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) { // Don't allow "delete" or "reply" on "failed" outgoing messages. return NO; } else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) { // Don't allow "delete" or "reply" on "sending" outgoing messages. return NO; } } return YES; } - (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)conversationCell:(ConversationViewCell *)cell shouldAllowReply:(BOOL)shouldAllowReply didLongpressSticker:(id)viewItem { OWSAssertDebug(viewItem); NSArray *messageActions = [ConversationViewItemActions mediaActionsWithConversationViewItem:viewItem shouldAllowReply:shouldAllowReply delegate:self]; [self presentMessageActions:messageActions withFocusedCell:cell]; } - (void)conversationCell:(ConversationViewCell *)cell didTapAvatar:(id)viewItem { OWSAssertDebug(viewItem); if (viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { OWSFailDebug(@"not an incoming message."); return; } TSIncomingMessage *incomingMessage = (TSIncomingMessage *)viewItem.interaction; GroupViewHelper *groupViewHelper = [[GroupViewHelper alloc] initWithThreadViewModel:self.threadViewModel]; groupViewHelper.delegate = self; MemberActionSheet *actionSheet = [[MemberActionSheet alloc] initWithAddress:incomingMessage.authorAddress groupViewHelper:groupViewHelper]; [actionSheet presentFromViewController:self]; } - (void)conversationCell:(ConversationViewCell *)cell didChangeLongpress:(id)viewItem { if (! [self.messageActionsViewController.focusedInteraction.uniqueId isEqualToString:viewItem.interaction.uniqueId]) { OWSFailDebug(@"Received longpress update for unexpected cell"); return; } [self.messageActionsViewController didChangeLongpress]; } - (void)conversationCell:(ConversationViewCell *)cell didEndLongpress:(id)viewItem { if (! [self.messageActionsViewController.focusedInteraction.uniqueId isEqualToString:viewItem.interaction.uniqueId]) { OWSFailDebug(@"Received longpress update for unexpected cell"); return; } [self.messageActionsViewController didEndLongpress]; } - (void)conversationCell:(ConversationViewCell *)cell didTapReactions:(id)viewItem { OWSAssertDebug(viewItem); if (!viewItem.reactionState.hasReactions) { OWSFailDebug(@"missing reaction state"); return; } if (![viewItem.interaction isKindOfClass:[TSMessage class]]) { OWSFailDebug(@"Unexpected interaction type"); return; } ReactionsDetailSheet *detailSheet = [[ReactionsDetailSheet alloc] initWithReactionState:viewItem.reactionState message:(TSMessage *)viewItem.interaction]; [self presentViewController:detailSheet animated:YES completion:nil]; self.reactionsDetailSheet = detailSheet; } - (BOOL)conversationCellHasPendingMessageRequest:(ConversationViewCell *)cell { return self.threadViewModel.hasPendingMessageRequest; } - (BOOL)isShowingSelectionUI { return self.uiMode == ConversationUIMode_Selection; } - (BOOL)isViewItemSelected:(id)viewItem { return [self.selectedItems objectForKey:viewItem.interaction.uniqueId] != nil; } - (void)conversationCell:(nonnull ConversationViewCell *)cell didSelectViewItem:(id)viewItem { OWSAssertDebug(self.isShowingSelectionUI); NSIndexPath *_Nullable indexPath = [self.conversationViewModel indexPathForViewItem:viewItem]; if (indexPath == nil) { OWSFailDebug(@"indexPath was unexpectedly nil"); return; } [self.collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; NSMutableDictionary *dict = [self.selectedItems mutableCopy]; dict[viewItem.interaction.uniqueId] = viewItem; self.selectedItems = [dict copy]; [self updateSelectionButtons]; [self updateSelectionHighlight]; } - (void)conversationCell:(nonnull ConversationViewCell *)cell didDeselectViewItem:(id)viewItem { OWSAssertDebug(self.isShowingSelectionUI); NSIndexPath *_Nullable indexPath = [self.conversationViewModel indexPathForViewItem:viewItem]; if (indexPath == nil) { OWSFailDebug(@"indexPath was unexpectedly nil"); return; } [self.collectionView deselectItemAtIndexPath:indexPath animated:NO]; NSMutableDictionary *dict = [self.selectedItems mutableCopy]; [dict removeObjectForKey:viewItem.interaction.uniqueId]; self.selectedItems = [dict copy]; [self updateSelectionButtons]; [self updateSelectionHighlight]; } - (void)reloadReactionsDetailSheetWithTransaction:(SDSAnyReadTransaction *)transaction { if (!self.reactionsDetailSheet) { return; } NSString *messageId = self.reactionsDetailSheet.messageId; NSNumber *_Nullable index = self.conversationViewModel.viewState.interactionIndexMap[messageId]; if (index == nil) { // The message no longer exists, dismiss the sheet. [self dismissReactionsDetailSheetAnimated:YES]; } id viewItem = [self viewItemForIndex:index.integerValue]; InteractionReactionState *_Nullable reactionState = viewItem.reactionState; if (!reactionState.hasReactions) { // There are no longer reactions on this message, dismiss the sheet. [self dismissReactionsDetailSheetAnimated:YES]; return; } // Update the detail sheet with the latest reaction // state, in case the reactions have changed. [self.reactionsDetailSheet setReactionState:reactionState transaction:transaction]; } - (void)dismissReactionsDetailSheetAnimated:(BOOL)animated { if (!self.reactionsDetailSheet) { return; } [self.reactionsDetailSheet dismissViewControllerAnimated:animated completion:^{ self.reactionsDetailSheet = nil; }]; } - (void)conversationCell:(ConversationViewCell *)cell didReplyToItem:(id)viewItem { [self populateReplyForViewItem:viewItem]; } - (void)presentAddThreadToProfileWhitelistWithSuccess:(void (^)(void))successHandler { [[OWSProfileManager shared] presentAddThreadToProfileWhitelist:self.thread fromViewController:self success:successHandler]; } #pragma mark - Audio Setup - (void)prepareAudioPlayerForViewItem:(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); } if (self.audioAttachmentPlayer) { // Is this player associated with this media adapter? if (self.audioAttachmentPlayer.owner == viewItem.interaction.uniqueId) { 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.interaction.uniqueId; [self.audioAttachmentPlayer setupAudioPlayer]; } #pragma mark - OWSMessageBubbleViewDelegate - (void)didTapImageViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream imageView:(UIView *)imageView { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); OWSAssertDebug(imageView); [self dismissKeyBoard]; MediaPageViewController *pageVC = [[MediaPageViewController alloc] initWithInitialMediaAttachment:attachmentStream thread:self.thread]; [self presentViewController:pageVC animated:YES completion:nil]; } - (void)didTapVideoViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream imageView:(UIImageView *)imageView { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); [self dismissKeyBoard]; MediaPageViewController *pageVC = [[MediaPageViewController alloc] initWithInitialMediaAttachment:attachmentStream thread:self.thread]; [self presentViewController:pageVC animated:YES completion:nil]; } - (void)didTapAudioViewItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream { [self prepareAudioPlayerForViewItem:viewItem attachmentStream:attachmentStream]; // Resume from where we left off [self.audioAttachmentPlayer setCurrentTime:viewItem.audioProgressSeconds]; [self.audioAttachmentPlayer togglePlayState]; } - (void)didScrubAudioViewItem:(id)viewItem toTime:(NSTimeInterval)time attachmentStream:(TSAttachmentStream *)attachmentStream { [self prepareAudioPlayerForViewItem:viewItem attachmentStream:attachmentStream]; [self.audioAttachmentPlayer setCurrentTime:time]; } - (void)didTapPdfForItem:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); PdfViewController *pdfView = [[PdfViewController alloc] initWithViewItem:viewItem attachmentStream:attachmentStream]; UIViewController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:pdfView]; [self presentFullScreenViewController:navigationController animated:YES completion:nil]; } - (void)didTapTruncatedTextMessage:(id)conversationItem { OWSAssertIsOnMainThread(); OWSAssertDebug(conversationItem); OWSAssertDebug([conversationItem.interaction isKindOfClass:[TSMessage class]]); if (conversationItem.displayableBodyText.canRenderTruncatedTextInline) { conversationItem.isTruncatedTextVisible = YES; CGPoint contentOffset = self.collectionView.contentOffset; [self reloadData]; [self.collectionView setContentOffset:contentOffset animated:NO]; } else { LongTextViewController *viewController = [[LongTextViewController alloc] initWithViewItem:conversationItem]; viewController.delegate = self; [self.navigationController pushViewController:viewController animated:YES]; } } - (void)didTapMention:(Mention *)mention { [ImpactHapticFeedback impactOccuredWithStyle:UIImpactFeedbackStyleLight]; GroupViewHelper *groupViewHelper = [[GroupViewHelper alloc] initWithThreadViewModel:self.threadViewModel]; groupViewHelper.delegate = self; MemberActionSheet *actionSheet = [[MemberActionSheet alloc] initWithAddress:mention.address groupViewHelper:groupViewHelper]; [actionSheet presentFromViewController:self]; } - (void)didTapContactShareViewItem:(id)conversationItem { OWSAssertIsOnMainThread(); OWSAssertDebug(conversationItem); OWSAssertDebug(conversationItem.contactShare); OWSAssertDebug([conversationItem.interaction isKindOfClass:[TSMessage class]]); ContactViewController *view = [[ContactViewController alloc] initWithContactShare:conversationItem.contactShare]; [self.navigationController pushViewController:view animated:YES]; } - (void)didTapSendMessageToContactShare:(ContactShareViewModel *)contactShare { OWSAssertIsOnMainThread(); OWSAssertDebug(contactShare); [self.contactShareViewHelper sendMessageWithContactShare:contactShare fromViewController:self]; } - (void)didTapSendInviteToContactShare:(ContactShareViewModel *)contactShare { OWSAssertIsOnMainThread(); OWSAssertDebug(contactShare); [self.contactShareViewHelper showInviteContactWithContactShare:contactShare fromViewController:self]; } - (void)didTapShowAddToContactUIForContactShare:(ContactShareViewModel *)contactShare { OWSAssertIsOnMainThread(); OWSAssertDebug(contactShare); [self.contactShareViewHelper showAddToContactsWithContactShare:contactShare fromViewController:self]; } - (void)didTapStickerPack:(StickerPackInfo *)stickerPackInfo { OWSAssertIsOnMainThread(); [self showStickerPack:stickerPackInfo]; } - (void)didTapGroupInviteLink:(NSURL *)url { OWSAssertIsOnMainThread(); OWSAssertDebug([GroupManager isPossibleGroupInviteLink:url]); [GroupInviteLinksUI openGroupInviteLink:url fromViewController:self]; } - (void)didTapFailedIncomingAttachment:(id)viewItem { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); // Restart failed downloads TSMessage *message = (TSMessage *)viewItem.interaction; [self handleFailedDownloadTapForMessage:message]; } - (void)didTapPendingMessageRequestIncomingAttachment:(id)viewItem { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); // Start downloads for message. TSMessage *message = (TSMessage *)viewItem.interaction; [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { NSArray *attachments = [message allAttachmentsWithTransaction:transaction.unwrapGrdbRead]; [self.attachmentDownloads downloadAttachmentsForMessage:message bypassPendingMessageRequest:YES attachments:attachments success:^(NSArray *attachmentStreams) { OWSLogInfo(@"Successfully downloaded attachment in thread: %@", message.threadWithSneakyTransaction); } failure:^(NSError *error) { OWSLogWarn(@"Failed to download message with error: %@", error); }]; }]; } - (void)didTapFailedOutgoingMessage:(TSOutgoingMessage *)message { OWSAssertIsOnMainThread(); OWSAssertDebug(message); [self resendFailedOutgoingMessage:message]; } - (void)didTapConversationItem:(id)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentPointer); TSMessage *message = (TSMessage *)viewItem.interaction; if (![message isKindOfClass:[TSMessage class]]) { OWSFailDebug(@"message had unexpected class: %@", message.class); return; } [self.attachmentDownloads downloadAttachmentPointer:attachmentPointer message:message bypassPendingMessageRequest:NO success:^(NSArray *attachmentStreams) { OWSAssertDebug(attachmentStreams.count == 1); TSAttachmentStream *attachmentStream = attachmentStreams.firstObject; DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [message anyUpdateMessageWithTransaction:transaction block:^(TSMessage *latestInstance) { [latestInstance setQuotedMessageThumbnailAttachmentStream:attachmentStream]; }]; }); } failure:^(NSError *error) { OWSLogWarn(@"Failed to redownload thumbnail with error: %@", error); }]; } - (void)didTapConversationItem:(id)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(quotedReply); OWSAssertDebug(quotedReply.timestamp > 0); OWSAssertDebug(quotedReply.authorAddress.isValid); __block NSIndexPath *_Nullable indexPath; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { indexPath = [self.conversationViewModel ensureLoadWindowContainsQuotedReply:quotedReply transaction:transaction]; }]; if (quotedReply.isRemotelySourced || !indexPath) { [self presentRemotelySourcedQuotedReplyToast]; return; } [self scrollToInteractionWithIndexPath:indexPath onScreenPercentage:1 position:ScrollToCenterIfNotEntirelyOnScreen 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; } if ([StickerPackInfo isStickerPackShareUrl:url]) { StickerPackInfo *_Nullable stickerPackInfo = [StickerPackInfo parseStickerPackShareUrl:url]; if (stickerPackInfo == nil) { OWSFailDebug(@"Could not parse sticker pack share URL: %@", url); } else { StickerPackViewController *packView = [[StickerPackViewController alloc] initWithStickerPackInfo:stickerPackInfo]; [packView presentFrom:self animated:YES]; return; } } if ([GroupManager isPossibleGroupInviteLink:url]) { [self didTapGroupInviteLink:url]; return; } [UIApplication.sharedApplication openURL:url options:@{} completionHandler:nil]; } - (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"); self.uiMode = ConversationUIMode_Normal; __block OWSQuotedReplyModel *quotedReply; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { quotedReply = [OWSQuotedReplyModel quotedReplyForSendingWithConversationViewItem:conversationItem transaction:transaction]; }]; if (![quotedReply isKindOfClass:[OWSQuotedReplyModel class]]) { OWSFailDebug(@"unexpected quotedMessage: %@", quotedReply.class); return; } if (SSKDebugFlags.internalLogging) { OWSLogInfo(@""); } self.inputToolbar.quotedReply = quotedReply; [self.inputToolbar beginEditingMessage]; } #pragma mark - OWSMessageStickerViewDelegate - (void)showStickerPack:(StickerPackInfo *)stickerPackInfo { OWSAssertIsOnMainThread(); StickerPackViewController *packView = [[StickerPackViewController alloc] initWithStickerPackInfo:stickerPackInfo]; [packView presentFrom:self animated:YES]; } #pragma mark - OWSMessageViewOnceViewDelegate - (void)didTapViewOnceAttachment:(id)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); OWSAssertDebug(attachmentStream); [ViewOnceMessageViewController tryToPresentWithInteraction:viewItem.interaction from:self]; } - (void)didTapViewOnceExpired:(id)viewItem { OWSAssertIsOnMainThread(); OWSAssertDebug(viewItem); if ([viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { [self presentViewOnceOutgoingToast]; } else { [self presentViewOnceAlreadyViewedToast]; } } #pragma mark - CNContactViewControllerDelegate - (void)contactViewController:(CNContactViewController *)viewController didCompleteWithContact:(nullable CNContact *)contact { [self.navigationController popToViewController:self animated:YES]; } #pragma mark - ContactsViewHelperObserver - (void)contactsViewHelperDidUpdateContacts { [self updateNavigationTitle]; [self reloadData]; } #pragma mark - Scroll Down Button - (void)createConversationScrollButtons { self.scrollDownButton = [[ConversationScrollButton alloc] initWithIconName:@"chevron-down-20"]; [self.scrollDownButton addTarget:self action:@selector(scrollDownButtonTapped) forControlEvents:UIControlEventTouchUpInside]; self.scrollDownButton.hidden = YES; self.scrollDownButton.alpha = 0; [self.view addSubview:self.scrollDownButton]; [self.scrollDownButton autoSetDimension:ALDimensionWidth toSize:ConversationScrollButton.buttonSize]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _scrollDownButton); [self.scrollDownButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:self.bottomBar withOffset:-16]; [self.scrollDownButton autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; self.scrollToNextMentionButton = [[ConversationScrollButton alloc] initWithIconName:@"mention-24"]; [self.scrollToNextMentionButton addTarget:self action:@selector(scrollToNextMentionButtonTapped) forControlEvents:UIControlEventTouchUpInside]; self.scrollToNextMentionButton.hidden = YES; self.scrollToNextMentionButton.alpha = 0; [self.view addSubview:self.scrollToNextMentionButton]; [self.scrollToNextMentionButton autoSetDimension:ALDimensionWidth toSize:ConversationScrollButton.buttonSize]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _scrollToNextMentionButton); [self.scrollToNextMentionButton autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:self.scrollDownButton withOffset:-10]; [self.scrollToNextMentionButton autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; } - (void)setUnreadMessageCount:(NSUInteger)unreadMessageCount { OWSAssertIsOnMainThread(); if (_unreadMessageCount != unreadMessageCount) { _unreadMessageCount = unreadMessageCount; [self configureScrollDownButtons]; } } - (void)setUnreadMentionMessages:(nullable NSArray *)unreadMentionMessages { OWSAssertIsOnMainThread(); if (_unreadMentionMessages != unreadMentionMessages) { _unreadMentionMessages = unreadMentionMessages; [self configureScrollDownButtons]; } } /// Checks to see if the unread message flag can be cleared. Shortcircuits if the flag is not set to begin with - (void)clearUnreadMessageFlagIfNecessary { OWSAssertIsOnMainThread(); if (self.unreadMessageCount > 0) { [self updateUnreadMessageFlagUsingAsyncTransaction]; } } - (void)updateUnreadMessageFlagUsingAsyncTransaction { // Resubmits to the main queue because we can't verify we're not already in a transaction we don't know about. // This method may be called in response to all sorts of view state changes, e.g. scroll state. These changes // can be a result of a UIKit response to app activity that already has an open transaction. // // We need a transaction to proceed, but we can't verify that we're not already in one (unless explicitly handed one) // To workaround this, we async a block to open a fresh transaction on the main queue. dispatch_async(dispatch_get_main_queue(), ^{ [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *newTransaction) { OWSAssertDebug(newTransaction); [self updateUnreadMessageFlagWithTransaction:newTransaction]; }]; }); } - (void)updateUnreadMessageFlagWithTransaction:(SDSAnyReadTransaction *)transaction { OWSAssertIsOnMainThread(); InteractionFinder *interactionFinder = [[InteractionFinder alloc] initWithThreadUniqueId:self.thread.uniqueId]; NSUInteger unreadCount = [interactionFinder unreadCountWithTransaction:transaction.unwrapGrdbRead]; [self setUnreadMessageCount:unreadCount]; self.unreadMentionMessages = [MentionFinder messagesMentioningWithAddress:self.tsAccountManager.localAddress in:self.thread includeReadMessages:NO transaction:transaction.unwrapGrdbRead]; } - (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 scrollToInteractionWithIndexPath:indexPathOfUnreadMessagesIndicator onScreenPercentage:1 position:ScrollToTop animated:YES]; return; } } [self scrollToBottomAnimated:YES]; } - (void)scrollToNextMentionButtonTapped { TSMessage *_Nullable nextMessage = self.unreadMentionMessages.firstObject; if (nextMessage) { [self scrollToInteractionWithUniqueId:nextMessage.uniqueId onScreenPercentage:1 position:ScrollToBottomIfNotEntirelyOnScreen animated:YES]; } } - (void)configureScrollDownButtons { OWSAssertIsOnMainThread(); 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)); BOOL isScrolledUpOnePage = scrollSpaceToBottom > pageHeight * 1.f; BOOL hasLaterMessageOffscreen = ([self lastSortIdInLoadedWindow] > [self lastVisibleSortId]) || [self.conversationViewModel canLoadNewerItems]; BOOL scrollDownWasHidden = self.isHidingScrollDownButton ? YES : self.scrollDownButton.hidden; BOOL scrollDownIsHidden = scrollDownWasHidden; BOOL scrollToNextMentionWasHidden = self.isHidingScrollToNextMentionButton ? YES : self.scrollToNextMentionButton.hidden; BOOL scrollToNextMentionIsHidden = scrollToNextMentionWasHidden; if (self.isInPreviewPlatter) { scrollDownIsHidden = YES; scrollToNextMentionIsHidden = YES; } else if (self.isPresentingMessageActions) { // Content offset calculations get messed up when we're presenting message actions // Don't change button visibility if we're presenting actions // no-op } else { BOOL shouldScrollDownAppear = isScrolledUpOnePage || hasLaterMessageOffscreen; scrollDownIsHidden = !shouldScrollDownAppear; BOOL shouldScrollToMentionAppear = shouldScrollDownAppear && self.unreadMentionMessages.count > 0; scrollToNextMentionIsHidden = !shouldScrollToMentionAppear; } BOOL scrollDownVisibilityDidChange = scrollDownIsHidden != scrollDownWasHidden; BOOL scrollToNextMentionVisibilityDidChange = scrollToNextMentionIsHidden != scrollToNextMentionWasHidden; if (scrollDownVisibilityDidChange || scrollToNextMentionVisibilityDidChange) { if (scrollDownVisibilityDidChange) { self.scrollDownButton.hidden = NO; self.isHidingScrollDownButton = scrollDownIsHidden; [self.scrollDownButton.layer removeAllAnimations]; } if (scrollToNextMentionVisibilityDidChange) { self.scrollToNextMentionButton.hidden = NO; self.isHidingScrollToNextMentionButton = scrollToNextMentionIsHidden; [self.scrollToNextMentionButton.layer removeAllAnimations]; } [UIView animateWithDuration:0.2 animations:^{ if (scrollDownVisibilityDidChange) { self.scrollDownButton.alpha = scrollDownIsHidden ? 0 : 1; } if (scrollToNextMentionVisibilityDidChange) { self.scrollToNextMentionButton.alpha = scrollToNextMentionIsHidden ? 0 : 1; } } completion:^(BOOL finished) { if (!finished) { return; } if (scrollDownVisibilityDidChange) { self.scrollDownButton.hidden = scrollDownIsHidden; self.isHidingScrollDownButton = NO; } if (scrollToNextMentionVisibilityDidChange) { self.scrollToNextMentionButton.hidden = scrollToNextMentionIsHidden; self.isHidingScrollToNextMentionButton = NO; } }]; } self.scrollDownButton.unreadCount = self.unreadMessageCount; self.scrollToNextMentionButton.unreadCount = self.unreadMentionMessages.count; } #pragma mark - Attachment Picking: Contacts - (void)chooseContactForSending { ContactsPicker *contactsPicker = [[ContactsPicker alloc] initWithAllowsMultipleSelection:NO subtitleCellType:SubtitleCellValueNone]; contactsPicker.contactsPickerDelegate = self; contactsPicker.title = NSLocalizedString(@"CONTACT_PICKER_TITLE", @"navbar title for contact picker when sharing a contact"); OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:contactsPicker]; [self dismissKeyBoard]; [self presentFormSheetViewController:navigationController animated:YES completion:nil]; } #pragma mark - Attachment Picking: Documents - (void)showAttachmentDocumentPickerMenu { ActionSheetController *actionSheet = [ActionSheetController new]; ActionSheetAction *mediaAction = [[ActionSheetAction alloc] initWithTitle:NSLocalizedString(@"MEDIA_FROM_LIBRARY_BUTTON", @"media picker option to choose from library") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { [self chooseFromLibraryAsDocument:YES]; }]; [actionSheet addAction:mediaAction]; ActionSheetAction *browseAction = [[ActionSheetAction alloc] initWithTitle:NSLocalizedString(@"BROWSE_FILES_BUTTON", @"browse files option from file sharing menu") style:ActionSheetActionStyleDefault handler:^(ActionSheetAction *action) { [self showDocumentPicker]; }]; [actionSheet addAction:browseAction]; [actionSheet addAction:OWSActionSheets.cancelAction]; [self dismissKeyBoard]; [self presentActionSheet:actionSheet]; } - (void)showDocumentPicker { 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; UIDocumentPickerViewController *pickerController = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:documentTypes inMode:pickerMode]; pickerController.delegate = self; [self dismissKeyBoard]; [self presentFormSheetViewController:pickerController animated:YES completion:nil]; } #pragma mark - Attachment Picking: GIFs - (void)showGifPicker { GifPickerNavigationViewController *gifModal = [GifPickerNavigationViewController new]; gifModal.approvalDelegate = self; [self dismissKeyBoard]; [self presentViewController:gifModal animated:YES completion:nil]; } - (void)messageWasSent:(TSOutgoingMessage *)message { OWSAssertIsOnMainThread(); OWSAssertDebug(message); if (SSKDebugFlags.internalLogging) { OWSLogInfo(@""); } self.lastMessageSentDate = [NSDate new]; [self.conversationViewModel clearUnreadMessagesIndicator]; self.inputToolbar.quotedReply = nil; if ([Environment.shared.preferences soundInForeground]) { SystemSoundID soundId = [OWSSounds systemSoundIDForSound:OWSStandardSound_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 presentFormSheetViewController: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(), ^{ [OWSActionSheets showActionSheetWithTitle: 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); NSError *error; _Nullable id dataSource = [DataSourcePath dataSourceWithURL:url shouldDeleteOnDeallocation:NO error:&error]; if (dataSource == nil) { OWSFailDebug(@"error: %@", error); dispatch_async(dispatch_get_main_queue(), ^{ [OWSActionSheets showActionSheetWithTitle: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 important 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 - Media Libary - (void)takePictureOrVideoWithPhotoCapture:(nullable PhotoCapture *)photoCapture { [BenchManager startEventWithTitle:@"Show-Camera" eventId:@"Show-Camera"]; [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. } SendMediaNavigationController *pickerModal = [SendMediaNavigationController showingCameraFirstWithPhotoCapture:photoCapture]; pickerModal.sendMediaNavDelegate = self; pickerModal.modalPresentationStyle = UIModalPresentationOverFullScreen; [self dismissKeyBoard]; [self presentViewController:pickerModal animated:YES completion:nil]; }]; }]; } - (void)chooseFromLibraryAsMedia { OWSAssertIsOnMainThread(); [self chooseFromLibraryAsDocument:NO]; } - (void)chooseFromLibraryAsDocument:(BOOL)shouldTreatAsDocument { OWSAssertIsOnMainThread(); [BenchManager startEventWithTitle:@"Show-Media-Library" eventId:@"Show-Media-Library"]; [self ows_askForMediaLibraryPermissions:^(BOOL granted) { if (!granted) { OWSLogWarn(@"Media Library permission denied."); return; } SendMediaNavigationController *pickerModal; if (shouldTreatAsDocument) { pickerModal = [SendMediaNavigationController asMediaDocumentPicker]; } else { pickerModal = [SendMediaNavigationController showingMediaLibraryFirst]; } pickerModal.sendMediaNavDelegate = self; [self dismissKeyBoard]; [self presentFullScreenViewController:pickerModal animated:YES completion:nil]; }]; } #pragma mark - SendMediaNavDelegate - (void)sendMediaNavDidCancel:(SendMediaNavigationController *)sendMediaNavigationController { [self dismissViewControllerAnimated:YES completion:nil]; } - (void)sendMediaNav:(SendMediaNavigationController *)sendMediaNavigationController didApproveAttachments:(NSArray *)attachments messageBody:(nullable MessageBody *)messageBody { [self tryToSendAttachments:attachments messageBody:messageBody]; [self.inputToolbar clearTextMessageAnimated:NO]; // 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:nil]; } - (nullable MessageBody *)sendMediaNavInitialMessageBody:(SendMediaNavigationController *)sendMediaNavigationController { return self.inputToolbar.messageBody; } - (void)sendMediaNav:(SendMediaNavigationController *)sendMediaNavigationController didChangeMessageBody:(nullable MessageBody *)messageBody { [self.inputToolbar setMessageBody:messageBody animated:NO]; } - (NSString *)sendMediaNavApprovalButtonImageName { return @"send-solid-24"; } - (BOOL)sendMediaNavCanSaveAttachments { return YES; } - (nullable NSString *)sendMediaNavTextInputContextIdentifier { return self.textInputContextIdentifier; } - (NSArray *)sendMediaNavRecipientNames { return @[ [self.contactsManager displayNameForThreadWithSneakyTransaction:self.thread] ]; } - (NSArray *)sendMediaNavMentionableAddresses { if (!self.supportsMentions) { return @[]; } return self.thread.recipientAddresses; } #pragma mark - - (void)sendContactShare:(ContactShareViewModel *)contactShare { OWSAssertIsOnMainThread(); OWSAssertDebug(contactShare); OWSLogVerbose(@"Sending contact share."); __block BOOL didAddToProfileWhitelist; TSThread *thread = self.thread; DatabaseStorageAsyncWrite(SDSDatabaseStorage.shared, ^(SDSAnyWriteTransaction *transaction) { didAddToProfileWhitelist = [ThreadUtil addThreadToProfileWhitelistIfEmptyOrPendingRequest:thread transaction:transaction]; // TODO - in line with QuotedReply and other message attachments, saving should happen as part of sending // preparation rather than duplicated here and in the SAE if (contactShare.avatarImage) { [contactShare.dbRecord saveAvatarImage:contactShare.avatarImage transaction:transaction]; } [transaction addAsyncCompletion:^{ TSOutgoingMessage *message = [ThreadUtil enqueueMessageWithContactShare:contactShare.dbRecord thread:thread]; [self messageWasSent:message]; if (didAddToProfileWhitelist) { [self ensureBannerState]; } }]; }); } - (void)showApprovalDialogAfterProcessingVideoURL:(NSURL *)movieURL filename:(nullable NSString *)filename { OWSAssertIsOnMainThread(); [ModalActivityIndicatorViewController presentFromViewController:self canCancel:YES backgroundBlock:^(ModalActivityIndicatorViewController *modalActivityIndicator) { NSError *dataSourceError; id dataSource = [DataSourcePath dataSourceWithURL:movieURL shouldDeleteOnDeallocation:NO error:&dataSourceError]; if (dataSourceError != nil) { [self showErrorAlertForAttachment:nil]; return; } 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]; } }]; }) .catch(^(NSError *error) { OWSLogError(@"Error: %@.", error); [self showErrorAlertForAttachment:nil]; }); }]; } #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]; [self ows_showNoMicrophonePermissionActionSheet]; } }]; } - (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]; 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.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]; [OWSActionSheets showActionSheetWithTitle: 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; } NSError *error; _Nullable id dataSource = [DataSourcePath dataSourceWithURL:self.audioRecorder.url shouldDeleteOnDeallocation:YES error:&error]; self.audioRecorder = nil; if (error != nil) { OWSFailDebug(@"Couldn't load audioRecorder data: %@", error); 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 ] messageBody:nil]; } } - (void)stopRecording { [self.audioRecorder stop]; [self.audioSession endAudioActivity:self.recordVoiceNoteAudioActivity]; } - (void)cancelRecordingVoiceMemo { OWSAssertIsOnMainThread(); OWSLogDebug(@"cancelRecordingVoiceMemo"); [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.shared addBlockWithBlockObject:audioRecorder]; } else if (_audioRecorder) { [DeviceSleepManager.shared removeBlockWithBlockObject:_audioRecorder]; } _audioRecorder = audioRecorder; } #pragma mark Accessory View - (void)cameraButtonPressed { OWSAssertIsOnMainThread(); [self takePictureOrVideoWithPhotoCapture:nil]; } - (void)cameraButtonPressedWithPhotoCapture:(nullable PhotoCapture *)photoCapture { OWSAssertIsOnMainThread(); [self takePictureOrVideoWithPhotoCapture:photoCapture]; } - (void)galleryButtonPressed { OWSAssertIsOnMainThread(); [self chooseFromLibraryAsMedia]; } - (void)gifButtonPressed { OWSAssertIsOnMainThread(); [self showGifPicker]; } - (void)fileButtonPressed { OWSAssertIsOnMainThread(); [self showAttachmentDocumentPickerMenu]; } - (void)contactButtonPressed { OWSAssertIsOnMainThread(); [self chooseContactForSending]; } - (void)locationButtonPressed { OWSAssertIsOnMainThread(); LocationPicker *locationPicker = [LocationPicker new]; locationPicker.delegate = self; OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:locationPicker]; [self dismissKeyBoard]; [self presentFormSheetViewController:navigationController animated:YES completion:nil]; } - (void)didSelectRecentPhotoWithAsset:(PHAsset *)asset attachment:(SignalAttachment *)attachment { OWSAssertIsOnMainThread(); [self dismissKeyBoard]; SendMediaNavigationController *pickerModal = [SendMediaNavigationController showingApprovalWithPickedLibraryMediaAsset:asset attachment:attachment delegate:self]; [self presentFullScreenViewController:pickerModal animated:true completion:nil]; } - (void)setLastSortIdMarkedRead:(uint64_t)lastSortIdMarkedRead { OWSAssertIsOnMainThread(); OWSAssertDebug(self.isMarkingAsRead); _lastSortIdMarkedRead = lastSortIdMarkedRead; } - (void)markVisibleMessagesAsRead { OWSAssertIsOnMainThread(); if (self.presentedViewController) { return; } if (OWSWindowManager.shared.shouldShowCallView) { return; } if (self.navigationController.topViewController != self) { return; } // Always clear the thread unread flag [self clearThreadUnreadFlagIfNecessary]; uint64_t lastVisibleSortId = [self lastVisibleSortId]; BOOL isShowingUnreadMessage = (lastVisibleSortId > [self lastSortIdMarkedRead]); if (!self.isMarkingAsRead && isShowingUnreadMessage) { self.isMarkingAsRead = YES; [self clearUnreadMessageFlagIfNecessary]; [BenchManager benchAsyncWithTitle:@"marking as read" block:^(void (^_Nonnull benchCompletion)(void)) { [[OWSReadReceiptManager shared] markAsReadLocallyBeforeSortId:lastVisibleSortId thread:self.thread hasPendingMessageRequest:self.threadViewModel.hasPendingMessageRequest completion:^{ OWSAssertIsOnMainThread(); [self setLastSortIdMarkedRead:lastVisibleSortId]; self.isMarkingAsRead = NO; // If -markVisibleMessagesAsRead wasn't invoked on a // timer, we'd want to double check that the current // -lastVisibleSortId hasn't incremented since we // started the read receipt request. But we have a // timer, so if it has changed, this method will just // be reinvoked in <100ms. benchCompletion(); }]; }]; } } - (void)conversationSettingsDidUpdate { OWSAssertIsOnMainThread(); DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { // We updated the group, so if there was a pending message request we should accept it. [ThreadUtil addThreadToProfileWhitelistIfEmptyOrPendingRequest:self.thread transaction:transaction]; }); } - (void)popKeyBoard { [self.inputToolbar beginEditingMessage]; } - (void)dismissKeyBoard { [self.inputToolbar endEditingMessage]; [self.inputToolbar clearDesiredKeyboard]; } #pragma mark Drafts - (void)loadDraftInCompose { OWSAssertIsOnMainThread(); __block MessageBody *_Nullable draft; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { draft = [self.thread currentDraftWithTransaction:transaction]; }]; OWSAssertDebug(self.inputToolbar != nil); OWSAssertDebug(self.inputToolbar.messageBody.text.length == 0); [self.inputToolbar setMessageBody:draft animated:NO]; } - (void)saveDraft { if (!self.inputToolbar.hidden) { TSThread *thread = self.thread; MessageBody *currentDraft = [self.inputToolbar messageBody]; DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [thread updateWithDraft:currentDraft transaction:transaction]; }); } } #pragma mark 3D Touch Preview Actions - (NSArray> *)previewActionItems { return @[]; } #pragma mark - ConversationHeaderViewDelegate - (void)didTapConversationHeaderView:(ConversationHeaderView *)conversationHeaderView { [self showConversationSettings]; } #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 { if (textView.text.length > 0) { [self.typingIndicators didStartTypingOutgoingInputInThread:self.thread]; } } - (void)inputTextViewSendMessagePressed { [self sendButtonPressed]; } - (void)didPasteAttachment:(SignalAttachment *_Nullable)attachment { // If the thing we pasted is sticker-like, send it immediately // and render it borderless. if (attachment.isBorderless) { [self tryToSendAttachments:@[ attachment ] messageBody:nil]; } else { [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 initialMessageBody:self.inputToolbar.messageBody approvalDelegate:self]; [self presentFullScreenViewController:modal animated:YES completion:nil]; } - (void)tryToSendAttachments:(NSArray *)attachments messageBody:(MessageBody *_Nullable)messageBody { DispatchMainThreadSafe(^{ __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { [self showUnblockConversationUI:^(BOOL isBlocked) { if (!isBlocked) { [weakSelf tryToSendAttachments:attachments messageBody:messageBody]; } }]; return; } BOOL didShowSNAlert = [self showSafetyNumberConfirmationIfNecessaryWithConfirmationText:[SafetyNumberStrings confirmSendButton] completion:^(BOOL didConfirmIdentity) { if (didConfirmIdentity) { [weakSelf tryToSendAttachments:attachments messageBody:messageBody]; } }]; if (didShowSNAlert) { return; } for (SignalAttachment *attachment in attachments) { if ([attachment hasError]) { OWSLogWarn(@"Invalid attachment: %@.", attachment ? [attachment errorName] : @"Missing data"); [self showErrorAlertForAttachment:attachment]; return; } } BOOL didAddToProfileWhitelist = [ThreadUtil addThreadToProfileWhitelistIfEmptyOrPendingRequestWithSneakyTransaction:self.thread]; __block TSOutgoingMessage *message; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *_Nonnull transaction) { message = [ThreadUtil enqueueMessageWithBody:messageBody mediaAttachments:attachments thread:self.thread quotedReplyModel:self.inputToolbar.quotedReply linkPreviewDraft:nil transaction:transaction]; }]; [self messageWasSent:message]; if (didAddToProfileWhitelist) { [self ensureBannerState]; } }); } - (void)applyTheme { OWSAssertIsOnMainThread(); // make sure toolbar extends below iPhoneX home button. self.view.backgroundColor = Theme.toolbarBackgroundColor; self.collectionView.backgroundColor = Theme.backgroundColor; [self updateNavigationTitle]; [self updateNavigationBarSubtitleLabel]; [self createInputToolbar]; [self updateInputToolbarLayout]; [self updateBarButtonItems]; [self reloadData]; // Re-styling the message actions is tricky, // since this happens rarely just dismiss [self dismissMessageActionsAnimated:NO]; [self dismissReactionsDetailSheetAnimated:NO]; } - (void)reloadData { if (self.viewHasEverAppeared) { // [UICollectionView reloadData] sometimes has no effect. // This might be a regression in iOS 13? reloadSections // does not appear to have the same issue. [UIView performWithoutAnimation:^{ [self.collectionView reloadSections:[NSIndexSet indexSetWithIndex:0]]; [self.collectionView.collectionViewLayout invalidateLayout]; }]; } else { // Don't reload sections until the view has appeared and the // collection view has loaded. [self.collectionView reloadData]; [self.collectionView.collectionViewLayout invalidateLayout]; } } - (void)createInputToolbar { MessageBody *_Nullable existingDraft; if (_inputToolbar != nil) { existingDraft = _inputToolbar.messageBody; } _inputToolbar = [[ConversationInputToolbar alloc] initWithConversationStyle:self.conversationStyle]; self.inputToolbar.inputToolbarDelegate = self; self.inputToolbar.inputTextViewDelegate = self; self.inputToolbar.mentionDelegate = self; [self.inputToolbar setMessageBody:existingDraft animated:NO]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _inputToolbar); // reloadBottomBar is expensive and we need to avoid it while // initially configuring the view. viewWillAppear() will call // reloadBottomBar(). After viewWillAppear(), we need to call // reloadBottomBar() to reflect changes in the theme. if (self.hasViewWillAppearOccurred) { [self reloadBottomBar]; } } #pragma mark - AttachmentApprovalViewControllerDelegate - (void)attachmentApprovalDidAppear:(AttachmentApprovalViewController *)attachmentApproval { // no-op } - (void)attachmentApproval:(AttachmentApprovalViewController *)attachmentApproval didApproveAttachments:(NSArray *)attachments messageBody:(MessageBody *_Nullable)messageBody { [self tryToSendAttachments:attachments messageBody:messageBody]; [self.inputToolbar clearTextMessageAnimated:NO]; [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 didChangeMessageBody:(nullable MessageBody *)newMessageBody { [self.inputToolbar setMessageBody:newMessageBody animated:NO]; } - (nullable NSString *)attachmentApprovalTextInputContextIdentifier { return self.textInputContextIdentifier; } - (NSArray *)attachmentApprovalRecipientNames { return @[ [self.contactsManager displayNameForThreadWithSneakyTransaction:self.thread] ]; } - (NSArray *)attachmentApprovalMentionableAddresses { if (!self.supportsMentions) { return @[]; } return self.thread.recipientAddresses; } #pragma mark - - (void)showErrorAlertForAttachment:(SignalAttachment *_Nullable)attachment { OWSAssertDebug(attachment == nil || [attachment hasError]); NSString *errorMessage = (attachment ? [attachment localizedErrorDescription] : [SignalAttachment missingDataErrorMessage]); OWSLogError(@": %@", errorMessage); [OWSActionSheets showActionSheetWithTitle: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; } #pragma mark - UIScrollViewDelegate - (void)updateLastKnownDistanceFromBottom { // Never update the lastKnownDistanceFromBottom, // if we're presenting the message actions which // temporarily meddles with the content insets. if (!self.isPresentingMessageActions) { self.lastKnownDistanceFromBottom = @(self.safeDistanceFromBottom); } } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { // Constantly try to update the lastKnownDistanceFromBottom. [self updateLastKnownDistanceFromBottom]; [self configureScrollDownButtons]; [self scheduleScrollUpdateTimer]; } - (void)scheduleScrollUpdateTimer { [self.scrollUpdateTimer invalidate]; self.scrollUpdateTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.1f target:self selector:@selector(scrollUpdateTimerDidFire) userInfo:nil repeats:NO]; } - (void)scrollUpdateTimerDidFire { if (!self.viewHasEverAppeared) { return; } [self autoLoadMoreIfNecessary]; [self saveLastVisibleSortIdAndOnScreenPercentage]; } - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { self.userHasScrolled = YES; self.isUserScrolling = YES; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)willDecelerate { if (!self.isUserScrolling) { return; } self.isUserScrolling = NO; if (willDecelerate) { self.isWaitingForDeceleration = willDecelerate; } else { [self scheduleScrollUpdateTimer]; } } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { if (!self.isWaitingForDeceleration) { return; } self.isWaitingForDeceleration = NO; [self scheduleScrollUpdateTimer]; } #pragma mark - ConversationSettingsViewDelegate - (void)resendGroupUpdateForErrorMessage:(TSErrorMessage *)message { OWSAssertIsOnMainThread(); OWSAssertDebug([self.thread isKindOfClass:[TSGroupThread class]]); OWSAssertDebug(message); TSGroupThread *groupThread = (TSGroupThread *)self.thread; [GroupManager sendGroupUpdateMessageObjcWithThread:groupThread].thenOn( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ OWSLogInfo(@"Group updated, removing group creation error."); DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [message anyRemoveWithTransaction:transaction]; }); }); } - (void)conversationColorWasUpdated { [self.conversationStyle updateProperties]; [self.headerView updateAvatar]; [self resetContentAndLayoutWithSneakyTransaction]; } - (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)setUiMode:(ConversationUIMode)newValue { ConversationUIMode oldValue = _uiMode; if (newValue == oldValue) { return; } _uiMode = newValue; [self uiModeDidChangeWithOldValue:oldValue]; } #pragma mark - Conversation Search - (void)conversationSettingsDidRequestConversationSearch { self.uiMode = ConversationUIMode_Search; [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]; }); }]; } #pragma mark ConversationSearchControllerDelegate - (void)didDismissSearchController:(UISearchController *)searchController { OWSLogVerbose(@""); OWSAssertIsOnMainThread(); // This method is called not only when the user taps "cancel" in the searchController, but also // called when the searchController was dismissed because we switched to another uiMode, like // "selection". We only want to revert to "normal" in the former case - when the user tapped // "cancel" in the search controller. Otherwise, if we're already in another mode, like // "selection", we want to stay in that mode. if (self.uiMode == ConversationUIMode_Search) { self.uiMode = ConversationUIMode_Normal; } } - (void)conversationSearchController:(ConversationSearchController *)conversationSearchController didUpdateSearchResults:(nullable ConversationScreenSearchResultSet *)conversationScreenSearchResultSet { OWSAssertIsOnMainThread(); OWSLogVerbose(@"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 scrollToInteractionWithUniqueId:messageId onScreenPercentage:1 position:ScrollToCenterIfNotEntirelyOnScreen animated:YES]; [BenchManager completeEventWithEventId:[NSString stringWithFormat:@"Conversation Search Nav: %@", messageId]]; } #pragma mark - ConversationViewLayoutDelegate - (BOOL)shouldHideCollectionViewContent { // Avoid layout and measurement during view configuration. return !self.hasViewWillAppearOccurred; } - (NSArray> *)layoutItems { if (self.shouldHideCollectionViewContent) { return 0; } return self.viewItems; } - (CGFloat)layoutHeaderHeight { return (self.showLoadOlderHeader ? LoadMoreMessagesView.fixedHeight : 0.f); } - (CGFloat)layoutFooterHeight { return (self.showLoadNewerHeader ? LoadMoreMessagesView.fixedHeight : 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 acceptAutocorrectSuggestion]; [self tryToSendTextMessage:self.inputToolbar.messageBody updateKeyboardState:YES]; } - (void)tryToSendTextMessage:(MessageBody *)messageBody updateKeyboardState:(BOOL)updateKeyboardState { OWSAssertIsOnMainThread(); __weak ConversationViewController *weakSelf = self; if ([self isBlockedConversation]) { [self showUnblockConversationUI:^(BOOL isBlocked) { if (!isBlocked) { [weakSelf tryToSendTextMessage:messageBody updateKeyboardState:NO]; } }]; return; } BOOL didShowSNAlert = [self showSafetyNumberConfirmationIfNecessaryWithConfirmationText:[SafetyNumberStrings confirmSendButton] completion:^(BOOL didConfirmIdentity) { if (didConfirmIdentity) { [weakSelf resetVerificationStateToDefault]; [weakSelf tryToSendTextMessage:messageBody updateKeyboardState:NO]; } }]; if (didShowSNAlert) { return; } if (messageBody.text.length < 1) { return; } BOOL didAddToProfileWhitelist = [ThreadUtil addThreadToProfileWhitelistIfEmptyOrPendingRequestWithSneakyTransaction:self.thread]; __block TSOutgoingMessage *message; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { message = [ThreadUtil enqueueMessageWithBody:messageBody thread:self.thread quotedReplyModel:self.inputToolbar.quotedReply linkPreviewDraft:self.inputToolbar.linkPreviewDraft transaction:transaction]; }]; [self.conversationViewModel clearUnreadMessagesIndicator]; [self.conversationViewModel appendUnsavedOutgoingTextMessage:message]; [self messageWasSent:message]; // Clearing the text message is a key part of the send animation. // It takes 10-15ms, but we do it inline rather than dispatch async // since the send can't feel "complete" without it. [BenchManager benchWithTitle:@"clearTextMessageAnimated" block:^{ [self.inputToolbar clearTextMessageAnimated:YES]; }]; [BenchManager completeEventWithEventId:@"fromSendUntil_clearTextMessageAnimated"]; dispatch_async(dispatch_get_main_queue(), ^{ // After sending we want to return from the numeric keyboard to the // alphabetical one. Because this is so slow (40-50ms), we prefer it // happens async, after any more essential send UI work is done. [BenchManager benchWithTitle:@"toggleDefaultKeyboard" block:^{ [self.inputToolbar toggleDefaultKeyboard]; }]; [BenchManager completeEventWithEventId:@"fromSendUntil_toggleDefaultKeyboard"]; }); TSThread *thread = self.thread; DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [thread updateWithDraft:nil transaction:transaction]; }); if (didAddToProfileWhitelist) { [self ensureBannerState]; } } - (void)sendSticker:(StickerInfo *)stickerInfo { OWSAssertIsOnMainThread(); OWSAssertDebug(stickerInfo); OWSLogVerbose(@"Sending sticker."); TSOutgoingMessage *message = [ThreadUtil enqueueMessageWithInstalledSticker:stickerInfo thread:self.thread]; [self messageWasSent:message]; } - (void)presentManageStickersView { OWSAssertIsOnMainThread(); ManageStickersViewController *manageStickersView = [ManageStickersViewController new]; OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:manageStickersView]; [self presentFormSheetViewController:navigationController animated:YES completion:nil]; } - (void)updateToolbarHeight { [self updateInputAccessoryPlaceholderHeight]; // Normally, the keyboard frame change triggered by updating // the bottom bar height will cause the content insets to reload. // However, if the toolbar updates while it's not the first // responder (e.g. dismissing a quoted reply) we need to preserve // our constraints here. if (!self.inputToolbar.isInputViewFirstResponder) { [self updateContentInsetsAnimated:NO]; } } - (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)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; } } #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 configureScrollDownButtons]; } #pragma mark - UICollectionViewDataSource - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { if (self.shouldHideCollectionViewContent) { return 0; } 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; messageCell.messageStickerView.delegate = self; messageCell.messageViewOnceView.delegate = self; } cell.conversationStyle = self.conversationStyle; [cell loadForDisplay]; [cell layoutIfNeeded]; // This must happen after load for display, since the tap // gesture doesn't get added to a view until this point. if ([cell isKindOfClass:[OWSMessageCell class]]) { OWSMessageCell *messageCell = (OWSMessageCell *)cell; [self.tapGestureRecognizer requireGestureRecognizerToFail:messageCell.messageViewTapGestureRecognizer]; [self.tapGestureRecognizer requireGestureRecognizerToFail:messageCell.contentViewTapGestureRecognizer]; [messageCell.messageViewTapGestureRecognizer requireGestureRecognizerToFail:self.panGestureRecognizer]; [messageCell.contentViewTapGestureRecognizer requireGestureRecognizerToFail:self.panGestureRecognizer]; } #ifdef DEBUG // TODO: Confirm with nancy if this will work. NSString *cellName = [NSString stringWithFormat:@"interaction.%@", NSUUID.UUID.UUIDString]; if (viewItem.hasBodyText && viewItem.displayableBodyText.displayAttributedText.length > 0) { NSString *textForId = [viewItem.displayableBodyText.displayAttributedText.string stringByReplacingOccurrencesOfString:@" " withString:@"_"]; cellName = [NSString stringWithFormat:@"message.text.%@", textForId]; } else if (viewItem.stickerInfo) { cellName = [NSString stringWithFormat:@"message.sticker.%@", [viewItem.stickerInfo asKey]]; } cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, cellName); #endif return cell; } - (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath { if ([kind isEqualToString:UICollectionElementKindSectionHeader] || [kind isEqualToString:UICollectionElementKindSectionFooter]) { LoadMoreMessagesView *loadMoreView = [self.collectionView dequeueReusableSupplementaryViewOfKind:kind withReuseIdentifier:LoadMoreMessagesView.reuseIdentifier forIndexPath:indexPath]; [loadMoreView configureForDisplay]; return loadMoreView; } OWSFailDebug(@"unexpected supplementaryElement: %@", kind); return [UICollectionReusableView new]; } #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 (self.isPresentingMessageActions) { NSValue *_Nullable contentOffset = [self contentOffsetForMessageActionInteraction]; if (contentOffset != nil) { return contentOffset.CGPointValue; } } if (self.scrollContinuity == kScrollContinuityBottom && self.lastKnownDistanceFromBottom && CurrentAppContext().isAppForegroundAndActive) { 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 = -self.collectionView.safeAreaInsets.top; 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 = self.collectionView.adjustedContentInset; // 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 - ContactsPickerDelegate - (void)contactsPickerDidCancel:(ContactsPicker *)contactsPicker { OWSLogDebug(@""); [self dismissViewControllerAnimated:YES completion:nil]; } - (void)contactsPicker:(ContactsPicker *)contactsPicker contactFetchDidFail:(NSError *)error { OWSLogDebug(@"with error %@", error); [self dismissViewControllerAnimated:YES completion:nil]; } - (void)contactsPicker:(ContactsPicker *)contactsPicker didSelectContact:(Contact *)contact { OWSAssertDebug(contact); OWSAssertDebug(contact.cnContactId); CNContact *_Nullable cnContact = [self.contactsManager cnContactWithId:contact.cnContactId]; if (!cnContact) { OWSFailDebug(@"Could not load system contact."); return; } OWSLogDebug(@"with contact: %@", contact); OWSContact *_Nullable contactShareRecord = [OWSContacts contactForSystemContact:cnContact]; if (!contactShareRecord) { OWSFailDebug(@"Could not convert system contact."); return; } BOOL isProfileAvatar = NO; __block NSData *_Nullable avatarImageData = [self.contactsManager avatarDataForCNContactId:cnContact.identifier]; for (SignalServiceAddress *address in contact.registeredAddresses) { if (avatarImageData) { break; } avatarImageData = [self.contactsManager profileImageDataForAddressWithSneakyTransaction:address]; if (avatarImageData) { isProfileAvatar = YES; } } contactShareRecord.isProfileAvatar = isProfileAvatar; ContactShareViewModel *contactShare = [[ContactShareViewModel alloc] initWithContactShareRecord:contactShareRecord avatarImageData:avatarImageData]; ContactShareApprovalViewController *approveContactShare = [[ContactShareApprovalViewController alloc] initWithContactShare:contactShare]; approveContactShare.delegate = self; OWSAssertDebug(contactsPicker.navigationController); [contactsPicker.navigationController pushViewController:approveContactShare animated:YES]; } - (void)contactsPicker:(ContactsPicker *)contactsPicker didSelectMultipleContacts:(NSArray *)contacts { OWSFailDebug(@"with contacts: %@", contacts); [self dismissViewControllerAnimated:YES completion:nil]; } - (BOOL)contactsPicker:(ContactsPicker *)contactsPicker shouldSelectContact:(Contact *)contact { // Any reason to preclude contacts? return YES; } #pragma mark - ContactShareApprovalViewControllerDelegate - (void)approveContactShare:(ContactShareApprovalViewController *)approveContactShare didApproveContactShare:(ContactShareViewModel *)contactShare { OWSLogInfo(@""); [self dismissViewControllerAnimated:YES completion:^{ [self sendContactShare:contactShare]; }]; } - (void)approveContactShare:(ContactShareApprovalViewController *)approveContactShare didCancelContactShare:(ContactShareViewModel *)contactShare { OWSLogInfo(@""); [self dismissViewControllerAnimated:YES completion:nil]; } - (nullable NSString *)contactApprovalCustomTitle:(ContactShareApprovalViewController *)contactApproval { return nil; } - (nullable NSString *)contactApprovalRecipientsDescription:(ContactShareApprovalViewController *)contactApproval { OWSLogInfo(@""); __block NSString *result; [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { result = [self.contactsManager displayNameForThread:self.thread transaction:transaction]; }]; return result; } - (ApprovalMode)contactApprovalMode:(ContactShareApprovalViewController *)contactApproval { OWSLogInfo(@""); return ApprovalModeSend; } #pragma mark - ContactShareViewHelperDelegate - (void)didCreateOrEditContact { OWSLogInfo(@""); [self dismissViewControllerAnimated:YES completion:nil]; } #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]; } - (void)presentViewOnceAlreadyViewedToast { OWSLogInfo(@""); NSString *toastText = NSLocalizedString(@"VIEW_ONCE_ALREADY_VIEWED_TOAST", @"Toast alert text shown when tapping on a view-once message that has already been viewed."); ToastController *toastController = [[ToastController alloc] initWithText:toastText]; CGFloat bottomInset = kToastInset + self.collectionView.contentInset.bottom + self.view.layoutMargins.bottom; [toastController presentToastViewFromBottomOfView:self.view inset:bottomInset]; } - (void)presentViewOnceOutgoingToast { OWSLogInfo(@""); NSString *toastText = NSLocalizedString( @"VIEW_ONCE_OUTGOING_TOAST", @"Toast alert text shown when tapping on a view-once message that you have sent."); 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 - 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)conversationViewModelDidUpdateWithSneakyTransaction:(ConversationUpdate *)conversationUpdate { [self.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { [self conversationViewModelDidUpdate:conversationUpdate transaction:transaction]; }]; } - (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate transaction:(SDSAnyReadTransaction *)transaction { OWSAssertIsOnMainThread(); OWSAssertDebug(conversationUpdate); OWSAssertDebug(self.conversationViewModel); if (!self.viewLoaded) { // It's safe to ignore updates before the view loads; // viewWillAppear will call resetContentAndLayout. return; } NSUInteger oldPendingMemberRequestCount = self.pendingMemberRequestCount; TSThread *_Nullable lastestThread = [TSThread anyFetchWithUniqueId:self.thread.uniqueId transaction:transaction]; if (lastestThread == nil) { lastestThread = self.thread; } _threadViewModel = [[ThreadViewModel alloc] initWithThread:lastestThread transaction:transaction]; [self updateNavigationBarSubtitleLabel]; [self updateBarButtonItems]; NSUInteger newPendingMemberRequestCount = self.pendingMemberRequestCount; if (oldPendingMemberRequestCount != newPendingMemberRequestCount && self.canApprovePendingMemberRequests) { [self ensureBannerState]; } // If the message has been deleted / disappeared, we need to dismiss [self dismissMessageActionsIfNecessary]; [self reloadReactionsDetailSheetWithTransaction:transaction]; if (self.isGroupConversation) { [self updateNavigationTitle]; } if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Minor) { [self showMessageRequestDialogIfRequiredAsync]; return; } else if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Reload) { [self resetContentAndLayoutWithTransaction:transaction]; [self updateUnreadMessageFlagWithTransaction:transaction]; [self showMessageRequestDialogIfRequiredAsync]; return; } [self resetShowLoadMore]; 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); BOOL isSusceptibleToCrashAfterDeletingLastItem; if (@available(iOS 12, *)) { isSusceptibleToCrashAfterDeletingLastItem = NO; } else { isSusceptibleToCrashAfterDeletingLastItem = YES; } NSNumber *_Nullable interactionCount; if (isSusceptibleToCrashAfterDeletingLastItem) { interactionCount = @([self.thread numberOfInteractionsWithTransaction:transaction]); } __block BOOL shouldInvalidateLayout = NO; 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] ]]; if (isSusceptibleToCrashAfterDeletingLastItem) { OWSAssertDebug(interactionCount != nil); if (interactionCount.unsignedLongValue == 0) { shouldInvalidateLayout = YES; } } 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]] && conversationUpdate.shouldJumpToOutgoingMessage) { 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; } } } if (shouldInvalidateLayout) { OWSLogDebug(@"invalidating layout"); [self.layout invalidateLayout]; } }; BOOL shouldAnimateUpdates = conversationUpdate.shouldAnimateUpdates; void (^batchUpdatesCompletion)(BOOL) = ^(BOOL finished) { OWSAssertIsOnMainThread(); // We can't use the transaction parameter; this completion // will be run async. [self updateUnreadMessageFlagUsingAsyncTransaction]; [self configureScrollDownButtons]; [self showMessageRequestDialogIfRequired]; if (scrollToBottom) { [self scrollToBottomAnimated:NO]; } // Try to update the lastKnownDistanceFromBottom; the content size may have changed. [self updateLastKnownDistanceFromBottom]; if (!finished) { OWSLogInfo(@"performBatchUpdates did not finish"); // If did not finish, reset to get back to a known good state. [self resetContentAndLayoutWithSneakyTransaction]; } else { if (self.isShowingSelectionUI) { [self maintainSelectionAfterMappingChange]; [self updateSelectionHighlight]; } } }; @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]; [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); // To maintain scroll position after changing the items loaded in the conversation view: // // 1. in conversationViewModelWillLoadMoreItems // - Get position of some interactions cell before transition. // - Get content offset before transition // // 2. Load More // // 3. in conversationViewModelDidLoadMoreItems // - Get position of that same interaction's cell (it'll have a new index) // - Get content offset after transition // - Offset scrollViewContent so that the cell is in the same spot after as it was before. NSIndexPath *_Nullable indexPath = self.lastVisibleIndexPath; if (indexPath == nil) { // nothing visible yet return; } id viewItem = [self viewItemForIndex:indexPath.row]; if (viewItem == nil) { OWSFailDebug(@"viewItem was unexpectedly nil"); return; } UIView *cell = [self collectionView:self.collectionView cellForItemAtIndexPath:indexPath]; if (cell == nil) { OWSFailDebug(@"cell was unexpectedly nil"); return; } CGRect frame = cell.frame; CGPoint contentOffset = self.collectionView.contentOffset; self.scrollStateBeforeLoadingMore = [[ConversationScrollState alloc] initWithReferenceViewItem:viewItem referenceFrame:frame contentOffset:contentOffset]; } - (void)conversationViewModelDidLoadMoreItems { OWSAssertIsOnMainThread(); OWSAssertDebug(self.conversationViewModel); [self.layout prepareLayout]; ConversationScrollState *_Nullable scrollState = self.scrollStateBeforeLoadingMore; if (scrollState == nil) { OWSFailDebug(@"scrollState was unexpectedly nil"); return; } NSIndexPath *_Nullable newIndexPath = [self.conversationViewModel indexPathForViewItem:scrollState.referenceViewItem]; if (newIndexPath == nil) { OWSFailDebug(@"newIndexPath was unexpectedly nil"); return; } UIView *_Nullable cell = [self collectionView:self.collectionView cellForItemAtIndexPath:newIndexPath]; if (cell == nil) { OWSFailDebug(@"cell was unexpectedly nil"); return; } CGRect newFrame = cell.frame; // distance from top of cell to top of content pane. CGFloat previousDistance = scrollState.referenceFrame.origin.y - scrollState.contentOffset.y; CGFloat newDistance = newFrame.origin.y - previousDistance; CGPoint newContentOffset = CGPointMake(0, newDistance); // Note: It's important that we call `setContentOffset:animated:NO` rather than `setContentOffset:`, // even though `setContentOffset:` is, by default, not animated. UICollectionView does some // other work in `setContentOffset:animated:NO`. Without that additional work, we see situations // where contentOffset is incorrectly reset to the top - causing the user to inexplicably be // farther back in their history than they expect. // // When using `[self.collectionView setContentOffset:newContentOffset]`, a trivial repro is: // // - have enough messages that you can load in a couple pages (e.g. 100) // - tap the top of the navbar to hit UICollectionView's "scroll to top" tap gesture // - you see "loading more..." which is shortly replaced by the newly loaded messages // - At this point you would expect to maintain the conversation context, such that the messages // visible before loading are visible at the same screen coordinates. // - But instead, after the messages load in, you are immediately scrolled back even farther // to the *new* top of the conversation, causing *another* page of messages to be loaded. // // I'm unclear what the underlying issue is, but it may be related to: // - we set contentOffset here, but collectionView hasn't yet internally updated it's contentSize // to reflect the new layout. Maybe this triggers a "reset". // - Manually setting the collectionView.contentSize view [collectionView setContentSize:]` to // the new `[self safeContentHeight]` also did not remedy the issue, so it seems like there is // some other relevant state. // - I could find no public API to trigger collectionView to update it's own contentSize sync, // but a debugger shows it as happening as a result of `[collectionView layoutSubviews]` // - manually calling layout methods doesn't update the content size: e.g. // - [collectionView layoutIfNeeded]; // <- doesn't help // - [collectionView setNeedsLayout]; [collectionView layoutIfNeeded]; // <- doesn't help // - [collectionView layoutSubviews]; // <- doesn't help [self.collectionView setContentOffset:newContentOffset animated:NO]; } - (void)conversationViewModelRangeDidChangeWithTransaction:(SDSAnyReadTransaction *)transaction { OWSAssertIsOnMainThread(); if (!self.conversationViewModel) { OWSFailDebug(@"conversationViewModel was unexpectedly nil"); return; } [self updateShowLoadMoreHeadersWithTransaction:transaction]; } - (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]; [self dismissMessageActionsAnimated:NO]; [self dismissReactionsDetailSheetAnimated:NO]; __weak ConversationViewController *weakSelf = self; [coordinator animateAlongsideTransition:^(id context) { [self scrollToLastVisibleInteractionAnimated:NO]; } completion:^(id context) { ConversationViewController *strongSelf = weakSelf; if (!strongSelf) { return; } // When transition animation is complete, update layout to reflect // new size. [strongSelf resetForSizeOrOrientationChange]; [strongSelf updateInputToolbarLayout]; [self scrollToLastVisibleInteractionAnimated:NO]; }]; } - (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; [self ensureBannerState]; [self updateBarButtonItems]; [self updateNavigationBarSubtitleLabel]; } - (void)resetForSizeOrOrientationChange { self.scrollContinuity = kScrollContinuityBottom; self.conversationStyle.viewWidth = floor(self.collectionView.width); // Evacuate cached cell sizes. for (id viewItem in self.viewItems) { [viewItem clearCachedLayoutState]; } [self reloadData]; if (self.viewHasEverAppeared) { // Try to update the lastKnownDistanceFromBottom; the content size may have changed. [self updateLastKnownDistanceFromBottom]; } [self updateInputToolbarLayout]; [self updateLeftBarItem]; [self maintainSelectionAfterMappingChange]; [self updateSelectionHighlight]; } - (void)viewSafeAreaInsetsDidChange { [super viewSafeAreaInsetsDidChange]; [self updateContentInsetsAnimated:NO]; [self updateInputToolbarLayout]; } - (void)updateInputToolbarLayout { [self.inputToolbar updateLayoutWithSafeAreaInsets:self.view.safeAreaInsets]; } #pragma mark - Message Request - (void)showMessageRequestDialogIfRequiredAsync { __weak ConversationViewController *weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf showMessageRequestDialogIfRequired]; }); } - (void)showMessageRequestDialogIfRequired { OWSAssertIsOnMainThread(); if (self.threadViewModel.hasPendingMessageRequest || self.isLocalUserRequestingMember) { [self.requestView removeFromSuperview]; if (self.isLocalUserRequestingMember) { MemberRequestView *memberRequestView = [[MemberRequestView alloc] initWithThreadViewModel:self.threadViewModel fromViewController:self]; memberRequestView.delegate = self; self.requestView = memberRequestView; } else { MessageRequestView *messageRequestView = [[MessageRequestView alloc] initWithThreadViewModel:self.threadViewModel]; messageRequestView.delegate = self; self.requestView = messageRequestView; } [self reloadBottomBar]; } else { if (self.requestView != nil) { [self dismissMessageRequestView]; } else { [self reloadBottomBar]; [self updateInputVisibility]; } } } - (void)dismissMessageRequestView { OWSAssertIsOnMainThread(); if (!self.requestView) { return; } // Slide the request view off the bottom of the screen. CGFloat bottomInset = self.view.safeAreaInsets.bottom; UIView *dismissingView = self.requestView; self.requestView = nil; [self reloadBottomBar]; [self updateInputVisibility]; // Add the view on top of the new bottom bar (if there is one), // and then slide it off screen to reveal the new input view. [self.view addSubview:dismissingView]; [dismissingView autoPinWidthToSuperview]; [dismissingView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; CGRect endFrame = dismissingView.bounds; endFrame.origin.y -= endFrame.size.height + bottomInset; [UIView animateWithDuration:0.2 animations:^{ dismissingView.bounds = endFrame; } completion:^(BOOL finished) { [dismissingView removeFromSuperview]; }]; } #pragma mark - LocationPickerDelegate - (void)didPickLocation:(LocationPicker *)locationPicker location:(Location *)location { OWSAssertIsOnMainThread(); OWSAssertDebug(location); OWSLogVerbose(@"Sending location share."); __weak ConversationViewController *weakSelf = self; [location prepareAttachmentObjc].then(^(SignalAttachment *attachment) { OWSAssertIsOnMainThread(); OWSAssertDebug([attachment isKindOfClass:[SignalAttachment class]]); __strong typeof(self) strongSelf = weakSelf; if (!strongSelf) { return; } __block TSOutgoingMessage *message; [strongSelf.databaseStorage uiReadWithBlock:^(SDSAnyReadTransaction *transaction) { message = [ThreadUtil enqueueMessageWithBody:[[MessageBody alloc] initWithText:location.messageText ranges:MessageBodyRanges.empty] mediaAttachments:@[ attachment ] thread:strongSelf.thread quotedReplyModel:nil linkPreviewDraft:nil transaction:transaction]; }]; [strongSelf messageWasSent:message]; }); } #pragma mark - InputAccessoryViewPlaceholderDelegate - (void)inputAccessoryPlaceholderKeyboardIsDismissingInteractively { // No animation, just follow along with the keyboard. self.isDismissingInteractively = YES; [self updateBottomBarPosition]; self.isDismissingInteractively = NO; } - (void)inputAccessoryPlaceholderKeyboardIsDismissingWithAnimationDuration:(NSTimeInterval)animationDuration animationCurve:(UIViewAnimationCurve)animationCurve { [self handleKeyboardStateChange:animationDuration animationCurve:animationCurve]; } - (void)inputAccessoryPlaceholderKeyboardIsPresentingWithAnimationDuration:(NSTimeInterval)animationDuration animationCurve:(UIViewAnimationCurve)animationCurve { [self handleKeyboardStateChange:animationDuration animationCurve:animationCurve]; } - (void)handleKeyboardStateChange:(NSTimeInterval)animationDuration animationCurve:(UIViewAnimationCurve)animationCurve { if (self.shouldAnimateKeyboardChanges && animationDuration > 0) { // The animation curve provided by the keyboard notifications // is a private value not represented in UIViewAnimationOptions. // We don't use a block based animation here because it's not // possible to pass a curve directly to block animations. [UIView beginAnimations:@"keyboardStateChange" context:nil]; [UIView setAnimationBeginsFromCurrentState:YES]; [UIView setAnimationCurve:animationCurve]; [UIView setAnimationDuration:animationDuration]; [self updateBottomBarPosition]; [UIView commitAnimations]; [self updateContentInsetsAnimated:YES]; } else { [self updateBottomBarPosition]; [self updateContentInsetsAnimated:NO]; } } // MARK: - - (void)reloadBottomBar { UIView *bottomView; if (self.requestView != nil) { bottomView = self.requestView; } else { switch (self.uiMode) { case ConversationUIMode_Search: bottomView = self.searchController.resultsBar; break; case ConversationUIMode_Selection: bottomView = self.selectionToolbar; break; case ConversationUIMode_Normal: bottomView = self.inputToolbar; break; } } if (bottomView.superview == self.bottomBar && self.viewHasEverAppeared) { // Do nothing, the view has not changed. return; } for (UIView *subView in self.bottomBar.subviews) { [subView removeFromSuperview]; } [self.bottomBar addSubview:bottomView]; // The message requests view expects to extend into the safe area if (self.requestView) { [bottomView autoPinEdgesToSuperviewEdges]; } else { [bottomView autoPinEdgesToSuperviewMargins]; } [self updateInputAccessoryPlaceholderHeight]; [self updateContentInsetsAnimated:self.viewHasEverAppeared]; } - (void)updateInputAccessoryPlaceholderHeight { OWSAssertIsOnMainThread(); // If we're currently dismissing interactively, skip updating the // input accessory height. Changing it while dismissing can lead to // an infinite loop of keyboard frame changes as the listeners in // InputAcessoryViewPlaceholder will end up calling back here if // a dismissal is in progress. if (self.isDismissingInteractively) { return; } // Apply any pending layout changes to ensure we're measuring the up-to-date height. [self.bottomBar.superview layoutIfNeeded]; self.inputAccessoryPlaceholder.desiredHeight = self.bottomBar.height; } - (void)updateBottomBarPosition { OWSAssertIsOnMainThread(); // Don't update the bottom bar position if an interactive pop is in progress switch (self.navigationController.interactivePopGestureRecognizer.state) { case UIGestureRecognizerStatePossible: case UIGestureRecognizerStateFailed: break; default: return; } self.bottomBarBottomConstraint.constant = -self.inputAccessoryPlaceholder.keyboardOverlap; // We always want to apply the new bottom bar position immediately, // as this only happens during animations (interactive or otherwise) [self.bottomBar.superview layoutIfNeeded]; } - (void)updateContentInsetsAnimated:(BOOL)animated { OWSAssertIsOnMainThread(); // Don't update the content insets if an interactive pop is in progress switch (self.navigationController.interactivePopGestureRecognizer.state) { case UIGestureRecognizerStatePossible: case UIGestureRecognizerStateFailed: break; default: return; } [self.view layoutIfNeeded]; UIEdgeInsets oldInsets = self.collectionView.contentInset; UIEdgeInsets newInsets = oldInsets; newInsets.bottom = self.messageActionsExtraContentInsetPadding + self.inputAccessoryPlaceholder.keyboardOverlap + self.bottomBar.height - self.view.safeAreaInsets.bottom; newInsets.top = self.messageActionsExtraContentInsetPadding; BOOL wasScrolledToBottom = [self isScrolledToBottom]; // Changing the contentInset can change the contentOffset, so make sure we // stash the current value before making any changes. CGFloat oldYOffset = self.collectionView.contentOffset.y; if (!UIEdgeInsetsEqualToEdgeInsets(self.collectionView.contentInset, newInsets)) { self.collectionView.contentInset = newInsets; } self.collectionView.scrollIndicatorInsets = newInsets; void (^adjustInsets)(void) = ^(void) { // Adjust content offset to prevent the presented keyboard from obscuring content. BOOL hasViewBeenPresented = self.viewHasEverAppeared || self.isViewVisible; if (!hasViewBeenPresented) { // Do nothing. } else if (!self.viewHasEverAppeared) { // We need to apply the default scroll state between the first // viewWillAppear() and viewDidAppear(). [self scrollToDefaultPositionAnimated: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; // Only update the content offset if the inset has changed. if (insetChange != 0) { // The content offset can go negative, up to the size of the top layout guide. // This accounts for the extended layout under the navigation bar. OWSAssertDebug(self.topLayoutGuide.length == self.view.safeAreaInsets.top); CGFloat minYOffset = -self.view.safeAreaInsets.top; CGFloat newYOffset = CGFloatClamp(oldYOffset + insetChange, minYOffset, self.safeContentHeight); CGPoint newOffset = CGPointMake(0, newYOffset); [self.collectionView setContentOffset:newOffset animated:NO]; } } }; if (animated) { adjustInsets(); } else { [UIView performWithoutAnimation:adjustInsets]; } } #pragma mark - Keyboard Shortcuts - (void)focusInputToolbar { OWSAssertIsOnMainThread(); [self.inputToolbar clearDesiredKeyboard]; [self popKeyBoard]; } - (void)openAllMedia { OWSAssertIsOnMainThread(); [self showConversationSettingsAndShowAllMedia]; } - (void)openStickerKeyboard { OWSAssertIsOnMainThread(); [self.inputToolbar showStickerKeyboard]; } - (void)openAttachmentKeyboard { OWSAssertIsOnMainThread(); [self.inputToolbar showAttachmentKeyboard]; } - (void)openGifSearch { OWSAssertIsOnMainThread(); [self showGifPicker]; } @end NS_ASSUME_NONNULL_END