Merge branch 'charlesmchen/refineUnseenIndicator'

pull/1/head
Matthew Chen 7 years ago
commit 68d500b8f4

@ -136,7 +136,7 @@ CHECKOUT OPTIONS:
:commit: 7054e4b13ee5bcd6d524adb6dc9a726e8c466308
:git: https://github.com/WhisperSystems/JSQMessagesViewController.git
SignalServiceKit:
:commit: c7cc023541c9c7ee098f08f91949263fe0341625
:commit: 6acfab6a509f5ca4c0f8f25ac8830b496a97838e
:git: https://github.com/WhisperSystems/SignalServiceKit.git
SocketRocket:
:commit: 877ac7438be3ad0b45ef5ca3969574e4b97112bf

@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) TSMessageAdapterType messageType;
@property (nonatomic) BOOL isExpiringMessage;
@property (nonatomic) BOOL shouldStartExpireTimer;
@property (nonatomic) uint64_t expiresAtSeconds;
@property (nonatomic) double expiresAtSeconds;
@property (nonatomic) uint32_t expiresInSeconds;
@property (nonatomic) TSInteraction *interaction;
@ -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];
}

@ -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 <JSQMessagesViewController/JSQMessagesCollectionView.h>
#import <JSQMessagesViewController/JSQMessagesCollectionViewFlowLayout.h>
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];
}
}
@ -132,7 +139,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 +208,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 +217,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,11 +243,21 @@ 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<JSQMessageData>)messageData
{
OWSAssert(messageData);
OWSAssert([messageData conformsToProtocol:@protocol(OWSMessageData)]);
OWSAssert(((id<OWSMessageData>)messageData).interaction);
OWSAssert(((id<OWSMessageData>)messageData).interaction.uniqueId);
return @([messageData messageHash]);
}
@end
NS_ASSUME_NONNULL_END

