From c2f07bb3d8c9ca47281ef7322b7694d36dd715b7 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 11 Oct 2017 23:03:28 -0400 Subject: [PATCH] Restore message cell footers. // FREEBIE --- Signal.xcodeproj/project.pbxproj | 12 + Signal/src/UIView+OWS.h | 6 +- Signal/src/UIView+OWS.m | 35 ++- .../Cells/ConversationViewCell.h | 4 + .../Cells/OWSExpirationTimerView.h | 4 +- .../Cells/OWSExpirationTimerView.m | 259 +++++++++--------- .../Cells/OWSIncomingMessageCell.h | 2 - .../Cells/OWSIncomingMessageCell.m | 58 ---- .../ConversationView/Cells/OWSMessageCell.h | 16 -- .../ConversationView/Cells/OWSMessageCell.m | 210 +++++++++++--- .../Cells/OWSOutgoingMessageCell.m | 30 -- .../ConversationViewController.h | 3 - .../ConversationViewController.m | 104 ++++--- .../ConversationView/ConversationViewItem.h | 4 +- .../ConversationView/ConversationViewItem.m | 3 +- .../MessageMetadataViewController.swift | 71 +---- .../Utils/MessageRecipientState.swift | 135 +++++++++ 17 files changed, 560 insertions(+), 396 deletions(-) create mode 100644 Signal/src/ViewControllers/Utils/MessageRecipientState.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index e81d232c5..5c51a1466 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -101,6 +101,7 @@ 34D1F0B71F87F8850066283D /* OWSGenericAttachmentView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0B61F87F8850066283D /* OWSGenericAttachmentView.m */; }; 34D1F0BA1F8800D90066283D /* OWSAudioMessageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0B91F8800D90066283D /* OWSAudioMessageView.m */; }; 34D1F0BD1F8D108C0066283D /* AttachmentUploadView.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0BC1F8D108C0066283D /* AttachmentUploadView.m */; }; + 34D1F0C01F8EC1760066283D /* MessageRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0BF1F8EC1760066283D /* MessageRecipientState.swift */; }; 34D5CC961EA6AFAD005515DB /* OWSContactsSyncing.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CC951EA6AFAD005515DB /* OWSContactsSyncing.m */; }; 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */; }; 34D5CCB11EAE7E7F005515DB /* SelectRecipientViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCB01EAE7E7F005515DB /* SelectRecipientViewController.m */; }; @@ -568,6 +569,7 @@ 34D1F0B91F8800D90066283D /* OWSAudioMessageView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSAudioMessageView.m; sourceTree = ""; }; 34D1F0BB1F8D108C0066283D /* AttachmentUploadView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AttachmentUploadView.h; sourceTree = ""; }; 34D1F0BC1F8D108C0066283D /* AttachmentUploadView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AttachmentUploadView.m; sourceTree = ""; }; + 34D1F0BF1F8EC1760066283D /* MessageRecipientState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRecipientState.swift; sourceTree = ""; }; 34D5CC941EA6AFAD005515DB /* OWSContactsSyncing.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSContactsSyncing.h; sourceTree = ""; }; 34D5CC951EA6AFAD005515DB /* OWSContactsSyncing.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSContactsSyncing.m; sourceTree = ""; }; 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AvatarViewHelper.h; sourceTree = ""; }; @@ -1094,6 +1096,7 @@ 3400C7981EAFB772008A8584 /* ThreadViewHelper.m */, 340CB2251EAC25820001CAA1 /* UpdateGroupViewController.h */, 340CB2261EAC25820001CAA1 /* UpdateGroupViewController.m */, + 34D1F0BE1F8EC1760066283D /* Utils */, 34B3F8A01E8EA6040035BE1A /* ViewControllerUtils.h */, 34B3F8A11E8EA6040035BE1A /* ViewControllerUtils.m */, ); @@ -1176,6 +1179,14 @@ path = Cells; sourceTree = ""; }; + 34D1F0BE1F8EC1760066283D /* Utils */ = { + isa = PBXGroup; + children = ( + 34D1F0BF1F8EC1760066283D /* MessageRecipientState.swift */, + ); + path = Utils; + sourceTree = ""; + }; 34D8C0221ED3673300188D7C /* DebugUI */ = { isa = PBXGroup; children = ( @@ -2335,6 +2346,7 @@ 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 34B3F8811E8DF1700035BE1A /* LockInteractionController.m in Sources */, 34CCAF3B1F0C2748004084F4 /* OWSAddToContactViewController.m in Sources */, + 34D1F0C01F8EC1760066283D /* MessageRecipientState.swift in Sources */, 45F659731E1BD99C00444429 /* CallKitCallUIAdaptee.swift in Sources */, 45BB93381E688E14001E3939 /* UIDevice+featureSupport.swift in Sources */, 458DE9D61DEE3FD00071BB03 /* PeerConnectionClient.swift in Sources */, diff --git a/Signal/src/UIView+OWS.h b/Signal/src/UIView+OWS.h index a5a05cc06..d8287e78b 100644 --- a/Signal/src/UIView+OWS.h +++ b/Signal/src/UIView+OWS.h @@ -29,8 +29,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value); - (NSArray *)autoPinToSuperviewEdges; -- (void)autoHCenterInSuperview; -- (void)autoVCenterInSuperview; +- (NSLayoutConstraint *)autoHCenterInSuperview; +- (NSLayoutConstraint *)autoVCenterInSuperview; - (void)autoPinWidthToWidthOfView:(UIView *)view; - (void)autoPinHeightToHeightOfView:(UIView *)view; @@ -85,6 +85,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value); - (NSLayoutConstraint *)autoPinTrailingToSuperviewWithMargin:(CGFloat)margin; - (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view; - (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view margin:(CGFloat)margin; +- (NSLayoutConstraint *)autoPinTrailingToLeadingOfView:(UIView *)view; +- (NSLayoutConstraint *)autoPinTrailingToLeadingOfView:(UIView *)view margin:(CGFloat)margin; - (NSLayoutConstraint *)autoPinLeadingToView:(UIView *)view; - (NSLayoutConstraint *)autoPinLeadingToView:(UIView *)view margin:(CGFloat)margin; - (NSLayoutConstraint *)autoPinTrailingToView:(UIView *)view; diff --git a/Signal/src/UIView+OWS.m b/Signal/src/UIView+OWS.m index 81afc13dd..67e1ad0fa 100644 --- a/Signal/src/UIView+OWS.m +++ b/Signal/src/UIView+OWS.m @@ -89,14 +89,14 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value) return result; } -- (void)autoHCenterInSuperview +- (NSLayoutConstraint *)autoHCenterInSuperview { - [self autoAlignAxis:ALAxisVertical toSameAxisOfView:self.superview]; + return [self autoAlignAxis:ALAxisVertical toSameAxisOfView:self.superview]; } -- (void)autoVCenterInSuperview +- (NSLayoutConstraint *)autoVCenterInSuperview { - [self autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.superview]; + return [self autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.superview]; } - (void)autoPinWidthToWidthOfView:(UIView *)view @@ -302,17 +302,17 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value) - (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view { OWSAssert(view); - + return [self autoPinLeadingToTrailingOfView:view margin:0]; } - (NSLayoutConstraint *)autoPinLeadingToTrailingOfView:(UIView *)view margin:(CGFloat)margin { OWSAssert(view); - + if (SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(9, 0)) { NSLayoutConstraint *constraint = - [self.leadingAnchor constraintEqualToAnchor:view.trailingAnchor constant:margin]; + [self.leadingAnchor constraintEqualToAnchor:view.trailingAnchor constant:margin]; constraint.active = YES; return constraint; } else { @@ -320,6 +320,27 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value) } } +- (NSLayoutConstraint *)autoPinTrailingToLeadingOfView:(UIView *)view +{ + OWSAssert(view); + + return [self autoPinTrailingToLeadingOfView:view margin:0]; +} + +- (NSLayoutConstraint *)autoPinTrailingToLeadingOfView:(UIView *)view margin:(CGFloat)margin +{ + OWSAssert(view); + + if (SYSTEM_VERSION_GREATER_THAN_OR_EQUAL_TO(9, 0)) { + NSLayoutConstraint *constraint = + [self.trailingAnchor constraintEqualToAnchor:view.leadingAnchor constant:-margin]; + constraint.active = YES; + return constraint; + } else { + return [self autoPinEdge:ALEdgeTrailing toEdge:ALEdgeLeading ofView:view withOffset:-margin]; + } +} + - (NSLayoutConstraint *)autoPinLeadingToView:(UIView *)view { OWSAssert(view); diff --git a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h index 2cae4e9f4..20f83a0a6 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h @@ -39,6 +39,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)tappedAddToContactsOfferMessage:(OWSContactOffersInteraction *)interaction; - (void)tappedAddToProfileWhitelistOfferMessage:(OWSContactOffersInteraction *)interaction; +#pragma mark - Formatting + +- (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId; + @end #pragma mark - diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.h index e0cf596d0..f3f760eb4 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.h @@ -6,9 +6,11 @@ NS_ASSUME_NONNULL_BEGIN +extern const CGFloat kExpirationTimerViewSize; + @interface OWSExpirationTimerView : UIView -- (void)startTimerWithExpiresAtSeconds:(double)expiresAtSeconds initialDurationSeconds:(uint32_t)initialDurationSeconds; +- (void)startTimerWithExpiration:(uint64_t)expirationTimestamp initialDurationSeconds:(uint32_t)initialDurationSeconds; - (void)stopTimer; diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.m index 85d2a5f7b..59eff2c8f 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSExpirationTimerView.m @@ -4,24 +4,31 @@ #import "OWSExpirationTimerView.h" #import "ConversationViewController.h" +#import "NSDate+OWS.h" +#import "OWSMath.h" #import "UIColor+OWS.h" -#import +#import "UIView+OWS.h" +#import +#import NS_ASSUME_NONNULL_BEGIN -double const OWSExpirationTimerViewBlinkingSeconds = 2; +const CGFloat kExpirationTimerViewSize = 22.f; @interface OWSExpirationTimerView () @property (nonatomic) uint32_t initialDurationSeconds; -@property (atomic) double expiresAtSeconds; +@property (nonatomic) uint64_t expirationTimestamp; @property (nonatomic, readonly) UIImageView *emptyHourglassImageView; @property (nonatomic, readonly) UIImageView *fullHourglassImageView; -@property CGFloat ratioRemaining; +@property (nonatomic, nullable) CAGradientLayer *maskLayer; +@property (nonatomic, nullable) NSTimer *animationTimer; @end +#pragma mark - + @implementation OWSExpirationTimerView - (void)dealloc @@ -36,17 +43,7 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2; return self; } - self.clipsToBounds = YES; - - _emptyHourglassImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"ic_hourglass_empty"]]; - _emptyHourglassImageView.tintColor = [UIColor ows_blackColor]; - [self insertSubview:_emptyHourglassImageView atIndex:0]; - - _fullHourglassImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"ic_hourglass_full"]]; - _fullHourglassImageView.tintColor = [UIColor ows_darkGrayColor]; - [self insertSubview:_fullHourglassImageView atIndex:1]; - - _ratioRemaining = 1.0f; + [self commonInit]; return self; } @@ -58,151 +55,151 @@ double const OWSExpirationTimerViewBlinkingSeconds = 2; return self; } - self.clipsToBounds = YES; - - _emptyHourglassImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"ic_hourglass_empty"]]; - _emptyHourglassImageView.tintColor = [UIColor lightGrayColor]; - [self insertSubview:_emptyHourglassImageView atIndex:1]; - - _fullHourglassImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"ic_hourglass_full"]]; - _fullHourglassImageView.tintColor = [UIColor lightGrayColor]; - [self insertSubview:_fullHourglassImageView atIndex:0]; - - _ratioRemaining = 1.0f; + [self commonInit]; return self; } -- (void)layoutSubviews +- (void)commonInit { - CGFloat leftMargin = 0.0f; - CGFloat padding = 6.0f; - CGRect hourglassFrame - = CGRectMake(leftMargin, padding / 2, self.frame.size.height - padding, self.frame.size.height - padding); - self.emptyHourglassImageView.frame = hourglassFrame; - self.emptyHourglassImageView.bounds = hourglassFrame; - self.fullHourglassImageView.frame = hourglassFrame; - self.fullHourglassImageView.bounds = hourglassFrame; + self.clipsToBounds = YES; + + UIImage *hourglassEmptyImage = [[UIImage imageNamed:@"ic_hourglass_empty"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + UIImage *hourglassFullImage = [[UIImage imageNamed:@"ic_hourglass_full"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + _emptyHourglassImageView = [[UIImageView alloc] initWithImage:hourglassEmptyImage]; + self.emptyHourglassImageView.tintColor = [UIColor lightGrayColor]; + [self addSubview:self.emptyHourglassImageView]; + + _fullHourglassImageView = [[UIImageView alloc] initWithImage:hourglassFullImage]; + self.fullHourglassImageView.tintColor = [UIColor lightGrayColor]; + [self addSubview:self.fullHourglassImageView]; + + [self.emptyHourglassImageView autoPinHeightToSuperviewWithMargin:3.f]; + [self.emptyHourglassImageView autoHCenterInSuperview]; + [self.emptyHourglassImageView autoPinToSquareAspectRatio]; + [self.fullHourglassImageView autoPinHeightToSuperviewWithMargin:3.f]; + [self.fullHourglassImageView autoHCenterInSuperview]; + [self.fullHourglassImageView autoPinToSquareAspectRatio]; + [self autoSetDimension:ALDimensionWidth toSize:kExpirationTimerViewSize]; + [self autoSetDimension:ALDimensionHeight toSize:kExpirationTimerViewSize]; } -- (void)handleReappearNotification:(NSNotification *)notification +- (void)startTimerWithExpiration:(uint64_t)expirationTimestamp initialDurationSeconds:(uint32_t)initialDurationSeconds { - DDLogVerbose(@"%@ handleReappearNotification", self.logTag); - [self startAnimation]; + OWSAssert([NSThread isMainThread]); + + self.expirationTimestamp = expirationTimestamp; + self.initialDurationSeconds = initialDurationSeconds; + + [self ensureAnimations]; } -- (void)startTimerWithExpiresAtSeconds:(double)expiresAtSeconds initialDurationSeconds:(uint32_t)initialDurationSeconds +- (void)clearAnimations { - if (expiresAtSeconds == 0) { - DDLogWarn( - @"%@ Asked to animate expiration for message which hasn't started expiring. intitialDurationSeconds:%u", - self.logTag, - initialDurationSeconds); - } + [self.maskLayer removeAllAnimations]; + [self.maskLayer removeFromSuperlayer]; + self.maskLayer = nil; + [self.fullHourglassImageView.layer.mask removeFromSuperlayer]; + self.fullHourglassImageView.layer.mask = nil; + [self.layer removeAllAnimations]; + self.layer.opacity = 1.f; + self.emptyHourglassImageView.hidden = YES; + self.fullHourglassImageView.hidden = YES; + [self.animationTimer invalidate]; + self.animationTimer = nil; +} - DDLogVerbose(@"%@ Starting timer with expiresAtSeconds: %f initialDurationSeconds: %d", - self.logTag, - expiresAtSeconds, - initialDurationSeconds); +- (void)setFrame:(CGRect)frame { + BOOL sizeDidChange = CGSizeEqualToSize(self.frame.size, frame.size); + [super setFrame:frame]; + if (sizeDidChange) { + [self ensureAnimations]; + } +} - self.expiresAtSeconds = expiresAtSeconds; - self.initialDurationSeconds = initialDurationSeconds; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleReappearNotification:) - name:ConversationViewControllerDidAppearNotification - object:nil]; - [self startAnimation]; +- (void)setBounds:(CGRect)bounds { + BOOL sizeDidChange = CGSizeEqualToSize(self.bounds.size, bounds.size); + [super setBounds:bounds]; + if (sizeDidChange) { + [self ensureAnimations]; + } } -- (void)startAnimation +- (void)ensureAnimations { - DDLogVerbose(@"%@ Starting animation with expiresAtSeconds: %f initialDurationSeconds: %d", - self.logTag, - self.expiresAtSeconds, - self.initialDurationSeconds); + OWSAssert([NSThread isMainThread]); + + CGFloat secondsLeft = MAX(0, (self.expirationTimestamp - [NSDate ows_millisecondTimeStamp]) / 1000.f); - double secondsLeft = self.expiresAtSeconds - [NSDate new].timeIntervalSince1970; + [self clearAnimations]; + + const NSTimeInterval kBlinkAnimationDurationSeconds = 2; - if (secondsLeft < 0) { - secondsLeft = 0; + if (self.expirationTimestamp == 0) { + // If message hasn't started expiring yet, just show the full hourglass. + self.fullHourglassImageView.hidden = NO; + return; + } else if (secondsLeft <= kBlinkAnimationDurationSeconds + 0.1f) { + // If message has expired, just show the blinking empty hourglass. + self.emptyHourglassImageView.hidden = NO; + + // Flashing animation. + [UIView animateWithDuration:0.5f + delay:0.f + options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionAutoreverse | UIViewAnimationOptionRepeat + animations:^{ + self.layer.opacity = 0.f; + } completion:nil]; + return; } - - // Get hourglass frames to the proper size. - [self setNeedsLayout]; - [self layoutIfNeeded]; + + self.emptyHourglassImageView.hidden = NO; + self.fullHourglassImageView.hidden = NO; CAGradientLayer *maskLayer = [CAGradientLayer new]; + maskLayer.frame = self.fullHourglassImageView.bounds; + self.maskLayer = maskLayer; self.fullHourglassImageView.layer.mask = maskLayer; - - // Without this the hourglass appears empty too soon. - CGFloat borderOffset = 2.0; - maskLayer.frame = CGRectInset(self.fullHourglassImageView.frame, 0, -borderOffset); - + // Blur the top of the mask a bit with gradient maskLayer.colors = @[ (id)[UIColor clearColor].CGColor, (id)[UIColor blackColor].CGColor ]; - maskLayer.startPoint = CGPointMake(0.5f, 0); - maskLayer.endPoint = CGPointMake(0.5f, 0.2f); - - CGFloat ratioRemaining = ((CGFloat)secondsLeft / (CGFloat)self.initialDurationSeconds); - if (ratioRemaining < 0) { - ratioRemaining = 0.0; - } - CGPoint defaultPosition = maskLayer.position; - - CGPoint finalPosition - = CGPointMake(defaultPosition.x, defaultPosition.y + maskLayer.bounds.size.height - 2 * borderOffset); - CGPoint startingPosition = CGPointMake( - defaultPosition.x, finalPosition.y - maskLayer.bounds.size.height * ratioRemaining + borderOffset); - maskLayer.position = startingPosition; - - CABasicAnimation *revealAnimation = [CABasicAnimation animationWithKeyPath:@"position"]; - revealAnimation.duration = secondsLeft; - revealAnimation.fromValue = [NSValue valueWithCGPoint:startingPosition]; - revealAnimation.toValue = [NSValue valueWithCGPoint:finalPosition]; - - [maskLayer addAnimation:revealAnimation forKey:@"revealAnimation"]; - maskLayer.position = finalPosition; // don't snap back - - __weak typeof(self) wself = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, - (long long)((secondsLeft - OWSExpirationTimerViewBlinkingSeconds) * NSEC_PER_SEC)), - dispatch_get_main_queue(), - ^{ - [wself startBlinking]; - }); + maskLayer.startPoint = CGPointMake(0.5f, 0.f); + // Use a mask that is 20% tall to soften the edge of the animation. + const CGFloat kMaskEdgeFraction = 0.2f; + maskLayer.endPoint = CGPointMake(0.5f, kMaskEdgeFraction); + + NSTimeInterval timeUntilFlashing = MAX(0, secondsLeft - kBlinkAnimationDurationSeconds); + + CGFloat ratioRemaining = MAX(0.f, (timeUntilFlashing / (CGFloat)self.initialDurationSeconds)); + CGFloat alpha = 1.f - ratioRemaining; + CGFloat maskRange = self.fullHourglassImageView.height; + CGPoint startPosition = maskLayer.position; + startPosition.y += CGFloatLerp(maskRange * -kMaskEdgeFraction, maskRange, alpha); + CGPoint endPosition = maskLayer.position; + endPosition.y += maskRange; + + maskLayer.position = startPosition; + [CATransaction begin]; + CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"]; + animation.duration = timeUntilFlashing; + animation.fromValue = [NSValue valueWithCGPoint:startPosition]; + animation.toValue = [NSValue valueWithCGPoint:endPosition]; + [maskLayer addAnimation:animation forKey:@"slideAnimation"]; + maskLayer.position = endPosition; // don't snap back + [CATransaction commit]; + + self.animationTimer = [NSTimer weakScheduledTimerWithTimeInterval:timeUntilFlashing + target:self + selector:@selector(ensureAnimations) + userInfo:nil + repeats:NO]; } - (void)stopTimer { - [[NSNotificationCenter defaultCenter] removeObserver:self - name:ConversationViewControllerDidAppearNotification - object:nil]; - - [self.layer removeAnimationForKey:@"alphaBlink"]; - self.layer.opacity = 1; -} - -- (BOOL)itIsTimeToBlink -{ - double secondsLeft = self.expiresAtSeconds - [NSDate new].timeIntervalSince1970; - return secondsLeft <= OWSExpirationTimerViewBlinkingSeconds; -} - -- (void)startBlinking -{ - if (![self itIsTimeToBlink]) { - DDLogVerbose(@"Refusing to start blinking too early. Reused cell?"); - return; - } + OWSAssert([NSThread isMainThread]); - CABasicAnimation *blinkAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"]; - blinkAnimation.duration = 0.5; - blinkAnimation.fromValue = @(1.0); - blinkAnimation.toValue = @(0.0); - blinkAnimation.repeatCount = 4; - blinkAnimation.autoreverses = YES; - blinkAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - [self.layer addAnimation:blinkAnimation forKey:@"alphaBlink"]; + [self clearAnimations]; } #pragma mark - Logging diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.h index 36fa77949..f36c6201d 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.h @@ -4,8 +4,6 @@ #import "OWSMessageCell.h" -//#import "OWSExpirableMessageView.h" - NS_ASSUME_NONNULL_BEGIN // TODO: Remove this class. diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.m index 1bed4a2ea..c63dc31fc 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSIncomingMessageCell.m @@ -8,13 +8,6 @@ NS_ASSUME_NONNULL_BEGIN -@interface OWSIncomingMessageCell () - -//@property (strong, nonatomic) IBOutlet OWSExpirationTimerView *expirationTimerView; -//@property (strong, nonatomic) IBOutlet NSLayoutConstraint *expirationTimerViewWidthConstraint; - -@end - @implementation OWSIncomingMessageCell + (NSString *)cellReuseIdentifier @@ -27,57 +20,6 @@ NS_ASSUME_NONNULL_BEGIN return YES; } -//- (void)awakeFromNib -//{ -// [super awakeFromNib]; -// self.expirationTimerViewWidthConstraint.constant = 0.0; -//} -// -//- (void)prepareForReuse -//{ -// [super prepareForReuse]; -// self.expirationTimerViewWidthConstraint.constant = 0.0f; -// -// [self.mediaAdapter setCellVisible:NO]; -// -// // Clear this adapter's views IFF this was the last cell to use this adapter. -// [self.mediaAdapter clearCachedMediaViewsIfLastPresentingCell:self]; -// [_mediaAdapter setLastPresentingCell:nil]; -// -// self.mediaAdapter = nil; -//} -// -//- (void)setMediaAdapter:(nullable id)mediaAdapter -//{ -// _mediaAdapter = mediaAdapter; -// -// // Mark this as the last cell to use this adapter. -// [_mediaAdapter setLastPresentingCell:self]; -//} -// -//// pragma mark - OWSMessageCollectionViewCell -// -//// TODO: -//- (void)setCellVisible:(BOOL)isVisible -//{ -// [self.mediaAdapter setCellVisible:isVisible]; -//} -// -//// pragma mark - OWSExpirableMessageView -// -//- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds -// initialDurationSeconds:(uint32_t)initialDurationSeconds -//{ -// self.expirationTimerViewWidthConstraint.constant = OWSExpirableMessageViewTimerWidth; -// [self.expirationTimerView startTimerWithExpiresAtSeconds:expiresAtSeconds -// initialDurationSeconds:initialDurationSeconds]; -//} -// -//- (void)stopExpirationTimer -//{ -// [self.expirationTimerView stopTimer]; -//} - @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.h index 7c1f6f7bf..d7e0ae92b 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.h @@ -4,27 +4,11 @@ #import "ConversationViewCell.h" -//#import "JSQMessagesCollectionViewCell+OWS.h" -//#import "OWSExpirableMessageView.h" -//#import "OWSMessageMediaAdapter.h" - NS_ASSUME_NONNULL_BEGIN @class OWSExpirationTimerView; -// TODO: Move to source. -static const CGFloat OWSExpirableMessageViewTimerWidth = 10.0f; - @interface OWSMessageCell : ConversationViewCell -// - -@property (nonatomic, readonly) OWSExpirationTimerView *expirationTimerView; -@property (nonatomic, readonly) NSLayoutConstraint *expirationTimerViewWidthConstraint; - -- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds - initialDurationSeconds:(uint32_t)initialDurationSeconds; - -- (void)stopExpirationTimer; @end diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m index 5623543f0..ea80b765e 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m @@ -8,14 +8,13 @@ #import "ConversationViewItem.h" #import "NSAttributedString+OWS.h" #import "OWSAudioMessageView.h" +#import "OWSExpirationTimerView.h" #import "OWSGenericAttachmentView.h" #import "Signal-Swift.h" #import "UIColor+OWS.h" #import #import -//#import "OWSExpirationTimerView.h" - NS_ASSUME_NONNULL_BEGIN @interface OWSMessageCell () @@ -32,11 +31,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, nullable) AttachmentPointerView *attachmentPointerView; @property (nonatomic, nullable) OWSGenericAttachmentView *attachmentView; @property (nonatomic, nullable) OWSAudioMessageView *audioMessageView; +@property (nonatomic) UIView *footerView; +@property (nonatomic) UILabel *footerLabel; +@property (nonatomic, nullable) OWSExpirationTimerView *expirationTimerView; @property (nonatomic, nullable) NSArray *dateHeaderConstraints; @property (nonatomic, nullable) NSArray *contentConstraints; - -//@property (strong, nonatomic) OWSExpirationTimerView *expirationTimerView; -//@property (strong, nonatomic) NSLayoutConstraint *expirationTimerViewWidthConstraint; +@property (nonatomic, nullable) NSArray *footerConstraints; @end @@ -61,8 +61,11 @@ NS_ASSUME_NONNULL_BEGIN self.payloadView = [UIView containerView]; [self.contentView addSubview:self.payloadView]; + self.footerView = [UIView containerView]; + [self.contentView addSubview:self.footerView]; + self.dateHeaderLabel = [UILabel new]; - self.dateHeaderLabel.font = [UIFont ows_regularFontWithSize:16.f]; + self.dateHeaderLabel.font = [UIFont ows_regularFontWithSize:12.f]; self.dateHeaderLabel.textAlignment = NSTextAlignmentCenter; self.dateHeaderLabel.textColor = [UIColor lightGrayColor]; [self.contentView addSubview:self.dateHeaderLabel]; @@ -81,15 +84,23 @@ NS_ASSUME_NONNULL_BEGIN [self.bubbleImageView addSubview:self.textLabel]; OWSAssert(self.textLabel.superview); + self.footerLabel = [UILabel new]; + self.footerLabel.font = [UIFont ows_regularFontWithSize:12.f]; + self.footerLabel.textColor = [UIColor lightGrayColor]; + [self.footerView addSubview:self.footerLabel]; + // Hide these views by default. self.bubbleImageView.hidden = YES; self.textLabel.hidden = YES; self.dateHeaderLabel.hidden = YES; + self.footerLabel.hidden = YES; [self.dateHeaderLabel autoPinEdgeToSuperviewEdge:ALEdgeTop]; [self.payloadView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.dateHeaderLabel]; - [self.payloadView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; [self.payloadView autoPinWidthToSuperview]; + [self.footerView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.payloadView]; + [self.footerView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; + [self.footerView autoPinWidthToSuperview]; UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)]; @@ -151,6 +162,7 @@ NS_ASSUME_NONNULL_BEGIN self.bubbleImageView.image = bubbleImageData.messageBubbleImage; [self updateDateHeader:contentWidth]; + [self updateFooter]; switch (self.cellType) { case OWSMessageCellType_TextMessage: @@ -183,9 +195,6 @@ NS_ASSUME_NONNULL_BEGIN } } - // [self.textLabel addBorderWithColor:[UIColor blueColor]]; - // [self.bubbleImageView addBorderWithColor:[UIColor greenColor]]; - // dispatch_async(dispatch_get_main_queue(), ^{ // NSLog(@"---- %@", self.viewItem.interaction.debugDescription); // NSLog(@"cell: %@", NSStringFromCGRect(self.frame)); @@ -206,7 +215,7 @@ NS_ASSUME_NONNULL_BEGIN [dateHeaderDateFormatter setDoesRelativeDateFormatting:YES]; [dateHeaderDateFormatter setDateStyle:NSDateFormatterMediumStyle]; [dateHeaderDateFormatter setTimeStyle:NSDateFormatterNoStyle]; - + dateHeaderTimeFormatter = [NSDateFormatter new]; [dateHeaderTimeFormatter setLocale:[NSLocale currentLocale]]; [dateHeaderTimeFormatter setDoesRelativeDateFormatting:YES]; @@ -257,15 +266,106 @@ NS_ASSUME_NONNULL_BEGIN } } +- (CGFloat)footerHeight +{ + BOOL showFooter = NO; + + TSMessage *message = (TSMessage *)self.viewItem.interaction; + BOOL hasExpirationTimer = message.shouldStartExpireTimer; + + if (hasExpirationTimer) { + showFooter = YES; + } else if (!self.isIncoming) { + showFooter = YES; + } else if (self.viewItem.isGroupThread) { + showFooter = YES; + } else { + showFooter = NO; + } + + return (showFooter ? MAX(kExpirationTimerViewSize, + self.footerLabel.font.lineHeight) + : 0.f); +} + +- (void)updateFooter +{ + OWSAssert(self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage + || self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage); + + TSMessage *message = (TSMessage *)self.viewItem.interaction; + BOOL hasExpirationTimer = message.shouldStartExpireTimer; + NSAttributedString *attributedText = nil; + if (!self.isIncoming) { + TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)message; + NSString *statusMessage = + [MessageRecipientStatusUtils statusMessageWithOutgoingMessage:outgoingMessage referenceView:self]; + attributedText = [[NSAttributedString alloc] initWithString:statusMessage attributes:@{}]; + } else if (self.viewItem.isGroupThread) { + TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction; + attributedText = [self.delegate attributedContactOrProfileNameForPhoneIdentifier:incomingMessage.authorId]; + } + + if (!hasExpirationTimer && + !attributedText) { + self.footerLabel.hidden = YES; + self.footerConstraints = @[ + [self.footerView autoSetDimension:ALDimensionHeight toSize:0], + ]; + return; + } + + if (hasExpirationTimer) + { + self.expirationTimerView = [OWSExpirationTimerView new]; + [self.footerView addSubview:self.expirationTimerView]; + } + if (attributedText) { + self.footerLabel.attributedText = attributedText; + self.footerLabel.hidden = NO; + } + + if (hasExpirationTimer && + attributedText) { + self.footerConstraints = @[ + [self.expirationTimerView autoVCenterInSuperview], + [self.footerLabel autoVCenterInSuperview], + (self.isIncoming + ? [self.expirationTimerView autoPinLeadingToSuperview] + : [self.expirationTimerView autoPinTrailingToSuperview]), + (self.isIncoming + ? [self.footerLabel autoPinLeadingToTrailingOfView:self.expirationTimerView margin:0.f] + : [self.footerLabel autoPinTrailingToLeadingOfView:self.expirationTimerView margin:0.f]), + [self.footerView autoSetDimension:ALDimensionHeight toSize:self.footerHeight], + ]; + } else if (hasExpirationTimer) { + self.footerConstraints = @[ + [self.expirationTimerView autoVCenterInSuperview], + (self.isIncoming + ? [self.expirationTimerView autoPinLeadingToSuperview] + : [self.expirationTimerView autoPinTrailingToSuperview]), + [self.footerView autoSetDimension:ALDimensionHeight toSize:self.footerHeight], + ]; + } else if (attributedText) { + self.footerConstraints = @[ + [self.footerLabel autoVCenterInSuperview], + (self.isIncoming + ? [self.footerLabel autoPinLeadingToSuperview] + : [self.footerLabel autoPinTrailingToSuperview]), + [self.footerView autoSetDimension:ALDimensionHeight toSize:self.footerHeight], + ]; + } else { + OWSFail(@"%@ Cell unexpectedly has neither expiration timer nor footer text.", self.logTag); + } +} + - (UIFont *)dateHeaderDateFont { - // TODO: Refine. return [UIFont boldSystemFontOfSize:12.0f]; } - (UIFont *)dateHeaderTimeFont { - // TODO: Refine. return [UIFont systemFontOfSize:12.0f]; } @@ -297,6 +397,9 @@ NS_ASSUME_NONNULL_BEGIN } self.stillImageView = [[UIImageView alloc] initWithImage:image]; + // We need to specify a contentMode since the size of the image + // might not match the aspect ratio of the view. + self.stillImageView.contentMode = UIViewContentModeScaleAspectFill; // Use trilinear filters for better scaling quality at // some performance cost. self.stillImageView.layer.minificationFilter = kCAFilterTrilinear; @@ -323,6 +426,9 @@ NS_ASSUME_NONNULL_BEGIN self.animatedImageView = [[YYAnimatedImageView alloc] init]; self.animatedImageView.image = animatedImage; + // We need to specify a contentMode since the size of the image + // might not match the aspect ratio of the view. + self.animatedImageView.contentMode = UIViewContentModeScaleAspectFill; [self replaceBubbleWithView:self.animatedImageView]; [self addAttachmentUploadViewIfNecessary:self.animatedImageView]; } @@ -356,6 +462,9 @@ NS_ASSUME_NONNULL_BEGIN } self.stillImageView = [[UIImageView alloc] initWithImage:image]; + // We need to specify a contentMode since the size of the image + // might not match the aspect ratio of the view. + self.stillImageView.contentMode = UIViewContentModeScaleAspectFill; // Use trilinear filters for better scaling quality at // some performance cost. self.stillImageView.layer.minificationFilter = kCAFilterTrilinear; @@ -434,15 +543,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)cropViewToBubbbleShape:(UIView *)view { - // OWSAssert(CGRectEqualToRect(self.bounds, self.contentView.frame)); - // DDLogError(@"cropViewToBubbbleShape: %@ %@", self.viewItem.interaction.uniqueId, - // self.viewItem.interaction.description); DDLogError(@"\t %@ %@ %@ %@", - // NSStringFromCGRect(self.frame), - // NSStringFromCGRect(self.contentView.frame), - // NSStringFromCGRect(view.frame), - // NSStringFromCGRect(view.superview.bounds)); - - // view.frame = view.superview.bounds; view.frame = self.bounds; [JSQMessagesMediaViewBubbleImageMasker applyBubbleImageMaskToMediaView:view isOutgoing:!self.isIncoming]; } @@ -531,6 +631,7 @@ NS_ASSUME_NONNULL_BEGIN OWSAssert(cellSize.width > 0 && cellSize.height > 0); cellSize.height += self.dateHeaderHeight; + cellSize.height += self.footerHeight; return cellSize; } @@ -599,12 +700,16 @@ NS_ASSUME_NONNULL_BEGIN self.contentConstraints = nil; [NSLayoutConstraint deactivateConstraints:self.dateHeaderConstraints]; self.dateHeaderConstraints = nil; + [NSLayoutConstraint deactivateConstraints:self.footerConstraints]; + self.footerConstraints = nil; // The text label is used so frequently that we always keep one around. self.dateHeaderLabel.text = nil; self.dateHeaderLabel.hidden = YES; self.textLabel.text = nil; self.textLabel.hidden = YES; + self.footerLabel.text = nil; + self.footerLabel.hidden = YES; self.bubbleImageView.image = nil; self.bubbleImageView.hidden = YES; @@ -621,17 +726,12 @@ NS_ASSUME_NONNULL_BEGIN [self.audioMessageView removeFromSuperview]; self.audioMessageView = nil; self.attachmentUploadView = nil; + if (self.expirationTimerView) + [self.expirationTimerView stopTimer]; + [self.expirationTimerView removeFromSuperview]; + self.expirationTimerView = nil; } -//- (void)awakeFromNib -//{ -// [super awakeFromNib]; -// self.expirationTimerViewWidthConstraint.constant = 0.0; -// -// // Our text alignment needs to adapt to RTL. -// self.cellBottomLabel.textAlignment = [self.cellBottomLabel textAlignmentUnnatural]; -//} -// //- (void)prepareForReuse //{ // [super prepareForReuse]; @@ -666,21 +766,43 @@ NS_ASSUME_NONNULL_BEGIN //{ // return [UIColor whiteColor]; //} + +#pragma mark - Notifications + +- (void)setIsCellVisible:(BOOL)isCellVisible { + if (self.isCellVisible == isCellVisible) { + return; + } + + [super setIsCellVisible:isCellVisible]; + + if (isCellVisible) { + TSMessage *message = (TSMessage *)self.viewItem.interaction; + if (message.shouldStartExpireTimer) { + uint64_t expirationTimestamp = message.expiresAt; + uint32_t expiresInSeconds = message.expiresInSeconds; + [self.expirationTimerView startTimerWithExpiration:expirationTimestamp + initialDurationSeconds:expiresInSeconds]; + } else { + [self.expirationTimerView stopTimer]; + } + } else { + [self.expirationTimerView stopTimer]; + } +} + +// case TSInfoMessageAdapter: { +// // HACK this will get called when we get a new info message, but there's gotta be a better spot for this. +// OWSDisappearingMessagesConfiguration *configuration = +// [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; +// [self setBarButtonItemsForDisappearingMessagesConfiguration:configuration]; // -//// pragma mark - OWSExpirableMessageView -// -//- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds -// initialDurationSeconds:(uint32_t)initialDurationSeconds -//{ -// self.expirationTimerViewWidthConstraint.constant = OWSExpirableMessageViewTimerWidth; -// [self.expirationTimerView startTimerWithExpiresAtSeconds:expiresAtSeconds -// initialDurationSeconds:initialDurationSeconds]; -//} +// if (message.shouldStartExpireTimer && [cell conformsToProtocol:@protocol(OWSExpirableMessageView)]) { +// id expirableView = (id)cell; +// [expirableView startExpirationTimerWithExpiresAtSeconds:message.expiresAtSeconds +// initialDurationSeconds:message.expiresInSeconds]; +// } // -//- (void)stopExpirationTimer -//{ -// [self.expirationTimerView stopTimer]; -//} #pragma mark - Gesture recognizers diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSOutgoingMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSOutgoingMessageCell.m index 6c90b4a9c..c34006c8f 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSOutgoingMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSOutgoingMessageCell.m @@ -21,36 +21,6 @@ NS_ASSUME_NONNULL_BEGIN return NO; } -//- (void)prepareForReuse -//{ -// [super prepareForReuse]; -// self.mediaView.alpha = 1.0; -// self.expirationTimerViewWidthConstraint.constant = 0.0f; -// -// [self.mediaAdapter setCellVisible:NO]; -// -// // Clear this adapter's views IFF this was the last cell to use this adapter. -// [self.mediaAdapter clearCachedMediaViewsIfLastPresentingCell:self]; -// [_mediaAdapter setLastPresentingCell:nil]; -// -// self.mediaAdapter = nil; -//} -// -//// pragma mark - OWSExpirableMessageView -// -//- (void)startExpirationTimerWithExpiresAtSeconds:(double)expiresAtSeconds -// initialDurationSeconds:(uint32_t)initialDurationSeconds -//{ -// self.expirationTimerViewWidthConstraint.constant = OWSExpirableMessageViewTimerWidth; -// [self.expirationTimerView startTimerWithExpiresAtSeconds:expiresAtSeconds -// initialDurationSeconds:initialDurationSeconds]; -//} -// -//- (void)stopExpirationTimer -//{ -// [self.expirationTimerView stopTimer]; -//} - @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.h b/Signal/src/ViewControllers/ConversationView/ConversationViewController.h index ab5b2c579..1fb8fc3ff 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.h @@ -8,9 +8,6 @@ NS_ASSUME_NONNULL_BEGIN @class TSThread; -// TODO: Audit this. -extern NSString *const ConversationViewControllerDidAppearNotification; - @interface ConversationViewController : OWSViewController @property (nonatomic, readonly) TSThread *thread; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index b8699c9cb..bb6f1063c 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -101,8 +101,6 @@ static const int kConversationInitialMaxRangeSize = kYapDatabasePageSize * kYapD static const int kYapDatabaseRangeMaxLength = kYapDatabasePageSize * kYapDatabaseMaxPageCount; static const int kYapDatabaseRangeMinLength = 0; -NSString *const ConversationViewControllerDidAppearNotification = @"ConversationViewControllerDidAppear"; - typedef enum : NSUInteger { kMediaTypePicture, kMediaTypeVideo, @@ -505,7 +503,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { - (void)applicationWillEnterForeground:(NSNotification *)notification { [self startReadTimer]; - [self startExpirationTimerAnimations]; self.isAppInBackground = NO; } @@ -541,9 +538,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { self.isViewVisible = YES; - // 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 load this VC without going via the HomeViewController. [self.contactsManager requestSystemContactsOnce]; @@ -983,15 +977,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { self.isUserScrolling = NO; } -- (void)startExpirationTimerAnimations -{ - OWSAssert([NSThread isMainThread]); - - // This notification should be posted synchronously. - [[NSNotificationCenter defaultCenter] postNotificationName:ConversationViewControllerDidAppearNotification - object:nil]; -} - - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; @@ -1791,6 +1776,14 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { #pragma mark - ConversationViewCellDelegate +- (NSAttributedString *)attributedContactOrProfileNameForPhoneIdentifier:(NSString *)recipientId +{ + OWSAssert([NSThread isMainThread]); + OWSAssert(recipientId.length > 0); + + return [self.contactsManager attributedContactOrProfileNameForPhoneIdentifier:recipientId]; +} + - (void)tappedUnknownContactBlockOfferMessage:(OWSContactOffersInteraction *)interaction { if (![self.thread isKindOfClass:[TSContactThread class]]) { @@ -2770,9 +2763,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { } NSMutableSet *rowsThatChangedSize = [[self reloadViewItems] mutableCopy]; - for (NSNumber *row in rowsThatChangedSize) { - DDLogError(@"might reload: %@", row); - } BOOL wasAtBottom = [self isScrolledToBottom]; // We want sending messages to feel snappy. So, if the only @@ -3711,6 +3701,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { _isViewVisible = isViewVisible; [self updateShouldObserveDBModifications]; + [self updateCellsVisible]; } - (void)setIsAppInBackground:(BOOL)isAppInBackground @@ -3718,6 +3709,15 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { _isAppInBackground = isAppInBackground; [self updateShouldObserveDBModifications]; + [self updateCellsVisible]; +} + +- (void)updateCellsVisible +{ + BOOL isCellVisible = self.isViewVisible && !self.isAppInBackground; + for (ConversationViewCell *cell in self.collectionView.visibleCells) { + cell.isCellVisible = isCellVisible; + } } - (void)updateShouldObserveDBModifications @@ -3820,6 +3820,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { NSMutableDictionary *viewItemMap = [NSMutableDictionary new]; NSUInteger count = [self.messageMappings numberOfItemsInSection:0]; + BOOL isGroupThread = self.isGroupConversation; // TODO: Recycle view items where possible. // TODO: Distinguish interaction types through some enum. @@ -3835,7 +3836,7 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { if (viewItem) { viewItem.lastRow = viewItem.row; } else { - viewItem = [[ConversationViewItem alloc] initWithTSInteraction:interaction]; + viewItem = [[ConversationViewItem alloc] initWithTSInteraction:interaction isGroupThread:isGroupThread]; } viewItem.row = (NSInteger)row; [viewItems addObject:viewItem]; @@ -3896,6 +3897,57 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { previousViewItemTimestamp = viewItem.interaction.timestampForSorting; } + // Update the "shouldShowDate" property of the view items. + OWSInteractionType lastInteractionType = OWSInteractionType_Unknown; + for (ConversationViewItem *viewItem in viewItems) { + OWSInteractionType interactionType = viewItem.interaction.interactionType; + lastInteractionType = interactionType; + // BOOL canShowDate = NO; + // switch (viewItem.interaction.interactionType) { + // case OWSInteractionType_Unknown: + // case OWSInteractionType_UnreadIndicator: + // case OWSInteractionType_Offer: + // canShowDate = NO; + // break; + // case OWSInteractionType_IncomingMessage: + // case OWSInteractionType_OutgoingMessage: + // case OWSInteractionType_Error: + // case OWSInteractionType_Info: + // case OWSInteractionType_Call: + // canShowDate = YES; + // break; + // } + // + // BOOL shouldShowDate = NO; + // if (!canShowDate) { + // shouldShowDate = NO; + // shouldShowDateOnNextViewItem = YES; + // } else if (shouldShowDateOnNextViewItem) { + // shouldShowDate = YES; + // shouldShowDateOnNextViewItem = NO; + // } else { + // uint64_t viewItemTimestamp = viewItem.interaction.timestampForSorting; + // OWSAssert(viewItemTimestamp > 0); + // OWSAssert(previousViewItemTimestamp > 0); + // uint64_t timeDifferenceMs = viewItemTimestamp - previousViewItemTimestamp; + // static const uint64_t kShowTimeIntervalMs = 5 * kMinuteInMs; + // if (timeDifferenceMs > kShowTimeIntervalMs) { + // shouldShowDate = YES; + // } + // shouldShowDateOnNextViewItem = NO; + // } + // if (viewItem.shouldShowDate != shouldShowDate) { + // // If this is an existing view item and it has changed size, + // // note that so that we can reload this cell while doing + // // incremental updates. + // if (viewItem.lastRow != NSNotFound) { + // [rowsThatChangedSize addObject:@(viewItem.lastRow)]; + // } + // } + // viewItem.shouldShowDate = shouldShowDate; + // previousViewItemTimestamp = viewItem.interaction.timestampForSorting; + } + self.viewItems = viewItems; self.viewItemMap = viewItemMap; @@ -3957,20 +4009,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [cell loadForDisplay:self.layout.contentWidth]; return cell; - - // case TSInfoMessageAdapter: { - // // HACK this will get called when we get a new info message, but there's gotta be a better spot for this. - // OWSDisappearingMessagesConfiguration *configuration = - // [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; - // [self setBarButtonItemsForDisappearingMessagesConfiguration:configuration]; - // - // if (message.shouldStartExpireTimer && [cell conformsToProtocol:@protocol(OWSExpirableMessageView)]) { - // id expirableView = (id)cell; - // [expirableView startExpirationTimerWithExpiresAtSeconds:message.expiresAtSeconds - // initialDurationSeconds:message.expiresInSeconds]; - // } - // - // return cell; } #pragma mark - UICollectionViewDelegate diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h index 0001ac089..780b4beb2 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.h @@ -39,6 +39,8 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); @property (nonatomic, readonly) TSInteraction *interaction; +@property (nonatomic, readonly) BOOL isGroupThread; + @property (nonatomic) BOOL shouldShowDate; @property (nonatomic) NSInteger row; @@ -47,7 +49,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType); //@property (nonatomic, weak) ConversationViewCell *lastCell; - (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithTSInteraction:(TSInteraction *)interaction; +- (instancetype)initWithTSInteraction:(TSInteraction *)interaction isGroupThread:(BOOL)isGroupThread; - (ConversationViewCell *)dequeueCellForCollectionView:(UICollectionView *)collectionView indexPath:(NSIndexPath *)indexPath; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m index 970231e95..b517b843f 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewItem.m @@ -61,7 +61,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) @implementation ConversationViewItem -- (instancetype)initWithTSInteraction:(TSInteraction *)interaction +- (instancetype)initWithTSInteraction:(TSInteraction *)interaction isGroupThread:(BOOL)isGroupThread { self = [super init]; @@ -70,6 +70,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType) } _interaction = interaction; + _isGroupThread = isGroupThread; self.row = NSNotFound; self.lastRow = NSNotFound; diff --git a/Signal/src/ViewControllers/MessageMetadataViewController.swift b/Signal/src/ViewControllers/MessageMetadataViewController.swift index 597dddb0a..b2b4d0709 100644 --- a/Signal/src/ViewControllers/MessageMetadataViewController.swift +++ b/Signal/src/ViewControllers/MessageMetadataViewController.swift @@ -9,15 +9,6 @@ class MessageMetadataViewController: OWSViewController { static let TAG = "[MessageMetadataViewController]" let TAG = "[MessageMetadataViewController]" - enum MessageRecipientState { - case uploading - case sending - case sent - case delivered - case read - case failed - } - // MARK: Properties let contactsManager: OWSContactsManager @@ -174,7 +165,7 @@ class MessageMetadataViewController: OWSViewController { let isGroupThread = message.thread.isGroupThread() - let recipientStatusGroups: [MessageRecipientState] = [ + let recipientStatusGroups: [MessageRecipientStatus] = [ .read, .uploading, .delivered, @@ -194,7 +185,7 @@ class MessageMetadataViewController: OWSViewController { } for recipientId in thread.recipientIdentifiers { - let (recipientStatus, statusMessage) = self.recipientStatus(outgoingMessage: outgoingMessage, recipientId: recipientId) + let (recipientStatus, statusMessage) = MessageRecipientStatusUtils.recipientStatusAndStatusMessage(outgoingMessage: outgoingMessage, recipientId: recipientId, referenceView:self.view) guard recipientStatus == recipientStatusGroup else { continue @@ -202,7 +193,7 @@ class MessageMetadataViewController: OWSViewController { if groupRows.count < 1 { if isGroupThread { - groupRows.append(valueRow(name: MessageRecipientStateName(recipientStatusGroup), + groupRows.append(valueRow(name: MessageRecipientStatusName(recipientStatusGroup), value:"")) } @@ -424,60 +415,6 @@ class MessageMetadataViewController: OWSViewController { return rows } - private func recipientStatus(outgoingMessage: TSOutgoingMessage, recipientId: String) -> (MessageRecipientState, String) { - // Legacy messages don't have "recipient read" state or "per-recipient delivery" state, - // so we fall back to `TSOutgoingMessageState` which is not per-recipient and therefore - // might be misleading. - - let recipientReadMap = outgoingMessage.recipientReadMap - if let readTimestamp = recipientReadMap[recipientId] { - assert(outgoingMessage.messageState == .sentToService) - let statusMessage = NSLocalizedString("MESSAGE_STATUS_READ", comment:"message footer for read messages").rtlSafeAppend(" ", referenceView:self.view) - .rtlSafeAppend( - DateUtil.formatPastTimestampRelativeToNow(readTimestamp.uint64Value), referenceView:self.view) - return (.read, statusMessage) - } - - let recipientDeliveryMap = outgoingMessage.recipientDeliveryMap - if let deliveryTimestamp = recipientDeliveryMap[recipientId] { - assert(outgoingMessage.messageState == .sentToService) - let statusMessage = NSLocalizedString("MESSAGE_STATUS_DELIVERED", - comment:"message status for message delivered to their recipient.").rtlSafeAppend(" ", referenceView:self.view) - .rtlSafeAppend( - DateUtil.formatPastTimestampRelativeToNow(deliveryTimestamp.uint64Value), referenceView:self.view) - return (.delivered, statusMessage) - } - - if outgoingMessage.wasDelivered { - let statusMessage = NSLocalizedString("MESSAGE_STATUS_DELIVERED", - comment:"message status for message delivered to their recipient.") - return (.delivered, statusMessage) - } - - if outgoingMessage.messageState == .unsent { - let statusMessage = NSLocalizedString("MESSAGE_STATUS_FAILED", comment:"message footer for failed messages") - return (.failed, statusMessage) - } else if outgoingMessage.messageState == .sentToService || - outgoingMessage.wasSent(toRecipient:recipientId) { - let statusMessage = - NSLocalizedString("MESSAGE_STATUS_SENT", - comment:"message footer for sent messages") - return (.sent, statusMessage) - } else if outgoingMessage.hasAttachments() { - assert(outgoingMessage.messageState == .attemptingOut) - - let statusMessage = NSLocalizedString("MESSAGE_STATUS_UPLOADING", - comment:"message footer while attachment is uploading") - return (.uploading, statusMessage) - } else { - assert(outgoingMessage.messageState == .attemptingOut) - - let statusMessage = NSLocalizedString("MESSAGE_STATUS_SENDING", - comment:"message status while message is sending.") - return (.sending, statusMessage) - } - } - private func nameLabel(text: String) -> UILabel { let label = UILabel() label.textColor = UIColor.black @@ -595,7 +532,7 @@ class MessageMetadataViewController: OWSViewController { updateContent() } - private func MessageRecipientStateName(_ value: MessageRecipientState) -> String { + private func MessageRecipientStatusName(_ value: MessageRecipientStatus) -> String { switch value { case .uploading: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING", diff --git a/Signal/src/ViewControllers/Utils/MessageRecipientState.swift b/Signal/src/ViewControllers/Utils/MessageRecipientState.swift new file mode 100644 index 000000000..286c7570f --- /dev/null +++ b/Signal/src/ViewControllers/Utils/MessageRecipientState.swift @@ -0,0 +1,135 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import Foundation + +enum MessageRecipientStatus { + case uploading + case sending + case sent + case delivered + case read + case failed +} + +class MessageRecipientStatusUtils: NSObject { + // MARK: Initializers + + @available(*, unavailable, message:"do not instantiate this class.") + private override init() { + } + + public class func recipientStatus(outgoingMessage: TSOutgoingMessage, + recipientId: String, + referenceView: UIView) -> MessageRecipientStatus { + let (messageRecipientStatus, _) = recipientStatusAndStatusMessage(outgoingMessage: outgoingMessage, + recipientId: recipientId, + referenceView: referenceView) + return messageRecipientStatus + } + + public class func statusMessage(outgoingMessage: TSOutgoingMessage, + recipientId: String, + referenceView: UIView) -> String { + let (_, statusMessage) = recipientStatusAndStatusMessage(outgoingMessage: outgoingMessage, + recipientId: recipientId, + referenceView: referenceView) + return statusMessage + } + + public class func recipientStatusAndStatusMessage(outgoingMessage: TSOutgoingMessage, + recipientId: String, + referenceView: UIView) -> (MessageRecipientStatus, String) { + // Legacy messages don't have "recipient read" state or "per-recipient delivery" state, + // so we fall back to `TSOutgoingMessageState` which is not per-recipient and therefore + // might be misleading. + + let recipientReadMap = outgoingMessage.recipientReadMap + if let readTimestamp = recipientReadMap[recipientId] { + assert(outgoingMessage.messageState == .sentToService) + let statusMessage = NSLocalizedString("MESSAGE_STATUS_READ", comment:"message footer for read messages").rtlSafeAppend(" ", referenceView:referenceView) + .rtlSafeAppend( + DateUtil.formatPastTimestampRelativeToNow(readTimestamp.uint64Value), referenceView:referenceView) + return (.read, statusMessage) + } + + let recipientDeliveryMap = outgoingMessage.recipientDeliveryMap + if let deliveryTimestamp = recipientDeliveryMap[recipientId] { + assert(outgoingMessage.messageState == .sentToService) + let statusMessage = NSLocalizedString("MESSAGE_STATUS_DELIVERED", + comment:"message status for message delivered to their recipient.").rtlSafeAppend(" ", referenceView:referenceView) + .rtlSafeAppend( + DateUtil.formatPastTimestampRelativeToNow(deliveryTimestamp.uint64Value), referenceView:referenceView) + return (.delivered, statusMessage) + } + + if outgoingMessage.wasDelivered { + let statusMessage = NSLocalizedString("MESSAGE_STATUS_DELIVERED", + comment:"message status for message delivered to their recipient.") + return (.delivered, statusMessage) + } + + if outgoingMessage.messageState == .unsent { + let statusMessage = NSLocalizedString("MESSAGE_STATUS_FAILED", comment:"message footer for failed messages") + return (.failed, statusMessage) + } else if outgoingMessage.messageState == .sentToService || + outgoingMessage.wasSent(toRecipient:recipientId) { + let statusMessage = + NSLocalizedString("MESSAGE_STATUS_SENT", + comment:"message footer for sent messages") + return (.sent, statusMessage) + } else if outgoingMessage.hasAttachments() { + assert(outgoingMessage.messageState == .attemptingOut) + + let statusMessage = NSLocalizedString("MESSAGE_STATUS_UPLOADING", + comment:"message footer while attachment is uploading") + return (.uploading, statusMessage) + } else { + assert(outgoingMessage.messageState == .attemptingOut) + + let statusMessage = NSLocalizedString("MESSAGE_STATUS_SENDING", + comment:"message status while message is sending.") + return (.sending, statusMessage) + } + } + + public class func statusMessage(outgoingMessage: TSOutgoingMessage, + referenceView: UIView) -> String { + + let recipientReadMap = outgoingMessage.recipientReadMap + if recipientReadMap.count > 0 { + assert(outgoingMessage.messageState == .sentToService) + return NSLocalizedString("MESSAGE_STATUS_READ", comment:"message footer for read messages") + } + + let recipientDeliveryMap = outgoingMessage.recipientDeliveryMap + if recipientDeliveryMap.count > 0 { + assert(outgoingMessage.messageState == .sentToService) + return NSLocalizedString("MESSAGE_STATUS_DELIVERED", + comment:"message status for message delivered to their recipient.") + } + + if outgoingMessage.wasDelivered { + return NSLocalizedString("MESSAGE_STATUS_DELIVERED", + comment:"message status for message delivered to their recipient.") + } + + if outgoingMessage.messageState == .unsent { + return NSLocalizedString("MESSAGE_STATUS_FAILED", comment:"message footer for failed messages") + } else if outgoingMessage.messageState == .sentToService { + return NSLocalizedString("MESSAGE_STATUS_SENT", + comment:"message footer for sent messages") + } else if outgoingMessage.hasAttachments() { + assert(outgoingMessage.messageState == .attemptingOut) + + return NSLocalizedString("MESSAGE_STATUS_UPLOADING", + comment:"message footer while attachment is uploading") + } else { + assert(outgoingMessage.messageState == .attemptingOut) + + return NSLocalizedString("MESSAGE_STATUS_SENDING", + comment:"message status while message is sending.") + } + } +}