From 19390abc41262a1f598a0fbdeb8ffa51915dc962 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 25 May 2017 18:00:41 -0400 Subject: [PATCH] Refine the unseen indicators. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix sizing of the unread indicator cells. * Fix conflicts between paging and “load window” of conversation view and unseen indicator. * Modify unseen indicator to indicate whether there are more unseen messages and safety number changes. * Fix conflicts between modifying the “load window” size and updating the dynamic interactions. * Clear the “bubble size calculator” cache whenever the view changes size. * Improve the scrolling behavior around “load more messages”. * Improve management of “load window” size. * Fix issues around caching of bubble sizes. // FREEBIE --- Signal/src/Models/OWSCall.m | 2 +- .../Models/OWSMessagesBubblesSizeCalculator.m | 95 ++++- .../TSMessageAdapaters/TSMessageAdapter.m | 3 +- Signal/src/UIView+OWS.m | 4 +- .../ViewControllers/MessagesViewController.m | 392 ++++++++++-------- .../ViewControllers/SignalsViewController.m | 30 +- Signal/src/util/ThreadUtil.h | 43 +- Signal/src/util/ThreadUtil.m | 177 ++++++-- Signal/src/views/OWSUnreadIndicatorCell.h | 7 + Signal/src/views/OWSUnreadIndicatorCell.m | 127 +++++- .../src/views/TSUnreadIndicatorInteraction.h | 9 +- .../src/views/TSUnreadIndicatorInteraction.m | 28 +- .../translations/en.lproj/Localizable.strings | 9 + 13 files changed, 671 insertions(+), 255 deletions(-) diff --git a/Signal/src/Models/OWSCall.m b/Signal/src/Models/OWSCall.m index 23bd2d101..dd035e38e 100644 --- a/Signal/src/Models/OWSCall.m +++ b/Signal/src/Models/OWSCall.m @@ -85,7 +85,7 @@ NS_ASSUME_NONNULL_BEGIN return [self initWithInteraction:callRecord callerId:contactThread.contactIdentifier callerDisplayName:name - date:callRecord.date + date:callRecord.dateForSorting status:status displayString:detailString]; } diff --git a/Signal/src/Models/OWSMessagesBubblesSizeCalculator.m b/Signal/src/Models/OWSMessagesBubblesSizeCalculator.m index 770a0086e..3286ad413 100644 --- a/Signal/src/Models/OWSMessagesBubblesSizeCalculator.m +++ b/Signal/src/Models/OWSMessagesBubblesSizeCalculator.m @@ -5,10 +5,12 @@ #import "OWSMessagesBubblesSizeCalculator.h" #import "OWSCall.h" #import "OWSDisplayedMessageCollectionViewCell.h" +#import "OWSUnreadIndicatorCell.h" #import "TSGenericAttachmentAdapter.h" #import "TSMessageAdapter.h" #import "UIFont+OWS.h" #import "tgmath.h" // generic math allows fmax to handle CGFLoat correctly on 32 & 64bit. +#import #import NS_ASSUME_NONNULL_BEGIN @@ -50,6 +52,11 @@ NS_ASSUME_NONNULL_BEGIN TSMessageAdapter *message = (TSMessageAdapter *)messageData; if (message.messageType == TSInfoMessageAdapter || message.messageType == TSErrorMessageAdapter) { return [self messageBubbleSizeForInfoMessageData:messageData atIndexPath:indexPath withLayout:layout]; + } else if (message.messageType == TSUnreadIndicatorAdapter) { + return [OWSUnreadIndicatorCell + cellSizeForInteraction:(TSUnreadIndicatorInteraction *)((TSMessageAdapter *)messageData).interaction + collectionViewWidth:layout.collectionView.bounds.size.width]; + return [self messageBubbleSizeForInfoMessageData:messageData atIndexPath:indexPath withLayout:layout]; } } @@ -65,7 +72,7 @@ NS_ASSUME_NONNULL_BEGIN } else { // END HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368 - return [super messageBubbleSizeForMessageData:messageData atIndexPath:indexPath withLayout:layout]; + return [self simple_messageBubbleSizeForMessageData:messageData atIndexPath:indexPath withLayout:layout]; } } @@ -119,7 +126,8 @@ NS_ASSUME_NONNULL_BEGIN withLayout:(JSQMessagesCollectionViewFlowLayout *)layout { UIFont *emojiFont = [UIFont fontWithName:@".AppleColorEmojiUI" size:layout.messageBubbleFont.pointSize]; - CGSize superSize = [super messageBubbleSizeForMessageData:messageData atIndexPath:indexPath withLayout:layout]; + CGSize superSize = + [self simple_messageBubbleSizeForMessageData:messageData atIndexPath:indexPath withLayout:layout]; int lines = (int)floor(superSize.height / emojiFont.lineHeight); // Add an extra pixel per line to fit the emoji. @@ -132,7 +140,9 @@ NS_ASSUME_NONNULL_BEGIN atIndexPath:(NSIndexPath *)indexPath withLayout:(JSQMessagesCollectionViewFlowLayout *)layout { - NSValue *cachedSize = [self.cache objectForKey:@([messageData messageHash])]; + id cacheKey = [self cacheKeyForMessageData:messageData]; + + NSValue *cachedSize = [self.cache objectForKey:cacheKey]; if (cachedSize != nil) { return [cachedSize CGSizeValue]; } @@ -199,7 +209,7 @@ NS_ASSUME_NONNULL_BEGIN finalSize = CGSizeMake(finalWidth, stringSize.height + verticalInsets); } - [self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:@([messageData messageHash])]; + [self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:cacheKey]; return finalSize; } @@ -208,7 +218,9 @@ NS_ASSUME_NONNULL_BEGIN atIndexPath:(NSIndexPath *)indexPath withLayout:(JSQMessagesCollectionViewFlowLayout *)layout { - NSValue *cachedSize = [self.cache objectForKey:@([messageData messageHash])]; + id cacheKey = [self cacheKeyForMessageData:messageData]; + + NSValue *cachedSize = [self.cache objectForKey:cacheKey]; if (cachedSize != nil) { return [cachedSize CGSizeValue]; } @@ -232,7 +244,78 @@ NS_ASSUME_NONNULL_BEGIN CGSize finalSize = CGSizeMake(finalWidth, stringSize.height + verticalInsets); - [self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:@([messageData messageHash])]; + [self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:cacheKey]; + + return finalSize; +} + +- (id)cacheKeyForMessageData:(id)messageData +{ + OWSAssert(messageData); + OWSAssert([messageData conformsToProtocol:@protocol(OWSMessageData)]); + OWSAssert(((id)messageData).interaction); + OWSAssert(((id)messageData).interaction.uniqueId); + + return ((id)messageData).interaction.uniqueId; +} + +// This method was lifted from JSQMessagesBubblesSizeCalculator and +// modified to use interaction.uniqueId as cache keys. +- (CGSize)simple_messageBubbleSizeForMessageData:(id)messageData + atIndexPath:(NSIndexPath *)indexPath + withLayout:(JSQMessagesCollectionViewFlowLayout *)layout +{ + id cacheKey = [self cacheKeyForMessageData:messageData]; + + NSValue *cachedSize = [self.cache objectForKey:cacheKey]; + if (cachedSize != nil) { + return [cachedSize CGSizeValue]; + } + + CGSize finalSize = CGSizeZero; + + if ([messageData isMediaMessage]) { + finalSize = [[messageData media] mediaViewDisplaySize]; + } else { + CGSize avatarSize = [self jsq_avatarSizeForMessageData:messageData withLayout:layout]; + + // from the cell xibs, there is a 2 point space between avatar and bubble + CGFloat spacingBetweenAvatarAndBubble = 2.0f; + CGFloat horizontalContainerInsets = layout.messageBubbleTextViewTextContainerInsets.left + + layout.messageBubbleTextViewTextContainerInsets.right; + CGFloat horizontalFrameInsets + = layout.messageBubbleTextViewFrameInsets.left + layout.messageBubbleTextViewFrameInsets.right; + + CGFloat horizontalInsetsTotal + = horizontalContainerInsets + horizontalFrameInsets + spacingBetweenAvatarAndBubble; + CGFloat maximumTextWidth = [self textBubbleWidthForLayout:layout] - avatarSize.width + - layout.messageBubbleLeftRightMargin - horizontalInsetsTotal; + + CGRect stringRect = [[messageData text] + boundingRectWithSize:CGSizeMake(maximumTextWidth, CGFLOAT_MAX) + options:(NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading) + attributes:@{ NSFontAttributeName : layout.messageBubbleFont } + context:nil]; + + CGSize stringSize = CGRectIntegral(stringRect).size; + + CGFloat verticalContainerInsets = layout.messageBubbleTextViewTextContainerInsets.top + + layout.messageBubbleTextViewTextContainerInsets.bottom; + CGFloat verticalFrameInsets + = layout.messageBubbleTextViewFrameInsets.top + layout.messageBubbleTextViewFrameInsets.bottom; + + // add extra 2 points of space (`self.additionalInset`), because `boundingRectWithSize:` is slightly off + // not sure why. magix. (shrug) if you know, submit a PR + CGFloat verticalInsets = verticalContainerInsets + verticalFrameInsets + self.additionalInset; + + // same as above, an extra 2 points of magix + CGFloat finalWidth + = MAX(stringSize.width + horizontalInsetsTotal, self.minimumBubbleWidth) + self.additionalInset; + + finalSize = CGSizeMake(finalWidth, stringSize.height + verticalInsets); + } + + [self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:cacheKey]; return finalSize; } diff --git a/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m b/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m index db76b8c7f..16d16ef79 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m +++ b/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m @@ -76,7 +76,7 @@ NS_ASSUME_NONNULL_BEGIN } _interaction = interaction; - _messageDate = interaction.date; + _messageDate = interaction.dateForSorting; self.interactionUniqueId = interaction.uniqueId; @@ -249,6 +249,7 @@ NS_ASSUME_NONNULL_BEGIN return call; } } else if ([interaction isKindOfClass:[TSUnreadIndicatorInteraction class]]) { + TSUnreadIndicatorInteraction *unreadIndicator = (TSUnreadIndicatorInteraction *)interaction; adapter.messageType = TSUnreadIndicatorAdapter; } else if ([interaction isKindOfClass:[TSErrorMessage class]]) { TSErrorMessage *errorMessage = (TSErrorMessage *)interaction; diff --git a/Signal/src/UIView+OWS.m b/Signal/src/UIView+OWS.m index 64143fc6e..01623dfc0 100644 --- a/Signal/src/UIView+OWS.m +++ b/Signal/src/UIView+OWS.m @@ -167,8 +167,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value) { OWSAssert(self.superview); - self.frame = CGRectMake(round(self.superview.left + (self.superview.width - self.width) * 0.5f), - round(self.superview.top + (self.superview.height - self.height) * 0.5f), + self.frame = CGRectMake(round((self.superview.width - self.width) * 0.5f), + round((self.superview.height - self.height) * 0.5f), self.width, self.height); } diff --git a/Signal/src/ViewControllers/MessagesViewController.m b/Signal/src/ViewControllers/MessagesViewController.m index a0353ff6d..a88d82294 100644 --- a/Signal/src/ViewControllers/MessagesViewController.m +++ b/Signal/src/ViewControllers/MessagesViewController.m @@ -81,12 +81,18 @@ @import Photos; -#define kYapDatabaseRangeLength 50 -#define kYapDatabaseRangeMaxLength 300 -#define kYapDatabaseRangeMinLength 20 -#define JSQ_TOOLBAR_ICON_HEIGHT 22 -#define JSQ_TOOLBAR_ICON_WIDTH 22 -#define JSQ_IMAGE_INSET 5 +// Always load up to 50 messsages when user arrives. +static const int kYapDatabasePageSize = 50; +// Never show more than 50*50 = 2,500 messages in conversation view at a time. +static const int kYapDatabaseMaxPageCount = 50; +// Never show more than 6*50 = 300 messages in conversation view when user +// arrives. +static const int kYapDatabaseMaxInitialPageCount = 6; +static const int kYapDatabaseRangeMaxLength = kYapDatabasePageSize * kYapDatabaseMaxPageCount; +static const int kYapDatabaseRangeMinLength = 0; +static const int JSQ_TOOLBAR_ICON_HEIGHT = 22; +static const int JSQ_TOOLBAR_ICON_WIDTH = 22; +static const int JSQ_IMAGE_INSET = 5; static NSTimeInterval const kTSMessageSentDateShowTimeInterval = 5 * 60; @@ -97,42 +103,6 @@ typedef enum : NSUInteger { kMediaTypeVideo, } kMediaTypes; - -#pragma mark - - -@interface OWSMessagesCollectionViewFlowLayout : JSQMessagesCollectionViewFlowLayout - -@property (nonatomic) BOOL ignoreLayout; - -@end - -#pragma mark - - -@implementation OWSMessagesCollectionViewFlowLayout - -- (void)prepareLayout -{ - if (self.ignoreLayout) { - DDLogInfo(@"%@ ignoring layout.", self.tag); - return; - } - [super prepareLayout]; -} - -#pragma mark - Logging - -+ (NSString *)tag -{ - return [NSString stringWithFormat:@"[%@]", self.class]; -} - -- (NSString *)tag -{ - return self.class.tag; -} - -@end - #pragma mark - @protocol OWSTextViewPasteDelegate @@ -799,7 +769,23 @@ typedef enum : NSUInteger { [self.messageMappings updateWithTransaction:transaction]; }]; self.page = 0; - [self updateRangeOptionsForPage:self.page]; + + if (self.offersAndIndicators.unreadIndicatorPosition != nil) { + long unreadIndicatorPosition = [self.offersAndIndicators.unreadIndicatorPosition longValue]; + // If there is an unread indicator, increase the initial load window + // to include it. + OWSAssert(unreadIndicatorPosition > 0); + OWSAssert(unreadIndicatorPosition <= kYapDatabaseRangeMaxLength); + + // We'd like to include at least N seen messages, if possible, + // to give the user the context of where they left off the conversation. + const int kPreferredSeenMessageCount = 1; + self.page = (NSUInteger)MAX(0, + MIN(kYapDatabaseMaxInitialPageCount - 1, + (unreadIndicatorPosition + kPreferredSeenMessageCount) / kYapDatabasePageSize)); + } + + [self updateMessageMappingRangeOptions]; [self updateLoadEarlierVisible]; [self.collectionView reloadData]; } @@ -881,6 +867,7 @@ typedef enum : NSUInteger { // invalidate layout [self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; + self.collectionView.collectionViewLayout.bubbleSizeCalculator = [[OWSMessagesBubblesSizeCalculator alloc] init]; } } @@ -982,12 +969,17 @@ typedef enum : NSUInteger { - (void)viewWillAppear:(BOOL)animated { - // Ignore layout requests in viewWillAppear. - // JSQMessagesView forces layout then invalidates the layout. - // Besides, we'll be changing the contents of the view below. - ((OWSMessagesCollectionViewFlowLayout *)self.collectionView.collectionViewLayout).ignoreLayout = YES; + // We need to update the dynamic interactions before we do any layout. + [self ensureThreadOffersAndIndicators]; + + // Triggering modified notification renders "call notification" when leaving full screen call view + [self.thread touch]; + + [self ensureBlockStateIndicator]; + + [self resetContentAndLayout]; + [super viewWillAppear:animated]; - ((OWSMessagesCollectionViewFlowLayout *)self.collectionView.collectionViewLayout).ignoreLayout = NO; // In case we're dismissing a CNContactViewController which requires default system appearance [UIUtil applySignalAppearence]; @@ -1001,16 +993,11 @@ typedef enum : NSUInteger { [self toggleObservers:YES]; - [self ensureThreadOffersAndIndicators]; - - // Triggering modified notification renders "call notification" when leaving full screen call view - [self.thread touch]; - // restart any animations that were stopped e.g. while inspecting the contact info screens. [self startExpirationTimerAnimations]; // We should have already requested contact access at this point, so this should be a no-op - // unless it ever becomes possible to to load this VC without going via the SignalsViewController + // unless it ever becomes possible to load this VC without going via the SignalsViewController. [self.contactsManager requestSystemContactsOnce]; OWSDisappearingMessagesConfiguration *configuration = @@ -1031,15 +1018,9 @@ typedef enum : NSUInteger { action:shareSelector], ]; - [self ensureBlockStateIndicator]; - - [self resetContentAndLayout]; [((OWSMessagesToolbarContentView *)self.inputToolbar.contentView)ensureSubviews]; - [self.collectionView.collectionViewLayout - invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; - [self.scrollLaterTimer invalidate]; // We want to scroll to the bottom _after_ the layout has been updated. self.scrollLaterTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.001f @@ -1069,18 +1050,40 @@ typedef enum : NSUInteger { NSIndexPath *_Nullable indexPath = [self indexPathOfUnreadMessagesIndicator]; if (indexPath) { - [self.collectionView scrollToItemAtIndexPath:indexPath - atScrollPosition:UICollectionViewScrollPositionTop - animated:NO]; + if (indexPath.section == 0 && indexPath.row == 0) { + [self.collectionView setContentOffset:CGPointZero animated:NO]; + } else { + [self.collectionView scrollToItemAtIndexPath:indexPath + atScrollPosition:UICollectionViewScrollPositionTop + animated:NO]; + } } else { [self scrollToBottomAnimated:NO]; } } +- (void)scrollToUnreadIndicatorAnimated +{ + [self.scrollLaterTimer invalidate]; + self.scrollLaterTimer = nil; + + NSIndexPath *_Nullable indexPath = [self indexPathOfUnreadMessagesIndicator]; + if (indexPath) { + if (indexPath.section == 0 && indexPath.row == 0) { + [self.collectionView setContentOffset:CGPointZero animated:YES]; + } else { + [self.collectionView scrollToItemAtIndexPath:indexPath + atScrollPosition:UICollectionViewScrollPositionTop + animated:YES]; + } + } +} + - (void)resetContentAndLayout { // Avoid layout corrupt issues and out-of-date message subtitles. - [self.collectionView.collectionViewLayout invalidateLayout]; + [self.collectionView.collectionViewLayout + invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; [self.collectionView reloadData]; } @@ -1536,7 +1539,6 @@ typedef enum : NSUInteger { // Overiding JSQMVC layout defaults - (void)initializeCollectionViewLayout { - self.collectionView.collectionViewLayout = [OWSMessagesCollectionViewFlowLayout new]; [self.collectionView.collectionViewLayout setMessageBubbleFont:[UIFont ows_dynamicTypeBodyFont]]; self.collectionView.showsVerticalScrollIndicator = NO; @@ -1560,7 +1562,6 @@ typedef enum : NSUInteger { self.outgoingBubbleImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor ows_materialBlueColor]]; self.currentlyOutgoingBubbleImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor ows_fadedBlueColor]]; self.outgoingMessageFailedImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor grayColor]]; - } #pragma mark - Identity @@ -1895,26 +1896,33 @@ typedef enum : NSUInteger { case TSCallAdapter: { OWSCall *call = (OWSCall *)message; cell = [self loadCallCellForCall:call atIndexPath:indexPath]; - } break; + break; + } case TSInfoMessageAdapter: { cell = [self loadInfoMessageCellForMessage:(TSMessageAdapter *)message atIndexPath:indexPath]; - } break; + break; + } case TSErrorMessageAdapter: { cell = [self loadErrorMessageCellForMessage:(TSMessageAdapter *)message atIndexPath:indexPath]; - } break; + break; + } case TSIncomingMessageAdapter: { cell = [self loadIncomingMessageCellForMessage:message atIndexPath:indexPath]; - } break; + break; + } case TSOutgoingMessageAdapter: { cell = [self loadOutgoingCellForMessage:message atIndexPath:indexPath]; - } break; + break; + } case TSUnreadIndicatorAdapter: { - cell = [self loadUnreadIndicatorCell:indexPath]; - } break; + cell = [self loadUnreadIndicatorCell:indexPath interaction:message.interaction]; + break; + } default: { DDLogWarn(@"using default cell constructor for message: %@", message); cell = (JSQMessagesCollectionViewCell *)[super collectionView:collectionView cellForItemAtIndexPath:indexPath]; - } break; + break; + } } cell.delegate = collectionView; @@ -1981,12 +1989,18 @@ typedef enum : NSUInteger { } - (JSQMessagesCollectionViewCell *)loadUnreadIndicatorCell:(NSIndexPath *)indexPath + interaction:(TSInteraction *)interaction { OWSAssert(indexPath); + OWSAssert(interaction); + OWSAssert([interaction isKindOfClass:[TSUnreadIndicatorInteraction class]]); + + TSUnreadIndicatorInteraction *unreadIndicator = (TSUnreadIndicatorInteraction *)interaction; OWSUnreadIndicatorCell *cell = [self.collectionView dequeueReusableCellWithReuseIdentifier:[OWSUnreadIndicatorCell cellReuseIdentifier] forIndexPath:indexPath]; + cell.interaction = unreadIndicator; [cell configure]; return cell; @@ -2528,25 +2542,73 @@ typedef enum : NSUInteger { - (void)collectionView:(JSQMessagesCollectionView *)collectionView header:(JSQMessagesLoadEarlierHeaderView *)headerView - didTapLoadEarlierMessagesButton:(UIButton *)sender { - if ([self shouldShowLoadEarlierMessages]) { - self.page++; - } + didTapLoadEarlierMessagesButton:(UIButton *)sender +{ - [self.scrollLaterTimer invalidate]; - self.scrollLaterTimer = nil; - NSInteger item = (NSInteger)[self scrollToItem]; + // 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.collectionView.contentSize.height - self.collectionView.contentOffset.y; + + self.page = MIN(self.page + 1, (NSUInteger)kYapDatabaseMaxPageCount - 1); + + // To update a YapDatabaseViewMappings, you can call either: + // + // * [YapDatabaseViewMappings updateWithTransaction] + // * [YapDatabaseViewMappings getSectionChanges:rowChanges:forNotifications:withMappings:] + // + // ...but you can't call both. + // + // If ensureThreadOffersAndIndicators modifies the database, + // the mappings will be updated by yapDatabaseModified. + // This will leave the mapping range in a bad state. + // Therefore we temporarily disable observation of YapDatabaseModifiedNotification + // while updating the range and the dynamic interactions. + [[NSNotificationCenter defaultCenter] removeObserver:self name:YapDatabaseModifiedNotification object:nil]; + + // We need to update the dynamic interactions after loading earlier messages, + // since the unseen indicator may need to move or change. + [self ensureThreadOffersAndIndicators]; - [self updateRangeOptionsForPage:self.page]; + [self updateMessageMappingRangeOptions]; + // We need to `beginLongLivedReadTransaction` before we update our + // mapping in order to jump to the most recent commit. + [self.uiDatabaseConnection beginLongLivedReadTransaction]; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - [self.messageMappings updateWithTransaction:transaction]; + [self.messageMappings updateWithTransaction:transaction]; }]; - [self updateLayoutForEarlierMessagesWithOffset:item]; + [self.collectionView.collectionViewLayout + invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; + [self.collectionView reloadData]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(yapDatabaseModified:) + name:YapDatabaseModifiedNotification + object:nil]; + + [self.collectionView.collectionViewLayout + invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; + [self.collectionView reloadData]; + [self.collectionView layoutSubviews]; + + self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - scrollDistanceToBottom); + [self.scrollLaterTimer invalidate]; + // We want to scroll to the bottom _after_ the layout has been updated. + self.scrollLaterTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.001f + target:self + selector:@selector(scrollToUnreadIndicatorAnimated) + userInfo:nil + repeats:NO]; + + [self updateLoadEarlierVisible]; } - (BOOL)shouldShowLoadEarlierMessages { + if (self.page == kYapDatabaseMaxPageCount - 1) { + return NO; + } + __block BOOL show = YES; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { @@ -2558,8 +2620,8 @@ typedef enum : NSUInteger { } - (NSUInteger)scrollToItem { - __block NSUInteger item = - kYapDatabaseRangeLength * (self.page + 1) - [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; + __block NSUInteger item + = kYapDatabasePageSize * (self.page + 1) - [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId]; [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { @@ -2568,7 +2630,7 @@ typedef enum : NSUInteger { [[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId]; NSUInteger numberOfMessagesToLoad = numberOfTotalMessages - numberOfVisibleMessages; - BOOL canLoadFullRange = numberOfMessagesToLoad >= kYapDatabaseRangeLength; + BOOL canLoadFullRange = numberOfMessagesToLoad >= kYapDatabasePageSize; if (!canLoadFullRange) { item = numberOfMessagesToLoad; @@ -2582,23 +2644,10 @@ typedef enum : NSUInteger { [self setShowLoadEarlierMessagesHeader:[self shouldShowLoadEarlierMessages]]; } -- (void)updateLayoutForEarlierMessagesWithOffset:(NSInteger)offset { - [self.collectionView.collectionViewLayout - invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; - [self.collectionView reloadData]; - - [self.scrollLaterTimer invalidate]; - self.scrollLaterTimer = nil; - [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:offset inSection:0] - atScrollPosition:UICollectionViewScrollPositionTop - animated:NO]; - - [self updateLoadEarlierVisible]; -} - -- (void)updateRangeOptionsForPage:(NSUInteger)page { +- (void)updateMessageMappingRangeOptions +{ YapDatabaseViewRangeOptions *rangeOptions = - [YapDatabaseViewRangeOptions flexibleRangeWithLength:kYapDatabaseRangeLength * (page + 1) + [YapDatabaseViewRangeOptions flexibleRangeWithLength:kYapDatabasePageSize * (self.page + 1) offset:0 from:YapDatabaseViewEnd]; @@ -2903,15 +2952,18 @@ typedef enum : NSUInteger { { OWSAssert([NSThread isMainThread]); + const int initialMaxRangeSize = kYapDatabasePageSize * kYapDatabaseMaxInitialPageCount; + const int currentMaxRangeSize = (int)(self.page + 1) * kYapDatabasePageSize; + const int maxRangeSize = MAX(initialMaxRangeSize, currentMaxRangeSize); + self.offersAndIndicators = [ThreadUtil ensureThreadOffersAndIndicators:self.thread storageManager:self.storageManager contactsManager:self.contactsManager blockingManager:self.blockingManager hideUnreadMessagesIndicator:self.hasClearedUnreadMessagesIndicator - fixedUnreadIndicatorTimestamp:(self.offersAndIndicators.unreadIndicator - ? @(self.offersAndIndicators.unreadIndicator.timestamp) - : nil)]; + firstUnseenInteractionTimestamp:self.offersAndIndicators.firstUnseenInteractionTimestamp + maxRangeSize:maxRangeSize]; } - (void)clearUnreadMessagesIndicator @@ -3370,55 +3422,56 @@ typedef enum : NSUInteger { __block BOOL scrollToBottom = wasAtBottom; [self.collectionView performBatchUpdates:^{ - for (YapDatabaseViewRowChange *rowChange in messageRowChanges) { - switch (rowChange.type) { - case YapDatabaseViewChangeDelete: { - [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; - - YapCollectionKey *collectionKey = rowChange.collectionKey; - if (collectionKey.key) { - [self.messageAdapterCache removeObjectForKey:collectionKey.key]; - } - - break; - } - case YapDatabaseViewChangeInsert: { - [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; - - TSInteraction *interaction = [self interactionAtIndexPath:rowChange.newIndexPath]; - if ([interaction isKindOfClass:[TSOutgoingMessage class]]) { - scrollToBottom = YES; - shouldAnimateScrollToBottom = NO; - } - break; - } - case YapDatabaseViewChangeMove: { - [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; - [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; - break; - } - case YapDatabaseViewChangeUpdate: { - YapCollectionKey *collectionKey = rowChange.collectionKey; - if (collectionKey.key) { - [self.messageAdapterCache removeObjectForKey:collectionKey.key]; - } - [self.collectionView reloadItemsAtIndexPaths:@[ rowChange.indexPath ]]; - break; - } - } - } + for (YapDatabaseViewRowChange *rowChange in messageRowChanges) { + switch (rowChange.type) { + case YapDatabaseViewChangeDelete: { + [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; + + YapCollectionKey *collectionKey = rowChange.collectionKey; + if (collectionKey.key) { + [self.messageAdapterCache removeObjectForKey:collectionKey.key]; + } + + break; + } + case YapDatabaseViewChangeInsert: { + [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; + + TSInteraction *interaction = [self interactionAtIndexPath:rowChange.newIndexPath]; + if ([interaction isKindOfClass:[TSOutgoingMessage class]]) { + scrollToBottom = YES; + shouldAnimateScrollToBottom = NO; + } + break; + } + case YapDatabaseViewChangeMove: { + [self.collectionView deleteItemsAtIndexPaths:@[ rowChange.indexPath ]]; + [self.collectionView insertItemsAtIndexPaths:@[ rowChange.newIndexPath ]]; + break; + } + case YapDatabaseViewChangeUpdate: { + YapCollectionKey *collectionKey = rowChange.collectionKey; + if (collectionKey.key) { + [self.messageAdapterCache removeObjectForKey:collectionKey.key]; + } + [self.collectionView reloadItemsAtIndexPaths:@[ rowChange.indexPath ]]; + break; + } + } + } } completion:^(BOOL success) { - if (!success) { - [self.collectionView.collectionViewLayout - invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; - [self.collectionView reloadData]; - } - if (scrollToBottom) { - [self.scrollLaterTimer invalidate]; - self.scrollLaterTimer = nil; - [self scrollToBottomAnimated:shouldAnimateScrollToBottom]; - } + if (!success) { + [self.collectionView.collectionViewLayout + invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]]; + [self.collectionView reloadData]; + } + + if (scrollToBottom) { + [self.scrollLaterTimer invalidate]; + self.scrollLaterTimer = nil; + [self scrollToBottomAnimated:shouldAnimateScrollToBottom]; + } }]; } @@ -3436,26 +3489,23 @@ typedef enum : NSUInteger { return numberOfMessages; } -- (TSInteraction *)interactionAtIndexPath:(NSIndexPath *)indexPath { - __block TSInteraction *message = nil; +- (TSInteraction *)interactionAtIndexPath:(NSIndexPath *)indexPath +{ + OWSAssert(indexPath); + OWSAssert(indexPath.section == 0); + OWSAssert(self.messageMappings); + + __block TSInteraction *interaction; + [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - NSParameterAssert(viewTransaction != nil); - NSParameterAssert(self.messageMappings != nil); - NSParameterAssert(indexPath != nil); - NSUInteger row = (NSUInteger)indexPath.row; - NSUInteger section = (NSUInteger)indexPath.section; - NSUInteger numberOfItemsInSection __unused = [self.messageMappings numberOfItemsInSection:section]; - NSAssert(row < numberOfItemsInSection, - @"Cannot fetch message because row %d is >= numberOfItemsInSection %d", - (int)row, - (int)numberOfItemsInSection); - - message = [viewTransaction objectAtRow:row inSection:section withMappings:self.messageMappings]; - NSParameterAssert(message != nil); + YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; + OWSAssert(viewTransaction); + interaction = [viewTransaction objectAtRow:(NSUInteger)indexPath.row + inSection:(NSUInteger)indexPath.section + withMappings:self.messageMappings]; + OWSAssert(interaction); }]; - - return message; + return interaction; } - (id)messageAtIndexPath:(NSIndexPath *)indexPath diff --git a/Signal/src/ViewControllers/SignalsViewController.m b/Signal/src/ViewControllers/SignalsViewController.m index f4169b21d..c85d14a6d 100644 --- a/Signal/src/ViewControllers/SignalsViewController.m +++ b/Signal/src/ViewControllers/SignalsViewController.m @@ -336,7 +336,7 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS #pragma mark - startup -- (void)displayAnyUnseenUpgradeExperience +- (NSArray *)unseenUpgradeExperiences { AssertIsOnMainThread(); @@ -344,14 +344,31 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS [self.editingDbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { unseenUpgrades = [self.experienceUpgradeFinder allUnseenWithTransaction:transaction]; }]; + return unseenUpgrades; +} + +- (void)markAllUpgradeExperiencesAsSeen +{ + AssertIsOnMainThread(); + + [self.editingDbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { + [self.experienceUpgradeFinder markAllAsSeenWithTransaction:transaction]; + }]; +} + +- (void)displayAnyUnseenUpgradeExperience +{ + AssertIsOnMainThread(); + + NSArray *unseenUpgrades = [self unseenUpgradeExperiences]; if (unseenUpgrades.count > 0) { ExperienceUpgradesPageViewController *experienceUpgradeViewController = [[ExperienceUpgradesPageViewController alloc] initWithExperienceUpgrades:unseenUpgrades]; - [self presentViewController:experienceUpgradeViewController animated:YES completion:^{ - [self.editingDbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction * _Nonnull transaction) { - [self.experienceUpgradeFinder markAllAsSeenWithTransaction:transaction]; - }]; - }]; + [self presentViewController:experienceUpgradeViewController + animated:YES + completion:^{ + [self markAllUpgradeExperiencesAsSeen]; + }]; } } @@ -773,7 +790,6 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS [self checkIfEmptyView]; } - - (IBAction)unwindSettingsDone:(UIStoryboardSegue *)segue { } diff --git a/Signal/src/util/ThreadUtil.h b/Signal/src/util/ThreadUtil.h index f64de412e..606e5006f 100644 --- a/Signal/src/util/ThreadUtil.h +++ b/Signal/src/util/ThreadUtil.h @@ -16,7 +16,25 @@ NS_ASSUME_NONNULL_BEGIN @interface ThreadOffersAndIndicators : NSObject -@property (nonatomic, nullable) TSUnreadIndicatorInteraction *unreadIndicator; +// If there are unseen messages in the thread, this is the index +// of the unseen indicator, counting from the _end_ of the conversation +// history. +// +// This is used by MessageViewController to increase the +// range size of the mappings (the load window of the conversation) +// to include the unread indicator. +@property (nonatomic, nullable) NSNumber *unreadIndicatorPosition; + +// If there are unseen messages in the thread, this is the timestamp +// of the oldest unseen messaage. +// +// Once we enter messages view, we mark all messages read, so we need +// a snapshot of what the first unread message was when we entered the +// view so that we can call ensureThreadOffersAndIndicators:... +// repeatedly. The unread indicator should continue to show up until +// it has been cleared, at which point hideUnreadMessagesIndicator is +// YES in ensureThreadOffersAndIndicators:... +@property (nonatomic, nullable) NSNumber *firstUnseenInteractionTimestamp; @end @@ -33,17 +51,30 @@ NS_ASSUME_NONNULL_BEGIN messageSender:(OWSMessageSender *)messageSender; // This method will create and/or remove any offers and indicators -// necessary for this thread. +// necessary for this thread. This includes: +// +// * Block offers. +// * "Add to contacts" offers. +// * Unread indicators. +// +// Parameters: // -// * If hideUnreadMessagesIndicator is YES, there will be no "unread indicator". -// * Otherwise, if fixedUnreadIndicatorTimestamp is non-null, there will be a "unread indicator". -// * Otherwise, there will be a "unread indicator" if there is one unread message. +// * hideUnreadMessagesIndicator: If YES, the "unread indicator" has +// been cleared and should not be shown. +// * firstUnseenInteractionTimestamp: A snapshot of unseen message state +// when we entered the conversation view. See comments on +// ThreadOffersAndIndicators. +// * maxRangeSize: Loading a lot of messages in conversation view is +// slow and unwieldy. This number represents the maximum current +// size of the "load window" in that view. The unread indicator should +// always be inserted within that window. + (ThreadOffersAndIndicators *)ensureThreadOffersAndIndicators:(TSThread *)thread storageManager:(TSStorageManager *)storageManager contactsManager:(OWSContactsManager *)contactsManager blockingManager:(OWSBlockingManager *)blockingManager hideUnreadMessagesIndicator:(BOOL)hideUnreadMessagesIndicator - fixedUnreadIndicatorTimestamp:(NSNumber *_Nullable)fixedUnreadIndicatorTimestamp; + firstUnseenInteractionTimestamp:(nullable NSNumber *)firstUnseenInteractionTimestamp + maxRangeSize:(int)maxRangeSize; @end diff --git a/Signal/src/util/ThreadUtil.m b/Signal/src/util/ThreadUtil.m index e54058316..07009537d 100644 --- a/Signal/src/util/ThreadUtil.m +++ b/Signal/src/util/ThreadUtil.m @@ -86,12 +86,15 @@ NS_ASSUME_NONNULL_BEGIN contactsManager:(OWSContactsManager *)contactsManager blockingManager:(OWSBlockingManager *)blockingManager hideUnreadMessagesIndicator:(BOOL)hideUnreadMessagesIndicator - fixedUnreadIndicatorTimestamp:(NSNumber *_Nullable)fixedUnreadIndicatorTimestamp + firstUnseenInteractionTimestamp: + (nullable NSNumber *)firstUnseenInteractionTimestampParameter + maxRangeSize:(int)maxRangeSize { OWSAssert(thread); OWSAssert(storageManager); OWSAssert(contactsManager); OWSAssert(blockingManager); + OWSAssert(maxRangeSize > 0); ThreadOffersAndIndicators *result = [ThreadOffersAndIndicators new]; @@ -101,9 +104,9 @@ NS_ASSUME_NONNULL_BEGIN __block OWSAddToContactsOfferMessage *existingAddToContactsOffer = nil; __block OWSUnknownContactBlockOfferMessage *existingBlockOffer = nil; __block TSUnreadIndicatorInteraction *existingUnreadIndicator = nil; + NSMutableArray *safetyNumberChanges = [NSMutableArray new]; __block TSIncomingMessage *firstIncomingMessage = nil; __block TSOutgoingMessage *firstOutgoingMessage = nil; - __block TSIncomingMessage *firstUnreadMessage = nil; __block long outgoingMessageCount = 0; // We use different views for performance reasons. @@ -121,30 +124,40 @@ NS_ASSUME_NONNULL_BEGIN } else if ([object isKindOfClass:[TSUnreadIndicatorInteraction class]]) { OWSAssert(!existingUnreadIndicator); existingUnreadIndicator = (TSUnreadIndicatorInteraction *)object; + } else if ([object isKindOfClass:[TSInvalidIdentityKeyErrorMessage class]]) { + [safetyNumberChanges addObject:object]; } else { DDLogError(@"Unexpected dynamic interaction type: %@", [object class]); OWSAssert(0); } }]; - [[transaction ext:TSUnreadDatabaseViewExtensionName] - enumerateRowsInGroup:thread.uniqueId - usingBlock:^( - NSString *collection, NSString *key, id object, id metadata, NSUInteger index, BOOL *stop) { - if (![object isKindOfClass:[TSIncomingMessage class]]) { - DDLogError(@"Unexpected unread message type: %@", [object class]); - OWSAssert(0); - return; - } - TSIncomingMessage *incomingMessage = (TSIncomingMessage *)object; - if (incomingMessage.wasRead) { - DDLogError(@"Unexpectedly read unread message"); - OWSAssert(0); - return; - } - firstUnreadMessage = incomingMessage; - *stop = YES; - }]; + // IFF this variable is non-null, there are unseen messages in the thread. + __block NSNumber *firstUnseenInteractionTimestamp; + if (firstUnseenInteractionTimestampParameter) { + firstUnseenInteractionTimestamp = firstUnseenInteractionTimestampParameter; + } else { + [[transaction ext:TSUnseenDatabaseViewExtensionName] + enumerateRowsInGroup:thread.uniqueId + usingBlock:^(NSString *collection, + NSString *key, + id object, + id metadata, + NSUInteger index, + BOOL *stop) { + + if (![object isKindOfClass:[TSInteraction class]]) { + DDLogError(@"Unexpected unread message type: %@", [object class]); + OWSAssert(0); + return; + } + OWSAssert(!((id)object).wasRead); + TSInteraction *interaction = (TSInteraction *)object; + firstUnseenInteractionTimestamp = @(interaction.timestampForSorting); + *stop = YES; + }]; + } + [[transaction ext:TSMessageDatabaseViewExtensionName] enumerateRowsInGroup:thread.uniqueId usingBlock:^( @@ -155,8 +168,8 @@ NS_ASSUME_NONNULL_BEGIN if (!firstIncomingMessage) { firstIncomingMessage = incomingMessage; } else { - OWSAssert([[firstIncomingMessage receiptDateForSorting] - compare:[incomingMessage receiptDateForSorting]] + OWSAssert( + [[firstIncomingMessage dateForSorting] compare:[incomingMessage dateForSorting]] == NSOrderedAscending); } } else if ([object isKindOfClass:[TSOutgoingMessage class]]) { @@ -164,8 +177,8 @@ NS_ASSUME_NONNULL_BEGIN if (!firstOutgoingMessage) { firstOutgoingMessage = outgoingMessage; } else { - OWSAssert([[firstOutgoingMessage receiptDateForSorting] - compare:[outgoingMessage receiptDateForSorting]] + OWSAssert( + [[firstOutgoingMessage dateForSorting] compare:[outgoingMessage dateForSorting]] == NSOrderedAscending); } outgoingMessageCount++; @@ -175,10 +188,88 @@ NS_ASSUME_NONNULL_BEGIN } }]; + + // Enumerate in reverse to count the number of unseen messages + // after the unseen messages indicator. + __block long visibleUnseenMessageCount = 0; + __block BOOL hasMoreUnseenMessages = NO; + __block TSInteraction *interactionAfterUnreadIndicator = nil; + NSUInteger missingUnseenSafetyNumberChangeCount = 0; + if (firstUnseenInteractionTimestamp) { + [[transaction ext:TSMessageDatabaseViewExtensionName] + enumerateRowsInGroup:thread.uniqueId + withOptions:NSEnumerationReverse + usingBlock:^(NSString *collection, + NSString *key, + id object, + id metadata, + NSUInteger index, + BOOL *stop) { + + if (![object isKindOfClass:[TSInteraction class]]) { + OWSFail(@"Expected a TSInteraction"); + return; + } + + if ([object isKindOfClass:[TSUnreadIndicatorInteraction class]]) { + // Ignore existing unread indicator, if any. + return; + } + + TSInteraction *interaction = (TSInteraction *)object; + + if (interaction.timestampForSorting + < firstUnseenInteractionTimestamp.unsignedLongLongValue) { + // By default we want the unread indicator to appear just before + // the first unread message. + *stop = YES; + return; + } + + visibleUnseenMessageCount++; + + interactionAfterUnreadIndicator = interaction; + + if (visibleUnseenMessageCount + 1 >= maxRangeSize) { + // If there are more unseen messages than can be displayed in the + // messages view, show the unread indicator at the top of the + // displayed messages. + *stop = YES; + hasMoreUnseenMessages = YES; + } + }]; + + OWSAssert(interactionAfterUnreadIndicator); + + if (hasMoreUnseenMessages) { + NSMutableSet *missingUnseenSafetyNumberChanges = [NSMutableSet set]; + for (TSInvalidIdentityKeyErrorMessage *safetyNumberChange in safetyNumberChanges) { + BOOL isUnseen = safetyNumberChange.timestampForSorting + >= firstUnseenInteractionTimestamp.unsignedLongLongValue; + if (!isUnseen) { + continue; + } + BOOL isMissing + = safetyNumberChange.timestampForSorting < interactionAfterUnreadIndicator.timestampForSorting; + if (!isMissing) { + continue; + } + [missingUnseenSafetyNumberChanges addObject:safetyNumberChange.newIdentityKey]; + } + + missingUnseenSafetyNumberChangeCount = missingUnseenSafetyNumberChanges.count; + } + } + result.firstUnseenInteractionTimestamp = firstUnseenInteractionTimestamp; + if (hasMoreUnseenMessages) { + // The unread indicator is _before_ the last visible unseen message. + result.unreadIndicatorPosition = @(visibleUnseenMessageCount); + } + TSMessage *firstMessage = firstIncomingMessage; if (!firstMessage || (firstOutgoingMessage && - [[firstOutgoingMessage receiptDateForSorting] compare:[firstMessage receiptDateForSorting]] + [[firstOutgoingMessage dateForSorting] compare:[firstMessage dateForSorting]] == NSOrderedAscending)) { firstMessage = firstOutgoingMessage; } @@ -223,7 +314,7 @@ NS_ASSUME_NONNULL_BEGIN BOOL hasOutgoingBeforeIncomingInteraction = (firstOutgoingMessage && (!firstIncomingMessage || - [[firstOutgoingMessage receiptDateForSorting] compare:[firstIncomingMessage receiptDateForSorting]] + [[firstOutgoingMessage dateForSorting] compare:[firstIncomingMessage dateForSorting]] == NSOrderedAscending)); if (hasOutgoingBeforeIncomingInteraction) { // If there is an outgoing message before an incoming message @@ -237,6 +328,7 @@ NS_ASSUME_NONNULL_BEGIN const int kUnreadIndicatorOfferOffset = -1; if (existingBlockOffer && !shouldHaveBlockOffer) { + DDLogInfo(@"Removing block offer"); [existingBlockOffer removeWithTransaction:transaction]; } else if (!existingBlockOffer && shouldHaveBlockOffer) { DDLogInfo(@"Creating block offer for unknown contact"); @@ -244,7 +336,7 @@ NS_ASSUME_NONNULL_BEGIN // We want the block offer to be the first interaction in their // conversation's timeline, so we back-date it to slightly before // the first incoming message (which we know is the first message). - uint64_t blockOfferTimestamp = (uint64_t)((long long)firstMessage.timestamp + kBlockOfferOffset); + uint64_t blockOfferTimestamp = (uint64_t)((long long)firstMessage.timestampForSorting + kBlockOfferOffset); NSString *recipientId = ((TSContactThread *)thread).contactIdentifier; TSMessage *offerMessage = @@ -255,6 +347,7 @@ NS_ASSUME_NONNULL_BEGIN } if (existingAddToContactsOffer && !shouldHaveAddToContactsOffer) { + DDLogInfo(@"Removing 'add to contacts' offer"); [existingAddToContactsOffer removeWithTransaction:transaction]; } else if (!existingAddToContactsOffer && shouldHaveAddToContactsOffer) { @@ -263,7 +356,8 @@ NS_ASSUME_NONNULL_BEGIN // We want the offer to be the first interaction in their // conversation's timeline, so we back-date it to slightly before // the first incoming message (which we know is the first message). - uint64_t offerTimestamp = (uint64_t)((long long)firstMessage.timestamp + kAddToContactsOfferOffset); + uint64_t offerTimestamp + = (uint64_t)((long long)firstMessage.timestampForSorting + kAddToContactsOfferOffset); NSString *recipientId = ((TSContactThread *)thread).contactIdentifier; TSMessage *offerMessage = [OWSAddToContactsOfferMessage addToContactsOfferMessage:offerTimestamp @@ -272,10 +366,12 @@ NS_ASSUME_NONNULL_BEGIN [offerMessage saveWithTransaction:transaction]; } - BOOL shouldHaveUnreadIndicator - = ((firstUnreadMessage != nil || fixedUnreadIndicatorTimestamp != nil) && !hideUnreadMessagesIndicator); + BOOL shouldHaveUnreadIndicator = (interactionAfterUnreadIndicator && !hideUnreadMessagesIndicator); if (!shouldHaveUnreadIndicator) { if (existingUnreadIndicator) { + DDLogInfo(@"%@ Removing obsolete TSUnreadIndicatorInteraction: %@", + self.tag, + existingUnreadIndicator.uniqueId); [existingUnreadIndicator removeWithTransaction:transaction]; } } else { @@ -283,26 +379,27 @@ NS_ASSUME_NONNULL_BEGIN // message in the conversation timeline... // // ...unless we have a fixed timestamp for the unread indicator. - uint64_t indicatorTimestamp = (uint64_t)(fixedUnreadIndicatorTimestamp - ? [fixedUnreadIndicatorTimestamp longLongValue] - : ((long long)firstUnreadMessage.timestamp + kUnreadIndicatorOfferOffset)); + uint64_t indicatorTimestamp = (uint64_t)( + (long long)interactionAfterUnreadIndicator.timestampForSorting + kUnreadIndicatorOfferOffset); - if (indicatorTimestamp && existingUnreadIndicator.timestamp == indicatorTimestamp) { + if (indicatorTimestamp && existingUnreadIndicator.timestampForSorting == indicatorTimestamp) { // Keep the existing indicator; it is in the correct position. - - result.unreadIndicator = existingUnreadIndicator; } else { if (existingUnreadIndicator) { + DDLogInfo(@"%@ Removing TSUnreadIndicatorInteraction due to changed timestamp: %@", + self.tag, + existingUnreadIndicator.uniqueId); [existingUnreadIndicator removeWithTransaction:transaction]; } - DDLogInfo(@"%@ Creating TSUnreadIndicatorInteraction", self.tag); - TSUnreadIndicatorInteraction *indicator = - [[TSUnreadIndicatorInteraction alloc] initWithTimestamp:indicatorTimestamp thread:thread]; + [[TSUnreadIndicatorInteraction alloc] initWithTimestamp:indicatorTimestamp + thread:thread + hasMoreUnseenMessages:hasMoreUnseenMessages + missingUnseenSafetyNumberChangeCount:missingUnseenSafetyNumberChangeCount]; [indicator saveWithTransaction:transaction]; - result.unreadIndicator = indicator; + DDLogInfo(@"%@ Creating TSUnreadIndicatorInteraction: %@", self.tag, indicator.uniqueId); } } }]; diff --git a/Signal/src/views/OWSUnreadIndicatorCell.h b/Signal/src/views/OWSUnreadIndicatorCell.h index 5abb6b231..7b31c05ab 100644 --- a/Signal/src/views/OWSUnreadIndicatorCell.h +++ b/Signal/src/views/OWSUnreadIndicatorCell.h @@ -5,8 +5,15 @@ #import #import +@class TSUnreadIndicatorInteraction; + @interface OWSUnreadIndicatorCell : JSQMessagesCollectionViewCell +@property (nonatomic) TSUnreadIndicatorInteraction *interaction; + - (void)configure; ++ (CGSize)cellSizeForInteraction:(TSUnreadIndicatorInteraction *)interaction + collectionViewWidth:(CGFloat)collectionViewWidth; + @end diff --git a/Signal/src/views/OWSUnreadIndicatorCell.m b/Signal/src/views/OWSUnreadIndicatorCell.m index c2c34c9d9..462164a1d 100644 --- a/Signal/src/views/OWSUnreadIndicatorCell.m +++ b/Signal/src/views/OWSUnreadIndicatorCell.m @@ -3,7 +3,9 @@ // #import "OWSUnreadIndicatorCell.h" +#import "NSBundle+JSQMessages.h" #import "OWSBezierPathView.h" +#import "TSUnreadIndicatorInteraction.h" #import "UIColor+OWS.h" #import "UIFont+OWS.h" #import "UIView+OWS.h" @@ -11,7 +13,8 @@ @interface OWSUnreadIndicatorCell () -@property (nonatomic) UILabel *label; +@property (nonatomic) UILabel *titleLabel; +@property (nonatomic) UILabel *subtitleLabel; @property (nonatomic) OWSBezierPathView *leftPathView; @property (nonatomic) OWSBezierPathView *rightPathView; @@ -30,13 +33,21 @@ { self.backgroundColor = [UIColor whiteColor]; - if (!self.label) { - self.label = [UILabel new]; - self.label.text = NSLocalizedString( - @"MESSAGES_VIEW_UNREAD_INDICATOR", @"Indicator that separates read from unread messages."); - self.label.textColor = [UIColor ows_infoMessageBorderColor]; - self.label.font = [UIFont ows_mediumFontWithSize:12.f]; - [self.contentView addSubview:self.label]; + if (!self.titleLabel) { + self.titleLabel = [UILabel new]; + self.titleLabel.text = [OWSUnreadIndicatorCell titleForInteraction:self.interaction]; + self.titleLabel.textColor = [UIColor ows_infoMessageBorderColor]; + self.titleLabel.font = [OWSUnreadIndicatorCell textFont]; + [self.contentView addSubview:self.titleLabel]; + + self.subtitleLabel = [UILabel new]; + self.subtitleLabel.text = [OWSUnreadIndicatorCell subtitleForInteraction:self.interaction]; + self.subtitleLabel.textColor = [UIColor ows_infoMessageBorderColor]; + self.subtitleLabel.font = [OWSUnreadIndicatorCell textFont]; + self.subtitleLabel.numberOfLines = 0; + self.subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping; + self.subtitleLabel.textAlignment = NSTextAlignmentCenter; + [self.contentView addSubview:self.subtitleLabel]; CGFloat kLineThickness = 0.5f; CGFloat kLineMargin = 5.f; @@ -61,14 +72,106 @@ } } ++ (UIFont *)textFont +{ + return [UIFont ows_mediumFontWithSize:12.f]; +} + ++ (NSString *)titleForInteraction:(TSUnreadIndicatorInteraction *)interaction +{ + return NSLocalizedString(@"MESSAGES_VIEW_UNREAD_INDICATOR", @"Indicator that separates read from unread messages."); +} + ++ (NSString *)subtitleForInteraction:(TSUnreadIndicatorInteraction *)interaction +{ + if (!interaction.hasMoreUnseenMessages) { + return nil; + } + NSString *subtitleFormat = (interaction.missingUnseenSafetyNumberChangeCount > 0 + ? NSLocalizedString(@"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES_FORMAT", + @"Messages that indicates that there are more unseen messages that be revealed by tapping the 'load " + @"earlier messages' button. Embeds {{the name of the 'load earlier messages' button}}") + : NSLocalizedString( + @"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES_AND_SAFETY_NUMBER_CHANGES_FORMAT", + @"Messages that indicates that there are more unseen messages including safety number changes that " + @"be revealed by tapping the 'load earlier messages' button. Embeds {{the name of the 'load earlier " + @"messages' button}}.")); + NSString *loadMoreButtonName = [NSBundle jsq_localizedStringForKey:@"load_earlier_messages"]; + return [NSString stringWithFormat:subtitleFormat, loadMoreButtonName]; +} + ++ (CGFloat)subtitleHMargin +{ + return 20.f; +} + ++ (CGFloat)subtitleVSpacing +{ + return 3.f; +} + ++ (CGFloat)vMargin +{ + return 5.f; +} + - (void)layoutSubviews { [super layoutSubviews]; - [self.label sizeToFit]; - [self.label centerOnSuperview]; - self.leftPathView.frame = CGRectMake(0, 0, self.label.left, self.height); - self.rightPathView.frame = CGRectMake(self.label.right, 0, self.width - self.label.right, self.height); + [self.titleLabel sizeToFit]; + if (self.subtitleLabel.text.length < 1) { + [self.titleLabel centerOnSuperview]; + } else { + CGSize subtitleSize = [self.subtitleLabel + sizeThatFits:CGSizeMake( + self.contentView.width - [OWSUnreadIndicatorCell subtitleHMargin] * 2.f, CGFLOAT_MAX)]; + CGFloat contentHeight + = ceil(self.titleLabel.height) + OWSUnreadIndicatorCell.subtitleVSpacing + ceil(subtitleSize.height); + + self.titleLabel.frame = CGRectMake(round((self.titleLabel.superview.width - self.titleLabel.width) * 0.5f), + round((self.titleLabel.superview.height - contentHeight) * 0.5f), + ceil(self.titleLabel.width), + ceil(self.titleLabel.height)); + self.subtitleLabel.frame = CGRectMake(round((self.titleLabel.superview.width - subtitleSize.width) * 0.5f), + round(self.titleLabel.bottom + OWSUnreadIndicatorCell.subtitleVSpacing), + ceil(subtitleSize.width), + ceil(subtitleSize.height)); + } + + self.leftPathView.frame = CGRectMake(0, self.titleLabel.top, self.titleLabel.left, self.titleLabel.height); + self.rightPathView.frame = CGRectMake( + self.titleLabel.right, self.titleLabel.top, self.width - self.titleLabel.right, self.titleLabel.height); +} + ++ (CGSize)cellSizeForInteraction:(TSUnreadIndicatorInteraction *)interaction + collectionViewWidth:(CGFloat)collectionViewWidth +{ + CGSize result = CGSizeMake(collectionViewWidth, 0); + result.height += self.vMargin * 2.f; + + NSString *title = [self titleForInteraction:interaction]; + NSString *subtitle = [self subtitleForInteraction:interaction]; + + // Creating a UILabel to measure the layout is expensive, but it's the only + // reliable way to do it. Unread indicators should be rare, so this is acceptable. + UILabel *label = [UILabel new]; + label.font = [self textFont]; + label.text = title; + result.height += ceil([label sizeThatFits:CGSizeZero].height); + + if (subtitle.length > 0) { + result.height += self.subtitleVSpacing; + + label.text = subtitle; + // The subtitle may wrap to a second line. + label.lineBreakMode = NSLineBreakByWordWrapping; + label.numberOfLines = 0; + result.height += ceil( + [label sizeThatFits:CGSizeMake(collectionViewWidth - self.subtitleHMargin * 2.f, CGFLOAT_MAX)].height); + } + + return result; } @end diff --git a/Signal/src/views/TSUnreadIndicatorInteraction.h b/Signal/src/views/TSUnreadIndicatorInteraction.h index a408e7de6..c4a3f94a9 100644 --- a/Signal/src/views/TSUnreadIndicatorInteraction.h +++ b/Signal/src/views/TSUnreadIndicatorInteraction.h @@ -8,9 +8,16 @@ NS_ASSUME_NONNULL_BEGIN @interface TSUnreadIndicatorInteraction : TSMessage +@property (atomic, readonly) BOOL hasMoreUnseenMessages; + +@property (atomic, readonly) NSUInteger missingUnseenSafetyNumberChangeCount; + - (instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER; -- (instancetype)initWithTimestamp:(uint64_t)timestamp thread:(TSThread *)thread NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithTimestamp:(uint64_t)timestamp + thread:(TSThread *)thread + hasMoreUnseenMessages:(BOOL)hasMoreUnseenMessages + missingUnseenSafetyNumberChangeCount:(NSUInteger)missingUnseenSafetyNumberChangeCount NS_DESIGNATED_INITIALIZER; @end diff --git a/Signal/src/views/TSUnreadIndicatorInteraction.m b/Signal/src/views/TSUnreadIndicatorInteraction.m index a86c69955..1ed1f05bf 100644 --- a/Signal/src/views/TSUnreadIndicatorInteraction.m +++ b/Signal/src/views/TSUnreadIndicatorInteraction.m @@ -6,6 +6,15 @@ NS_ASSUME_NONNULL_BEGIN +@interface TSUnreadIndicatorInteraction () + +@property (atomic) BOOL hasMoreUnseenMessages; + +- (instancetype)initWithTimestamp:(uint64_t)timestamp thread:(TSThread *)thread NS_DESIGNATED_INITIALIZER; + +@end + + @implementation TSUnreadIndicatorInteraction - (instancetype)initWithCoder:(NSCoder *)coder @@ -13,7 +22,10 @@ NS_ASSUME_NONNULL_BEGIN return [super initWithCoder:coder]; } -- (instancetype)initWithTimestamp:(uint64_t)timestamp thread:(TSThread *)thread +- (instancetype)initWithTimestamp:(uint64_t)timestamp + thread:(TSThread *)thread + hasMoreUnseenMessages:(BOOL)hasMoreUnseenMessages + missingUnseenSafetyNumberChangeCount:(NSUInteger)missingUnseenSafetyNumberChangeCount { self = [super initWithTimestamp:timestamp inThread:thread @@ -26,17 +38,17 @@ NS_ASSUME_NONNULL_BEGIN return self; } + _hasMoreUnseenMessages = hasMoreUnseenMessages; + _missingUnseenSafetyNumberChangeCount = missingUnseenSafetyNumberChangeCount; + return self; } -- (nullable NSDate *)receiptDateForSorting +- (BOOL)shouldUseReceiptDateForSorting { - // Always use date, since we're creating these interactions after the fact - // and back-dating them. - // - // By default [TSMessage receiptDateForSorting] will prefer to use receivedAtDate - // which is not back-dated. - return self.date; + // Use the timestamp, not the "received at" timestamp to sort, + // since we're creating these interactions after the fact and back-dating them. + return NO; } - (BOOL)isDynamicInteraction diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 1d9df0ede..7ef055594 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -670,6 +670,9 @@ /* table cell label in conversation settings */ "LIST_GROUP_MEMBERS_ACTION" = "List Group Members"; +/* No comment provided by engineer. */ +"load_earlier_messages" = "load_earlier_messages"; + /* No comment provided by engineer. */ "LOGGING_SECTION" = "Logging"; @@ -721,6 +724,12 @@ /* Indicator that separates read from unread messages. */ "MESSAGES_VIEW_UNREAD_INDICATOR" = "Unread Messages"; +/* Messages that indicates that there are more unseen messages including safety number changes that be revealed by tapping the 'load earlier messages' button. Embeds {{the name of the 'load earlier messages' button}}. */ +"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES_AND_SAFETY_NUMBER_CHANGES_FORMAT" = "There are more unread messages (including safety number changes) above. Tap \"%@\" to see them."; + +/* Messages that indicates that there are more unseen messages that be revealed by tapping the 'load earlier messages' button. Embeds {{the name of the 'load earlier messages' button}} */ +"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES_FORMAT" = "There are more unread messages above. Tap \"%@\" to see them."; + /* {{number of minutes}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 minutes}}'. See other *_TIME_AMOUNT strings */ "MINUTES_TIME_AMOUNT" = "%u minutes";