@ -24,7 +24,7 @@ typedef NS_ENUM(NSInteger, TSMessageAdapterType) {
@property (nonatomic, readonly) TSInteraction *interaction;
@property (nonatomic, readonly) BOOL isExpiringMessage;
@property (nonatomic, readonly) BOOL shouldStartExpireTimer;
@property (nonatomic, readonly) uint64_t expiresAtSeconds;
@property (nonatomic, readonly) double expiresAtSeconds;
@property (nonatomic, readonly) uint32_t expiresInSeconds;
@end

@ -54,10 +54,9 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) TSMessageAdapterType messageType;
@property (nonatomic) BOOL isExpiringMessage;
@property (nonatomic) BOOL shouldStartExpireTimer;
@property (nonatomic) uint64_t expiresAtSeconds;
@property (nonatomic) double expiresAtSeconds;
@property (nonatomic) uint32_t expiresInSeconds;
@property (nonatomic) NSDate *messageDate;
@property (nonatomic) NSString *messageBody;
@property (nonatomic) NSString *interactionUniqueId;
@ -76,14 +75,13 @@ NS_ASSUME_NONNULL_BEGIN
}
_interaction = interaction;
_messageDate = interaction.date;
self.interactionUniqueId = interaction.uniqueId;
if ([interaction isKindOfClass:[TSMessage class]]) {
TSMessage *message = (TSMessage *)interaction;
_isExpiringMessage = message.isExpiringMessage;
_expiresAtSeconds = message.expiresAt / 1000;
_expiresAtSeconds = message.expiresAt / 1000.0;
_expiresInSeconds = message.expiresInSeconds;
_shouldStartExpireTimer = message.shouldStartExpireTimer;
} else {
@ -93,6 +91,18 @@ NS_ASSUME_NONNULL_BEGIN
return self;
}
+ (NSCache *)displayableTextCache
{
static NSCache *cache = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
cache = [NSCache new];
// Cache the results for up to 1,000 messages.
cache.countLimit = 1000;
});
return cache;
}
+ (id<OWSMessageData>)messageViewDataWithInteraction:(TSInteraction *)interaction inThread:(TSThread *)thread contactsManager:(id<ContactsManagerProtocol>)contactsManager
{
TSMessageAdapter *adapter = [[TSMessageAdapter alloc] initWithInteraction:interaction];
@ -138,23 +148,30 @@ NS_ASSUME_NONNULL_BEGIN
if ([attachment isKindOfClass:[TSAttachmentStream class]]) {
TSAttachmentStream *stream = (TSAttachmentStream *)attachment;
if ([attachment.contentType isEqualToString:OWSMimeTypeOversizeTextMessage]) {
NSData *textData = [NSData dataWithContentsOfURL:stream.mediaURL];
NSString *fullText = [[NSString alloc] initWithData:textData encoding:NSUTF8StringEncoding];
// Only show up to 2kb of text.
const NSUInteger kMaxTextDisplayLength = 2 * 1024;
NSString *displayText = [[DisplayableTextFilter new] displayableText:fullText];
if (displayText.length > kMaxTextDisplayLength) {
// Trim whitespace before _AND_ after slicing the snipper from the string.
NSString *snippet =
[[[displayText stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]
NSString *displayableText = [[self displayableTextCache] objectForKey:interaction.uniqueId];
if (!displayableText) {
NSData *textData = [NSData dataWithContentsOfURL:stream.mediaURL];
NSString *fullText = [[NSString alloc] initWithData:textData encoding:NSUTF8StringEncoding];
// Only show up to 2kb of text.
const NSUInteger kMaxTextDisplayLength = 2 * 1024;
displayableText = [[DisplayableTextFilter new] displayableText:fullText];
if (displayableText.length > kMaxTextDisplayLength) {
// Trim whitespace before _AND_ after slicing the snipper from the string.
NSString *snippet = [[[displayableText
stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]
substringWithRange:NSMakeRange(0, kMaxTextDisplayLength)]
stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
displayText =
[NSString stringWithFormat:NSLocalizedString(@"OVERSIZE_TEXT_DISPLAY_FORMAT",
@"A display format for oversize text messages."),
snippet];
displayableText =
[NSString stringWithFormat:NSLocalizedString(@"OVERSIZE_TEXT_DISPLAY_FORMAT",
@"A display format for oversize text messages."),
snippet];
}
if (!displayableText) {
displayableText = @"";
}
[[self displayableTextCache] setObject:displayableText forKey:interaction.uniqueId];
}
adapter.messageBody = displayText;
adapter.messageBody = displayableText;
} else if ([stream isAnimated]) {
adapter.mediaItem =
[[TSAnimatedAdapter alloc] initWithAttachment:stream incoming:isIncomingAttachment];
@ -188,6 +205,16 @@ NS_ASSUME_NONNULL_BEGIN
NSStringFromClass([attachment class]));
}
}
} else {
NSString *displayableText = [[self displayableTextCache] objectForKey:interaction.uniqueId];
if (!displayableText) {
displayableText = [[DisplayableTextFilter new] displayableText:message.body];
if (!displayableText) {
displayableText = @"";
}
[[self displayableTextCache] setObject:displayableText forKey:interaction.uniqueId];
}
adapter.messageBody = displayableText;
}
} else if ([interaction isKindOfClass:[TSCall class]]) {
TSCall *callRecord = (TSCall *)interaction;
@ -246,7 +273,7 @@ NS_ASSUME_NONNULL_BEGIN
}
- (NSDate *)date {
return self.messageDate;
return self.interaction.dateForSorting;
}
#pragma mark - OWSMessageEditing Protocol

@ -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);
}

