From 32d3eed7bac3502aa9ea69fc7ae1ab5025bd3f17 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 31 Oct 2018 10:05:24 -0400 Subject: [PATCH 1/2] Add ConversationViewModel. --- Signal.xcodeproj/project.pbxproj | 6 + .../ColorPickerViewController.swift | 4 + .../ConversationViewController.m | 1692 ++++------------- .../ConversationView/ConversationViewItem.h | 6 + .../ConversationView/ConversationViewItem.m | 5 + .../ConversationView/ConversationViewModel.h | 108 ++ .../ConversationView/ConversationViewModel.m | 1284 +++++++++++++ 7 files changed, 1831 insertions(+), 1274 deletions(-) create mode 100644 Signal/src/ViewControllers/ConversationView/ConversationViewModel.h create mode 100644 Signal/src/ViewControllers/ConversationView/ConversationViewModel.m diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index e859c13e6..de3b60800 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 340FC8CA20517B84007AEB0F /* OWSBackupImportJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8C820517B84007AEB0F /* OWSBackupImportJob.m */; }; 340FC8CD20518C77007AEB0F /* OWSBackupJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8CC20518C76007AEB0F /* OWSBackupJob.m */; }; 340FC8D0205BF2FA007AEB0F /* OWSBackupIO.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC8CE205BF2FA007AEB0F /* OWSBackupIO.m */; }; + 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 341341EE2187467900192D59 /* ConversationViewModel.m */; }; 341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */ = {isa = PBXBuildFile; fileRef = 341F2C0E1F2B8AE700D07D6B /* DebugUIMisc.m */; }; 3421981C21061D2E00C57195 /* ByteParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3421981B21061D2E00C57195 /* ByteParserTest.swift */; }; 34277A5E20751BDC006049F2 /* OWSQuotedMessageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34277A5C20751BDC006049F2 /* OWSQuotedMessageView.m */; }; @@ -651,6 +652,8 @@ 340FC8CC20518C76007AEB0F /* OWSBackupJob.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupJob.m; sourceTree = ""; }; 340FC8CE205BF2FA007AEB0F /* OWSBackupIO.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackupIO.m; sourceTree = ""; }; 340FC8CF205BF2FA007AEB0F /* OWSBackupIO.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupIO.h; sourceTree = ""; }; + 341341ED2187467900192D59 /* ConversationViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConversationViewModel.h; sourceTree = ""; }; + 341341EE2187467900192D59 /* ConversationViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewModel.m; sourceTree = ""; }; 341458471FBE11C4005ABCF9 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = translations/fa.lproj/Localizable.strings; sourceTree = ""; }; 341F2C0D1F2B8AE700D07D6B /* DebugUIMisc.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIMisc.h; sourceTree = ""; }; 341F2C0E1F2B8AE700D07D6B /* DebugUIMisc.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIMisc.m; sourceTree = ""; }; @@ -1566,6 +1569,8 @@ 34D1F0701F8678AA0066283D /* ConversationViewItem.m */, 34D1F0711F8678AA0066283D /* ConversationViewLayout.h */, 34D1F0721F8678AA0066283D /* ConversationViewLayout.m */, + 341341ED2187467900192D59 /* ConversationViewModel.h */, + 341341EE2187467900192D59 /* ConversationViewModel.m */, ); path = ConversationView; sourceTree = ""; @@ -3367,6 +3372,7 @@ 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */, 34D1F0B71F87F8850066283D /* OWSGenericAttachmentView.m in Sources */, 34D920E720E179C200D51158 /* OWSMessageFooterView.m in Sources */, + 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */, 348BB25D20A0C5530047AEC2 /* ContactShareViewHelper.swift in Sources */, 34B3F8801E8DF1700035BE1A /* InviteFlow.swift in Sources */, 457C87B82032645C008D52D6 /* DebugUINotifications.swift in Sources */, diff --git a/Signal/src/ViewControllers/ColorPickerViewController.swift b/Signal/src/ViewControllers/ColorPickerViewController.swift index 671fa8d77..3409c99ec 100644 --- a/Signal/src/ViewControllers/ColorPickerViewController.swift +++ b/Signal/src/ViewControllers/ColorPickerViewController.swift @@ -344,6 +344,10 @@ private class MockConversationViewItem: NSObject, ConversationViewItem { super.init() } + func itemId() -> String { + return interaction.uniqueId! + } + func dequeueCell(for collectionView: UICollectionView, indexPath: IndexPath) -> ConversationViewCell { owsFailDebug("unexpected invocation") return ConversationViewCell(forAutoLayout: ()) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index b2f1c526d..e2a037011 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -14,6 +14,7 @@ #import "ConversationViewCell.h" #import "ConversationViewItem.h" #import "ConversationViewLayout.h" +#import "ConversationViewModel.h" #import "DateUtil.h" #import "DebugUITableViewController.h" #import "FingerprintViewController.h" @@ -91,24 +92,6 @@ NS_ASSUME_NONNULL_BEGIN -// Always load up to n messages when user arrives. -// -// The smaller this number is, the faster the conversation can display. -// To test, shrink you accessability font as much as possible, then count how many 1-line system info messages (our shortest cells) can -// fit on screen at a time on an iPhoneX -// -// PERF: we could do less messages on shorter (older, slower) devices -// PERF: we could cache the cell height, since some messages will be much taller. -static const int kYapDatabasePageSize = 25; - -// Never show more than n messages in conversation view when user arrives. -static const int kConversationInitialMaxRangeSize = 300; - -// Never show more than n messages in conversation view at a time. -static const int kYapDatabaseRangeMaxLength = 25000; - -static const int kYapDatabaseRangeMinLength = 0; - static const CGFloat kLoadMoreHeaderHeight = 60.f; static const CGFloat kToastInset = 10; @@ -146,39 +129,20 @@ typedef enum : NSUInteger { UITextViewDelegate, ConversationCollectionViewDelegate, ConversationInputToolbarDelegate, - GifPickerViewControllerDelegate> + GifPickerViewControllerDelegate, + ConversationViewModelDelegate> @property (nonatomic) TSThread *thread; -@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; +@property (nonatomic, readonly) ConversationViewModel *conversationViewModel; + @property (nonatomic, readonly) OWSAudioActivity *recordVoiceNoteAudioActivity; @property (nonatomic, readonly) NSTimeInterval viewControllerCreatedAt; -// These two properties must be updated in lockstep. -// -// * The first (required) step is to update uiDatabaseConnection using beginLongLivedReadTransaction. -// * The second (required) step is to update messageMappings. -// * The third (optional) step is to update the messageMappings range using -// updateMessageMappingRangeOptions. -// * The fourth (optional) step is to update the view items using reloadViewItems. -// * The steps must be done in strict order. -// * If we do any of the steps, we must do all of the required steps. -// * We can't use messageMappings or viewItems after the first step until we've -// done the last step; i.e.. we can't do any layout, since that uses the view -// items which haven't been updated yet. -// * If the first and/or second steps changes the set of messages -// their ordering and/or their state, we must do the third and fourth steps. -// * If we do the third step, we must call resetContentAndLayout afterward. -@property (nonatomic, readonly) YapDatabaseConnection *uiDatabaseConnection; -@property (nonatomic) YapDatabaseViewMappings *messageMappings; - @property (nonatomic, readonly) ConversationInputToolbar *inputToolbar; @property (nonatomic, readonly) ConversationCollectionView *collectionView; @property (nonatomic, readonly) ConversationViewLayout *layout; @property (nonatomic, readonly) ConversationStyle *conversationStyle; -@property (nonatomic) NSArray> *viewItems; -@property (nonatomic) NSMutableDictionary> *viewItemCache; - @property (nonatomic, nullable) AVAudioRecorder *audioRecorder; @property (nonatomic, nullable) OWSAudioPlayer *audioAttachmentPlayer; @property (nonatomic, nullable) NSUUID *voiceMessageUUID; @@ -194,9 +158,7 @@ typedef enum : NSUInteger { @property (nonatomic, readonly) UILabel *backButtonUnreadCountLabel; @property (nonatomic, readonly) NSUInteger backButtonUnreadCount; -@property (nonatomic) NSUInteger lastRangeLength; @property (nonatomic) ConversationViewAction actionOnOpen; -@property (nonatomic, nullable) NSString *focusMessageIdOnOpen; @property (nonatomic) BOOL peek; @@ -205,8 +167,6 @@ typedef enum : NSUInteger { @property (nonatomic) BOOL userHasScrolled; @property (nonatomic, nullable) NSDate *lastMessageSentDate; -@property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; -@property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; @property (nonatomic) BOOL showLoadMoreHeader; @property (nonatomic) UILabel *loadMoreHeader; @property (nonatomic) uint64_t lastVisibleTimestamp; @@ -222,16 +182,19 @@ typedef enum : NSUInteger { @property (nonatomic) BOOL isViewCompletelyAppeared; @property (nonatomic) BOOL isViewVisible; -@property (nonatomic) BOOL shouldObserveDBModifications; +@property (nonatomic) BOOL shouldObserveVMUpdates; @property (nonatomic) BOOL viewHasEverAppeared; @property (nonatomic) BOOL hasUnreadMessages; @property (nonatomic) BOOL isPickingMediaAsDocument; @property (nonatomic, nullable) NSNumber *previousLastTimestamp; +@property (nonatomic, nullable) NSNumber *previousViewItemCount; +@property (nonatomic, nullable) NSNumber *previousViewTopToContentBottom; @property (nonatomic, nullable) NSNumber *viewHorizonTimestamp; @property (nonatomic) ContactShareViewHelper *contactShareViewHelper; @property (nonatomic) NSTimer *reloadTimer; @property (nonatomic, nullable) NSDate *lastReloadDate; -@property (nonatomic, nullable) NSDate *collapseCutoffDate; + +@property (nonatomic) CGFloat scrollDistanceToBottomSnapshot; @end @@ -353,18 +316,6 @@ typedef enum : NSUInteger { selector:@selector(didChangePreferredContentSize:) name:UIContentSizeCategoryDidChangeNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(uiDatabaseDidUpdateExternally:) - name:OWSUIDatabaseConnectionDidUpdateExternallyNotification - object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(uiDatabaseWillUpdate:) - name:OWSUIDatabaseConnectionWillUpdateNotification - object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(uiDatabaseDidUpdate:) - name:OWSUIDatabaseConnectionDidUpdateNotification - object:OWSPrimaryStorage.sharedManager.dbNotificationObject]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationWillEnterForeground:) name:OWSApplicationWillEnterForegroundNotification @@ -393,10 +344,6 @@ typedef enum : NSUInteger { selector:@selector(profileWhitelistDidChange:) name:kNSNotificationName_ProfileWhitelistDidChange object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(signalAccountsDidChange:) - name:OWSContactsManagerSignalAccountsDidChangeNotification - object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillChangeFrame:) name:UIKeyboardWillChangeFrameNotification @@ -410,12 +357,6 @@ typedef enum : NSUInteger { return self.thread.isGroupThread; } -- (void)signalAccountsDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self ensureDynamicInteractions]; -} - (void)otherUsersProfileDidChange:(NSNotification *)notification { @@ -445,11 +386,11 @@ typedef enum : NSUInteger { NSString *_Nullable recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId]; NSData *_Nullable groupId = notification.userInfo[kNSNotificationKey_ProfileGroupId]; if (recipientId.length > 0 && [self.thread.recipientIdentifiers containsObject:recipientId]) { - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; } else if (groupId.length > 0 && self.thread.isGroupThread) { TSGroupThread *groupThread = (TSGroupThread *)self.thread; if ([groupThread.groupModel.groupId isEqualToData:groupId]) { - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; [self ensureBannerState]; } } @@ -492,24 +433,15 @@ typedef enum : NSUInteger { _thread = thread; self.actionOnOpen = action; - self.focusMessageIdOnOpen = focusMessageId; _cellMediaCache = [NSCache new]; // Cache the cell media for ~24 cells. self.cellMediaCache.countLimit = 24; _conversationStyle = [[ConversationStyle alloc] initWithThread:thread]; - // We need to update the "unread indicator" _before_ we determine the initial range - // size, since it depends on where the unread indicator is placed. - self.lastRangeLength = 0; - [self ensureDynamicInteractions]; - [[OWSPrimaryStorage sharedManager] updateUIDatabaseConnectionToLatest]; + _conversationViewModel = + [[ConversationViewModel alloc] initWithThread:thread focusMessageIdOnOpen:focusMessageId delegate:self]; - [self createNewMessageMappings]; - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in configureForThread."); - } - - [self updateShouldObserveDBModifications]; + [self updateShouldObserveVMUpdates]; self.reloadTimer = [NSTimer weakScheduledTimerWithTimeInterval:1.f target:self @@ -527,8 +459,8 @@ typedef enum : NSUInteger { { OWSAssertIsOnMainThread(); - if (self.isUserScrolling || !self.isViewCompletelyAppeared || !self.isViewVisible - || !self.shouldObserveDBModifications || !self.viewHasEverAppeared) { + if (self.isUserScrolling || !self.isViewCompletelyAppeared || !self.isViewVisible || !self.shouldObserveVMUpdates + || !self.viewHasEverAppeared) { return; } @@ -594,6 +526,7 @@ typedef enum : NSUInteger { [self addNotificationListeners]; [self loadDraftInCompose]; [self applyTheme]; + [self.conversationViewModel viewDidLoad]; } - (void)createContents @@ -633,6 +566,8 @@ typedef enum : NSUInteger { [self.loadMoreHeader autoPinWidthToWidthOfView:self.view]; [self.loadMoreHeader autoPinEdgeToSuperviewEdge:ALEdgeTop]; [self.loadMoreHeader autoSetDimension:ALDimensionHeight toSize:kLoadMoreHeaderHeight]; + + [self updateShowLoadMoreHeader]; } - (BOOL)becomeFirstResponder @@ -676,16 +611,12 @@ typedef enum : NSUInteger { - (void)applicationDidEnterBackground:(NSNotification *)notification { [self updateCellsVisible]; - if (self.hasClearedUnreadMessagesIndicator) { - self.hasClearedUnreadMessagesIndicator = NO; - [self.dynamicInteractions clearUnreadIndicatorState]; - } [self.cellMediaCache removeAllObjects]; } - (void)applicationWillResignActive:(NSNotification *)notification { - [self updateShouldObserveDBModifications]; + [self updateShouldObserveVMUpdates]; [self cancelVoiceMemo]; self.isUserScrolling = NO; [self saveDraft]; @@ -697,7 +628,7 @@ typedef enum : NSUInteger { - (void)applicationDidBecomeActive:(NSNotification *)notification { - [self updateShouldObserveDBModifications]; + [self updateShouldObserveVMUpdates]; [self startReadTimer]; } @@ -760,6 +691,16 @@ typedef enum : NSUInteger { } } +- (NSArray> *)viewItems +{ + return self.conversationViewModel.viewItems; +} + +- (ThreadDynamicInteractions *)dynamicInteractions +{ + return self.conversationViewModel.dynamicInteractions; +} + - (NSIndexPath *_Nullable)indexPathOfUnreadMessagesIndicator { NSInteger row = 0; @@ -774,7 +715,7 @@ typedef enum : NSUInteger { - (NSIndexPath *_Nullable)indexPathOfMessageOnOpen { - OWSAssertDebug(self.focusMessageIdOnOpen); + OWSAssertDebug(self.conversationViewModel.focusMessageIdOnOpen); OWSAssertDebug(self.dynamicInteractions.focusMessagePosition); if (!self.dynamicInteractions.focusMessagePosition) { @@ -801,7 +742,7 @@ typedef enum : NSUInteger { } NSIndexPath *_Nullable indexPath = nil; - if (self.focusMessageIdOnOpen) { + if (self.conversationViewModel.focusMessageIdOnOpen) { indexPath = [self indexPathOfMessageOnOpen]; } @@ -844,10 +785,7 @@ typedef enum : NSUInteger { { // Avoid layout corrupt issues and out-of-date message subtitles. self.lastReloadDate = [NSDate new]; - self.collapseCutoffDate = [NSDate new]; - if (![self reloadViewItems]) { - OWSFailDebug(@"failed to reload view items in resetContentAndLayout."); - } + [self.conversationViewModel viewDidResetContentAndLayout]; [self.collectionView.collectionViewLayout invalidateLayout]; [self.collectionView reloadData]; } @@ -1176,7 +1114,7 @@ typedef enum : NSUInteger { [self updateBackButtonUnreadCount]; [self autoLoadMoreIfNecessary]; - self.focusMessageIdOnOpen = nil; + self.conversationViewModel.focusMessageIdOnOpen = nil; self.isViewCompletelyAppeared = YES; self.viewHasEverAppeared = YES; @@ -1655,61 +1593,15 @@ typedef enum : NSUInteger { } static const CGFloat kThreshold = 50.f; if (self.collectionView.contentOffset.y < kThreshold) { - [self loadAnotherPageOfMessages]; + [self.conversationViewModel loadAnotherPageOfMessages]; } } -- (void)loadAnotherPageOfMessages -{ - BOOL hasEarlierUnseenMessages = self.dynamicInteractions.unreadIndicator.hasMoreUnseenMessages; - - [self loadNMoreMessages:kYapDatabasePageSize]; - - // Don’t auto-scroll after “loading more messages” unless we have “more unseen messages”. - // - // Otherwise, tapping on "load more messages" autoscrolls you downward which is completely wrong. - if (hasEarlierUnseenMessages && !self.focusMessageIdOnOpen) { - // Ensure view items are updated before trying to scroll to the - // unread indicator. - // - // loadNMoreMessages calls resetMappings which calls ensureDynamicInteractions, - // which may move the unread indicator, and for scrollToUnreadIndicatorAnimated - // to work properly, the view items need to be updated to reflect that change. - [[OWSPrimaryStorage sharedManager] updateUIDatabaseConnectionToLatest]; - - [self scrollToUnreadIndicatorAnimated]; - } -} - -- (void)loadNMoreMessages:(NSUInteger)numberOfMessagesToLoad -{ - // We want to restore the current scroll state after we update the range, update - // the dynamic interactions and re-layout. Here we take a "before" snapshot. - CGFloat scrollDistanceToBottom = self.safeContentHeight - self.collectionView.contentOffset.y; - - self.lastRangeLength = MIN(self.lastRangeLength + numberOfMessagesToLoad, (NSUInteger)kYapDatabaseRangeMaxLength); - - [self resetMappings]; - - [self.layout prepareLayout]; - - self.collectionView.contentOffset = CGPointMake(0, self.safeContentHeight - scrollDistanceToBottom); -} - - (void)updateShowLoadMoreHeader { - if (self.lastRangeLength == kYapDatabaseRangeMaxLength) { - self.showLoadMoreHeader = NO; - return; - } + OWSAssertDebug(self.conversationViewModel); - NSUInteger loadWindowSize = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; - __block NSUInteger totalMessageCount; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - totalMessageCount = - [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; - }]; - self.showLoadMoreHeader = loadWindowSize < totalMessageCount; + self.showLoadMoreHeader = self.conversationViewModel.canLoadMoreItems; } - (void)setShowLoadMoreHeader:(BOOL)showLoadMoreHeader @@ -1746,62 +1638,6 @@ typedef enum : NSUInteger { [self updateBarButtonItems]; } -- (void)updateMessageMappingRangeOptions -{ - NSUInteger rangeLength = 0; - - if (self.lastRangeLength == 0) { - // If this is the first time we're configuring the range length, - // try to take into account the position of the unread indicator - // and the "focus message". - OWSAssertDebug(self.dynamicInteractions); - - if (self.focusMessageIdOnOpen) { - OWSAssertDebug(self.dynamicInteractions.focusMessagePosition); - if (self.dynamicInteractions.focusMessagePosition) { - OWSLogVerbose(@"ensuring load of focus message: %@", self.dynamicInteractions.focusMessagePosition); - rangeLength = MAX(rangeLength, 1 + self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue); - } - } - - if (self.dynamicInteractions.unreadIndicator) { - NSUInteger unreadIndicatorPosition - = (NSUInteger)self.dynamicInteractions.unreadIndicator.unreadIndicatorPosition; - - // If there is an unread indicator, increase the initial load window - // to include it. - OWSAssertDebug(unreadIndicatorPosition > 0); - OWSAssertDebug(unreadIndicatorPosition <= kYapDatabaseRangeMaxLength); - - // We'd like to include at least N seen messages, - // to give the user the context of where they left off the conversation. - const NSUInteger kPreferredSeenMessageCount = 1; - rangeLength = MAX(rangeLength, unreadIndicatorPosition + kPreferredSeenMessageCount); - } - } - - // Always try to load at least a single page of messages. - rangeLength = MAX(rangeLength, kYapDatabasePageSize); - - // Range size should monotonically increase. - rangeLength = MAX(rangeLength, self.lastRangeLength); - - // Enforce max range size. - rangeLength = MIN(rangeLength, kYapDatabaseRangeMaxLength); - - self.lastRangeLength = rangeLength; - - YapDatabaseViewRangeOptions *rangeOptions = - [YapDatabaseViewRangeOptions flexibleRangeWithLength:rangeLength offset:0 from:YapDatabaseViewEnd]; - - rangeOptions.maxLength = MAX(rangeLength, kYapDatabaseRangeMaxLength); - rangeOptions.minLength = kYapDatabaseRangeMinLength; - - [self.messageMappings setRangeOptions:rangeOptions forGroup:self.thread.uniqueId]; - [self updateShowLoadMoreHeader]; - self.collapseCutoffDate = [NSDate new]; -} - #pragma mark Bubble User Actions - (void)handleFailedDownloadTapForMessage:(TSMessage *)message @@ -1995,7 +1831,7 @@ typedef enum : NSUInteger { { [[OWSWindowManager sharedManager] hideMenuActionsWindow]; - [self updateShouldObserveDBModifications]; + [self updateShouldObserveVMUpdates]; } - (void)menuActions:(MenuActionsViewController *)menuActionsViewController @@ -2090,7 +1926,7 @@ typedef enum : NSUInteger { [[OWSWindowManager sharedManager] showMenuActionsWindow:menuActionsViewController]; - [self updateShouldObserveDBModifications]; + [self updateShouldObserveVMUpdates]; } - (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId @@ -2395,104 +2231,12 @@ typedef enum : NSUInteger { OWSAssertDebug(quotedReply.timestamp > 0); OWSAssertDebug(quotedReply.authorId.length > 0); - // We try to find the index of the item within the current thread's - // interactions that includes the "quoted interaction". - // - // NOTE: There are two indices: - // - // * The "group index" of the member of the database views group at - // the db conneciton's current checkpoint. - // * The "index row/section" in the message mapping. - // - // NOTE: Since the range _IS NOT_ filtered by author, - // and timestamp collisions are possible, it's possible - // for: - // - // * The range to include more than the "quoted interaction". - // * The range to be non-empty but NOT include the "quoted interaction", - // although this would be a bug. - __block TSInteraction *_Nullable quotedInteraction; - __block NSUInteger threadInteractionCount = 0; - __block NSNumber *_Nullable groupIndex = nil; - - if (quotedReply.isRemotelySourced) { + NSIndexPath *_Nullable indexPath = [self.conversationViewModel ensureLoadWindowContainsQuotedReply:quotedReply]; + if (!indexPath) { [self presentRemotelySourcedQuotedReplyToast]; return; } - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - quotedInteraction = [ThreadUtil findInteractionInThreadByTimestamp:quotedReply.timestamp - authorId:quotedReply.authorId - threadUniqueId:self.thread.uniqueId - transaction:transaction]; - if (!quotedInteraction) { - return; - } - - YapDatabaseAutoViewTransaction *_Nullable extension = - [transaction extension:TSMessageDatabaseViewExtensionName]; - if (!extension) { - OWSFailDebug(@"Couldn't load view."); - return; - } - - threadInteractionCount = [extension numberOfItemsInGroup:self.thread.uniqueId]; - - groupIndex = [self findGroupIndexOfThreadInteraction:quotedInteraction transaction:transaction]; - }]; - - if (!quotedInteraction || !groupIndex) { - [self presentMissingQuotedReplyToast]; - return; - } - - NSUInteger indexRow = 0; - NSUInteger indexSection = 0; - BOOL isInMappings = [self.messageMappings getRow:&indexRow - section:&indexSection - forIndex:groupIndex.unsignedIntegerValue - inGroup:self.thread.uniqueId]; - - if (!isInMappings) { - NSInteger desiredWindowSize = MAX(0, 1 + (NSInteger)threadInteractionCount - groupIndex.integerValue); - NSUInteger oldLoadWindowSize = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; - NSInteger additionalItemsToLoad = MAX(0, desiredWindowSize - (NSInteger)oldLoadWindowSize); - if (additionalItemsToLoad < 1) { - OWSLogError(@"Couldn't determine how to load quoted reply."); - return; - } - - // Try to load more messages so that the quoted message - // is in the load window. - // - // This may fail if the quoted message is very old, in which - // case we'll load the max number of messages. - [self loadNMoreMessages:(NSUInteger)additionalItemsToLoad]; - - // `loadNMoreMessages` will reset the mapping and possibly - // integrate new changes, so we need to reload the "group index" - // of the quoted message. - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - groupIndex = [self findGroupIndexOfThreadInteraction:quotedInteraction transaction:transaction]; - }]; - - if (!quotedInteraction || !groupIndex) { - OWSLogError(@"Failed to find quoted reply in group."); - return; - } - - isInMappings = [self.messageMappings getRow:&indexRow - section:&indexSection - forIndex:groupIndex.unsignedIntegerValue - inGroup:self.thread.uniqueId]; - - if (!isInMappings) { - OWSLogError(@"Could not load quoted reply into mapping."); - return; - } - } - - NSIndexPath *indexPath = [NSIndexPath indexPathForRow:(NSInteger)indexRow inSection:(NSInteger)indexSection]; [self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionTop animated:YES]; @@ -2500,28 +2244,6 @@ typedef enum : NSUInteger { // TODO: Highlight the quoted message? } -- (nullable NSNumber *)findGroupIndexOfThreadInteraction:(TSInteraction *)interaction - transaction:(YapDatabaseReadTransaction *)transaction -{ - OWSAssertDebug(interaction); - OWSAssertDebug(transaction); - - YapDatabaseAutoViewTransaction *_Nullable extension = [transaction extension:TSMessageDatabaseViewExtensionName]; - if (!extension) { - OWSFailDebug(@"Couldn't load view."); - return nil; - } - - NSUInteger groupIndex = 0; - BOOL foundInGroup = - [extension getGroup:nil index:&groupIndex forKey:interaction.uniqueId inCollection:TSInteraction.collection]; - if (!foundInGroup) { - OWSLogError(@"Couldn't find quoted message in group."); - return nil; - } - return @(groupIndex); -} - - (void)showDetailViewForViewItem:(id)conversationItem { OWSAssertIsOnMainThread(); @@ -2586,58 +2308,7 @@ typedef enum : NSUInteger { - (void)contactsViewHelperDidUpdateContacts { - [self ensureDynamicInteractions]; -} - -- (void)ensureDynamicInteractions -{ - OWSAssertIsOnMainThread(); - - const int currentMaxRangeSize = (int)self.lastRangeLength; - const int maxRangeSize = MAX(kConversationInitialMaxRangeSize, currentMaxRangeSize); - - self.dynamicInteractions = [ThreadUtil ensureDynamicInteractionsForThread:self.thread - contactsManager:self.contactsManager - blockingManager:self.blockingManager - dbConnection:self.editingDatabaseConnection - hideUnreadMessagesIndicator:self.hasClearedUnreadMessagesIndicator - lastUnreadIndicator:self.dynamicInteractions.unreadIndicator - focusMessageId:self.focusMessageIdOnOpen - maxRangeSize:maxRangeSize]; -} - -- (void)clearUnreadMessagesIndicator -{ - OWSAssertIsOnMainThread(); - - NSIndexPath *_Nullable indexPathOfUnreadIndicator = [self indexPathOfUnreadMessagesIndicator]; - if (indexPathOfUnreadIndicator) { - id oldIndicatorItem = [self viewItemForIndex:indexPathOfUnreadIndicator.row]; - OWSAssertDebug(oldIndicatorItem); - - // TODO ideally this would be happening within the *same* transaction that caused the unreadMessageIndicator - // to be cleared. - [self.editingDatabaseConnection - asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [oldIndicatorItem.interaction touchWithTransaction:transaction]; - }]; - } - - if (self.hasClearedUnreadMessagesIndicator) { - // ensureDynamicInteractionsForThread is somewhat expensive - // so we don't want to call it unnecessarily. - return; - } - - // Once we've cleared the unread messages indicator, - // make sure we don't show it again. - self.hasClearedUnreadMessagesIndicator = YES; - - if (self.dynamicInteractions.unreadIndicator) { - // If we've just cleared the "unread messages" indicator, - // update the dynamic interactions. - [self ensureDynamicInteractions]; - } + [self.conversationViewModel ensureDynamicInteractions]; } - (void)createConversationScrollButtons @@ -2675,7 +2346,7 @@ typedef enum : NSUInteger { _hasUnreadMessages = hasUnreadMessages; self.scrollDownButton.hasUnreadMessages = hasUnreadMessages; - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; } - (void)scrollDownButtonTapped @@ -2820,7 +2491,7 @@ typedef enum : NSUInteger { [self tryToSendAttachmentIfApproved:attachment]; [ThreadUtil addThreadToProfileWhitelistIfEmptyContactThread:self.thread]; - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; } - (void)messageWasSent:(TSOutgoingMessage *)message @@ -2830,7 +2501,7 @@ typedef enum : NSUInteger { [self updateLastVisibleTimestamp:message.timestampForSorting]; self.lastMessageSentDate = [NSDate new]; - [self clearUnreadMessagesIndicator]; + [self.conversationViewModel clearUnreadMessagesIndicator]; self.inputToolbar.quotedReply = nil; if (!Environment.shared.preferences.hasSentAMessage) { @@ -3155,7 +2826,7 @@ typedef enum : NSUInteger { [self messageWasSent:message]; if (didAddToProfileWhitelist) { - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; } } @@ -3182,7 +2853,7 @@ typedef enum : NSUInteger { [self messageWasSent:message]; if (didAddToProfileWhitelist) { - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; } }]; } @@ -3238,457 +2909,112 @@ typedef enum : NSUInteger { return OWSPrimaryStorage.sharedManager.dbReadWriteConnection; } -- (void)uiDatabaseDidUpdateExternally:(NSNotification *)notification +- (BOOL)isScrolledToBottom { - OWSAssertIsOnMainThread(); + CGFloat contentHeight = self.safeContentHeight; - OWSLogVerbose(@""); + // 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. + const CGFloat kIsAtBottomTolerancePts = 5; + // 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 contentOffsetYBottom + = MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height); - if (self.shouldObserveDBModifications) { - // External database modifications can't be converted into incremental updates, - // so rebuild everything. This is expensive and usually isn't necessary, but - // there's no alternative. - // - // We don't need to do this if we're not observing db modifications since we'll - // do it when we resume. - [self resetMappings]; - } + CGFloat distanceFromBottom = contentOffsetYBottom - self.collectionView.contentOffset.y; + BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts; + + return isScrolledToBottom; } -- (void)uiDatabaseWillUpdate:(NSNotification *)notification +#pragma mark - Audio + +- (void)requestRecordingVoiceMemo { - // 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 + OWSAssertIsOnMainThread(); + + NSUUID *voiceMessageUUID = [NSUUID UUID]; + self.voiceMessageUUID = voiceMessageUUID; + + __weak typeof(self) weakSelf = self; + [self ows_askForMicrophonePermissions:^(BOOL granted) { + __strong typeof(self) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + if (strongSelf.voiceMessageUUID != voiceMessageUUID) { + // This voice message recording has been cancelled + // before recording could begin. + return; + } + + if (granted) { + [strongSelf startRecordingVoiceMemo]; + } else { + OWSLogInfo(@"we do not have recording permission."); + [strongSelf cancelVoiceMemo]; + [OWSAlerts showNoMicrophonePermissionAlert]; + } + }]; } -- (void)uiDatabaseDidUpdate:(NSNotification *)notification +- (void)startRecordingVoiceMemo { OWSAssertIsOnMainThread(); - if (!self.shouldObserveDBModifications) { - return; - } + OWSLogInfo(@"startRecordingVoiceMemo"); - OWSLogVerbose(@""); + // Cancel any ongoing audio playback. + [self.audioAttachmentPlayer stop]; + self.audioAttachmentPlayer = nil; - NSArray *notifications = notification.userInfo[OWSUIDatabaseConnectionNotificationsKey]; - OWSAssertDebug([notifications isKindOfClass:[NSArray class]]); - - [self updateBackButtonUnreadCount]; - [self updateNavigationBarSubtitleLabel]; + NSString *temporaryDirectory = OWSTemporaryDirectory(); + NSString *filename = [NSString stringWithFormat:@"%lld.m4a", [NSDate ows_millisecondTimeStamp]]; + NSString *filepath = [temporaryDirectory stringByAppendingPathComponent:filename]; + NSURL *fileURL = [NSURL fileURLWithPath:filepath]; - if (self.isGroupConversation) { - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.thread reloadWithTransaction:transaction]; - }]; - [self updateNavigationTitle]; + // Setup audio session + BOOL configuredAudio = [self.audioSession startAudioActivity:self.recordVoiceNoteAudioActivity]; + if (!configuredAudio) { + OWSFailDebug(@"Couldn't configure audio session"); + [self cancelVoiceMemo]; + return; } - [self updateDisappearingMessagesConfiguration]; - - if (![[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] hasChangesForGroup:self.thread.uniqueId - inNotifications:notifications]) { - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.messageMappings updateWithTransaction:transaction]; - }]; + 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; } - NSArray *sectionChanges = nil; - NSArray *rowChanges = nil; - [[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] getSectionChanges:§ionChanges - rowChanges:&rowChanges - forNotifications:notifications - withMappings:self.messageMappings]; - - if ([sectionChanges count] == 0 && [rowChanges count] == 0) { - // YapDatabase will ignore insertions within the message mapping's - // range that are not within the current mapping's contents. We - // may need to extend the mapping's contents to reflect the current - // range. - [self updateMessageMappingRangeOptions]; - // Calling resetContentAndLayout is a bit expensive. - // Since by definition this won't affect any cells in the previous - // range, it should be sufficient to call invalidateLayout. - // - // TODO: Investigate whether we can just call invalidateLayout. - [self resetContentAndLayout]; - return; - } - - // We need to reload any modified interactions _before_ we call - // reloadViewItems. - BOOL hasMalformedRowChange = NO; - for (YapDatabaseViewRowChange *rowChange in rowChanges) { - switch (rowChange.type) { - case YapDatabaseViewChangeUpdate: { - YapCollectionKey *collectionKey = rowChange.collectionKey; - if (collectionKey.key) { - id _Nullable viewItem = self.viewItemCache[collectionKey.key]; - if (viewItem) { - [self reloadInteractionForViewItem:viewItem]; - } else { - hasMalformedRowChange = YES; - } - } else if (rowChange.indexPath && rowChange.originalIndex < self.viewItems.count) { - // Do nothing, this is a pseudo-update generated due to - // setCellDrawingDependencyOffsets. - OWSAssertDebug(rowChange.changes == YapDatabaseViewChangedDependency); - } else { - hasMalformedRowChange = YES; - } - break; - } - case YapDatabaseViewChangeDelete: { - // Discard cached view items after deletes. - YapCollectionKey *collectionKey = rowChange.collectionKey; - if (collectionKey.key) { - [self.viewItemCache removeObjectForKey:collectionKey.key]; - } else { - hasMalformedRowChange = YES; - } - break; - } - default: - break; - } - if (hasMalformedRowChange) { - break; - } - } - - if (hasMalformedRowChange) { - // These errors seems to be very rare; they can only be reproduced - // using the more extreme actions in the debug UI. - OWSFailDebug(@"hasMalformedRowChange"); - [self resetMappings]; - [self updateLastVisibleTimestamp]; - [self scrollToBottomAnimated:NO]; - return; - } - - NSUInteger oldViewItemCount = self.viewItems.count; - if (![self reloadViewItems]) { - // These errors are rare. - OWSFailDebug(@"could not reload view items; hard resetting message mappings."); - [self resetMappings]; - [self updateLastVisibleTimestamp]; - [self scrollToBottomAnimated:NO]; - return; - } - - BOOL wasAtBottom = [self isScrolledToBottom]; - // We want sending messages to feel snappy. So, if the only - // update is a new outgoing message AND we're already scrolled to - // the bottom of the conversation, skip the scroll animation. - __block BOOL shouldAnimateScrollToBottom = !wasAtBottom; - // We want to scroll to the bottom if the user: - // - // a) already was at the bottom of the conversation. - // b) is inserting new interactions. - __block BOOL scrollToBottom = wasAtBottom; - - void (^batchUpdates)(void) = ^{ - for (YapDatabaseViewRowChange *rowChange in rowChanges) { - switch (rowChange.type) { - case YapDatabaseViewChangeDelete: { - OWSLogVerbose(@"YapDatabaseViewChangeDelete collectionKey: %@, indexPath: %@, finalIndex: %lu", - rowChange.collectionKey, - rowChange.indexPath, - (unsigned long)rowChange.finalIndex); - [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; - YapCollectionKey *collectionKey = rowChange.collectionKey; - OWSAssertDebug(collectionKey.key.length > 0); - break; - } - case YapDatabaseViewChangeInsert: { - OWSLogVerbose(@"YapDatabaseViewChangeInsert collectionKey: %@, newIndexPath: %@, finalIndex: %lu", - rowChange.collectionKey, - rowChange.newIndexPath, - (unsigned long)rowChange.finalIndex); - [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; - - id _Nullable viewItem = - [self viewItemForIndex:(NSInteger)rowChange.finalIndex]; - if ([viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - if (!outgoingMessage.isFromLinkedDevice) { - scrollToBottom = YES; - shouldAnimateScrollToBottom = NO; - } - } - break; - } - case YapDatabaseViewChangeMove: { - OWSLogVerbose(@"YapDatabaseViewChangeMove collectionKey: %@, indexPath: %@, newIndexPath: %@, " - @"finalIndex: %lu", - rowChange.collectionKey, - rowChange.indexPath, - rowChange.newIndexPath, - (unsigned long)rowChange.finalIndex); - [self.collectionView moveItemAtIndexPath:rowChange.indexPath toIndexPath:rowChange.newIndexPath]; - break; - } - case YapDatabaseViewChangeUpdate: { - OWSLogVerbose(@"YapDatabaseViewChangeUpdate collectionKey: %@, indexPath: %@, finalIndex: %lu", - rowChange.collectionKey, - rowChange.indexPath, - (unsigned long)rowChange.finalIndex); - [self.collectionView reloadItemsAtIndexPaths:@[ rowChange.indexPath ]]; - break; - } - } - } - }; - - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldViewItemCount, self.viewItems.count); - - BOOL shouldAnimateUpdates = [self shouldAnimateRowUpdates:rowChanges oldViewItemCount:oldViewItemCount]; - void (^batchUpdatesCompletion)(BOOL) = ^(BOOL finished) { - OWSAssertIsOnMainThread(); - - - if (!finished) { - OWSLogInfo(@"performBatchUpdates did not finish"); - } - - [self updateLastVisibleTimestamp]; - - if (scrollToBottom && shouldAnimateUpdates) { - [self scrollToBottomAnimated:shouldAnimateScrollToBottom]; - } - }; - - @try { - if (shouldAnimateUpdates) { - [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; - } else { - // HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a - // UIKit Crash like: - // - // *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:], - // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760 - // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While - // preparing update a visible view at {length = 2, path = 0 - 142} - // wasn't found in the current data model and was not in an update animation. This is an internal - // error.' - // - // I'm unclear if this is a bug in UIKit, or if we're doing something crazy in - // ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the - // conversation. See `DebugUIMessages#thrashCellsInThread:` - [UIView - animateWithDuration:0.0 - animations:^{ - [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; - if (scrollToBottom) { - [self scrollToBottomAnimated:shouldAnimateUpdates]; - } - }]; - } - } @catch (NSException *exception) { - OWSFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", - exception.description, - exception.name, - exception.reason, - exception.userInfo); - - for (YapDatabaseViewRowChange *rowChange in rowChanges) { - switch (rowChange.type) { - case YapDatabaseViewChangeDelete: - OWSLogWarn(@"YapDatabaseViewChangeDelete collectionKey: %@, indexPath: %@, finalIndex: %lu", - rowChange.collectionKey, - rowChange.indexPath, - (unsigned long)rowChange.finalIndex); - break; - case YapDatabaseViewChangeInsert: - OWSLogWarn(@"YapDatabaseViewChangeInsert collectionKey: %@, newIndexPath: %@, finalIndex: %lu", - rowChange.collectionKey, - rowChange.newIndexPath, - (unsigned long)rowChange.finalIndex); - break; - case YapDatabaseViewChangeMove: - OWSLogWarn(@"YapDatabaseViewChangeMove collectionKey: %@, indexPath: %@, finalIndex: %@, " - @"finalIndex: %lu", - rowChange.collectionKey, - rowChange.indexPath, - rowChange.newIndexPath, - (unsigned long)rowChange.finalIndex); - break; - case YapDatabaseViewChangeUpdate: - OWSLogWarn(@"YapDatabaseViewChangeUpdate collectionKey: %@, indexPath: %@, finalIndex: %lu, " - @"isDependency: %d", - rowChange.collectionKey, - rowChange.indexPath, - (unsigned long)rowChange.finalIndex, - rowChange.changes == YapDatabaseViewChangedDependency); - break; - } - } - - @throw exception; - } - - self.lastReloadDate = [NSDate new]; -} - -- (BOOL)shouldAnimateRowUpdates:(NSArray *)rowChanges - oldViewItemCount:(NSUInteger)oldViewItemCount -{ - OWSAssertDebug(rowChanges); - - // If user sends a new outgoing message, don't animate the change. - BOOL isOnlyModifyingLastMessage = YES; - for (YapDatabaseViewRowChange *rowChange in rowChanges) { - switch (rowChange.type) { - case YapDatabaseViewChangeDelete: - isOnlyModifyingLastMessage = NO; - break; - case YapDatabaseViewChangeInsert: { - id _Nullable viewItem = [self viewItemForIndex:(NSInteger)rowChange.finalIndex]; - if (([viewItem.interaction isKindOfClass:[TSIncomingMessage class]] || - [viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) - && rowChange.finalIndex >= oldViewItemCount) { - continue; - } - isOnlyModifyingLastMessage = NO; - break; - } - case YapDatabaseViewChangeMove: - isOnlyModifyingLastMessage = NO; - break; - case YapDatabaseViewChangeUpdate: { - if (rowChange.changes == YapDatabaseViewChangedDependency) { - continue; - } - id _Nullable viewItem = [self viewItemForIndex:(NSInteger)rowChange.finalIndex]; - if (([viewItem.interaction isKindOfClass:[TSIncomingMessage class]] || - [viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) - && rowChange.finalIndex >= oldViewItemCount) { - continue; - } - isOnlyModifyingLastMessage = NO; - break; - } - } - } - BOOL shouldAnimateRowUpdates = !isOnlyModifyingLastMessage; - return shouldAnimateRowUpdates; -} - -- (BOOL)isScrolledToBottom -{ - CGFloat contentHeight = self.safeContentHeight; - - // 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. - const CGFloat kIsAtBottomTolerancePts = 5; - // 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 contentOffsetYBottom - = MAX(0.f, contentHeight + self.collectionView.contentInset.bottom - self.collectionView.bounds.size.height); - - CGFloat distanceFromBottom = contentOffsetYBottom - self.collectionView.contentOffset.y; - BOOL isScrolledToBottom = distanceFromBottom <= kIsAtBottomTolerancePts; - - return isScrolledToBottom; -} - -#pragma mark - Audio - -- (void)requestRecordingVoiceMemo -{ - OWSAssertIsOnMainThread(); - - NSUUID *voiceMessageUUID = [NSUUID UUID]; - self.voiceMessageUUID = voiceMessageUUID; - - __weak typeof(self) weakSelf = self; - [self ows_askForMicrophonePermissions:^(BOOL granted) { - __strong typeof(self) strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - if (strongSelf.voiceMessageUUID != voiceMessageUUID) { - // This voice message recording has been cancelled - // before recording could begin. - return; - } - - if (granted) { - [strongSelf startRecordingVoiceMemo]; - } else { - OWSLogInfo(@"we do not have recording permission."); - [strongSelf cancelVoiceMemo]; - [OWSAlerts showNoMicrophonePermissionAlert]; - } - }]; -} - -- (void)startRecordingVoiceMemo -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@"startRecordingVoiceMemo"); - - // Cancel any ongoing audio playback. - [self.audioAttachmentPlayer stop]; - self.audioAttachmentPlayer = nil; - - NSString *temporaryDirectory = OWSTemporaryDirectory(); - NSString *filename = [NSString stringWithFormat:@"%lld.m4a", [NSDate ows_millisecondTimeStamp]]; - NSString *filepath = [temporaryDirectory stringByAppendingPathComponent:filename]; - NSURL *fileURL = [NSURL fileURLWithPath:filepath]; - - // Setup audio session - BOOL configuredAudio = [self.audioSession startAudioActivity:self.recordVoiceNoteAudioActivity]; - if (!configuredAudio) { - OWSFailDebug(@"Couldn't configure audio session"); - [self cancelVoiceMemo]; - return; - } - - NSError *error; - // Initiate and prepare the recorder - self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:fileURL - settings:@{ - AVFormatIDKey : @(kAudioFormatMPEG4AAC), - AVSampleRateKey : @(44100), - AVNumberOfChannelsKey : @(2), - AVEncoderBitRateKey : @(128 * 1024), - } - error:&error]; - 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]; + self.audioRecorder.meteringEnabled = YES; + + if (![self.audioRecorder prepareToRecord]) { + OWSFailDebug(@"audioRecorder couldn't prepareToRecord."); + [self cancelVoiceMemo]; return; } @@ -4536,7 +3862,7 @@ typedef enum : NSUInteger { [self.inputToolbar clearTextMessageAnimated:YES]; [self clearDraft]; if (didAddToProfileWhitelist) { - [self ensureDynamicInteractions]; + [self.conversationViewModel ensureDynamicInteractions]; } } @@ -4614,7 +3940,7 @@ typedef enum : NSUInteger { { _isViewVisible = isViewVisible; - [self updateShouldObserveDBModifications]; + [self updateShouldObserveVMUpdates]; [self updateCellsVisible]; } @@ -4627,74 +3953,48 @@ typedef enum : NSUInteger { } } -- (void)updateShouldObserveDBModifications +- (void)updateShouldObserveVMUpdates { if (!CurrentAppContext().isAppForegroundAndActive) { - self.shouldObserveDBModifications = NO; + self.shouldObserveVMUpdates = NO; return; } if (!self.isViewVisible) { - self.shouldObserveDBModifications = NO; + self.shouldObserveVMUpdates = NO; return; } if (OWSWindowManager.sharedManager.isPresentingMenuActions) { - self.shouldObserveDBModifications = NO; + self.shouldObserveVMUpdates = NO; return; } - self.shouldObserveDBModifications = YES; + self.shouldObserveVMUpdates = YES; } -- (void)setShouldObserveDBModifications:(BOOL)shouldObserveDBModifications +- (void)setShouldObserveVMUpdates:(BOOL)shouldObserveVMUpdates { - if (_shouldObserveDBModifications == shouldObserveDBModifications) { + if (_shouldObserveVMUpdates == shouldObserveVMUpdates) { return; } - _shouldObserveDBModifications = shouldObserveDBModifications; + _shouldObserveVMUpdates = shouldObserveVMUpdates; - if (self.shouldObserveDBModifications) { - OWSLogVerbose(@"resume observation of database modifications."); - // We need to call resetMappings when we _resume_ observing DB modifications, - // since we've been ignore DB modifications so the mappings can be wrong. - // - // resetMappings can however have the side effect of increasing the mapping's - // "window" size. If that happens, we need to restore the scroll state. - - // Snapshot the scroll state by measuring the "distance from top of view to - // bottom of content"; if the mapping's "window" size grows, it will grow - // _upward_. - CGFloat viewTopToContentBottom = 0; - OWSAssertDebug([self.collectionView.collectionViewLayout isKindOfClass:[ConversationViewLayout class]]); - ConversationViewLayout *conversationViewLayout - = (ConversationViewLayout *)self.collectionView.collectionViewLayout; - // To avoid laying out the collection view during initial view - // presentation, don't trigger layout here (via safeContentHeight) - // until layout has been done at least once. - if (conversationViewLayout.hasEverHadLayout) { - viewTopToContentBottom = self.safeContentHeight - self.collectionView.contentOffset.y; - } + if (self.shouldObserveVMUpdates) { + OWSLogVerbose(@"resume observation of view model."); - NSUInteger oldCellCount = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; - - // ViewItems modified while we were not observing may be stale. - // - // TODO: have a more fine-grained cache expiration based on rows modified. - [self.viewItemCache removeAllObjects]; - - // Snapshot the "previousLastTimestamp" value; it will be cleared by resetMappings. - NSNumber *_Nullable previousLastTimestamp = self.previousLastTimestamp; - - [self resetMappings]; - - NSUInteger newCellCount = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; + [self resetContentAndLayout]; + [self updateBackButtonUnreadCount]; + [self updateNavigationBarSubtitleLabel]; + [self updateDisappearingMessagesConfiguration]; // Detect changes in the mapping's "window" size. - if (oldCellCount != newCellCount) { + if (self.previousViewTopToContentBottom && self.previousViewItemCount + && self.previousViewItemCount.unsignedIntegerValue != self.viewItems.count) { CGFloat newContentHeight = self.safeContentHeight; - CGPoint newContentOffset = CGPointMake(0, MAX(0, newContentHeight - viewTopToContentBottom)); + CGPoint newContentOffset + = CGPointMake(0, MAX(0, newContentHeight - self.previousViewTopToContentBottom.floatValue)); [self.collectionView setContentOffset:newContentOffset animated:NO]; } @@ -4703,8 +4003,8 @@ typedef enum : NSUInteger { // first item at or after the "view horizon". See the comments below which explain // the "view horizon". id _Nullable lastViewItem = self.viewItems.lastObject; - BOOL hasAddedNewItems = (lastViewItem && previousLastTimestamp - && lastViewItem.interaction.timestamp > previousLastTimestamp.unsignedLongLongValue); + BOOL hasAddedNewItems = (lastViewItem && self.previousLastTimestamp + && lastViewItem.interaction.timestamp > self.previousLastTimestamp.unsignedLongLongValue); OWSLogInfo(@"hasAddedNewItems: %d", hasAddedNewItems); if (hasAddedNewItems) { @@ -4721,9 +4021,9 @@ typedef enum : NSUInteger { } } self.viewHorizonTimestamp = nil; - OWSLogVerbose(@"resumed observation of database modifications."); + OWSLogVerbose(@"resumed observation of view model."); } else { - OWSLogVerbose(@"pausing observation of database modifications."); + OWSLogVerbose(@"pausing observation of view model."); // When stopping observation, try to record the timestamp of the "view horizon". // The "view horizon" is where we'll want to focus the users when we resume // observation if any changes have happened while we weren't observing. @@ -4736,9 +4036,12 @@ typedef enum : NSUInteger { id _Nullable lastViewItem = self.viewItems.lastObject; if (lastViewItem) { self.previousLastTimestamp = @(lastViewItem.interaction.timestamp); + self.previousViewItemCount = @(self.viewItems.count); } else { self.previousLastTimestamp = nil; + self.previousViewItemCount = nil; } + __block TSInteraction *_Nullable firstUnseenInteraction = nil; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { firstUnseenInteraction = @@ -4753,13 +4056,29 @@ typedef enum : NSUInteger { } else { self.viewHorizonTimestamp = nil; } - OWSLogVerbose(@"paused observation of database modifications."); + + // Snapshot the scroll state by measuring the "distance from top of view to + // bottom of content"; if the mapping's "window" size grows, it will grow + // _upward_. + OWSAssertDebug([self.collectionView.collectionViewLayout isKindOfClass:[ConversationViewLayout class]]); + ConversationViewLayout *conversationViewLayout + = (ConversationViewLayout *)self.collectionView.collectionViewLayout; + // To avoid laying out the collection view during initial view + // presentation, don't trigger layout here (via safeContentHeight) + // until layout has been done at least once. + if (conversationViewLayout.hasEverHadLayout) { + self.previousViewTopToContentBottom = @(self.safeContentHeight - self.collectionView.contentOffset.y); + } else { + self.previousViewTopToContentBottom = nil; + } + + OWSLogVerbose(@"paused observation of view model."); } } - (nullable NSIndexPath *)firstIndexPathAtViewHorizonTimestamp { - OWSAssertDebug(self.shouldObserveDBModifications); + OWSAssertDebug(self.shouldObserveVMUpdates); if (!self.viewHorizonTimestamp) { return nil; @@ -4798,107 +4117,6 @@ typedef enum : NSUInteger { } } -// We stop observing database modifications when the app or this view is not visible -// (see: shouldObserveDBModifications). When we resume observing db modifications, -// we want to extend the "range" of this view to include any items added to this -// thread while we were not observing. -- (void)extendRangeToIncludeUnobservedItems -{ - if (!self.shouldObserveDBModifications) { - return; - } - if (!self.previousLastTimestamp) { - return; - } - - uint64_t previousLastTimestamp = self.previousLastTimestamp.unsignedLongLongValue; - __block NSUInteger addedItemCount = 0; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [[transaction ext:TSMessageDatabaseViewExtensionName] - enumerateRowsInGroup:self.thread.uniqueId - withOptions:NSEnumerationReverse - usingBlock:^(NSString *collection, - NSString *key, - id object, - id metadata, - NSUInteger index, - BOOL *stop) { - - if (![object isKindOfClass:[TSInteraction class]]) { - OWSFailDebug(@"Expected a TSInteraction: %@", [object class]); - return; - } - - TSInteraction *interaction = (TSInteraction *)object; - if (interaction.timestamp <= previousLastTimestamp) { - *stop = YES; - return; - } - - addedItemCount++; - }]; - }]; - OWSLogInfo(@"extendRangeToIncludeUnobservedItems: %zd", addedItemCount); - self.lastRangeLength += addedItemCount; - // We only want to do this once, so clear the "previous last timestamp". - self.previousLastTimestamp = nil; -} - -- (void)createNewMessageMappings -{ - if (self.thread.uniqueId.length > 0) { - self.messageMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[ self.thread.uniqueId ] - view:TSMessageDatabaseViewExtensionName]; - } else { - OWSFailDebug(@"uniqueId unexpectedly empty for thread: %@", self.thread); - self.messageMappings = - [[YapDatabaseViewMappings alloc] initWithGroups:@[] view:TSMessageDatabaseViewExtensionName]; - } - - // Cells' appearance can depend on adjacent cells in both directions. - [self.messageMappings setCellDrawingDependencyOffsets:[NSSet setWithArray:@[ - @(-1), - @(+1), - ]] - forGroup:self.thread.uniqueId]; - - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.messageMappings updateWithTransaction:transaction]; - }]; - // We need to impose the range restrictions on the mappings immediately to avoid - // doing a great deal of unnecessary work and causing a perf hotspot. - [self updateMessageMappingRangeOptions]; -} - -- (void)resetMappings -{ - // If we're entering "active" mode (e.g. view is visible and app is in foreground), - // reset all state updated by yapDatabaseModified:. - if (self.messageMappings != nil) { - // Before we begin observing database modifications, make sure - // our mapping and table state is up-to-date. - [self extendRangeToIncludeUnobservedItems]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.messageMappings updateWithTransaction:transaction]; - }]; - [self updateMessageMappingRangeOptions]; - } - self.collapseCutoffDate = [NSDate new]; - - [self resetContentAndLayout]; - [self ensureDynamicInteractions]; - [self updateBackButtonUnreadCount]; - [self updateNavigationBarSubtitleLabel]; - - // There appears to be a bug in YapDatabase that sometimes delays modifications - // made in another process (e.g. the SAE) from showing up in other processes. - // There's a simple workaround: a trivial write to the database flushes changes - // made from other processes. - [self.editingDatabaseConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - [transaction setObject:[NSUUID UUID].UUIDString forKey:@"conversation_view_noop_mod" inCollection:@"temp"]; - }]; -} - #pragma mark - ConversationCollectionViewDelegate - (void)collectionViewWillChangeLayout @@ -4916,317 +4134,6 @@ typedef enum : NSUInteger { #pragma mark - View Items -// This is a key method. It builds or rebuilds the list of -// cell view models. -// -// Returns NO on error. -- (BOOL)reloadViewItems -{ - NSMutableArray> *viewItems = [NSMutableArray new]; - NSMutableDictionary> *viewItemCache = [NSMutableDictionary new]; - - NSUInteger count = [self.messageMappings numberOfItemsInSection:0]; - BOOL isGroupThread = self.isGroupConversation; - - __block BOOL hasError = NO; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - OWSAssertDebug(viewTransaction); - for (NSUInteger row = 0; row < count; row++) { - TSInteraction *interaction = - [viewTransaction objectAtRow:row inSection:0 withMappings:self.messageMappings]; - if (!interaction) { - OWSFailDebug( - @"missing interaction in message mappings: %lu / %lu.", (unsigned long)row, (unsigned long)count); - // TODO: Add analytics. - hasError = YES; - continue; - } - if (!interaction.uniqueId) { - OWSFailDebug(@"invalid interaction in message mappings: %lu / %lu: %@.", - (unsigned long)row, - (unsigned long)count, - interaction); - // TODO: Add analytics. - hasError = YES; - continue; - } - - id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; - if (!viewItem) { - viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction - isGroupThread:isGroupThread - transaction:transaction - conversationStyle:self.conversationStyle]; - } - [viewItems addObject:viewItem]; - OWSAssertDebug(!viewItemCache[interaction.uniqueId]); - viewItemCache[interaction.uniqueId] = viewItem; - } - }]; - - // Flag to ensure that we only increment once per launch. - if (hasError) { - OWSLogWarn(@"incrementing version of: %@", TSMessageDatabaseViewExtensionName); - [OWSPrimaryStorage incrementVersionOfDatabaseExtension:TSMessageDatabaseViewExtensionName]; - } - - // Update the "break" properties (shouldShowDate and unreadIndicator) of the view items. - BOOL shouldShowDateOnNextViewItem = YES; - uint64_t previousViewItemTimestamp = 0; - OWSUnreadIndicator *_Nullable unreadIndicator = self.dynamicInteractions.unreadIndicator; - uint64_t collapseCutoffTimestamp = [NSDate ows_millisecondsSince1970ForDate:self.collapseCutoffDate]; - - BOOL hasPlacedUnreadIndicator = NO; - for (id viewItem in viewItems) { - BOOL canShowDate = NO; - switch (viewItem.interaction.interactionType) { - case OWSInteractionType_Unknown: - case OWSInteractionType_Offer: - canShowDate = NO; - break; - case OWSInteractionType_IncomingMessage: - case OWSInteractionType_OutgoingMessage: - case OWSInteractionType_Error: - case OWSInteractionType_Info: - case OWSInteractionType_Call: - canShowDate = YES; - break; - } - - uint64_t viewItemTimestamp = viewItem.interaction.timestampForSorting; - OWSAssertDebug(viewItemTimestamp > 0); - - BOOL shouldShowDate = NO; - if (previousViewItemTimestamp == 0) { - shouldShowDateOnNextViewItem = YES; - } else if (![DateUtil isSameDayWithTimestamp:previousViewItemTimestamp timestamp:viewItemTimestamp]) { - shouldShowDateOnNextViewItem = YES; - } - - if (shouldShowDateOnNextViewItem && canShowDate) { - shouldShowDate = YES; - shouldShowDateOnNextViewItem = NO; - } - - viewItem.shouldShowDate = shouldShowDate; - - previousViewItemTimestamp = viewItemTimestamp; - - // When a conversation without unread messages receives an incoming message, - // we call ensureDynamicInteractions to ensure that the unread indicator (etc.) - // state is updated accordingly. However this is done in a separate transaction. - // We don't want to show the incoming message _without_ an unread indicator and - // then immediately re-render it _with_ an unread indicator. - // - // To avoid this, we use a temporary instance of OWSUnreadIndicator whenever - // we find an unread message that _should_ have an unread indicator, but no - // unread indicator exists yet on dynamicInteractions. - BOOL isItemUnread = ([viewItem.interaction conformsToProtocol:@protocol(OWSReadTracking)] - && !((id)viewItem.interaction).wasRead); - if (isItemUnread && !unreadIndicator && !hasPlacedUnreadIndicator && !self.hasClearedUnreadMessagesIndicator) { - - unreadIndicator = - [[OWSUnreadIndicator alloc] initUnreadIndicatorWithTimestamp:viewItem.interaction.timestamp - hasMoreUnseenMessages:NO - missingUnseenSafetyNumberChangeCount:0 - unreadIndicatorPosition:0 - firstUnseenInteractionTimestamp:viewItem.interaction.timestamp]; - } - - // Place the unread indicator onto the first appropriate view item, - // if any. - if (unreadIndicator && viewItem.interaction.timestampForSorting >= unreadIndicator.timestamp) { - viewItem.unreadIndicator = unreadIndicator; - unreadIndicator = nil; - hasPlacedUnreadIndicator = YES; - } else { - viewItem.unreadIndicator = nil; - } - } - if (unreadIndicator) { - // This isn't necessarily a bug - all of the interactions after the - // unread indicator may have disappeared or been deleted. - OWSLogWarn(@"Couldn't find an interaction to hang the unread indicator on."); - } - - // Update the properties of the view items. - // - // NOTE: This logic uses the break properties which are set in the previous pass. - for (NSUInteger i = 0; i < viewItems.count; i++) { - id viewItem = viewItems[i]; - id _Nullable previousViewItem = (i > 0 ? viewItems[i - 1] : nil); - id _Nullable nextViewItem = (i + 1 < viewItems.count ? viewItems[i + 1] : nil); - BOOL shouldShowSenderAvatar = NO; - BOOL shouldHideFooter = NO; - BOOL isFirstInCluster = YES; - BOOL isLastInCluster = YES; - NSAttributedString *_Nullable senderName = nil; - - OWSInteractionType interactionType = viewItem.interaction.interactionType; - NSString *timestampText = [DateUtil formatTimestampShort:viewItem.interaction.timestamp]; - - if (interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; - MessageReceiptStatus receiptStatus = - [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:outgoingMessage]; - BOOL isDisappearingMessage = outgoingMessage.isExpiringMessage; - - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - TSOutgoingMessage *nextOutgoingMessage = (TSOutgoingMessage *)nextViewItem.interaction; - MessageReceiptStatus nextReceiptStatus = - [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:nextOutgoingMessage]; - NSString *nextTimestampText = [DateUtil formatTimestampShort:nextViewItem.interaction.timestamp]; - - // We can skip the "outgoing message status" footer if the next message - // has the same footer and no "date break" separates us... - // ...but always show "failed to send" status - // ...and always show the "disappearing messages" animation. - shouldHideFooter - = ([timestampText isEqualToString:nextTimestampText] && receiptStatus == nextReceiptStatus - && outgoingMessage.messageState != TSOutgoingMessageStateFailed - && outgoingMessage.messageState != TSOutgoingMessageStateSending && !nextViewItem.hasCellHeader - && !isDisappearingMessage); - } - - // clustering - if (previousViewItem == nil) { - isFirstInCluster = YES; - } else if (viewItem.hasCellHeader) { - isFirstInCluster = YES; - } else { - isFirstInCluster = previousViewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage; - } - - if (nextViewItem == nil) { - isLastInCluster = YES; - } else if (nextViewItem.hasCellHeader) { - isLastInCluster = YES; - } else { - isLastInCluster = nextViewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage; - } - } else if (interactionType == OWSInteractionType_IncomingMessage) { - - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)viewItem.interaction; - NSString *incomingSenderId = incomingMessage.authorId; - OWSAssertDebug(incomingSenderId.length > 0); - BOOL isDisappearingMessage = incomingMessage.isExpiringMessage; - - NSString *_Nullable nextIncomingSenderId = nil; - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - TSIncomingMessage *nextIncomingMessage = (TSIncomingMessage *)nextViewItem.interaction; - nextIncomingSenderId = nextIncomingMessage.authorId; - OWSAssertDebug(nextIncomingSenderId.length > 0); - } - - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - NSString *nextTimestampText = [DateUtil formatTimestampShort:nextViewItem.interaction.timestamp]; - // We can skip the "incoming message status" footer in a cluster if the next message - // has the same footer and no "date break" separates us. - // ...but always show the "disappearing messages" animation. - shouldHideFooter = ([timestampText isEqualToString:nextTimestampText] && !nextViewItem.hasCellHeader && - [NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId] - && !isDisappearingMessage); - } - - // clustering - if (previousViewItem == nil) { - isFirstInCluster = YES; - } else if (viewItem.hasCellHeader) { - isFirstInCluster = YES; - } else if (previousViewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { - isFirstInCluster = YES; - } else { - TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousViewItem.interaction; - isFirstInCluster = ![incomingSenderId isEqual:previousIncomingMessage.authorId]; - } - - if (nextViewItem == nil) { - isLastInCluster = YES; - } else if (nextViewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { - isLastInCluster = YES; - } else if (nextViewItem.hasCellHeader) { - isLastInCluster = YES; - } else { - TSIncomingMessage *nextIncomingMessage = (TSIncomingMessage *)nextViewItem.interaction; - isLastInCluster = ![incomingSenderId isEqual:nextIncomingMessage.authorId]; - } - - if (viewItem.isGroupThread) { - // Show the sender name for incoming group messages unless - // the previous message has the same sender name and - // no "date break" separates us. - BOOL shouldShowSenderName = YES; - if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) { - - TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousViewItem.interaction; - NSString *previousIncomingSenderId = previousIncomingMessage.authorId; - OWSAssertDebug(previousIncomingSenderId.length > 0); - - shouldShowSenderName - = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId] - || viewItem.hasCellHeader); - } - if (shouldShowSenderName) { - senderName = [self.contactsManager - attributedContactOrProfileNameForPhoneIdentifier:incomingSenderId - primaryAttributes:[OWSMessageBubbleView - senderNamePrimaryAttributes] - secondaryAttributes:[OWSMessageBubbleView - senderNameSecondaryAttributes]]; - } - - // Show the sender avatar for incoming group messages unless - // the next message has the same sender avatar and - // no "date break" separates us. - shouldShowSenderAvatar = YES; - if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { - shouldShowSenderAvatar = (![NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId] - || nextViewItem.hasCellHeader); - } - } - } - - if (viewItem.interaction.timestampForSorting > collapseCutoffTimestamp) { - shouldHideFooter = NO; - } - - viewItem.isFirstInCluster = isFirstInCluster; - viewItem.isLastInCluster = isLastInCluster; - viewItem.shouldShowSenderAvatar = shouldShowSenderAvatar; - viewItem.shouldHideFooter = shouldHideFooter; - viewItem.senderName = senderName; - } - - self.viewItems = viewItems; - self.viewItemCache = viewItemCache; - - return !hasError; -} - -// Whenever an interaction is modified, we need to reload it from the DB -// and update the corresponding view item. -- (void)reloadInteractionForViewItem:(id)viewItem -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(viewItem); - - // This should never happen, but don't crash in production if we have a bug. - if (!viewItem) { - return; - } - - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - TSInteraction *_Nullable interaction = - [TSInteraction fetchObjectWithUniqueID:viewItem.interaction.uniqueId transaction:transaction]; - if (!interaction) { - OWSFailDebug(@"could not reload interaction"); - } else { - [viewItem replaceInteraction:interaction transaction:transaction]; - } - }]; -} - - (nullable id)viewItemForIndex:(NSInteger)index { if (index < 0 || index >= (NSInteger)self.viewItems.count) { @@ -5433,6 +4340,243 @@ typedef enum : NSUInteger { [super presentViewController:viewController animated:animated completion:completion]; } +#pragma mark - ConversationViewModelDelegate + +- (void)conversationViewModelWillUpdate +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(self.conversationViewModel); + + OWSLogVerbose(@""); + + // HACK to work around radar #28167779 + // "UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout" + // more: https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue + // This was our #2 crash, and much exacerbated by the refactoring somewhere between 2.6.2.0-2.6.3.8 + // + // NOTE: It's critical we do this before beginLongLivedReadTransaction. + // We want to relayout our contents using the old message mappings and + // view items before they are updated. + [self.collectionView layoutIfNeeded]; + // ENDHACK to work around radar #28167779 +} + +- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(conversationUpdate); + OWSAssertDebug(self.conversationViewModel); + + if (!self.shouldObserveVMUpdates) { + return; + } + + OWSLogVerbose(@""); + + [self updateBackButtonUnreadCount]; + [self updateNavigationBarSubtitleLabel]; + + if (self.isGroupConversation) { + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + [self.thread reloadWithTransaction:transaction]; + }]; + [self updateNavigationTitle]; + } + + [self updateDisappearingMessagesConfiguration]; + + if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Minor) { + return; + } else if (conversationUpdate.conversationUpdateType == ConversationUpdateType_Reload) { + [self resetContentAndLayout]; + [self updateLastVisibleTimestamp]; + [self scrollToBottomAnimated:NO]; + return; + } + + OWSAssertDebug(conversationUpdate.conversationUpdateType == ConversationUpdateType_Diff); + OWSAssertDebug(conversationUpdate.updateItems); + + BOOL wasAtBottom = [self isScrolledToBottom]; + // We want sending messages to feel snappy. So, if the only + // update is a new outgoing message AND we're already scrolled to + // the bottom of the conversation, skip the scroll animation. + __block BOOL shouldAnimateScrollToBottom = !wasAtBottom; + // We want to scroll to the bottom if the user: + // + // a) already was at the bottom of the conversation. + // b) is inserting new interactions. + __block BOOL scrollToBottom = wasAtBottom; + + void (^batchUpdates)(void) = ^{ + OWSAssertIsOnMainThread(); + + const NSUInteger section = 0; + BOOL hasInserted = NO, hasUpdated = NO; + for (ConversationUpdateItem *updateItem in conversationUpdate.updateItems) { + switch (updateItem.updateItemType) { + case ConversationUpdateItemType_Delete: { + // Always perform deletes before inserts and updates. + OWSAssertDebug(!hasInserted && !hasUpdated); + [self.collectionView deleteItemsAtIndexPaths:@[ + [NSIndexPath indexPathForRow:(NSInteger)updateItem.oldIndex inSection:section] + ]]; + break; + } + case ConversationUpdateItemType_Insert: { + // Always perform inserts before updates. + OWSAssertDebug(!hasUpdated); + [self.collectionView insertItemsAtIndexPaths:@[ + [NSIndexPath indexPathForRow:(NSInteger)updateItem.newIndex inSection:section] + ]]; + hasInserted = YES; + + id viewItem = updateItem.viewItem; + OWSAssertDebug(viewItem); + if ([viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { + TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; + if (!outgoingMessage.isFromLinkedDevice) { + scrollToBottom = YES; + shouldAnimateScrollToBottom = NO; + } + } + + break; + } + case ConversationUpdateItemType_Update: { + [self.collectionView reloadItemsAtIndexPaths:@[ + [NSIndexPath indexPathForRow:(NSInteger)updateItem.newIndex inSection:section] + ]]; + hasUpdated = YES; + break; + } + } + } + }; + + BOOL shouldAnimateUpdates = conversationUpdate.shouldAnimateUpdates; + void (^batchUpdatesCompletion)(BOOL) = ^(BOOL finished) { + OWSAssertIsOnMainThread(); + + if (!finished) { + OWSLogInfo(@"performBatchUpdates did not finish"); + } + + [self updateLastVisibleTimestamp]; + + if (scrollToBottom && shouldAnimateUpdates) { + [self scrollToBottomAnimated:shouldAnimateScrollToBottom]; + } + }; + + @try { + if (shouldAnimateUpdates) { + [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; + + } else { + // HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a + // UIKit Crash like: + // + // *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:], + // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760 + // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While + // preparing update a visible view at {length = 2, path = 0 - 142} + // wasn't found in the current data model and was not in an update animation. This is an internal + // error.' + // + // I'm unclear if this is a bug in UIKit, or if we're doing something crazy in + // ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the + // conversation. See `DebugUIMessages#thrashCellsInThread:` + [UIView + animateWithDuration:0.0 + animations:^{ + [self.collectionView performBatchUpdates:batchUpdates completion:batchUpdatesCompletion]; + if (scrollToBottom) { + [self scrollToBottomAnimated:shouldAnimateUpdates]; + } + }]; + } + } @catch (NSException *exception) { + OWSFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", + exception.description, + exception.name, + exception.reason, + exception.userInfo); + + for (ConversationUpdateItem *updateItem in conversationUpdate.updateItems) { + switch (updateItem.updateItemType) { + case ConversationUpdateItemType_Delete: + OWSLogWarn(@"ConversationUpdateItemType_Delete class: %@, itemId: %@, oldIndex: %lu, " + @"newIndex: %lu", + [updateItem.viewItem class], + updateItem.viewItem.itemId, + (unsigned long)updateItem.oldIndex, + (unsigned long)updateItem.newIndex); + break; + case ConversationUpdateItemType_Insert: + OWSLogWarn(@"ConversationUpdateItemType_Insert class: %@, itemId: %@, oldIndex: %lu, " + @"newIndex: %lu", + [updateItem.viewItem class], + updateItem.viewItem.itemId, + (unsigned long)updateItem.oldIndex, + (unsigned long)updateItem.newIndex); + break; + case ConversationUpdateItemType_Update: + OWSLogWarn(@"ConversationUpdateItemType_Update class: %@, itemId: %@, oldIndex: %lu, " + @"newIndex: %lu", + [updateItem.viewItem class], + updateItem.viewItem.itemId, + (unsigned long)updateItem.oldIndex, + (unsigned long)updateItem.newIndex); + break; + } + } + + @throw exception; + } + + self.lastReloadDate = [NSDate new]; +} + +- (void)conversationViewModelWillLoadMoreItems +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(self.conversationViewModel); + + // We want to restore the current scroll state after we update the range, update + // the dynamic interactions and re-layout. Here we take a "before" snapshot. + self.scrollDistanceToBottomSnapshot = self.safeContentHeight - self.collectionView.contentOffset.y; +} + +- (void)conversationViewModelDidLoadMoreItems +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(self.conversationViewModel); + + [self.layout prepareLayout]; + + self.collectionView.contentOffset = CGPointMake(0, self.safeContentHeight - self.scrollDistanceToBottomSnapshot); +} + +- (void)conversationViewModelDidLoadPrevPage +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(self.conversationViewModel); + + [self scrollToUnreadIndicatorAnimated]; +} + +- (void)conversationViewModelRangeDidChange +{ + OWSAssertIsOnMainThread(); + + if (!self.conversationViewModel) { + return; + } + + [self updateShowLoadMoreHeader]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h index fe2bb42b9..89633bdc3 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h @@ -121,8 +121,14 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); - (BOOL)canSaveMedia; +// For view items that correspond to interactions, this is the interaction's unique id. +// For other view views (like the typing indicator), this is a unique, stable string. +- (NSString *)itemId; + @end +#pragma mark - + @interface ConversationInteractionViewItem : NSObject diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index 55c53fde0..e100027ac 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -153,6 +153,11 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) [TSContactThread conversationColorNameForRecipientId:incomingMessage.authorId transaction:transaction]; } +- (NSString *)itemId +{ + return self.interaction.uniqueId; +} + - (BOOL)hasBodyText { return _displayableBodyText != nil; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h new file mode 100644 index 000000000..feda9e737 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h @@ -0,0 +1,108 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +@class ConversationStyle; +@class ConversationViewModel; +@class OWSQuotedReplyModel; +@class TSThread; +@class ThreadDynamicInteractions; + +@protocol ConversationViewItem; + +typedef NS_ENUM(NSUInteger, ConversationUpdateType) { + // No view items in the load window were effected. + ConversationUpdateType_Minor, + // A subset of view items in the load window were effected; + // the view should be updated using the update items. + ConversationUpdateType_Diff, + // Complicated or unexpected changes occurred in the load window; + // the view should be reloaded. + ConversationUpdateType_Reload, +}; + +#pragma mark - + +typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { + ConversationUpdateItemType_Insert, + ConversationUpdateItemType_Delete, + ConversationUpdateItemType_Update, +}; + +#pragma mark - + +@interface ConversationUpdateItem : NSObject + +@property (nonatomic, readonly) ConversationUpdateItemType updateItemType; +// Only applies in the "delete" and "update" cases. +@property (nonatomic, readonly) NSUInteger oldIndex; +// Only applies in the "insert" and "update" cases. +@property (nonatomic, readonly) NSUInteger newIndex; +// Only applies in the "insert" and "update" cases. +@property (nonatomic, readonly, nullable) id viewItem; + +@end + +#pragma mark - + +@interface ConversationUpdate : NSObject + +@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType; +// Only applies in the "diff" case. +@property (nonatomic, readonly, nullable) NSArray *updateItems; +//// Only applies in the "diff" case. +@property (nonatomic, readonly) BOOL shouldAnimateUpdates; + +@end + +#pragma mark - + +@protocol ConversationViewModelDelegate + +- (void)conversationViewModelWillUpdate; +- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate; + +- (void)conversationViewModelWillLoadMoreItems; +- (void)conversationViewModelDidLoadMoreItems; +- (void)conversationViewModelDidLoadPrevPage; +- (void)conversationViewModelRangeDidChange; + +// TODO: It'd be nice to remove this. +- (BOOL)shouldObserveDBModifications; + +- (ConversationStyle *)conversationStyle; + +@end + +#pragma mark - + +@interface ConversationViewModel : NSObject + +@property (nonatomic, readonly) NSArray> *viewItems; +@property (nonatomic, nullable) NSString *focusMessageIdOnOpen; +@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithThread:(TSThread *)thread + focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen + delegate:(id)delegate NS_DESIGNATED_INITIALIZER; + +- (void)ensureDynamicInteractions; + +- (void)clearUnreadMessagesIndicator; + +- (void)loadAnotherPageOfMessages; + +- (void)viewDidResetContentAndLayout; + +- (void)viewDidLoad; + +- (BOOL)canLoadMoreItems; + +- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m new file mode 100644 index 000000000..550483944 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -0,0 +1,1284 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +#import "ConversationViewModel.h" +#import "ConversationViewItem.h" +#import "DateUtil.h" +#import "OWSMessageBubbleView.h" +#import "OWSQuotedReplyModel.h" +#import "Signal-Swift.h" +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@implementation ConversationUpdateItem + +- (instancetype)initWithUpdateItemType:(ConversationUpdateItemType)updateItemType + oldIndex:(NSUInteger)oldIndex + newIndex:(NSUInteger)newIndex + viewItem:(nullable id)viewItem +{ + self = [super init]; + if (!self) { + return self; + } + + _updateItemType = updateItemType; + _oldIndex = oldIndex; + _newIndex = newIndex; + _viewItem = viewItem; + + return self; +} + +@end + +#pragma mark - + +@implementation ConversationUpdate + +- (instancetype)initWithConversationUpdateType:(ConversationUpdateType)conversationUpdateType + updateItems:(nullable NSArray *)updateItems + shouldAnimateUpdates:(BOOL)shouldAnimateUpdates +{ + self = [super init]; + if (!self) { + return self; + } + + _conversationUpdateType = conversationUpdateType; + _updateItems = updateItems; + _shouldAnimateUpdates = shouldAnimateUpdates; + + return self; +} + ++ (ConversationUpdate *)minorUpdate +{ + return [[ConversationUpdate alloc] initWithConversationUpdateType:ConversationUpdateType_Minor + updateItems:nil + shouldAnimateUpdates:NO]; +} + ++ (ConversationUpdate *)reloadUpdate +{ + return [[ConversationUpdate alloc] initWithConversationUpdateType:ConversationUpdateType_Reload + updateItems:nil + shouldAnimateUpdates:NO]; +} + ++ (ConversationUpdate *)diffUpdateWithUpdateItems:(nullable NSArray *)updateItems + shouldAnimateUpdates:(BOOL)shouldAnimateUpdates +{ + return [[ConversationUpdate alloc] initWithConversationUpdateType:ConversationUpdateType_Diff + updateItems:updateItems + shouldAnimateUpdates:shouldAnimateUpdates]; +} + +@end + +#pragma mark - + +// Always load up to n messages when user arrives. +// +// The smaller this number is, the faster the conversation can display. +// To test, shrink you accessability font as much as possible, then count how many 1-line system info messages (our +// shortest cells) can fit on screen at a time on an iPhoneX +// +// PERF: we could do less messages on shorter (older, slower) devices +// PERF: we could cache the cell height, since some messages will be much taller. +static const int kYapDatabasePageSize = 25; + +// Never show more than n messages in conversation view when user arrives. +static const int kConversationInitialMaxRangeSize = 300; + +// Never show more than n messages in conversation view at a time. +static const int kYapDatabaseRangeMaxLength = 25000; + +static const int kYapDatabaseRangeMinLength = 0; + +#pragma mark - + +@interface ConversationViewModel () + +@property (nonatomic, weak) id delegate; + +@property (nonatomic, readonly) TSThread *thread; + +// The mapping must be updated in lockstep with the uiDatabaseConnection. +// +// * The first (required) step is to update uiDatabaseConnection using beginLongLivedReadTransaction. +// * The second (required) step is to update messageMappings. +// * The third (optional) step is to update the messageMappings range using +// updateMessageMappingRangeOptions. +// * The fourth (optional) step is to update the view items using reloadViewItems. +// * The steps must be done in strict order. +// * If we do any of the steps, we must do all of the required steps. +// * We can't use messageMappings or viewItems after the first step until we've +// done the last step; i.e.. we can't do any layout, since that uses the view +// items which haven't been updated yet. +// * If the first and/or second steps changes the set of messages +// their ordering and/or their state, we must do the third and fourth steps. +// * If we do the third step, we must call resetContentAndLayout afterward. +@property (nonatomic) YapDatabaseViewMappings *messageMappings; + +@property (nonatomic) NSArray> *viewItems; +@property (nonatomic) NSMutableDictionary> *viewItemCache; + +@property (nonatomic) NSUInteger lastRangeLength; +@property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; +@property (nonatomic) BOOL hasClearedUnreadMessagesIndicator; +@property (nonatomic, nullable) NSDate *collapseCutoffDate; + +@end + +#pragma mark - + +@implementation ConversationViewModel + +- (instancetype)initWithThread:(TSThread *)thread + focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen + delegate:(id)delegate +{ + self = [super init]; + if (!self) { + return self; + } + + OWSAssertDebug(thread); + OWSAssertDebug(delegate); + + _thread = thread; + _delegate = delegate; + self.focusMessageIdOnOpen = focusMessageIdOnOpen; + + [self configure]; + + return self; +} + +#pragma mark - Dependencies + +- (OWSPrimaryStorage *)primaryStorage +{ + OWSAssertDebug(SSKEnvironment.shared.primaryStorage); + + return SSKEnvironment.shared.primaryStorage; +} + +- (YapDatabaseConnection *)uiDatabaseConnection +{ + return self.primaryStorage.uiDatabaseConnection; +} + +- (YapDatabaseConnection *)editingDatabaseConnection +{ + return self.primaryStorage.dbReadWriteConnection; +} + +- (OWSContactsManager *)contactsManager +{ + return (OWSContactsManager *)SSKEnvironment.shared.contactsManager; +} + +- (OWSBlockingManager *)blockingManager +{ + return OWSBlockingManager.sharedManager; +} + +#pragma mark + +- (void)addNotificationListeners +{ + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(uiDatabaseDidUpdateExternally:) + name:OWSUIDatabaseConnectionDidUpdateExternallyNotification + object:self.primaryStorage.dbNotificationObject]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(uiDatabaseWillUpdate:) + name:OWSUIDatabaseConnectionWillUpdateNotification + object:self.primaryStorage.dbNotificationObject]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(uiDatabaseDidUpdate:) + name:OWSUIDatabaseConnectionDidUpdateNotification + object:self.primaryStorage.dbNotificationObject]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(applicationDidEnterBackground:) + name:OWSApplicationDidEnterBackgroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(signalAccountsDidChange:) + name:OWSContactsManagerSignalAccountsDidChangeNotification + object:nil]; +} + +- (void)signalAccountsDidChange:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + [self ensureDynamicInteractions]; +} + +- (void)configure +{ + OWSLogInfo(@""); + + // We need to update the "unread indicator" _before_ we determine the initial range + // size, since it depends on where the unread indicator is placed. + self.lastRangeLength = 0; + + [self ensureDynamicInteractions]; + [self.primaryStorage updateUIDatabaseConnectionToLatest]; + + [self createNewMessageMappings]; + if (![self reloadViewItems]) { + OWSFailDebug(@"failed to reload view items in configureForThread."); + } +} + +- (void)viewDidLoad +{ + [self addNotificationListeners]; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (BOOL)canLoadMoreItems +{ + if (self.lastRangeLength >= kYapDatabaseRangeMaxLength) { + return NO; + } + + NSUInteger loadWindowSize = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; + __block NSUInteger totalMessageCount; + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + totalMessageCount = + [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; + }]; + return loadWindowSize < totalMessageCount; +} + +- (void)applicationDidEnterBackground:(NSNotification *)notification +{ + if (self.hasClearedUnreadMessagesIndicator) { + self.hasClearedUnreadMessagesIndicator = NO; + [self.dynamicInteractions clearUnreadIndicatorState]; + } +} + +- (void)viewDidResetContentAndLayout +{ + self.collapseCutoffDate = [NSDate new]; + if (![self reloadViewItems]) { + OWSFailDebug(@"failed to reload view items in resetContentAndLayout."); + } +} + +- (void)loadAnotherPageOfMessages +{ + BOOL hasEarlierUnseenMessages = self.dynamicInteractions.unreadIndicator.hasMoreUnseenMessages; + + [self loadNMoreMessages:kYapDatabasePageSize]; + + // Don’t auto-scroll after “loading more messages” unless we have “more unseen messages”. + // + // Otherwise, tapping on "load more messages" autoscrolls you downward which is completely wrong. + if (hasEarlierUnseenMessages && !self.focusMessageIdOnOpen) { + // Ensure view items are updated before trying to scroll to the + // unread indicator. + // + // loadNMoreMessages calls resetMappings which calls ensureDynamicInteractions, + // which may move the unread indicator, and for scrollToUnreadIndicatorAnimated + // to work properly, the view items need to be updated to reflect that change. + [self.primaryStorage updateUIDatabaseConnectionToLatest]; + + [self.delegate conversationViewModelDidLoadPrevPage]; + } +} + +- (void)loadNMoreMessages:(NSUInteger)numberOfMessagesToLoad +{ + [self.delegate conversationViewModelWillLoadMoreItems]; + + self.lastRangeLength = MIN(self.lastRangeLength + numberOfMessagesToLoad, (NSUInteger)kYapDatabaseRangeMaxLength); + + [self resetMappings]; + + [self.delegate conversationViewModelDidLoadMoreItems]; +} + +- (void)updateMessageMappingRangeOptions +{ + NSUInteger rangeLength = 0; + + if (self.lastRangeLength == 0) { + // If this is the first time we're configuring the range length, + // try to take into account the position of the unread indicator + // and the "focus message". + OWSAssertDebug(self.dynamicInteractions); + + if (self.focusMessageIdOnOpen) { + OWSAssertDebug(self.dynamicInteractions.focusMessagePosition); + if (self.dynamicInteractions.focusMessagePosition) { + OWSLogVerbose(@"ensuring load of focus message: %@", self.dynamicInteractions.focusMessagePosition); + rangeLength = MAX(rangeLength, 1 + self.dynamicInteractions.focusMessagePosition.unsignedIntegerValue); + } + } + + if (self.dynamicInteractions.unreadIndicator) { + NSUInteger unreadIndicatorPosition + = (NSUInteger)self.dynamicInteractions.unreadIndicator.unreadIndicatorPosition; + + // If there is an unread indicator, increase the initial load window + // to include it. + OWSAssertDebug(unreadIndicatorPosition > 0); + OWSAssertDebug(unreadIndicatorPosition <= kYapDatabaseRangeMaxLength); + + // We'd like to include at least N seen messages, + // to give the user the context of where they left off the conversation. + const NSUInteger kPreferredSeenMessageCount = 1; + rangeLength = MAX(rangeLength, unreadIndicatorPosition + kPreferredSeenMessageCount); + } + } + + // Always try to load at least a single page of messages. + rangeLength = MAX(rangeLength, kYapDatabasePageSize); + + // Range size should monotonically increase. + rangeLength = MAX(rangeLength, self.lastRangeLength); + + // Enforce max range size. + rangeLength = MIN(rangeLength, kYapDatabaseRangeMaxLength); + + self.lastRangeLength = rangeLength; + + YapDatabaseViewRangeOptions *rangeOptions = + [YapDatabaseViewRangeOptions flexibleRangeWithLength:rangeLength offset:0 from:YapDatabaseViewEnd]; + + rangeOptions.maxLength = MAX(rangeLength, kYapDatabaseRangeMaxLength); + rangeOptions.minLength = kYapDatabaseRangeMinLength; + + [self.messageMappings setRangeOptions:rangeOptions forGroup:self.thread.uniqueId]; + [self.delegate conversationViewModelRangeDidChange]; + self.collapseCutoffDate = [NSDate new]; +} + +- (void)ensureDynamicInteractions +{ + OWSAssertIsOnMainThread(); + + const int currentMaxRangeSize = (int)self.lastRangeLength; + const int maxRangeSize = MAX(kConversationInitialMaxRangeSize, currentMaxRangeSize); + + self.dynamicInteractions = [ThreadUtil ensureDynamicInteractionsForThread:self.thread + contactsManager:self.contactsManager + blockingManager:self.blockingManager + dbConnection:self.editingDatabaseConnection + hideUnreadMessagesIndicator:self.hasClearedUnreadMessagesIndicator + lastUnreadIndicator:self.dynamicInteractions.unreadIndicator + focusMessageId:self.focusMessageIdOnOpen + maxRangeSize:maxRangeSize]; +} + +- (nullable id)viewItemForUnreadMessagesIndicator +{ + for (id viewItem in self.viewItems) { + if (viewItem.unreadIndicator) { + return viewItem; + } + } + return nil; +} + +- (void)clearUnreadMessagesIndicator +{ + OWSAssertIsOnMainThread(); + + // TODO: Remove by making unread indicator a view model concern. + id _Nullable oldIndicatorItem = [self viewItemForUnreadMessagesIndicator]; + if (oldIndicatorItem) { + // TODO ideally this would be happening within the *same* transaction that caused the unreadMessageIndicator + // to be cleared. + [self.editingDatabaseConnection + asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { + [oldIndicatorItem.interaction touchWithTransaction:transaction]; + }]; + } + + if (self.hasClearedUnreadMessagesIndicator) { + // ensureDynamicInteractionsForThread is somewhat expensive + // so we don't want to call it unnecessarily. + return; + } + + // Once we've cleared the unread messages indicator, + // make sure we don't show it again. + self.hasClearedUnreadMessagesIndicator = YES; + + if (self.dynamicInteractions.unreadIndicator) { + // If we've just cleared the "unread messages" indicator, + // update the dynamic interactions. + [self ensureDynamicInteractions]; + } +} + +#pragma mark - Storage access + +- (void)uiDatabaseDidUpdateExternally:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + OWSLogVerbose(@""); + + if (self.delegate.shouldObserveDBModifications) { + // External database modifications can't be converted into incremental updates, + // so rebuild everything. This is expensive and usually isn't necessary, but + // there's no alternative. + // + // We don't need to do this if we're not observing db modifications since we'll + // do it when we resume. + [self resetMappings]; + } +} + +- (void)uiDatabaseWillUpdate:(NSNotification *)notification +{ + [self.delegate conversationViewModelWillUpdate]; +} + +- (void)uiDatabaseDidUpdate:(NSNotification *)notification +{ + OWSAssertIsOnMainThread(); + + OWSLogVerbose(@""); + + NSArray *notifications = notification.userInfo[OWSUIDatabaseConnectionNotificationsKey]; + OWSAssertDebug([notifications isKindOfClass:[NSArray class]]); + + if (![[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] hasChangesForGroup:self.thread.uniqueId + inNotifications:notifications]) { + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + [self.messageMappings updateWithTransaction:transaction]; + }]; + + [self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate]; + return; + } + + NSArray *sectionChanges = nil; + NSArray *rowChanges = nil; + [[self.uiDatabaseConnection ext:TSMessageDatabaseViewExtensionName] getSectionChanges:§ionChanges + rowChanges:&rowChanges + forNotifications:notifications + withMappings:self.messageMappings]; + + if ([sectionChanges count] == 0 && [rowChanges count] == 0) { + // YapDatabase will ignore insertions within the message mapping's + // range that are not within the current mapping's contents. We + // may need to extend the mapping's contents to reflect the current + // range. + [self updateMessageMappingRangeOptions]; + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate]; + } + + NSMutableArray *oldItemIdList = [NSMutableArray new]; + for (id viewItem in self.viewItems) { + [oldItemIdList addObject:viewItem.itemId]; + } + + // We need to reload any modified interactions _before_ we call + // reloadViewItems. + BOOL hasMalformedRowChange = NO; + NSMutableSet *updatedItemSet = [NSMutableSet new]; + NSMutableSet *updatedNeighborItemSet = [NSMutableSet new]; + for (YapDatabaseViewRowChange *rowChange in rowChanges) { + switch (rowChange.type) { + case YapDatabaseViewChangeUpdate: { + YapCollectionKey *collectionKey = rowChange.collectionKey; + if (collectionKey.key) { + id _Nullable viewItem = self.viewItemCache[collectionKey.key]; + if (viewItem) { + [self reloadInteractionForViewItem:viewItem]; + [updatedItemSet addObject:viewItem.itemId]; + if (rowChange.changes == YapDatabaseViewChangedDependency) { + [updatedNeighborItemSet addObject:viewItem.itemId]; + } + } else { + hasMalformedRowChange = YES; + } + } else if (rowChange.indexPath && rowChange.originalIndex < self.viewItems.count) { + // Do nothing, this is a pseudo-update generated due to + // setCellDrawingDependencyOffsets. + OWSAssertDebug(rowChange.changes == YapDatabaseViewChangedDependency); + } else { + hasMalformedRowChange = YES; + } + break; + } + case YapDatabaseViewChangeDelete: { + // Discard cached view items after deletes. + YapCollectionKey *collectionKey = rowChange.collectionKey; + if (collectionKey.key) { + [self.viewItemCache removeObjectForKey:collectionKey.key]; + } else { + hasMalformedRowChange = YES; + } + break; + } + default: + break; + } + if (hasMalformedRowChange) { + break; + } + } + + if (hasMalformedRowChange) { + // These errors seems to be very rare; they can only be reproduced + // using the more extreme actions in the debug UI. + OWSFailDebug(@"hasMalformedRowChange"); + // resetMappings will call delegate.conversationViewModelDidUpdate. + [self resetMappings]; + return; + } + + NSUInteger oldViewItemCount = self.viewItems.count; + if (![self reloadViewItems]) { + // These errors are rare. + OWSFailDebug(@"could not reload view items; hard resetting message mappings."); + // resetMappings will call delegate.conversationViewModelDidUpdate. + [self resetMappings]; + return; + } + + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldViewItemCount, self.viewItems.count); + + [self updateViewWitholdItemIdList:oldItemIdList + updatedItemSet:updatedItemSet + oldViewItemCount:oldViewItemCount + updatedNeighborItemSet:updatedNeighborItemSet]; +} + +- (void)updateViewWitholdItemIdList:(NSArray *)oldItemIdList + updatedItemSet:(NSSet *)updatedItemSet + oldViewItemCount:(NSUInteger)oldViewItemCount + updatedNeighborItemSet:(nullable NSMutableSet *)updatedNeighborItemSet +{ + OWSAssertDebug(oldItemIdList); + OWSAssertDebug(updatedItemSet); + + if (oldItemIdList.count != [NSSet setWithArray:oldItemIdList].count) { + OWSFailDebug(@"Old view item list has duplicates."); + [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + return; + } + + NSMutableArray *newItemIdList = [NSMutableArray new]; + NSMutableDictionary> *newViewItemMap = [NSMutableDictionary new]; + for (id viewItem in self.viewItems) { + [newItemIdList addObject:viewItem.itemId]; + newViewItemMap[viewItem.itemId] = viewItem; + } + + if (newItemIdList.count != [NSSet setWithArray:newItemIdList].count) { + OWSFailDebug(@"New view item list has duplicates."); + [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + return; + } + + NSSet *oldItemIdSet = [NSSet setWithArray:oldItemIdList]; + NSSet *newItemIdSet = [NSSet setWithArray:newItemIdList]; + + // We use sets and dictionaries here to ensure perf. + NSMutableSet *deletedItemIdSet = [oldItemIdSet mutableCopy]; + [deletedItemIdSet minusSet:newItemIdSet]; + NSMutableSet *insertedItemIdSet = [newItemIdSet mutableCopy]; + [insertedItemIdSet minusSet:oldItemIdSet]; + NSArray *deletedItemIdList = [deletedItemIdSet.allObjects sortedArrayUsingSelector:@selector(compare:)]; + NSArray *insertedItemIdList = + [insertedItemIdSet.allObjects sortedArrayUsingSelector:@selector(compare:)]; + + // Try to generate a series of "update items" that safely transform + // the "old item list" into the "new item list". + NSMutableArray *updateItems = [NSMutableArray new]; + NSMutableArray *transformedItemList = [oldItemIdList mutableCopy]; + + // 1. Deletes - Always perform deletes before inserts and updates. + // + // NOTE: We use reverseObjectEnumerator to ensure that items + // are deleted in reverse order, to avoid confusion around + // each deletion affecting the indices of subsequent deletions. + for (NSString *itemId in deletedItemIdList.reverseObjectEnumerator) { + OWSAssertDebug([oldItemIdSet containsObject:itemId]); + OWSAssertDebug(![newItemIdSet containsObject:itemId]); + + NSUInteger oldIndex = [oldItemIdList indexOfObject:itemId]; + if (oldIndex == NSNotFound) { + OWSFailDebug(@"Can't find index of deleted view item."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + + [updateItems addObject:[[ConversationUpdateItem alloc] initWithUpdateItemType:ConversationUpdateItemType_Delete + oldIndex:oldIndex + newIndex:NSNotFound + viewItem:nil]]; + [transformedItemList removeObject:itemId]; + } + + // 2. Inserts - Always perform inserts before updates. + // + // NOTE: We DO NOT use reverseObjectEnumerator. + for (NSString *itemId in insertedItemIdList) { + OWSAssertDebug(![oldItemIdSet containsObject:itemId]); + OWSAssertDebug([newItemIdSet containsObject:itemId]); + + NSUInteger newIndex = [newItemIdList indexOfObject:itemId]; + if (newIndex == NSNotFound) { + OWSFailDebug(@"Can't find index of inserted view item."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + id _Nullable viewItem = newViewItemMap[itemId]; + if (!viewItem) { + OWSFailDebug(@"Can't find inserted view item."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + + [updateItems addObject:[[ConversationUpdateItem alloc] initWithUpdateItemType:ConversationUpdateItemType_Insert + oldIndex:NSNotFound + newIndex:newIndex + viewItem:viewItem]]; + [transformedItemList insertObject:itemId atIndex:newIndex]; + } + + if (![newItemIdList isEqualToArray:transformedItemList]) { + // We should be able to represent all transformations as a series of + // inserts, updates and deletes - moves should not be necessary. + // + // TODO: The unread indicator might end up being an exception. + OWSLogWarn(@"New and updated view item lists don't match."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + + // 3. Updates. + // + // NOTE: Order doesn't matter. + for (NSString *itemId in updatedItemSet) { + if (![newItemIdList containsObject:itemId]) { + OWSFailDebug(@"Updated view item not in new view item list."); + continue; + } + if ([insertedItemIdList containsObject:itemId]) { + continue; + } + NSUInteger oldIndex = [oldItemIdList indexOfObject:itemId]; + if (oldIndex == NSNotFound) { + OWSFailDebug(@"Can't find old index of updated view item."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + NSUInteger newIndex = [newItemIdList indexOfObject:itemId]; + if (newIndex == NSNotFound) { + OWSFailDebug(@"Can't find new index of updated view item."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + id _Nullable viewItem = newViewItemMap[itemId]; + if (!viewItem) { + OWSFailDebug(@"Can't find inserted view item."); + return [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; + } + [updateItems addObject:[[ConversationUpdateItem alloc] initWithUpdateItemType:ConversationUpdateItemType_Update + oldIndex:oldIndex + newIndex:newIndex + viewItem:viewItem]]; + } + + BOOL shouldAnimateUpdates = [self shouldAnimateUpdateItems:updateItems + oldViewItemCount:oldViewItemCount + updatedNeighborItemSet:updatedNeighborItemSet]; + + return [self.delegate + conversationViewModelDidUpdate:[ConversationUpdate diffUpdateWithUpdateItems:updateItems + shouldAnimateUpdates:shouldAnimateUpdates]]; +} + +- (BOOL)shouldAnimateUpdateItems:(NSArray *)updateItems + oldViewItemCount:(NSUInteger)oldViewItemCount + updatedNeighborItemSet:(nullable NSMutableSet *)updatedNeighborItemSet +{ + OWSAssertDebug(updateItems); + + // If user sends a new outgoing message, don't animate the change. + BOOL isOnlyModifyingLastMessage = YES; + for (ConversationUpdateItem *updateItem in updateItems) { + switch (updateItem.updateItemType) { + case ConversationUpdateItemType_Delete: + isOnlyModifyingLastMessage = NO; + break; + case ConversationUpdateItemType_Insert: { + id viewItem = updateItem.viewItem; + OWSAssertDebug(viewItem); + if (([viewItem.interaction isKindOfClass:[TSIncomingMessage class]] || + [viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) + && updateItem.newIndex >= oldViewItemCount) { + continue; + } + isOnlyModifyingLastMessage = NO; + break; + } + case ConversationUpdateItemType_Update: { + id viewItem = updateItem.viewItem; + if ([updatedNeighborItemSet containsObject:viewItem.itemId]) { + continue; + } + OWSAssertDebug(viewItem); + if (([viewItem.interaction isKindOfClass:[TSIncomingMessage class]] || + [viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) + && updateItem.newIndex >= oldViewItemCount) { + continue; + } + isOnlyModifyingLastMessage = NO; + break; + } + } + } + BOOL shouldAnimateRowUpdates = !isOnlyModifyingLastMessage; + return shouldAnimateRowUpdates; +} + +- (void)createNewMessageMappings +{ + if (self.thread.uniqueId.length > 0) { + self.messageMappings = [[YapDatabaseViewMappings alloc] initWithGroups:@[ self.thread.uniqueId ] + view:TSMessageDatabaseViewExtensionName]; + } else { + OWSFailDebug(@"uniqueId unexpectedly empty for thread: %@", self.thread); + self.messageMappings = + [[YapDatabaseViewMappings alloc] initWithGroups:@[] view:TSMessageDatabaseViewExtensionName]; + } + + // Cells' appearance can depend on adjacent cells in both directions. + // + // TODO: We could do the same thing in our logic to generate "update items" + // in updateViewWitholdItemIdList. + [self.messageMappings setCellDrawingDependencyOffsets:[NSSet setWithArray:@[ + @(-1), + @(+1), + ]] + forGroup:self.thread.uniqueId]; + + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + [self.messageMappings updateWithTransaction:transaction]; + }]; + // We need to impose the range restrictions on the mappings immediately to avoid + // doing a great deal of unnecessary work and causing a perf hotspot. + [self updateMessageMappingRangeOptions]; +} + +- (void)resetMappings +{ + // If we're entering "active" mode (e.g. view is visible and app is in foreground), + // reset all state updated by yapDatabaseModified:. + if (self.messageMappings != nil) { + // Make sure our mapping and range state is up-to-date. + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + [self.messageMappings updateWithTransaction:transaction]; + }]; + [self updateMessageMappingRangeOptions]; + } + self.collapseCutoffDate = [NSDate new]; + + [self ensureDynamicInteractions]; + + // There appears to be a bug in YapDatabase that sometimes delays modifications + // made in another process (e.g. the SAE) from showing up in other processes. + // There's a simple workaround: a trivial write to the database flushes changes + // made from other processes. + [self.editingDatabaseConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + [transaction setObject:[NSUUID UUID].UUIDString forKey:@"conversation_view_noop_mod" inCollection:@"temp"]; + }]; + + [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; +} + +#pragma mark - View Items + +// This is a key method. It builds or rebuilds the list of +// cell view models. +// +// Returns NO on error. +- (BOOL)reloadViewItems +{ + NSMutableArray> *viewItems = [NSMutableArray new]; + NSMutableDictionary> *viewItemCache = [NSMutableDictionary new]; + + NSUInteger count = [self.messageMappings numberOfItemsInSection:0]; + BOOL isGroupThread = self.thread.isGroupThread; + ConversationStyle *conversationStyle = self.delegate.conversationStyle; + + __block BOOL hasError = NO; + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; + OWSAssertDebug(viewTransaction); + for (NSUInteger row = 0; row < count; row++) { + TSInteraction *interaction = + [viewTransaction objectAtRow:row inSection:0 withMappings:self.messageMappings]; + if (!interaction) { + OWSFailDebug( + @"missing interaction in message mappings: %lu / %lu.", (unsigned long)row, (unsigned long)count); + // TODO: Add analytics. + hasError = YES; + continue; + } + if (!interaction.uniqueId) { + OWSFailDebug(@"invalid interaction in message mappings: %lu / %lu: %@.", + (unsigned long)row, + (unsigned long)count, + interaction); + // TODO: Add analytics. + hasError = YES; + continue; + } + + id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; + if (!viewItem) { + viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction + isGroupThread:isGroupThread + transaction:transaction + conversationStyle:conversationStyle]; + } + [viewItems addObject:viewItem]; + OWSAssertDebug(!viewItemCache[interaction.uniqueId]); + viewItemCache[interaction.uniqueId] = viewItem; + } + }]; + + // Flag to ensure that we only increment once per launch. + if (hasError) { + OWSLogWarn(@"incrementing version of: %@", TSMessageDatabaseViewExtensionName); + [OWSPrimaryStorage incrementVersionOfDatabaseExtension:TSMessageDatabaseViewExtensionName]; + } + + // Update the "break" properties (shouldShowDate and unreadIndicator) of the view items. + BOOL shouldShowDateOnNextViewItem = YES; + uint64_t previousViewItemTimestamp = 0; + OWSUnreadIndicator *_Nullable unreadIndicator = self.dynamicInteractions.unreadIndicator; + uint64_t collapseCutoffTimestamp = [NSDate ows_millisecondsSince1970ForDate:self.collapseCutoffDate]; + + BOOL hasPlacedUnreadIndicator = NO; + for (id viewItem in viewItems) { + BOOL canShowDate = NO; + switch (viewItem.interaction.interactionType) { + case OWSInteractionType_Unknown: + case OWSInteractionType_Offer: + canShowDate = NO; + break; + case OWSInteractionType_IncomingMessage: + case OWSInteractionType_OutgoingMessage: + case OWSInteractionType_Error: + case OWSInteractionType_Info: + case OWSInteractionType_Call: + canShowDate = YES; + break; + } + + uint64_t viewItemTimestamp = viewItem.interaction.timestampForSorting; + OWSAssertDebug(viewItemTimestamp > 0); + + BOOL shouldShowDate = NO; + if (previousViewItemTimestamp == 0) { + shouldShowDateOnNextViewItem = YES; + } else if (![DateUtil isSameDayWithTimestamp:previousViewItemTimestamp timestamp:viewItemTimestamp]) { + shouldShowDateOnNextViewItem = YES; + } + + if (shouldShowDateOnNextViewItem && canShowDate) { + shouldShowDate = YES; + shouldShowDateOnNextViewItem = NO; + } + + viewItem.shouldShowDate = shouldShowDate; + + previousViewItemTimestamp = viewItemTimestamp; + + // When a conversation without unread messages receives an incoming message, + // we call ensureDynamicInteractions to ensure that the unread indicator (etc.) + // state is updated accordingly. However this is done in a separate transaction. + // We don't want to show the incoming message _without_ an unread indicator and + // then immediately re-render it _with_ an unread indicator. + // + // To avoid this, we use a temporary instance of OWSUnreadIndicator whenever + // we find an unread message that _should_ have an unread indicator, but no + // unread indicator exists yet on dynamicInteractions. + BOOL isItemUnread = ([viewItem.interaction conformsToProtocol:@protocol(OWSReadTracking)] + && !((id)viewItem.interaction).wasRead); + if (isItemUnread && !unreadIndicator && !hasPlacedUnreadIndicator && !self.hasClearedUnreadMessagesIndicator) { + + unreadIndicator = + [[OWSUnreadIndicator alloc] initUnreadIndicatorWithTimestamp:viewItem.interaction.timestamp + hasMoreUnseenMessages:NO + missingUnseenSafetyNumberChangeCount:0 + unreadIndicatorPosition:0 + firstUnseenInteractionTimestamp:viewItem.interaction.timestamp]; + } + + // Place the unread indicator onto the first appropriate view item, + // if any. + if (unreadIndicator && viewItem.interaction.timestampForSorting >= unreadIndicator.timestamp) { + viewItem.unreadIndicator = unreadIndicator; + unreadIndicator = nil; + hasPlacedUnreadIndicator = YES; + } else { + viewItem.unreadIndicator = nil; + } + } + if (unreadIndicator) { + // This isn't necessarily a bug - all of the interactions after the + // unread indicator may have disappeared or been deleted. + OWSLogWarn(@"Couldn't find an interaction to hang the unread indicator on."); + } + + // Update the properties of the view items. + // + // NOTE: This logic uses the break properties which are set in the previous pass. + for (NSUInteger i = 0; i < viewItems.count; i++) { + id viewItem = viewItems[i]; + id _Nullable previousViewItem = (i > 0 ? viewItems[i - 1] : nil); + id _Nullable nextViewItem = (i + 1 < viewItems.count ? viewItems[i + 1] : nil); + BOOL shouldShowSenderAvatar = NO; + BOOL shouldHideFooter = NO; + BOOL isFirstInCluster = YES; + BOOL isLastInCluster = YES; + NSAttributedString *_Nullable senderName = nil; + + OWSInteractionType interactionType = viewItem.interaction.interactionType; + NSString *timestampText = [DateUtil formatTimestampShort:viewItem.interaction.timestamp]; + + if (interactionType == OWSInteractionType_OutgoingMessage) { + TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction; + MessageReceiptStatus receiptStatus = + [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:outgoingMessage]; + BOOL isDisappearingMessage = outgoingMessage.isExpiringMessage; + + if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { + TSOutgoingMessage *nextOutgoingMessage = (TSOutgoingMessage *)nextViewItem.interaction; + MessageReceiptStatus nextReceiptStatus = + [MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:nextOutgoingMessage]; + NSString *nextTimestampText = [DateUtil formatTimestampShort:nextViewItem.interaction.timestamp]; + + // We can skip the "outgoing message status" footer if the next message + // has the same footer and no "date break" separates us... + // ...but always show "failed to send" status + // ...and always show the "disappearing messages" animation. + shouldHideFooter + = ([timestampText isEqualToString:nextTimestampText] && receiptStatus == nextReceiptStatus + && outgoingMessage.messageState != TSOutgoingMessageStateFailed + && outgoingMessage.messageState != TSOutgoingMessageStateSending && !nextViewItem.hasCellHeader + && !isDisappearingMessage); + } + + // clustering + if (previousViewItem == nil) { + isFirstInCluster = YES; + } else if (viewItem.hasCellHeader) { + isFirstInCluster = YES; + } else { + isFirstInCluster = previousViewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage; + } + + if (nextViewItem == nil) { + isLastInCluster = YES; + } else if (nextViewItem.hasCellHeader) { + isLastInCluster = YES; + } else { + isLastInCluster = nextViewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage; + } + } else if (interactionType == OWSInteractionType_IncomingMessage) { + + TSIncomingMessage *incomingMessage = (TSIncomingMessage *)viewItem.interaction; + NSString *incomingSenderId = incomingMessage.authorId; + OWSAssertDebug(incomingSenderId.length > 0); + BOOL isDisappearingMessage = incomingMessage.isExpiringMessage; + + NSString *_Nullable nextIncomingSenderId = nil; + if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { + TSIncomingMessage *nextIncomingMessage = (TSIncomingMessage *)nextViewItem.interaction; + nextIncomingSenderId = nextIncomingMessage.authorId; + OWSAssertDebug(nextIncomingSenderId.length > 0); + } + + if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { + NSString *nextTimestampText = [DateUtil formatTimestampShort:nextViewItem.interaction.timestamp]; + // We can skip the "incoming message status" footer in a cluster if the next message + // has the same footer and no "date break" separates us. + // ...but always show the "disappearing messages" animation. + shouldHideFooter = ([timestampText isEqualToString:nextTimestampText] && !nextViewItem.hasCellHeader && + [NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId] + && !isDisappearingMessage); + } + + // clustering + if (previousViewItem == nil) { + isFirstInCluster = YES; + } else if (viewItem.hasCellHeader) { + isFirstInCluster = YES; + } else if (previousViewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { + isFirstInCluster = YES; + } else { + TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousViewItem.interaction; + isFirstInCluster = ![incomingSenderId isEqual:previousIncomingMessage.authorId]; + } + + if (nextViewItem == nil) { + isLastInCluster = YES; + } else if (nextViewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) { + isLastInCluster = YES; + } else if (nextViewItem.hasCellHeader) { + isLastInCluster = YES; + } else { + TSIncomingMessage *nextIncomingMessage = (TSIncomingMessage *)nextViewItem.interaction; + isLastInCluster = ![incomingSenderId isEqual:nextIncomingMessage.authorId]; + } + + if (viewItem.isGroupThread) { + // Show the sender name for incoming group messages unless + // the previous message has the same sender name and + // no "date break" separates us. + BOOL shouldShowSenderName = YES; + if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) { + + TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousViewItem.interaction; + NSString *previousIncomingSenderId = previousIncomingMessage.authorId; + OWSAssertDebug(previousIncomingSenderId.length > 0); + + shouldShowSenderName + = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId] + || viewItem.hasCellHeader); + } + if (shouldShowSenderName) { + senderName = [self.contactsManager + attributedContactOrProfileNameForPhoneIdentifier:incomingSenderId + primaryAttributes:[OWSMessageBubbleView + senderNamePrimaryAttributes] + secondaryAttributes:[OWSMessageBubbleView + senderNameSecondaryAttributes]]; + } + + // Show the sender avatar for incoming group messages unless + // the next message has the same sender avatar and + // no "date break" separates us. + shouldShowSenderAvatar = YES; + if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) { + shouldShowSenderAvatar = (![NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId] + || nextViewItem.hasCellHeader); + } + } + } + + if (viewItem.interaction.timestampForSorting > collapseCutoffTimestamp) { + shouldHideFooter = NO; + } + + viewItem.isFirstInCluster = isFirstInCluster; + viewItem.isLastInCluster = isLastInCluster; + viewItem.shouldShowSenderAvatar = shouldShowSenderAvatar; + viewItem.shouldHideFooter = shouldHideFooter; + viewItem.senderName = senderName; + } + + self.viewItems = viewItems; + self.viewItemCache = viewItemCache; + + return !hasError; +} + +// Whenever an interaction is modified, we need to reload it from the DB +// and update the corresponding view item. +- (void)reloadInteractionForViewItem:(id)viewItem +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(viewItem); + + // This should never happen, but don't crash in production if we have a bug. + if (!viewItem) { + return; + } + + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + TSInteraction *_Nullable interaction = + [TSInteraction fetchObjectWithUniqueID:viewItem.interaction.uniqueId transaction:transaction]; + if (!interaction) { + OWSFailDebug(@"could not reload interaction"); + } else { + [viewItem replaceInteraction:interaction transaction:transaction]; + } + }]; +} + +- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply +{ + OWSAssertIsOnMainThread(); + OWSAssertDebug(quotedReply); + OWSAssertDebug(quotedReply.timestamp > 0); + OWSAssertDebug(quotedReply.authorId.length > 0); + + // TODO: + // We try to find the index of the item within the current thread's + // interactions that includes the "quoted interaction". + // + // NOTE: There are two indices: + // + // * The "group index" of the member of the database views group at + // the db conneciton's current checkpoint. + // * The "index row/section" in the message mapping. + // + // NOTE: Since the range _IS NOT_ filtered by author, + // and timestamp collisions are possible, it's possible + // for: + // + // * The range to include more than the "quoted interaction". + // * The range to be non-empty but NOT include the "quoted interaction", + // although this would be a bug. + __block TSInteraction *_Nullable quotedInteraction; + __block NSUInteger threadInteractionCount = 0; + __block NSNumber *_Nullable groupIndex = nil; + + if (quotedReply.isRemotelySourced) { + return nil; + } + + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + quotedInteraction = [ThreadUtil findInteractionInThreadByTimestamp:quotedReply.timestamp + authorId:quotedReply.authorId + threadUniqueId:self.thread.uniqueId + transaction:transaction]; + if (!quotedInteraction) { + return; + } + + YapDatabaseAutoViewTransaction *_Nullable extension = + [transaction extension:TSMessageDatabaseViewExtensionName]; + if (!extension) { + OWSFailDebug(@"Couldn't load view."); + return; + } + + threadInteractionCount = [extension numberOfItemsInGroup:self.thread.uniqueId]; + + groupIndex = [self findGroupIndexOfThreadInteraction:quotedInteraction transaction:transaction]; + }]; + + if (!quotedInteraction || !groupIndex) { + return nil; + } + + NSUInteger indexRow = 0; + NSUInteger indexSection = 0; + BOOL isInMappings = [self.messageMappings getRow:&indexRow + section:&indexSection + forIndex:groupIndex.unsignedIntegerValue + inGroup:self.thread.uniqueId]; + + if (!isInMappings) { + NSInteger desiredWindowSize = MAX(0, 1 + (NSInteger)threadInteractionCount - groupIndex.integerValue); + NSUInteger oldLoadWindowSize = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; + NSInteger additionalItemsToLoad = MAX(0, desiredWindowSize - (NSInteger)oldLoadWindowSize); + if (additionalItemsToLoad < 1) { + OWSLogError(@"Couldn't determine how to load quoted reply."); + return nil; + } + + // Try to load more messages so that the quoted message + // is in the load window. + // + // This may fail if the quoted message is very old, in which + // case we'll load the max number of messages. + [self loadNMoreMessages:(NSUInteger)additionalItemsToLoad]; + + // `loadNMoreMessages` will reset the mapping and possibly + // integrate new changes, so we need to reload the "group index" + // of the quoted message. + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + groupIndex = [self findGroupIndexOfThreadInteraction:quotedInteraction transaction:transaction]; + }]; + + if (!quotedInteraction || !groupIndex) { + OWSLogError(@"Failed to find quoted reply in group."); + return nil; + } + + isInMappings = [self.messageMappings getRow:&indexRow + section:&indexSection + forIndex:groupIndex.unsignedIntegerValue + inGroup:self.thread.uniqueId]; + + if (!isInMappings) { + OWSLogError(@"Could not load quoted reply into mapping."); + return nil; + } + } + + // The mapping indices and view item indices don't always align for corrupt mappings. + __block TSInteraction *_Nullable interaction; + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { + YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; + OWSAssertDebug(viewTransaction); + interaction = [viewTransaction objectAtRow:indexRow inSection:indexSection withMappings:self.messageMappings]; + }]; + if (!interaction) { + OWSFailDebug(@"Could not locate interaction for quoted reply."); + return nil; + } + id _Nullable viewItem = self.viewItemCache[interaction.uniqueId]; + if (!viewItem) { + OWSFailDebug(@"Could not locate view item for quoted reply."); + return nil; + } + NSUInteger viewItemIndex = [self.viewItems indexOfObject:viewItem]; + if (viewItemIndex == NSNotFound) { + OWSFailDebug(@"Could not locate view item index for quoted reply."); + return nil; + } + return [NSIndexPath indexPathForRow:(NSInteger)viewItemIndex inSection:0]; +} + +- (nullable NSNumber *)findGroupIndexOfThreadInteraction:(TSInteraction *)interaction + transaction:(YapDatabaseReadTransaction *)transaction +{ + OWSAssertDebug(interaction); + OWSAssertDebug(transaction); + + YapDatabaseAutoViewTransaction *_Nullable extension = [transaction extension:TSMessageDatabaseViewExtensionName]; + if (!extension) { + OWSFailDebug(@"Couldn't load view."); + return nil; + } + + NSUInteger groupIndex = 0; + BOOL foundInGroup = + [extension getGroup:nil index:&groupIndex forKey:interaction.uniqueId inCollection:TSInteraction.collection]; + if (!foundInGroup) { + OWSLogError(@"Couldn't find quoted message in group."); + return nil; + } + return @(groupIndex); +} + +@end + +NS_ASSUME_NONNULL_END From 834bba8880244cc18ba4f49479bdc23f4c6e9428 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 1 Nov 2018 14:51:40 -0400 Subject: [PATCH 2/2] Respond to CR. --- .../ConversationView/ConversationViewModel.m | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index 550483944..d84020efa 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -559,7 +559,6 @@ static const int kYapDatabaseRangeMinLength = 0; return; } - NSUInteger oldViewItemCount = self.viewItems.count; if (![self reloadViewItems]) { // These errors are rare. OWSFailDebug(@"could not reload view items; hard resetting message mappings."); @@ -568,17 +567,15 @@ static const int kYapDatabaseRangeMinLength = 0; return; } - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldViewItemCount, self.viewItems.count); + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewItems.count); - [self updateViewWitholdItemIdList:oldItemIdList + [self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:updatedItemSet - oldViewItemCount:oldViewItemCount updatedNeighborItemSet:updatedNeighborItemSet]; } -- (void)updateViewWitholdItemIdList:(NSArray *)oldItemIdList +- (void)updateViewWithOldItemIdList:(NSArray *)oldItemIdList updatedItemSet:(NSSet *)updatedItemSet - oldViewItemCount:(NSUInteger)oldViewItemCount updatedNeighborItemSet:(nullable NSMutableSet *)updatedNeighborItemSet { OWSAssertDebug(oldItemIdList); @@ -709,7 +706,7 @@ static const int kYapDatabaseRangeMinLength = 0; } BOOL shouldAnimateUpdates = [self shouldAnimateUpdateItems:updateItems - oldViewItemCount:oldViewItemCount + oldViewItemCount:oldItemIdList.count updatedNeighborItemSet:updatedNeighborItemSet]; return [self.delegate @@ -775,7 +772,7 @@ static const int kYapDatabaseRangeMinLength = 0; // Cells' appearance can depend on adjacent cells in both directions. // // TODO: We could do the same thing in our logic to generate "update items" - // in updateViewWitholdItemIdList. + // in updateViewWithOldItemIdList. [self.messageMappings setCellDrawingDependencyOffsets:[NSSet setWithArray:@[ @(-1), @(+1), @@ -792,8 +789,8 @@ static const int kYapDatabaseRangeMinLength = 0; - (void)resetMappings { - // If we're entering "active" mode (e.g. view is visible and app is in foreground), - // reset all state updated by yapDatabaseModified:. + OWSAssertDebug(self.messageMappings); + if (self.messageMappings != nil) { // Make sure our mapping and range state is up-to-date. [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {