diff --git a/Signal/Signal-Info.plist b/Signal/Signal-Info.plist index 0e7535531..d5218f796 100644 --- a/Signal/Signal-Info.plist +++ b/Signal/Signal-Info.plist @@ -9,7 +9,7 @@ OSXVersion 10.14.3 WebRTCCommit - 55de5593cc261fa9368c5ccde98884ed1e278ba0 M72 + 1445d719bf05280270e9f77576f80f973fd847f8 M73 CFBundleDevelopmentRegion en diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 28b6ed57d..9f826a3ff 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -108,25 +108,6 @@ typedef enum : NSUInteger { #pragma mark - -// We use snapshots to ensure that the view has a consistent -// representation of view model state which is not updated -// when the view is not observing view model changes. -@interface ConversationSnapshot : NSObject - -@property (nonatomic) NSArray> *viewItems; -@property (nonatomic) ThreadDynamicInteractions *dynamicInteractions; -@property (nonatomic) BOOL canLoadMoreItems; - -@end - -#pragma mark - - -@implementation ConversationSnapshot - -@end - -#pragma mark - - @interface ConversationViewController () > *)viewItems { - return self.conversationSnapshot.viewItems; + return self.conversationViewModel.viewState.viewItems; } - (ThreadDynamicInteractions *)dynamicInteractions { - return self.conversationSnapshot.dynamicInteractions; + return self.conversationViewModel.dynamicInteractions; } - (NSIndexPath *_Nullable)indexPathOfUnreadMessagesIndicator { - NSInteger row = 0; - for (id viewItem in self.viewItems) { - if (viewItem.unreadIndicator) { - return [NSIndexPath indexPathForRow:row inSection:0]; - } - row++; + NSNumber *_Nullable unreadIndicatorIndex = self.conversationViewModel.viewState.unreadIndicatorIndex; + if (unreadIndicatorIndex == nil) { + return nil; } - return nil; + return [NSIndexPath indexPathForRow:unreadIndicatorIndex.integerValue inSection:0]; } - (NSIndexPath *_Nullable)indexPathOfMessageOnOpen @@ -882,7 +854,6 @@ typedef enum : NSUInteger { // Avoid layout corrupt issues and out-of-date message subtitles. self.lastReloadDate = [NSDate new]; [self.conversationViewModel viewDidResetContentAndLayout]; - [self tryToUpdateConversationSnapshot]; [self.collectionView.collectionViewLayout invalidateLayout]; [self.collectionView reloadData]; @@ -1287,7 +1258,7 @@ typedef enum : NSUInteger { self.isViewCompletelyAppeared = NO; - [[OWSWindowManager sharedManager] hideMenuActionsWindow]; + [self dismissMenuActions]; } - (void)viewDidDisappear:(BOOL)animated @@ -1749,7 +1720,7 @@ typedef enum : NSUInteger { { OWSAssertDebug(self.conversationViewModel); - self.showLoadMoreHeader = self.conversationSnapshot.canLoadMoreItems; + self.showLoadMoreHeader = self.conversationViewModel.canLoadMoreItems; } - (void)setShowLoadMoreHeader:(BOOL)showLoadMoreHeader @@ -1995,63 +1966,151 @@ typedef enum : NSUInteger { #pragma mark - MenuActionsViewControllerDelegate -- (void)menuActionsDidHide:(MenuActionsViewController *)menuActionsViewController +- (void)menuActionsWillPresent:(MenuActionsViewController *)menuActionsViewController { + OWSLogVerbose(@""); + + // While the menu actions are presented, temporarily use extra content + // inset padding so that interactions near the top or bottom of the + // collection view can be scrolled anywhere within the viewport. + // + // e.g. In a new conversation, there might be only a single message + // which we might want to scroll to the bottom of the screen to + // pin above the menu actions popup. + CGSize mainScreenSize = UIScreen.mainScreen.bounds.size; + self.extraContentInsetPadding = MAX(mainScreenSize.width, mainScreenSize.height); + + UIEdgeInsets contentInset = self.collectionView.contentInset; + contentInset.top += self.extraContentInsetPadding; + contentInset.bottom += self.extraContentInsetPadding; + self.collectionView.contentInset = contentInset; + + self.menuActionsViewController = menuActionsViewController; +} + +- (void)menuActionsIsPresenting:(MenuActionsViewController *)menuActionsViewController +{ + OWSLogVerbose(@""); + + // Changes made in this "is presenting" callback are animated by the caller. + [self scrollToMenuActionInteraction:NO]; +} + +- (void)menuActionsDidPresent:(MenuActionsViewController *)menuActionsViewController +{ + OWSLogVerbose(@""); + + [self scrollToMenuActionInteraction:NO]; +} + +- (void)menuActionsIsDismissing:(MenuActionsViewController *)menuActionsViewController +{ + OWSLogVerbose(@""); + + // Changes made in this "is dismissing" callback are animated by the caller. + [self clearMenuActionsState]; +} + +- (void)menuActionsDidDismiss:(MenuActionsViewController *)menuActionsViewController +{ + OWSLogVerbose(@""); + + [self dismissMenuActions]; +} + +- (void)dismissMenuActions +{ + OWSLogVerbose(@""); + + [self clearMenuActionsState]; [[OWSWindowManager sharedManager] hideMenuActionsWindow]; - - [self updateShouldObserveVMUpdates]; } -- (void)menuActions:(MenuActionsViewController *)menuActionsViewController - isPresentingWithVerticalFocusChange:(CGFloat)verticalChange +- (void)clearMenuActionsState { - UIEdgeInsets oldInset = self.collectionView.contentInset; - CGPoint oldOffset = self.collectionView.contentOffset; + OWSLogVerbose(@""); - UIEdgeInsets newInset = oldInset; - CGPoint newOffset = oldOffset; + if (self.menuActionsViewController == nil) { + return; + } - // In case the message is at the very top or bottom edge of the conversation we have to have these additional - // insets to be sure we can sufficiently scroll the contentOffset. - newInset.top += verticalChange; - newInset.bottom -= verticalChange; - newOffset.y -= verticalChange; + UIEdgeInsets contentInset = self.collectionView.contentInset; + contentInset.top -= self.extraContentInsetPadding; + contentInset.bottom -= self.extraContentInsetPadding; + self.collectionView.contentInset = contentInset; - OWSLogDebug(@"verticalChange: %f, insets: %@ -> %@", - verticalChange, - NSStringFromUIEdgeInsets(oldInset), - NSStringFromUIEdgeInsets(newInset)); - - // Because we're in the context of the frame-changing animation, these adjustments should happen - // in lockstep with the messageActions frame change. - self.collectionView.contentOffset = newOffset; - self.collectionView.contentInset = newInset; + self.menuActionsViewController = nil; + self.extraContentInsetPadding = 0; } -- (void)menuActions:(MenuActionsViewController *)menuActionsViewController - isDismissingWithVerticalFocusChange:(CGFloat)verticalChange +- (void)scrollToMenuActionInteractionIfNecessary { - UIEdgeInsets oldInset = self.collectionView.contentInset; - CGPoint oldOffset = self.collectionView.contentOffset; + if (self.menuActionsViewController != nil) { + [self scrollToMenuActionInteraction:NO]; + } +} - UIEdgeInsets newInset = oldInset; - CGPoint newOffset = oldOffset; +- (void)scrollToMenuActionInteraction:(BOOL)animated +{ + OWSAssertDebug(self.menuActionsViewController); - // In case the message is at the very top or bottom edge of the conversation we have to have these additional - // insets to be sure we can sufficiently scroll the contentOffset. - newInset.top -= verticalChange; - newInset.bottom += verticalChange; - newOffset.y += verticalChange; + NSValue *_Nullable contentOffset = [self contentOffsetForMenuActionInteraction]; + if (contentOffset == nil) { + OWSFailDebug(@"Missing contentOffset."); + return; + } + [self.collectionView setContentOffset:contentOffset.CGPointValue animated:animated]; +} - OWSLogDebug(@"verticalChange: %f, insets: %@ -> %@", - verticalChange, - NSStringFromUIEdgeInsets(oldInset), - NSStringFromUIEdgeInsets(newInset)); +- (nullable NSValue *)contentOffsetForMenuActionInteraction +{ + OWSAssertDebug(self.menuActionsViewController); - // Because we're in the context of the frame-changing animation, these adjustments should happen - // in lockstep with the messageActions frame change. - self.collectionView.contentOffset = newOffset; - self.collectionView.contentInset = newInset; + NSString *_Nullable menuActionInteractionId = self.menuActionsViewController.focusedInteraction.uniqueId; + if (menuActionInteractionId == nil) { + OWSFailDebug(@"Missing menu action interaction."); + return nil; + } + CGPoint modalTopWindow = [self.menuActionsViewController.focusUI convertPoint:CGPointZero toView:nil]; + CGPoint modalTopLocal = [self.view convertPoint:modalTopWindow fromView:nil]; + CGPoint offset = modalTopLocal; + CGFloat focusTop = offset.y - self.menuActionsViewController.vSpacing; + + NSNumber *_Nullable interactionIndex + = self.conversationViewModel.viewState.interactionIndexMap[menuActionInteractionId]; + if (interactionIndex == nil) { + // This is expected if the menu action interaction is being deleted. + return nil; + } + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:interactionIndex.integerValue inSection:0]; + UICollectionViewLayoutAttributes *_Nullable layoutAttributes = + [self.layout layoutAttributesForItemAtIndexPath:indexPath]; + if (layoutAttributes == nil) { + OWSFailDebug(@"Missing layoutAttributes."); + return nil; + } + CGRect cellFrame = layoutAttributes.frame; + return [NSValue valueWithCGPoint:CGPointMake(0, CGRectGetMaxY(cellFrame) - focusTop)]; +} + +- (void)dismissMenuActionsIfNecessary +{ + if (self.shouldDismissMenuActions) { + [self dismissMenuActions]; + } +} + +- (BOOL)shouldDismissMenuActions +{ + if (!OWSWindowManager.sharedManager.isPresentingMenuActions) { + return NO; + } + NSString *_Nullable menuActionInteractionId = self.menuActionsViewController.focusedInteraction.uniqueId; + if (menuActionInteractionId == nil) { + return NO; + } + // Check whether there is still a view item for this interaction. + return (self.conversationViewModel.viewState.interactionIndexMap[menuActionInteractionId] == nil); } #pragma mark - ConversationViewCellDelegate @@ -2100,14 +2159,13 @@ typedef enum : NSUInteger { - (void)presentMessageActions:(NSArray *)messageActions withFocusedCell:(ConversationViewCell *)cell { MenuActionsViewController *menuActionsViewController = - [[MenuActionsViewController alloc] initWithFocusedView:cell actions:messageActions]; + [[MenuActionsViewController alloc] initWithFocusedInteraction:cell.viewItem.interaction + focusedView:cell + actions:messageActions]; menuActionsViewController.delegate = self; - self.conversationViewModel.mostRecentMenuActionsViewItem = cell.viewItem; [[OWSWindowManager sharedManager] showMenuActionsWindow:menuActionsViewController]; - - [self updateShouldObserveVMUpdates]; } - (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId @@ -3788,8 +3846,12 @@ typedef enum : NSUInteger { // // Always reserve room for the input accessory, which we display even // if the keyboard is not active. + newInsets.top = 0; newInsets.bottom = MAX(0, self.view.height - self.bottomLayoutGuide.length - keyboardEndFrameConverted.origin.y); + newInsets.top += self.extraContentInsetPadding; + newInsets.bottom += self.extraContentInsetPadding; + BOOL wasScrolledToBottom = [self isScrolledToBottom]; void (^adjustInsets)(void) = ^(void) { @@ -4308,7 +4370,6 @@ typedef enum : NSUInteger { { _isViewVisible = isViewVisible; - [self updateShouldObserveVMUpdates]; [self updateCellsVisible]; } @@ -4321,134 +4382,8 @@ typedef enum : NSUInteger { } } -- (void)updateShouldObserveVMUpdates -{ - if (!CurrentAppContext().isAppForegroundAndActive) { - self.shouldObserveVMUpdates = NO; - return; - } - - if (!self.isViewVisible) { - self.shouldObserveVMUpdates = NO; - return; - } - - if (OWSWindowManager.sharedManager.isPresentingMenuActions) { - self.shouldObserveVMUpdates = NO; - return; - } - - self.shouldObserveVMUpdates = YES; -} - -- (void)setShouldObserveVMUpdates:(BOOL)shouldObserveVMUpdates -{ - if (_shouldObserveVMUpdates == shouldObserveVMUpdates) { - return; - } - - _shouldObserveVMUpdates = shouldObserveVMUpdates; - - if (self.shouldObserveVMUpdates) { - OWSLogVerbose(@"resume observation of view model."); - - [self updateConversationSnapshot]; - [self resetContentAndLayout]; - [self updateBackButtonUnreadCount]; - [self updateNavigationBarSubtitleLabel]; - [self updateDisappearingMessagesConfiguration]; - - // Detect changes in the mapping's "window" size. - if (self.previousViewTopToContentBottom && self.previousViewItemCount - && self.previousViewItemCount.unsignedIntegerValue != self.viewItems.count) { - CGFloat newContentHeight = self.safeContentHeight; - CGPoint newContentOffset - = CGPointMake(0, MAX(0, newContentHeight - self.previousViewTopToContentBottom.floatValue)); - [self.collectionView setContentOffset:newContentOffset animated:NO]; - } - - // When we resume observing database changes, we want to scroll to show the user - // any new items inserted while we were not observing. We therefore find the - // 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 && self.previousLastTimestamp - && lastViewItem.interaction.timestamp > self.previousLastTimestamp.unsignedLongLongValue); - - OWSLogInfo(@"hasAddedNewItems: %d", hasAddedNewItems); - if (hasAddedNewItems) { - NSIndexPath *_Nullable indexPathToShow = [self firstIndexPathAtViewHorizonTimestamp]; - if (indexPathToShow) { - // The goal is to show _both_ the last item before the "view horizon" and the - // first item after the "view horizon". We can't do "top on first item after" - // or "bottom on last item before" or we won't see the other. Unfortunately, - // this gets tricky if either is huge. The largest cells are oversize text, - // which should be rare. Other cells are considerably smaller than a screenful. - [self.collectionView scrollToItemAtIndexPath:indexPathToShow - atScrollPosition:UICollectionViewScrollPositionCenteredVertically - animated:NO]; - } - } - self.viewHorizonTimestamp = nil; - OWSLogVerbose(@"resumed observation of view model."); - } else { - 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. - // Ideally, we'll focus on those changes. But we can't skip over unread - // interactions, so we prioritize those, if any. - // - // We'll use this later to update the view to reflect any changes made while - // we were not observing the database. See extendRangeToIncludeUnobservedItems - // and the logic above. - 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 = - [[TSDatabaseView unseenDatabaseViewExtension:transaction] firstObjectInGroup:self.thread.uniqueId]; - }]; - if (firstUnseenInteraction) { - // If there are any unread interactions, focus on the first one. - self.viewHorizonTimestamp = @(firstUnseenInteraction.timestamp); - } else if (lastViewItem) { - // Otherwise, focus _just after_ the last interaction. - self.viewHorizonTimestamp = @(lastViewItem.interaction.timestamp + 1); - } else { - self.viewHorizonTimestamp = nil; - } - - // 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.shouldObserveVMUpdates); - if (!self.viewHorizonTimestamp) { return nil; } @@ -4571,6 +4506,13 @@ typedef enum : NSUInteger { - (CGPoint)collectionView:(UICollectionView *)collectionView targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset { + if (self.menuActionsViewController != nil) { + NSValue *_Nullable contentOffset = [self contentOffsetForMenuActionInteraction]; + if (contentOffset != nil) { + return contentOffset.CGPointValue; + } + } + if (self.scrollContinuity == kScrollContinuityBottom && self.lastKnownDistanceFromBottom) { NSValue *_Nullable contentOffset = [self contentOffsetForLastKnownDistanceFromBottom:self.lastKnownDistanceFromBottom.floatValue]; @@ -4795,11 +4737,6 @@ typedef enum : NSUInteger { #pragma mark - ConversationViewModelDelegate -- (BOOL)isObservingVMUpdates -{ - return self.shouldObserveVMUpdates; -} - - (void)conversationViewModelWillUpdate { OWSAssertIsOnMainThread(); @@ -4823,13 +4760,15 @@ typedef enum : NSUInteger { OWSAssertDebug(conversationUpdate); OWSAssertDebug(self.conversationViewModel); - if (!self.shouldObserveVMUpdates) { + if (!self.viewLoaded) { + // It's safe to ignore updates before the view loads; + // viewWillAppear will call resetContentAndLayout. return; } - [self updateConversationSnapshot]; [self updateBackButtonUnreadCount]; [self updateNavigationBarSubtitleLabel]; + [self dismissMenuActionsIfNecessary]; if (self.isGroupConversation) { [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { @@ -5037,13 +4976,6 @@ typedef enum : NSUInteger { [self scrollToBottomAnimated:NO]; } -- (void)conversationViewModelDidDeleteMostRecentMenuActionsViewItem -{ - OWSAssertIsOnMainThread(); - - [[OWSWindowManager sharedManager] hideMenuActionsWindow]; -} - #pragma mark - Orientation - (void)viewWillTransitionToSize:(CGSize)size @@ -5057,7 +4989,7 @@ typedef enum : NSUInteger { // in the content of this view. It's easier to dismiss the // "message actions" window when the device changes orientation // than to try to ensure this works in that case. - [[OWSWindowManager sharedManager] hideMenuActionsWindow]; + [self dismissMenuActions]; // Snapshot the "last visible row". NSIndexPath *_Nullable lastVisibleIndexPath = self.lastVisibleIndexPath; @@ -5083,7 +5015,9 @@ typedef enum : NSUInteger { [strongSelf updateInputToolbarLayout]; - if (lastVisibleIndexPath) { + if (self.menuActionsViewController != nil) { + [self scrollToMenuActionInteraction:NO]; + } else if (lastVisibleIndexPath) { [strongSelf.collectionView scrollToItemAtIndexPath:lastVisibleIndexPath atScrollPosition:UICollectionViewScrollPositionBottom animated:NO]; @@ -5137,26 +5071,6 @@ typedef enum : NSUInteger { [self updateScrollDownButtonLayout]; } -#pragma mark - Conversation Snapshot - -- (void)tryToUpdateConversationSnapshot -{ - if (!self.isObservingVMUpdates) { - return; - } - - [self updateConversationSnapshot]; -} - -- (void)updateConversationSnapshot -{ - ConversationSnapshot *conversationSnapshot = [ConversationSnapshot new]; - conversationSnapshot.viewItems = self.conversationViewModel.viewItems; - conversationSnapshot.dynamicInteractions = self.conversationViewModel.dynamicInteractions; - conversationSnapshot.canLoadMoreItems = self.conversationViewModel.canLoadMoreItems; - _conversationSnapshot = conversationSnapshot; -} - @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h index 8b218d541..a4025f1f7 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.h @@ -34,6 +34,19 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { #pragma mark - +@interface ConversationViewState : NSObject + +@property (nonatomic, readonly) NSArray> *viewItems; +@property (nonatomic, readonly) NSDictionary *interactionIndexMap; +// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys, +// as that won't preserve ordering. +@property (nonatomic, readonly) NSArray *interactionIds; +@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex; + +@end + +#pragma mark - + @interface ConversationUpdateItem : NSObject @property (nonatomic, readonly) ConversationUpdateItemType updateItemType; @@ -74,10 +87,6 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { // to prod the view to reset its scroll state, etc. - (void)conversationViewModelDidReset; -- (void)conversationViewModelDidDeleteMostRecentMenuActionsViewItem; - -- (BOOL)isObservingVMUpdates; - - (ConversationStyle *)conversationStyle; @end @@ -86,10 +95,9 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) { @interface ConversationViewModel : NSObject -@property (nonatomic, readonly) NSArray> *viewItems; +@property (nonatomic, readonly) ConversationViewState *viewState; @property (nonatomic, nullable) NSString *focusMessageIdOnOpen; @property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions; -@property (nonatomic, nullable) id mostRecentMenuActionsViewItem; - (instancetype)init NS_UNAVAILABLE; - (instancetype)initWithThread:(TSThread *)thread diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m index 99c928218..3c34ab266 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewModel.m @@ -44,6 +44,50 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - +@implementation ConversationViewState + +- (instancetype)initWithViewItems:(NSArray> *)viewItems +{ + self = [super init]; + if (!self) { + return self; + } + + _viewItems = viewItems; + NSMutableDictionary *interactionIndexMap = [NSMutableDictionary new]; + NSMutableArray *interactionIds = [NSMutableArray new]; + for (NSUInteger i = 0; i < self.viewItems.count; i++) { + id viewItem = self.viewItems[i]; + interactionIndexMap[viewItem.interaction.uniqueId] = @(i); + [interactionIds addObject:viewItem.interaction.uniqueId]; + + if (viewItem.unreadIndicator != nil) { + _unreadIndicatorIndex = @(i); + } + } + _interactionIndexMap = [interactionIndexMap copy]; + _interactionIds = [interactionIds copy]; + + return self; +} + +- (nullable id)unreadIndicatorViewItem +{ + if (self.unreadIndicatorIndex == nil) { + return nil; + } + NSUInteger index = self.unreadIndicatorIndex.unsignedIntegerValue; + if (index >= self.viewItems.count) { + OWSFailDebug(@"Invalid index."); + return nil; + } + return self.viewItems[index]; +} + +@end + +#pragma mark - + @implementation ConversationUpdateItem - (instancetype)initWithUpdateItemType:(ConversationUpdateItemType)updateItemType @@ -150,7 +194,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; // * Afterward, we must prod the view controller to update layout & view state. @property (nonatomic) ConversationMessageMapping *messageMapping; -@property (nonatomic) NSArray> *viewItems; +@property (nonatomic) ConversationViewState *viewState; @property (nonatomic) NSMutableDictionary> *viewItemCache; @property (nonatomic, nullable) ThreadDynamicInteractions *dynamicInteractions; @@ -187,6 +231,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; _persistedViewItems = @[]; _unsavedOutgoingMessages = @[]; self.focusMessageIdOnOpen = focusMessageIdOnOpen; + _viewState = [[ConversationViewState alloc] initWithViewItems:@[]]; [self configure]; @@ -492,22 +537,12 @@ static const int kYapDatabaseRangeMaxLength = 25000; } } -- (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]; + id _Nullable oldIndicatorItem = [self.viewState unreadIndicatorViewItem]; if (oldIndicatorItem) { // TODO ideally this would be happening within the *same* transaction that caused the unreadMessageIndicator // to be cleared. @@ -542,19 +577,12 @@ static const int kYapDatabaseRangeMaxLength = 25000; OWSLogVerbose(@""); - if (!self.delegate.isObservingVMUpdates) { - return; - } - // External database modifications (e.g. changes from another process such as the SAE) // are "flushed" using touchDbAsync when the app re-enters the foreground. } - (void)uiDatabaseWillUpdate:(NSNotification *)notification { - if (!self.delegate.isObservingVMUpdates) { - return; - } [self.delegate conversationViewModelWillUpdate]; } @@ -594,12 +622,6 @@ static const int kYapDatabaseRangeMaxLength = 25000; return; } - NSString *_Nullable mostRecentMenuActionsInterationId = self.mostRecentMenuActionsViewItem.interaction.uniqueId; - if (mostRecentMenuActionsInterationId != nil && - [diff.removedItemIds containsObject:mostRecentMenuActionsInterationId]) { - [self.delegate conversationViewModelDidDeleteMostRecentMenuActionsViewItem]; - } - NSMutableSet *diffAddedItemIds = [diff.addedItemIds mutableCopy]; NSMutableSet *diffRemovedItemIds = [diff.removedItemIds mutableCopy]; NSMutableSet *diffUpdatedItemIds = [diff.updatedItemIds mutableCopy]; @@ -626,10 +648,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; } } - NSMutableArray *oldItemIdList = [NSMutableArray new]; - for (id viewItem in self.viewItems) { - [oldItemIdList addObject:viewItem.itemId]; - } + NSArray *oldItemIdList = self.viewState.interactionIds; // We need to reload any modified interactions _before_ we call // reloadViewItems. @@ -668,7 +687,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; return; } - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewItems.count); + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewState.viewItems.count); [self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:updatedItemSet]; } @@ -681,10 +700,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; OWSLogVerbose(@""); - NSMutableArray *oldItemIdList = [NSMutableArray new]; - for (id viewItem in self.viewItems) { - [oldItemIdList addObject:viewItem.itemId]; - } + NSArray *oldItemIdList = self.viewState.interactionIds; if (![self reloadViewItems]) { // These errors are rare. @@ -695,7 +711,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; return; } - OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewItems.count); + OWSLogVerbose(@"self.viewItems.count: %zd -> %zd", oldItemIdList.count, self.viewState.viewItems.count); [self updateViewWithOldItemIdList:oldItemIdList updatedItemSet:[NSSet set]]; } @@ -705,23 +721,15 @@ static const int kYapDatabaseRangeMaxLength = 25000; OWSAssertDebug(oldItemIdList); OWSAssertDebug(updatedItemSetParam); - if (!self.delegate.isObservingVMUpdates) { - OWSLogVerbose(@"Skipping VM update."); - // We fire this event, but it will be ignored. - [self.delegate conversationViewModelDidUpdate:ConversationUpdate.minorUpdate]; - return; - } - if (oldItemIdList.count != [NSSet setWithArray:oldItemIdList].count) { OWSFailDebug(@"Old view item list has duplicates."); [self.delegate conversationViewModelDidUpdate:ConversationUpdate.reloadUpdate]; return; } - NSMutableArray *newItemIdList = [NSMutableArray new]; + NSArray *newItemIdList = self.viewState.interactionIds; NSMutableDictionary> *newViewItemMap = [NSMutableDictionary new]; - for (id viewItem in self.viewItems) { - [newItemIdList addObject:viewItem.itemId]; + for (id viewItem in self.viewState.viewItems) { newViewItemMap[viewItem.itemId] = viewItem; } @@ -1530,7 +1538,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; viewItem.senderName = senderName; } - self.viewItems = viewItems; + self.viewState = [[ConversationViewState alloc] initWithViewItems:viewItems]; self.viewItemCache = viewItemCache; return !hasError; @@ -1681,7 +1689,7 @@ static const int kYapDatabaseRangeMaxLength = 25000; // Update the view items if necessary. // We don't have to do this if they haven't been configured yet. - if (didChange && self.viewItems != nil) { + if (didChange && self.viewState.viewItems != nil) { // When we receive an incoming message, we clear any typing indicators // from that sender. Ideally, we'd like both changes (disappearance of // the typing indicators, appearance of the incoming message) to show up diff --git a/Signal/src/ViewControllers/MenuActionsViewController.swift b/Signal/src/ViewControllers/MenuActionsViewController.swift index 285268c25..4e04f6c76 100644 --- a/Signal/src/ViewControllers/MenuActionsViewController.swift +++ b/Signal/src/ViewControllers/MenuActionsViewController.swift @@ -21,9 +21,12 @@ public class MenuAction: NSObject { @objc protocol MenuActionsViewControllerDelegate: class { - func menuActionsDidHide(_ menuActionsViewController: MenuActionsViewController) - func menuActions(_ menuActionsViewController: MenuActionsViewController, isPresentingWithVerticalFocusChange: CGFloat) - func menuActions(_ menuActionsViewController: MenuActionsViewController, isDismissingWithVerticalFocusChange: CGFloat) + func menuActionsWillPresent(_ menuActionsViewController: MenuActionsViewController) + func menuActionsIsPresenting(_ menuActionsViewController: MenuActionsViewController) + func menuActionsDidPresent(_ menuActionsViewController: MenuActionsViewController) + + func menuActionsIsDismissing(_ menuActionsViewController: MenuActionsViewController) + func menuActionsDidDismiss(_ menuActionsViewController: MenuActionsViewController) } @objc @@ -32,18 +35,20 @@ class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { @objc weak var delegate: MenuActionsViewControllerDelegate? + @objc + public let focusedInteraction: TSInteraction + private let focusedView: UIView private let actionSheetView: MenuActionSheetView deinit { Logger.verbose("") - assert(didInformDelegateOfDismissalAnimation) - assert(didInformDelegateThatDisappearenceCompleted) } @objc - required init(focusedView: UIView, actions: [MenuAction]) { + required init(focusedInteraction: TSInteraction, focusedView: UIView, actions: [MenuAction]) { self.focusedView = focusedView + self.focusedInteraction = focusedInteraction self.actionSheetView = MenuActionSheetView(actions: actions) super.init(nibName: nil, bundle: nil) @@ -86,8 +91,7 @@ class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { // When the user has manually dismissed the menu, we do a nice animation // but if the view otherwise disappears (e.g. due to resigning active), // we still want to give the delegate the information it needs to restore it's UI. - ensureDelegateIsInformedOfDismissalAnimation() - ensureDelegateIsInformedThatDisappearenceCompleted() + delegate?.menuActionsDidDismiss(self) } // MARK: Orientation @@ -98,7 +102,6 @@ class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { // MARK: Present / Dismiss animations - var presentationFocusOffset: CGFloat? var snapshotView: UIView? private func addSnapshotFocusedView() -> UIView? { @@ -150,6 +153,7 @@ class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { let oldFocusFrame = self.view.convert(focusedView.frame, from: focusedViewSuperview) NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint]) self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(toSuperviewEdge: .bottom) + self.delegate?.menuActionsWillPresent(self) UIView.animate(withDuration: 0.2, delay: backgroundDuration, options: .curveEaseOut, @@ -160,35 +164,36 @@ class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { var newFocusFrame = oldFocusFrame // Position focused item just over the action sheet. - let padding: CGFloat = 10 - let overlap: CGFloat = (oldFocusFrame.maxY + padding) - newSheetFrame.minY + let overlap: CGFloat = (oldFocusFrame.maxY + self.vSpacing) - newSheetFrame.minY newFocusFrame.origin.y = oldFocusFrame.origin.y - overlap snapshotView.frame = newFocusFrame - let offset = -overlap - self.presentationFocusOffset = offset - self.delegate?.menuActions(self, isPresentingWithVerticalFocusChange: offset) + self.delegate?.menuActionsIsPresenting(self) }, - completion: nil) + completion: { (_) in + self.delegate?.menuActionsDidPresent(self) + }) + } + + @objc + public let vSpacing: CGFloat = 10 + + @objc + public var focusUI: UIView { + return actionSheetView } private func animateDismiss(action: MenuAction?) { guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else { owsFailDebug("actionSheetVerticalConstraint was unexpectedly nil") - self.delegate?.menuActionsDidHide(self) + delegate?.menuActionsDidDismiss(self) return } guard let snapshotView = self.snapshotView else { owsFailDebug("snapshotView was unexpectedly nil") - self.delegate?.menuActionsDidHide(self) - return - } - - guard let presentationFocusOffset = self.presentationFocusOffset else { - owsFailDebug("presentationFocusOffset was unexpectedly nil") - self.delegate?.menuActionsDidHide(self) + delegate?.menuActionsDidDismiss(self) return } @@ -203,48 +208,20 @@ class MenuActionsViewController: UIViewController, MenuActionSheetDelegate { animations: { self.view.backgroundColor = UIColor.clear self.actionSheetView.superview?.layoutIfNeeded() - snapshotView.frame.origin.y -= presentationFocusOffset // this helps when focused view is above navbars, etc. snapshotView.alpha = 0 - self.ensureDelegateIsInformedOfDismissalAnimation() + + self.delegate?.menuActionsIsDismissing(self) }, completion: { _ in self.view.isHidden = true - self.ensureDelegateIsInformedThatDisappearenceCompleted() + self.delegate?.menuActionsDidDismiss(self) if let action = action { action.block(action) } }) } - var didInformDelegateThatDisappearenceCompleted = false - func ensureDelegateIsInformedThatDisappearenceCompleted() { - guard !didInformDelegateThatDisappearenceCompleted else { - Logger.debug("ignoring redundant 'disappeared' notification") - return - } - didInformDelegateThatDisappearenceCompleted = true - - self.delegate?.menuActionsDidHide(self) - } - - var didInformDelegateOfDismissalAnimation = false - func ensureDelegateIsInformedOfDismissalAnimation() { - guard !didInformDelegateOfDismissalAnimation else { - Logger.debug("ignoring redundant 'dismissal' notification") - return - } - didInformDelegateOfDismissalAnimation = true - - guard let presentationFocusOffset = self.presentationFocusOffset else { - owsFailDebug("presentationFocusOffset was unexpectedly nil") - self.delegate?.menuActionsDidHide(self) - return - } - - self.delegate?.menuActions(self, isDismissingWithVerticalFocusChange: presentationFocusOffset) - } - // MARK: Actions @objc