@ -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 messages 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,6 +103,8 @@ typedef enum : NSUInteger {
kMediaTypeVideo,
} kMediaTypes;
#pragma mark -
@protocol OWSTextViewPasteDelegate <NSObject>
- (void)didPasteAttachment:(SignalAttachment * _Nullable)attachment;
@ -761,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];
}
@ -843,6 +867,7 @@ typedef enum : NSUInteger {
// invalidate layout
[self.collectionView.collectionViewLayout
invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
self.collectionView.collectionViewLayout.bubbleSizeCalculator = [[OWSMessagesBubblesSizeCalculator alloc] init];
}
}
@ -886,15 +911,7 @@ typedef enum : NSUInteger {
name:YapDatabaseModifiedNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(startReadTimer)
name:UIApplicationWillEnterForegroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(startExpirationTimerAnimations)
name:UIApplicationWillEnterForegroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(resetContentAndLayout)
selector:@selector(applicationWillEnterForeground:)
name:UIApplicationWillEnterForegroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
@ -924,6 +941,14 @@ typedef enum : NSUInteger {
}
}
- (void)applicationWillEnterForeground:(NSNotification *)notification
{
[self resetContentAndLayout];
[self startReadTimer];
[self startExpirationTimerAnimations];
[self ensureThreadOffersAndIndicators];
}
- (void)applicationWillResignActive:(NSNotification *)notification
{
[self cancelVoiceMemo];
@ -944,6 +969,16 @@ typedef enum : NSUInteger {
- (void)viewWillAppear:(BOOL)animated
{
// 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];
// In case we're dismissing a CNContactViewController which requires default system appearance
@ -958,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 =
@ -988,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
@ -1026,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];
}
@ -1516,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
@ -1851,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;
@ -1937,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;
@ -2484,25 +2542,69 @@ 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 updateRangeOptionsForPage:self.page];
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 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];
[[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) {
@ -2513,48 +2615,14 @@ typedef enum : NSUInteger {
return show;
}
- (NSUInteger)scrollToItem {
__block NSUInteger item =
kYapDatabaseRangeLength * (self.page + 1) - [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSUInteger numberOfVisibleMessages = [self.messageMappings numberOfItemsInGroup:self.thread.uniqueId];
NSUInteger numberOfTotalMessages =
[[transaction ext:TSMessageDatabaseViewExtensionName] numberOfItemsInGroup:self.thread.uniqueId];
NSUInteger numberOfMessagesToLoad = numberOfTotalMessages - numberOfVisibleMessages;
BOOL canLoadFullRange = numberOfMessagesToLoad >= kYapDatabaseRangeLength;
if (!canLoadFullRange) {
item = numberOfMessagesToLoad;
}
}];
return item == 0 ? item : item - 1;
}
- (void)updateLoadEarlierVisible {
[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];
@ -2859,15 +2927,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
@ -3326,55 +3397,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];
}
}];
}
@ -3392,26 +3464,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<OWSMessageData>)messageAtIndexPath:(NSIndexPath *)indexPath

@ -336,7 +336,7 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS
#pragma mark - startup
- (void)displayAnyUnseenUpgradeExperience
- (NSArray<ExperienceUpgrade *> *)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<ExperienceUpgrade *> *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 {
}

@ -47,6 +47,7 @@ NSString *const kTSStorageManager_AccountLastNames = @"kTSStorageManager_Account
return self;
}
// TODO: We need to configure the limits of this cache.
_avatarCache = [NSCache new];
_allContacts = @[];
_signalAccountMap = @{};

@ -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

