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