@ -86,27 +86,27 @@ 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];
[storageManager.dbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
const int kMaxBlockOfferOutgoingMessageCount = 10;
// Find any existing "dynamic" interactions.
__block OWSAddToContactsOfferMessage *existingAddToContactsOffer = nil;
__block OWSUnknownContactBlockOfferMessage *existingBlockOffer = nil;
__block TSUnreadIndicatorInteraction *existingUnreadIndicator = nil;
__block TSIncomingMessage *firstIncomingMessage = nil;
__block TSOutgoingMessage *firstOutgoingMessage = nil;
__block TSIncomingMessage *firstUnreadMessage = nil;
__block long outgoingMessageCount = 0;
[[transaction ext:TSMessageDatabaseViewExtensionName]
// We use different views for performance reasons.
[[transaction ext:TSDynamicMessagesDatabaseViewExtensionName]
enumerateRowsInGroup:thread.uniqueId
usingBlock:^(
NSString *collection, NSString *key, id object, id metadata, NSUInteger index, BOOL *stop) {
@ -120,43 +120,171 @@ NS_ASSUME_NONNULL_BEGIN
} else if ([object isKindOfClass:[TSUnreadIndicatorInteraction class]]) {
OWSAssert(!existingUnreadIndicator);
existingUnreadIndicator = (TSUnreadIndicatorInteraction *)object;
} else if ([object isKindOfClass:[TSIncomingMessage class]]) {
} else {
DDLogError(@"Unexpected dynamic interaction type: %@", [object class]);
OWSAssert(0);
}
}];
// Find any existing safety number changes.
//
// We use different views for performance reasons.
NSMutableArray<TSInvalidIdentityKeyErrorMessage *> *blockingSafetyNumberChanges = [NSMutableArray new];
NSMutableArray<TSInteraction *> *nonBlockingSafetyNumberChanges = [NSMutableArray new];
[[transaction ext:TSSafetyNumberChangeDatabaseViewExtensionName]
enumerateRowsInGroup:thread.uniqueId
usingBlock:^(
NSString *collection, NSString *key, id object, id metadata, NSUInteger index, BOOL *stop) {
if ([object isKindOfClass:[TSInvalidIdentityKeyErrorMessage class]]) {
[blockingSafetyNumberChanges addObject:object];
} else if ([object isKindOfClass:[TSErrorMessage class]]) {
TSErrorMessage *errorMessage = (TSErrorMessage *)object;
OWSAssert(errorMessage.errorType == TSErrorMessageNonBlockingIdentityChange);
[nonBlockingSafetyNumberChanges addObject:object];
} else {
DDLogError(@"Unexpected interaction type: %@", [object class]);
OWSAssert(0);
}
}];
// Determine if there are "unread" messages in this conversation.
// If we've been passed a firstUnseenInteractionTimestampParameter,
// just use that value in order to preserve continuity of the
// unread messages indicator after all messages in the conversation
// have been marked as read.
//
// IFF this variable is non-null, there are unseen messages in the thread.
__block NSNumber *firstUnseenInteractionTimestamp = nil;
if (firstUnseenInteractionTimestampParameter) {
firstUnseenInteractionTimestamp = firstUnseenInteractionTimestampParameter;
} else {
TSInteraction *firstUnseenInteraction =
[[transaction ext:TSUnseenDatabaseViewExtensionName] firstObjectInGroup:thread.uniqueId];
if (firstUnseenInteraction) {
firstUnseenInteractionTimestamp = @(firstUnseenInteraction.timestampForSorting);
}
}
__block TSIncomingMessage *firstIncomingMessage = nil;
__block TSOutgoingMessage *firstOutgoingMessage = nil;
__block long outgoingMessageCount = 0;
[[transaction ext:TSMessageDatabaseViewExtensionName]
enumerateRowsInGroup:thread.uniqueId
usingBlock:^(
NSString *collection, NSString *key, id object, id metadata, NSUInteger index, BOOL *stop) {
if ([object isKindOfClass:[TSIncomingMessage class]]) {
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)object;
if (!firstIncomingMessage) {
firstIncomingMessage = incomingMessage;
} else {
OWSAssert([[firstIncomingMessage receiptDateForSorting]
compare:[incomingMessage receiptDateForSorting]]
== NSOrderedAscending);
}
if (!incomingMessage.wasRead) {
if (!firstUnreadMessage) {
firstUnreadMessage = incomingMessage;
} else {
OWSAssert([[firstUnreadMessage receiptDateForSorting]
compare:[incomingMessage receiptDateForSorting]]
== NSOrderedAscending);
}
OWSAssert(
[firstIncomingMessage compareForSorting:incomingMessage] == NSOrderedAscending);
}
} else if ([object isKindOfClass:[TSOutgoingMessage class]]) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)object;
if (!firstOutgoingMessage) {
firstOutgoingMessage = outgoingMessage;
} else {
OWSAssert([[firstOutgoingMessage receiptDateForSorting]
compare:[outgoingMessage receiptDateForSorting]]
== NSOrderedAscending);
OWSAssert(
[firstOutgoingMessage compareForSorting:outgoingMessage] == NSOrderedAscending);
}
outgoingMessageCount++;
if (outgoingMessageCount >= kMaxBlockOfferOutgoingMessageCount) {
*stop = YES;
}
}
}];
// Enumerate in reverse to count the number of messages
// after the unseen messages indicator. Not all of
// them are unnecessarily unread, but we need to tell
// the messages view the position of the unread indicator,
// so that it can widen its "load window" to always show
// the unread 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<NSData *> *missingUnseenSafetyNumberChanges = [NSMutableSet set];
for (TSInvalidIdentityKeyErrorMessage *safetyNumberChange in blockingSafetyNumberChanges) {
BOOL isUnseen = safetyNumberChange.timestampForSorting
>= firstUnseenInteractionTimestamp.unsignedLongLongValue;
if (!isUnseen) {
continue;
}
BOOL isMissing
= safetyNumberChange.timestampForSorting < interactionAfterUnreadIndicator.timestampForSorting;
if (!isMissing) {
continue;
}
[missingUnseenSafetyNumberChanges addObject:safetyNumberChange.newIdentityKey];
}
// Count the de-duplicated "blocking" safety number changes and all
// of the "non-blocking" safety number changes.
missingUnseenSafetyNumberChangeCount
= (missingUnseenSafetyNumberChanges.count + nonBlockingSafetyNumberChanges.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]]
== NSOrderedAscending)) {
|| (firstOutgoingMessage && [firstOutgoingMessage compareForSorting:firstMessage] == NSOrderedAscending)) {
firstMessage = firstOutgoingMessage;
}
@ -200,8 +328,7 @@ NS_ASSUME_NONNULL_BEGIN
BOOL hasOutgoingBeforeIncomingInteraction = (firstOutgoingMessage
&& (!firstIncomingMessage ||
[[firstOutgoingMessage receiptDateForSorting] compare:[firstIncomingMessage receiptDateForSorting]]
== NSOrderedAscending));
[firstOutgoingMessage compareForSorting:firstIncomingMessage] == NSOrderedAscending));
if (hasOutgoingBeforeIncomingInteraction) {
// If there is an outgoing message before an incoming message
// the local user initiated this conversation, don't show a block offer.
@ -214,6 +341,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");
@ -221,7 +349,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 =
@ -232,6 +360,7 @@ NS_ASSUME_NONNULL_BEGIN
}
if (existingAddToContactsOffer && !shouldHaveAddToContactsOffer) {
DDLogInfo(@"Removing 'add to contacts' offer");
[existingAddToContactsOffer removeWithTransaction:transaction];
} else if (!existingAddToContactsOffer && shouldHaveAddToContactsOffer) {
@ -240,7 +369,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
@ -249,37 +379,40 @@ 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 {
// We want the block offer to appear just before the first unread incoming
// We want the unread indicator to appear just before the first unread incoming
// 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);
}
}
}];

@ -1,5 +1,6 @@
// Created by Michael Kirk on 9/29/16.
// Copyright © 2016 Open Whisper Systems. All rights reserved.
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@ -12,7 +13,7 @@ static const CGFloat OWSExpirableMessageViewTimerWidth = 10.0f;
@property (strong, nonatomic, readonly) IBOutlet OWSExpirationTimerView *expirationTimerView;
@property (strong, nonatomic, readonly) IBOutlet NSLayoutConstraint *expirationTimerViewWidthConstraint;
- (void)startExpirationTimerWithExpiresAtSeconds:(uint64_t)expiresAtSeconds
- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds
initialDurationSeconds:(uint32_t)initialDurationSeconds;
- (void)stopExpirationTimer;

@ -1,5 +1,6 @@
// Created by Michael Kirk on 9/29/16.
// Copyright © 2016 Open Whisper Systems. All rights reserved.
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import <UIKit/UIKit.h>
@ -7,8 +8,7 @@ NS_ASSUME_NONNULL_BEGIN
@interface OWSExpirationTimerView : UIView
- (void)startTimerWithExpiresAtSeconds:(uint64_t)expiresAtSeconds
initialDurationSeconds:(uint32_t)initialDurationSeconds;
- (void)startTimerWithExpiresAtSeconds:(double)expiresAtSeconds initialDurationSeconds:(uint32_t)initialDurationSeconds;
- (void)stopTimer;

@ -1,5 +1,6 @@
// Created by Michael Kirk on 9/29/16.
// Copyright © 2016 Open Whisper Systems. All rights reserved.
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "OWSExpirationTimerView.h"
#import "MessagesViewController.h"
@ -14,7 +15,7 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2;
@interface OWSExpirationTimerView ()
@property (nonatomic) uint32_t initialDurationSeconds;
@property (atomic) uint64_t expiresAtSeconds;
@property (atomic) double expiresAtSeconds;
@property (nonatomic, readonly) UIImageView *emptyHourglassImageView;
@property (nonatomic, readonly) UIImageView *fullHourglassImageView;
@ -91,8 +92,7 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2;
[self startAnimation];
}
- (void)startTimerWithExpiresAtSeconds:(uint64_t)expiresAtSeconds
initialDurationSeconds:(uint32_t)initialDurationSeconds
- (void)startTimerWithExpiresAtSeconds:(double)expiresAtSeconds initialDurationSeconds:(uint32_t)initialDurationSeconds
{
if (expiresAtSeconds == 0) {
DDLogWarn(
@ -101,7 +101,7 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2;
initialDurationSeconds);
}
DDLogVerbose(@"%@ Starting timer with expiresAtSeconds: %llu initialDurationSeconds: %d",
DDLogVerbose(@"%@ Starting timer with expiresAtSeconds: %f initialDurationSeconds: %d",
self.logTag,
expiresAtSeconds,
initialDurationSeconds);
@ -117,12 +117,12 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2;
- (void)startAnimation
{
DDLogVerbose(@"%@ Starting animation with expiresAtSeconds: %llu initialDurationSeconds: %d",
DDLogVerbose(@"%@ Starting animation with expiresAtSeconds: %f initialDurationSeconds: %d",
self.logTag,
self.expiresAtSeconds,
self.initialDurationSeconds);
double secondsLeft = (double)self.expiresAtSeconds - [NSDate new].timeIntervalSince1970;
double secondsLeft = self.expiresAtSeconds - [NSDate new].timeIntervalSince1970;
if (secondsLeft < 0) {
secondsLeft = 0;
@ -186,7 +186,7 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2;
- (BOOL)itIsTimeToBlink
{
double secondsLeft = (double)self.expiresAtSeconds - [NSDate new].timeIntervalSince1970;
double secondsLeft = self.expiresAtSeconds - [NSDate new].timeIntervalSince1970;
return secondsLeft <= OWSExpirationTimerViewBlinkingSeconds;
}

@ -55,7 +55,7 @@ NS_ASSUME_NONNULL_BEGIN
// pragma mark - OWSExpirableMessageView
- (void)startExpirationTimerWithExpiresAtSeconds:(uint64_t)expiresAtSeconds
- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds
initialDurationSeconds:(uint32_t)initialDurationSeconds
{
self.expirationTimerViewWidthConstraint.constant = OWSExpirableMessageViewTimerWidth;

@ -61,7 +61,7 @@ NS_ASSUME_NONNULL_BEGIN
// pragma mark - OWSExpirableMessageView
- (void)startExpirationTimerWithExpiresAtSeconds:(uint64_t)expiresAtSeconds
- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds
initialDurationSeconds:(uint32_t)initialDurationSeconds
{
self.expirationTimerViewWidthConstraint.constant = OWSExpirableMessageViewTimerWidth;

@ -5,8 +5,15 @@
#import <JSQMessagesViewController/JSQMessagesCollectionViewCell.h>
#import <UIKit/UIKit.h>
@class TSUnreadIndicatorInteraction;
@interface OWSUnreadIndicatorCell : JSQMessagesCollectionViewCell
@property (nonatomic) TSUnreadIndicatorInteraction *interaction;
- (void)configure;
+ (CGSize)cellSizeForInteraction:(TSUnreadIndicatorInteraction *)interaction
collectionViewWidth:(CGFloat)collectionViewWidth;
@end

@ -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

@ -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

@ -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,22 @@ NS_ASSUME_NONNULL_BEGIN
return self;
}
_hasMoreUnseenMessages = hasMoreUnseenMessages;
_missingUnseenSafetyNumberChangeCount = missingUnseenSafetyNumberChangeCount;
return self;
}
- (nullable NSDate *)receiptDateForSorting
- (BOOL)shouldUseReceiptDateForSorting
{
// 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
{
// 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;
return YES;
}
@end

@ -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";

Loading…
Cancel
Save