From 7f0fa1228eea86f36317e92827c36df8e7ae259c Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 5 Apr 2018 13:19:13 -0400 Subject: [PATCH] Extract message bubble view. --- Signal.xcodeproj/project.pbxproj | 6 + .../Cells/OWSMessageBubbleView.h | 46 + .../Cells/OWSMessageBubbleView.m | 1101 ++++++++++++++++ .../ConversationView/Cells/OWSMessageCell.m | 1136 +++-------------- .../ConversationViewController.m | 8 + .../src/ViewControllers/HomeViewController.m | 9 + 6 files changed, 1351 insertions(+), 955 deletions(-) create mode 100644 Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h create mode 100644 Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index ca30b3827..8d10968c9 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -153,6 +153,7 @@ 3478506C1FD9B78A007B8332 /* NoopNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347850681FD9B78A007B8332 /* NoopNotificationsManager.swift */; }; 347850711FDAEB17007B8332 /* OWSUserProfile.m in Sources */ = {isa = PBXBuildFile; fileRef = 3478506F1FDAEB16007B8332 /* OWSUserProfile.m */; }; 347850721FDAEB17007B8332 /* OWSUserProfile.h in Headers */ = {isa = PBXBuildFile; fileRef = 347850701FDAEB16007B8332 /* OWSUserProfile.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496744C2076768700080B5F /* OWSMessageBubbleView.m */; }; 34A55F3720485465002CC6DE /* OWS2FARegistrationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34A55F3520485464002CC6DE /* OWS2FARegistrationViewController.m */; }; 34A910601FFEB114000C4745 /* OWSBackup.m in Sources */ = {isa = PBXBuildFile; fileRef = 34A9105F1FFEB114000C4745 /* OWSBackup.m */; }; 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B0796B1FCF46B000E248C2 /* MainAppContext.m */; }; @@ -726,6 +727,8 @@ 347850701FDAEB16007B8332 /* OWSUserProfile.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUserProfile.h; sourceTree = ""; }; 348F2EAD1F0D21BC00D4ECE0 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceSleepManager.swift; sourceTree = ""; }; 3495BC911F1426B800B478F5 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = translations/ar.lproj/Localizable.strings; sourceTree = ""; }; + 3496744B2076768600080B5F /* OWSMessageBubbleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageBubbleView.h; sourceTree = ""; }; + 3496744C2076768700080B5F /* OWSMessageBubbleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSMessageBubbleView.m; sourceTree = ""; }; 34A55F3520485464002CC6DE /* OWS2FARegistrationViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWS2FARegistrationViewController.m; sourceTree = ""; }; 34A55F3620485464002CC6DE /* OWS2FARegistrationViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWS2FARegistrationViewController.h; sourceTree = ""; }; 34A9105E1FFEB113000C4745 /* OWSBackup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackup.h; sourceTree = ""; }; @@ -1664,6 +1667,8 @@ 34D1F09E1F867BFC0066283D /* OWSExpirationTimerView.m */, 34D1F0B51F87F8850066283D /* OWSGenericAttachmentView.h */, 34D1F0B61F87F8850066283D /* OWSGenericAttachmentView.m */, + 3496744B2076768600080B5F /* OWSMessageBubbleView.h */, + 3496744C2076768700080B5F /* OWSMessageBubbleView.m */, 34D1F0A11F867BFC0066283D /* OWSMessageCell.h */, 34D1F0A21F867BFC0066283D /* OWSMessageCell.m */, 34DBF000206BD5A400025978 /* OWSMessageTextView.h */, @@ -3222,6 +3227,7 @@ 458E38371D668EBF0094BD24 /* OWSDeviceProvisioningURLParser.m in Sources */, 4517642B1DE939FD00EDB8B9 /* ContactCell.swift in Sources */, 340FC8AB204DAC8D007AEB0F /* DomainFrontingCountryViewController.m in Sources */, + 3496744D2076768700080B5F /* OWSMessageBubbleView.m in Sources */, 34B3F8751E8DF1700035BE1A /* CallViewController.swift in Sources */, 34D8C0281ED3673300188D7C /* DebugUITableViewController.m in Sources */, 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h new file mode 100644 index 000000000..606321d49 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.h @@ -0,0 +1,46 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +NS_ASSUME_NONNULL_BEGIN + +@class ConversationViewItem; + +typedef NS_ENUM(NSUInteger, OWSMessageGestureLocation) { + // Message text, etc. + OWSMessageGestureLocation_Default, + OWSMessageGestureLocation_OversizeText, + OWSMessageGestureLocation_Media, + OWSMessageGestureLocation_QuotedReply, +}; + +@interface OWSMessageBubbleView : UIView + +@property (nonatomic, nullable) ConversationViewItem *viewItem; + +@property (nonatomic) int contentWidth; + +@property (nonatomic) NSCache *cellMediaCache; + +@property (nonatomic, nullable, readonly) UIView *lastBodyMediaView; + +- (instancetype)init NS_UNAVAILABLE; + +- (instancetype)initWithFrame:(CGRect)frame NS_DESIGNATED_INITIALIZER; + +- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +- (void)configureViews; + +- (void)loadContent; +- (void)unloadContent; + +- (CGSize)sizeForViewWidth:(int)viewWidth contentWidth:(int)contentWidth; + +- (void)prepareForReuse; + +- (OWSMessageGestureLocation)gestureLocationForLocation:(CGPoint)locationInMessageBubble; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m new file mode 100644 index 000000000..12db92c19 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -0,0 +1,1101 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +#import "OWSMessageBubbleView.h" +#import "AttachmentUploadView.h" +#import "ConversationViewItem.h" +#import "OWSAudioMessageView.h" +#import "OWSBubbleStrokeView.h" +#import "OWSBubbleView.h" +#import "OWSGenericAttachmentView.h" +#import "OWSMessageTextView.h" +#import "OWSQuotedMessageView.h" +#import "Signal-Swift.h" +#import "UIColor+OWS.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface OWSMessageBubbleView () + +@property (nonatomic) OWSBubbleView *bubbleView; + +@property (nonatomic) OWSMessageTextView *bodyTextView; + +@property (nonatomic, nullable) UIView *lastQuotedMessageView; + +@property (nonatomic, nullable) UIView *lastBodyMediaView; + +// Should lazy-load expensive view contents (images, etc.). +// Should do nothing if view is already loaded. +@property (nonatomic, nullable) dispatch_block_t loadCellContentBlock; +// Should unload all expensive view contents (images, etc.). +@property (nonatomic, nullable) dispatch_block_t unloadCellContentBlock; + +@property (nonatomic, nullable) NSMutableArray *viewConstraints; + +@end + +@implementation OWSMessageBubbleView + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + + if (!self) { + return self; + } + + [self commontInit]; + + return self; +} + +- (void)commontInit +{ + OWSAssert(!self.bodyTextView); + + _viewConstraints = [NSMutableArray new]; + + self.layoutMargins = UIEdgeInsetsZero; + self.userInteractionEnabled = NO; + + self.bubbleView = [OWSBubbleView new]; + self.bubbleView.layoutMargins = UIEdgeInsetsZero; + [self addSubview:self.bubbleView]; + [self.bubbleView autoPinEdgesToSuperviewEdges]; + + self.bodyTextView = [self newTextView]; + // Setting dataDetectorTypes is expensive. Do it just once. + self.bodyTextView.dataDetectorTypes + = (UIDataDetectorTypeLink | UIDataDetectorTypeAddress | UIDataDetectorTypeCalendarEvent); + self.bodyTextView.hidden = YES; +} + +- (OWSMessageTextView *)newTextView +{ + OWSMessageTextView *textView = [OWSMessageTextView new]; + textView.backgroundColor = [UIColor clearColor]; + textView.opaque = NO; + textView.editable = NO; + textView.selectable = YES; + textView.textContainerInset = UIEdgeInsetsZero; + textView.contentInset = UIEdgeInsetsZero; + textView.textContainer.lineFragmentPadding = 0; + textView.scrollEnabled = NO; + return textView; +} + +- (UIFont *)textMessageFont +{ + OWSAssert(DisplayableText.kMaxJumbomojiCount == 5); + + CGFloat basePointSize = [UIFont ows_dynamicTypeBodyFont].pointSize; + switch (self.displayableBodyText.jumbomojiCount) { + case 0: + break; + case 1: + return [UIFont ows_regularFontWithSize:basePointSize + 18.f]; + case 2: + return [UIFont ows_regularFontWithSize:basePointSize + 12.f]; + case 3: + case 4: + case 5: + return [UIFont ows_regularFontWithSize:basePointSize + 6.f]; + default: + OWSFail(@"%@ Unexpected jumbomoji count: %zd", self.logTag, self.displayableBodyText.jumbomojiCount); + break; + } + + return [UIFont ows_dynamicTypeBodyFont]; +} + +#pragma mark - Convenience Accessors + +// TODO: Remove as many of these convenience methods as possible. + +- (OWSMessageCellType)cellType +{ + return self.viewItem.messageCellType; +} + +- (BOOL)hasBodyText +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem); + + return self.viewItem.hasBodyText; +} + +- (nullable DisplayableText *)displayableBodyText +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem.displayableBodyText); + + return self.viewItem.displayableBodyText; +} + +- (nullable TSAttachmentStream *)attachmentStream +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem.attachmentStream); + + return self.viewItem.attachmentStream; +} + +- (nullable TSAttachmentPointer *)attachmentPointer +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem.attachmentPointer); + + return self.viewItem.attachmentPointer; +} + +- (CGSize)mediaSize +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem.mediaSize.width > 0 && self.viewItem.mediaSize.height > 0); + + return self.viewItem.mediaSize; +} + +- (BOOL)isQuotedReply +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem); + + return self.viewItem.isQuotedReply; +} + +- (BOOL)hasQuotedText +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem); + + return self.viewItem.hasQuotedText; +} + +- (BOOL)hasQuotedAttachment +{ + // This should always be valid for the appropriate cell types. + OWSAssert(self.viewItem); + + return self.viewItem.hasQuotedAttachment; +} + +- (TSMessage *)message +{ + OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); + + return (TSMessage *)self.viewItem.interaction; +} + +- (BOOL)isIncoming +{ + return self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage; +} + +- (BOOL)isOutgoing +{ + return self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage; +} + +#pragma mark - + +- (BOOL)hasNonImageBodyContent +{ + switch (self.cellType) { + case OWSMessageCellType_Unknown: + case OWSMessageCellType_TextMessage: + case OWSMessageCellType_OversizeTextMessage: + case OWSMessageCellType_GenericAttachment: + case OWSMessageCellType_DownloadingAttachment: + return YES; + case OWSMessageCellType_StillImage: + case OWSMessageCellType_AnimatedImage: + case OWSMessageCellType_Audio: + case OWSMessageCellType_Video: + return self.hasBodyText; + } +} + +- (BOOL)hasBodyTextContent +{ + switch (self.cellType) { + case OWSMessageCellType_Unknown: + case OWSMessageCellType_TextMessage: + case OWSMessageCellType_OversizeTextMessage: + return YES; + case OWSMessageCellType_GenericAttachment: + case OWSMessageCellType_DownloadingAttachment: + case OWSMessageCellType_StillImage: + case OWSMessageCellType_AnimatedImage: + case OWSMessageCellType_Audio: + case OWSMessageCellType_Video: + // Is there a caption? + return self.hasBodyText; + } +} + +#pragma mark - Load + +- (void)configureViews +{ + OWSAssert(self.viewItem); + OWSAssert(self.viewItem.interaction); + OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); + OWSAssert(self.contentWidth > 0); + + CGSize bodyMediaContentSize = [self bodyMediaSizeForContentWidth:self.contentWidth]; + CGSize bodyTextContentSize = [self bodyTextSizeForContentWidth:self.contentWidth includeMargins:NO]; + + self.bubbleView.isOutgoing = self.isOutgoing; + self.bubbleView.hideTail = self.viewItem.shouldHideBubbleTail; + + if ([self.viewItem.interaction isKindOfClass:[TSMessage class]] && self.hasNonImageBodyContent) { + TSMessage *message = (TSMessage *)self.viewItem.interaction; + self.bubbleView.bubbleColor = [self.bubbleFactory bubbleColorWithMessage:message]; + } else { + // Media-only messages should have no background color; they will fill the bubble's bounds + // and we don't want artifacts at the edges. + self.bubbleView.bubbleColor = nil; + } + + UIView *_Nullable lastSubview = nil; + CGFloat bottomMargin = 0; + + if (self.isQuotedReply) { + OWSAssert(!lastSubview); + + TSMessage *message = (TSMessage *)self.viewItem.interaction; + OWSQuotedMessageView *quotedMessageView = [OWSQuotedMessageView + quotedMessageViewForConversation:message.quotedMessage + displayableQuotedText:(self.viewItem.hasQuotedText ? self.viewItem.displayableQuotedText : nil)]; + self.lastQuotedMessageView = quotedMessageView; + [quotedMessageView createContents]; + [self.bubbleView addSubview:quotedMessageView]; + + CGFloat bubbleLeadingMargin = (self.isIncoming ? kBubbleThornSideInset : 0.f); + CGFloat bubbleTrailingMargin = (self.isIncoming ? 0.f : kBubbleThornSideInset); + [self.viewConstraints addObjectsFromArray:@[ + [quotedMessageView autoPinLeadingToSuperviewMarginWithInset:bubbleLeadingMargin], + [quotedMessageView autoPinTrailingToSuperviewMarginWithInset:bubbleTrailingMargin], + ]]; + + if (lastSubview) { + [self.viewConstraints + addObject:[quotedMessageView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview]]; + } else { + [self.viewConstraints addObject:[quotedMessageView autoPinEdgeToSuperviewEdge:ALEdgeTop]]; + } + lastSubview = quotedMessageView; + bottomMargin = 0; + + [self.bubbleView addPartnerView:quotedMessageView.boundsStrokeView]; + } + + UIView *_Nullable bodyMediaView = nil; + BOOL bodyMediaViewHasGreedyWidth = NO; + switch (self.cellType) { + case OWSMessageCellType_Unknown: + case OWSMessageCellType_TextMessage: + case OWSMessageCellType_OversizeTextMessage: + break; + case OWSMessageCellType_StillImage: + OWSAssert(self.viewItem.attachmentStream); + bodyMediaView = [self loadViewForStillImage]; + break; + case OWSMessageCellType_AnimatedImage: + OWSAssert(self.viewItem.attachmentStream); + bodyMediaView = [self loadViewForAnimatedImage]; + break; + case OWSMessageCellType_Video: + OWSAssert(self.viewItem.attachmentStream); + bodyMediaView = [self loadViewForVideo]; + break; + case OWSMessageCellType_Audio: + OWSAssert(self.viewItem.attachmentStream); + bodyMediaView = [self loadViewForAudio]; + bodyMediaViewHasGreedyWidth = YES; + break; + case OWSMessageCellType_GenericAttachment: + bodyMediaView = [self loadViewForGenericAttachment]; + bodyMediaViewHasGreedyWidth = YES; + break; + case OWSMessageCellType_DownloadingAttachment: + bodyMediaView = [self loadViewForDownloadingAttachment]; + bodyMediaViewHasGreedyWidth = YES; + break; + } + + if (bodyMediaView) { + OWSAssert(self.loadCellContentBlock); + OWSAssert(self.unloadCellContentBlock); + OWSAssert(!lastSubview); + + bodyMediaView.clipsToBounds = YES; + + self.lastBodyMediaView = bodyMediaView; + bodyMediaView.userInteractionEnabled = NO; + if (self.isMediaBeingSent) { + bodyMediaView.layer.opacity = 0.75f; + } + + [self.bubbleView addSubview:bodyMediaView]; + // This layout can lead to extreme cropping of media content, + // e.g. a very tall portrait image + long caption. The media + // view will have "max width", so the image will be cropped to + // roughly a square. + // TODO: Myles is considering alternatives. + [self.viewConstraints addObjectsFromArray:@[ + [bodyMediaView autoPinLeadingToSuperviewMarginWithInset:0], + [bodyMediaView autoPinTrailingToSuperviewMarginWithInset:0], + ]]; + // We need constraints to control the vertical sizing of media and text views, but we use + // lower priority so that when a message only contains media it uses the exact bounds of + // the message view. + [NSLayoutConstraint + autoSetPriority:UILayoutPriorityDefaultLow + forConstraints:^{ + [self.viewConstraints + addObject:[bodyMediaView autoSetDimension:ALDimensionHeight toSize:bodyMediaContentSize.height]]; + }]; + + if (lastSubview) { + [self.viewConstraints + addObject:[bodyMediaView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview withOffset:0]]; + } else { + [self.viewConstraints addObject:[bodyMediaView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:0]]; + } + lastSubview = bodyMediaView; + bottomMargin = 0; + + BOOL shouldStrokeMediaView = [bodyMediaView isKindOfClass:[UIImageView class]]; + if (shouldStrokeMediaView) { + OWSBubbleStrokeView *bubbleStrokeView = [OWSBubbleStrokeView new]; + bubbleStrokeView.strokeThickness = 1.f; + bubbleStrokeView.strokeColor = [UIColor colorWithWhite:0.f alpha:0.1f]; + + [self.bubbleView addSubview:bubbleStrokeView]; + [bubbleStrokeView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:bodyMediaView]; + [bubbleStrokeView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:bodyMediaView]; + [bubbleStrokeView autoPinEdge:ALEdgeLeft toEdge:ALEdgeLeft ofView:bodyMediaView]; + [bubbleStrokeView autoPinEdge:ALEdgeRight toEdge:ALEdgeRight ofView:bodyMediaView]; + + [self.bubbleView addPartnerView:bubbleStrokeView]; + } + } + + OWSMessageTextView *_Nullable bodyTextView = nil; + // We render malformed messages as "empty text" messages, + // so create a text view if there is no body media view. + if (self.hasBodyText || !bodyMediaView) { + bodyTextView = [self configureBodyTextView]; + } + if (bodyTextView) { + [self.bubbleView addSubview:bodyTextView]; + [self.viewConstraints addObjectsFromArray:@[ + [bodyTextView autoPinLeadingToSuperviewMarginWithInset:self.textLeadingMargin], + [bodyTextView autoPinTrailingToSuperviewMarginWithInset:self.textTrailingMargin], + ]]; + // We need constraints to control the vertical sizing of media and text views, but we use + // lower priority so that when a message only contains media it uses the exact bounds of + // the message view. + [NSLayoutConstraint + autoSetPriority:UILayoutPriorityDefaultLow + forConstraints:^{ + [self.viewConstraints + addObject:[bodyTextView autoSetDimension:ALDimensionHeight toSize:bodyTextContentSize.height]]; + }]; + if (lastSubview) { + [self.viewConstraints addObject:[bodyTextView autoPinEdge:ALEdgeTop + toEdge:ALEdgeBottom + ofView:lastSubview + withOffset:self.textTopMargin]]; + } else { + [self.viewConstraints + addObject:[bodyTextView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:self.textTopMargin]]; + } + lastSubview = bodyTextView; + bottomMargin = self.textBottomMargin; + } + + UIView *_Nullable tapForMoreLabel = [self createTapForMoreLabelIfNecessary]; + if (tapForMoreLabel) { + OWSAssert(lastSubview); + OWSAssert(lastSubview == bodyTextView); + [self.bubbleView addSubview:tapForMoreLabel]; + [self.viewConstraints addObjectsFromArray:@[ + [tapForMoreLabel autoPinLeadingToSuperviewMarginWithInset:self.textLeadingMargin], + [tapForMoreLabel autoPinTrailingToSuperviewMarginWithInset:self.textTrailingMargin], + [tapForMoreLabel autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview], + [tapForMoreLabel autoSetDimension:ALDimensionHeight toSize:self.tapForMoreHeight], + ]]; + lastSubview = tapForMoreLabel; + bottomMargin = self.textBottomMargin; + } + + OWSAssert(lastSubview); + [self.viewConstraints addObjectsFromArray:@[ + [lastSubview autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:bottomMargin], + ]]; +} + +// We now eagerly create our view hierarchy (to do this exactly once per cell usage) +// but lazy-load any expensive media (photo, gif, etc.) used in those views. Note that +// this lazy-load can fail, in which case we modify the view hierarchy to use an "error" +// state. The didCellMediaFailToLoad reflects media load fails. +- (nullable id)tryToLoadCellMedia:(nullable id (^)(void))loadCellMediaBlock + mediaView:(UIView *)mediaView + cacheKey:(NSString *)cacheKey + shouldSkipCache:(BOOL)shouldSkipCache +{ + OWSAssert(self.attachmentStream); + OWSAssert(mediaView); + OWSAssert(cacheKey); + OWSAssert(self.cellMediaCache); + + if (self.viewItem.didCellMediaFailToLoad) { + return nil; + } + + id _Nullable cellMedia = [self.cellMediaCache objectForKey:cacheKey]; + if (cellMedia) { + DDLogVerbose(@"%@ cell media cache hit", self.logTag); + return cellMedia; + } + cellMedia = loadCellMediaBlock(); + if (cellMedia) { + DDLogVerbose(@"%@ cell media cache miss", self.logTag); + if (!shouldSkipCache) { + [self.cellMediaCache setObject:cellMedia forKey:cacheKey]; + } + } else { + DDLogError(@"%@ Failed to load cell media: %@", [self logTag], [self.attachmentStream mediaURL]); + self.viewItem.didCellMediaFailToLoad = YES; + // TODO: Do we need to hide/remove the media view? + [self showAttachmentErrorViewWithMediaView:mediaView]; + } + return cellMedia; +} + +#pragma mark - Load / Unload + +- (void)loadContent +{ + if (self.loadCellContentBlock) { + self.loadCellContentBlock(); + } +} + +- (void)unloadContent +{ + if (self.unloadCellContentBlock) { + self.unloadCellContentBlock(); + } +} + +#pragma mark - Subviews + +- (OWSMessageTextView *)configureBodyTextView +{ + OWSAssert(self.hasBodyText); + + BOOL shouldIgnoreEvents = NO; + if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { + // Ignore taps on links in outgoing messages that haven't been sent yet, as + // this interferes with "tap to retry". + TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; + shouldIgnoreEvents = outgoingMessage.messageState != TSOutgoingMessageStateSentToService; + } + [self.class loadForTextDisplay:self.bodyTextView + text:self.displayableBodyText.displayText + textColor:self.bodyTextColor + font:self.textMessageFont + shouldIgnoreEvents:shouldIgnoreEvents]; + return self.bodyTextView; +} + ++ (void)loadForTextDisplay:(OWSMessageTextView *)textView + text:(NSString *)text + textColor:(UIColor *)textColor + font:(UIFont *)font + shouldIgnoreEvents:(BOOL)shouldIgnoreEvents +{ + textView.hidden = NO; + textView.text = text; + textView.textColor = textColor; + + // Honor dynamic type in the message bodies. + textView.font = font; + textView.linkTextAttributes = @{ + NSForegroundColorAttributeName : textColor, + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle | NSUnderlinePatternSolid) + }; + textView.shouldIgnoreEvents = shouldIgnoreEvents; +} + +- (BOOL)hasTapForMore +{ + if (!self.hasBodyText) { + return NO; + } else if (!self.displayableBodyText.isTextTruncated) { + return NO; + } else { + return YES; + } +} + +- (nullable UIView *)createTapForMoreLabelIfNecessary +{ + if (!self.hasTapForMore) { + return nil; + } + + UILabel *tapForMoreLabel = [UILabel new]; + tapForMoreLabel.text = NSLocalizedString(@"CONVERSATION_VIEW_OVERSIZE_TEXT_TAP_FOR_MORE", + @"Indicator on truncated text messages that they can be tapped to see the entire text message."); + tapForMoreLabel.font = [self tapForMoreFont]; + tapForMoreLabel.textColor = [self.bodyTextColor colorWithAlphaComponent:0.85]; + tapForMoreLabel.textAlignment = [tapForMoreLabel textAlignmentUnnatural]; + + return tapForMoreLabel; +} + +- (UIView *)loadViewForStillImage +{ + OWSAssert(self.attachmentStream); + OWSAssert([self.attachmentStream isImage]); + + UIImageView *stillImageView = [UIImageView new]; + // We need to specify a contentMode since the size of the image + // might not match the aspect ratio of the view. + stillImageView.contentMode = UIViewContentModeScaleAspectFill; + // Use trilinear filters for better scaling quality at + // some performance cost. + stillImageView.layer.minificationFilter = kCAFilterTrilinear; + stillImageView.layer.magnificationFilter = kCAFilterTrilinear; + [self addAttachmentUploadViewIfNecessary:stillImageView]; + + __weak OWSMessageBubbleView *weakSelf = self; + self.loadCellContentBlock = ^{ + OWSMessageBubbleView *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); + if (stillImageView.image) { + return; + } + // Don't cache large still images. + // + // TODO: Don't use full size images in the message cells. + const NSUInteger kMaxCachableSize = 1024 * 1024; + BOOL shouldSkipCache = + [OWSFileSystem fileSizeOfPath:strongSelf.attachmentStream.filePath].unsignedIntegerValue < kMaxCachableSize; + stillImageView.image = [strongSelf tryToLoadCellMedia:^{ + OWSCAssert([strongSelf.attachmentStream isImage]); + return strongSelf.attachmentStream.image; + } + mediaView:stillImageView + cacheKey:strongSelf.attachmentStream.uniqueId + shouldSkipCache:shouldSkipCache]; + }; + self.unloadCellContentBlock = ^{ + OWSMessageBubbleView *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); + stillImageView.image = nil; + }; + + return stillImageView; +} + +- (UIView *)loadViewForAnimatedImage +{ + OWSAssert(self.attachmentStream); + OWSAssert([self.attachmentStream isAnimated]); + + YYAnimatedImageView *animatedImageView = [[YYAnimatedImageView alloc] init]; + // We need to specify a contentMode since the size of the image + // might not match the aspect ratio of the view. + animatedImageView.contentMode = UIViewContentModeScaleAspectFill; + [self addAttachmentUploadViewIfNecessary:animatedImageView]; + + __weak OWSMessageBubbleView *weakSelf = self; + self.loadCellContentBlock = ^{ + OWSMessageBubbleView *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + OWSCAssert(strongSelf.lastBodyMediaView == animatedImageView); + if (animatedImageView.image) { + return; + } + animatedImageView.image = [strongSelf tryToLoadCellMedia:^{ + OWSCAssert([strongSelf.attachmentStream isAnimated]); + + NSString *_Nullable filePath = [strongSelf.attachmentStream filePath]; + YYImage *_Nullable animatedImage = nil; + if (filePath && [NSData ows_isValidImageAtPath:filePath]) { + animatedImage = [YYImage imageWithContentsOfFile:filePath]; + } + return animatedImage; + } + mediaView:animatedImageView + cacheKey:strongSelf.attachmentStream.uniqueId + shouldSkipCache:NO]; + }; + self.unloadCellContentBlock = ^{ + OWSMessageBubbleView *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + OWSCAssert(strongSelf.lastBodyMediaView == animatedImageView); + animatedImageView.image = nil; + }; + + return animatedImageView; +} + +- (UIView *)loadViewForAudio +{ + OWSAssert(self.attachmentStream); + OWSAssert([self.attachmentStream isAudio]); + + OWSAudioMessageView *audioMessageView = [[OWSAudioMessageView alloc] initWithAttachment:self.attachmentStream + isIncoming:self.isIncoming + viewItem:self.viewItem]; + self.viewItem.lastAudioMessageView = audioMessageView; + [audioMessageView createContents]; + [self addAttachmentUploadViewIfNecessary:audioMessageView]; + + self.loadCellContentBlock = ^{ + // Do nothing. + }; + self.unloadCellContentBlock = ^{ + // Do nothing. + }; + + return audioMessageView; +} + +- (UIView *)loadViewForVideo +{ + OWSAssert(self.attachmentStream); + OWSAssert([self.attachmentStream isVideo]); + + UIImageView *stillImageView = [UIImageView new]; + // We need to specify a contentMode since the size of the image + // might not match the aspect ratio of the view. + stillImageView.contentMode = UIViewContentModeScaleAspectFill; + // Use trilinear filters for better scaling quality at + // some performance cost. + stillImageView.layer.minificationFilter = kCAFilterTrilinear; + stillImageView.layer.magnificationFilter = kCAFilterTrilinear; + + UIImage *videoPlayIcon = [UIImage imageNamed:@"play_button"]; + UIImageView *videoPlayButton = [[UIImageView alloc] initWithImage:videoPlayIcon]; + [stillImageView addSubview:videoPlayButton]; + [videoPlayButton autoCenterInSuperview]; + [self addAttachmentUploadViewIfNecessary:stillImageView + attachmentStateCallback:^(BOOL isAttachmentReady) { + videoPlayButton.hidden = !isAttachmentReady; + }]; + + __weak OWSMessageBubbleView *weakSelf = self; + self.loadCellContentBlock = ^{ + OWSMessageBubbleView *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); + if (stillImageView.image) { + return; + } + stillImageView.image = [strongSelf tryToLoadCellMedia:^{ + OWSCAssert([strongSelf.attachmentStream isVideo]); + + return strongSelf.attachmentStream.image; + } + mediaView:stillImageView + cacheKey:strongSelf.attachmentStream.uniqueId + shouldSkipCache:NO]; + }; + self.unloadCellContentBlock = ^{ + OWSMessageBubbleView *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); + stillImageView.image = nil; + }; + + return stillImageView; +} + +- (UIView *)loadViewForGenericAttachment +{ + OWSAssert(self.viewItem.attachmentStream); + OWSGenericAttachmentView *attachmentView = + [[OWSGenericAttachmentView alloc] initWithAttachment:self.attachmentStream isIncoming:self.isIncoming]; + [attachmentView createContents]; + [self addAttachmentUploadViewIfNecessary:attachmentView]; + + self.loadCellContentBlock = ^{ + // Do nothing. + }; + self.unloadCellContentBlock = ^{ + // Do nothing. + }; + + return attachmentView; +} + +- (UIView *)loadViewForDownloadingAttachment +{ + OWSAssert(self.attachmentPointer); + + UIView *customView = [UIView new]; + switch (self.attachmentPointer.state) { + case TSAttachmentPointerStateEnqueued: + customView.backgroundColor + = (self.isIncoming ? [UIColor jsq_messageBubbleLightGrayColor] : [UIColor ows_fadedBlueColor]); + break; + case TSAttachmentPointerStateDownloading: + customView.backgroundColor + = (self.isIncoming ? [UIColor jsq_messageBubbleLightGrayColor] : [UIColor ows_fadedBlueColor]); + break; + case TSAttachmentPointerStateFailed: + customView.backgroundColor = [UIColor grayColor]; + break; + } + + AttachmentPointerView *attachmentPointerView = + [[AttachmentPointerView alloc] initWithAttachmentPointer:self.attachmentPointer isIncoming:self.isIncoming]; + [customView addSubview:attachmentPointerView]; + [attachmentPointerView autoPinWidthToSuperviewWithMargin:20.f]; + [attachmentPointerView autoVCenterInSuperview]; + + self.loadCellContentBlock = ^{ + // Do nothing. + }; + self.unloadCellContentBlock = ^{ + // Do nothing. + }; + + return customView; +} + +- (void)addAttachmentUploadViewIfNecessary:(UIView *)attachmentView +{ + [self addAttachmentUploadViewIfNecessary:attachmentView + attachmentStateCallback:^(BOOL isAttachmentReady){ + }]; +} + +- (void)addAttachmentUploadViewIfNecessary:(UIView *)attachmentView + attachmentStateCallback:(AttachmentStateBlock)attachmentStateCallback +{ + OWSAssert(attachmentView); + OWSAssert(attachmentStateCallback); + OWSAssert(self.attachmentStream); + + if (self.isOutgoing) { + if (!self.attachmentStream.isUploaded) { + AttachmentUploadView *attachmentUploadView = + [[AttachmentUploadView alloc] initWithAttachment:self.attachmentStream + attachmentStateCallback:attachmentStateCallback]; + [attachmentView addSubview:attachmentUploadView]; + [attachmentUploadView autoPinToSuperviewEdges]; + } + } +} + +- (void)showAttachmentErrorViewWithMediaView:(UIView *)mediaView +{ + OWSAssert(mediaView); + + // TODO: We could do a better job of indicating that the media could not be loaded. + UIView *errorView = [UIView new]; + errorView.backgroundColor = [UIColor colorWithWhite:0.85f alpha:1.f]; + errorView.userInteractionEnabled = NO; + [mediaView addSubview:errorView]; + [errorView autoPinEdgesToSuperviewEdges]; +} + +#pragma mark - Measurement + +// Size of "message body" text, not quoted reply text. +- (CGSize)bodyTextSizeForContentWidth:(int)contentWidth includeMargins:(BOOL)includeMargins +{ + if (!self.hasBodyText) { + return CGSizeZero; + } + + BOOL isRTL = self.isRTL; + CGFloat leftMargin = isRTL ? self.textTrailingMargin : self.textLeadingMargin; + CGFloat rightMargin = isRTL ? self.textLeadingMargin : self.textTrailingMargin; + + const int maxMessageWidth = [self maxMessageWidthForContentWidth:contentWidth]; + const int maxTextWidth = (int)floor(maxMessageWidth - (leftMargin + rightMargin)); + + OWSMessageTextView *bodyTextView = [self configureBodyTextView]; + CGSize textSize = CGSizeCeil([bodyTextView sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); + textSize.width = MIN(textSize.width, maxTextWidth); + CGSize result = textSize; + + if (includeMargins) { + result.width += leftMargin + rightMargin; + result.height += self.textTopMargin + self.textBottomMargin; + } + + return CGSizeCeil(result); +} + +- (CGSize)bodyMediaSizeForContentWidth:(int)contentWidth +{ + const int maxMessageWidth = [self maxMessageWidthForContentWidth:contentWidth]; + + switch (self.cellType) { + case OWSMessageCellType_Unknown: + case OWSMessageCellType_TextMessage: + case OWSMessageCellType_OversizeTextMessage: { + return CGSizeZero; + } + case OWSMessageCellType_StillImage: + case OWSMessageCellType_AnimatedImage: + case OWSMessageCellType_Video: { + OWSAssert(self.mediaSize.width > 0); + OWSAssert(self.mediaSize.height > 0); + + // TODO: Adjust this behavior. + + CGFloat contentAspectRatio = self.mediaSize.width / self.mediaSize.height; + // Clamp the aspect ratio so that very thin/wide content is presented + // in a reasonable way. + const CGFloat minAspectRatio = 0.35f; + const CGFloat maxAspectRatio = 1 / minAspectRatio; + contentAspectRatio = MAX(minAspectRatio, MIN(maxAspectRatio, contentAspectRatio)); + + const CGFloat maxMediaWidth = maxMessageWidth; + const CGFloat maxMediaHeight = maxMessageWidth; + CGFloat mediaWidth = maxMediaHeight * contentAspectRatio; + CGFloat mediaHeight = maxMediaHeight; + if (mediaWidth > maxMediaWidth) { + mediaWidth = maxMediaWidth; + mediaHeight = maxMediaWidth / contentAspectRatio; + } + + // We don't want to blow up small images unnecessarily. + const CGFloat kMinimumSize = 150.f; + CGFloat shortSrcDimension = MIN(self.mediaSize.width, self.mediaSize.height); + CGFloat shortDstDimension = MIN(mediaWidth, mediaHeight); + if (shortDstDimension > kMinimumSize && shortDstDimension > shortSrcDimension) { + CGFloat factor = kMinimumSize / shortDstDimension; + mediaWidth *= factor; + mediaHeight *= factor; + } + + return CGSizeRound(CGSizeMake(mediaWidth, mediaHeight)); + } + case OWSMessageCellType_Audio: + return CGSizeMake(maxMessageWidth, OWSAudioMessageView.bubbleHeight); + case OWSMessageCellType_GenericAttachment: + return CGSizeMake(maxMessageWidth, [OWSGenericAttachmentView bubbleHeight]); + case OWSMessageCellType_DownloadingAttachment: + return CGSizeMake(200, 90); + } +} + +- (int)maxMessageWidthForContentWidth:(int)contentWidth +{ + return (int)floor(contentWidth * 0.8f); +} + +- (CGSize)quotedMessageSizeForViewWidth:(int)viewWidth + contentWidth:(int)contentWidth + includeMargins:(BOOL)includeMargins +{ + OWSAssert(self.viewItem); + OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); + + if (!self.isQuotedReply) { + return CGSizeZero; + } + + TSMessage *message = (TSMessage *)self.viewItem.interaction; + OWSQuotedMessageView *quotedMessageView = [OWSQuotedMessageView + quotedMessageViewForConversation:message.quotedMessage + displayableQuotedText:(self.hasQuotedText ? self.viewItem.displayableQuotedText : nil)]; + const int maxMessageWidth = [self maxMessageWidthForContentWidth:contentWidth]; + CGSize result = [quotedMessageView sizeForMaxWidth:maxMessageWidth - kBubbleThornSideInset]; + result.width += kBubbleThornSideInset; + + return result; +} + +- (CGSize)sizeForViewWidth:(int)viewWidth contentWidth:(int)contentWidth +{ + OWSAssert(self.viewItem); + OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); + + CGSize cellSize = CGSizeZero; + + CGSize quotedMessageSize = + [self quotedMessageSizeForViewWidth:viewWidth contentWidth:contentWidth includeMargins:YES]; + cellSize.width = MAX(cellSize.width, quotedMessageSize.width); + cellSize.height += quotedMessageSize.height; + + CGSize mediaContentSize = [self bodyMediaSizeForContentWidth:contentWidth]; + cellSize.width = MAX(cellSize.width, mediaContentSize.width); + cellSize.height += mediaContentSize.height; + + CGSize textContentSize = [self bodyTextSizeForContentWidth:contentWidth includeMargins:YES]; + cellSize.width = MAX(cellSize.width, textContentSize.width); + cellSize.height += textContentSize.height; + + OWSAssert(cellSize.width > 0 && cellSize.height > 0); + + if (self.hasTapForMore) { + cellSize.height += self.tapForMoreHeight; + } + + cellSize = CGSizeCeil(cellSize); + + return cellSize; +} + +- (UIFont *)tapForMoreFont +{ + return [UIFont ows_regularFontWithSize:12.f]; +} + +- (CGFloat)tapForMoreHeight +{ + return (CGFloat)ceil([self tapForMoreFont].lineHeight * 1.25); +} + +#pragma mark - + +- (CGFloat)textLeadingMargin +{ + CGFloat result = kBubbleTextHInset; + if (self.isIncoming) { + result += kBubbleThornSideInset; + } + return result; +} + +- (CGFloat)textTrailingMargin +{ + CGFloat result = kBubbleTextHInset; + if (!self.isIncoming) { + result += kBubbleThornSideInset; + } + return result; +} + +- (CGFloat)textTopMargin +{ + return kBubbleTextVInset; +} + +- (CGFloat)textBottomMargin +{ + return kBubbleTextVInset + kBubbleThornVInset; +} + +- (UIColor *)bodyTextColor +{ + return self.isIncoming ? [UIColor blackColor] : [UIColor whiteColor]; +} + +- (BOOL)isMediaBeingSent +{ + if (self.isIncoming) { + return NO; + } + if (self.cellType == OWSMessageCellType_DownloadingAttachment) { + return NO; + } + if (!self.attachmentStream) { + return NO; + } + TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; + return outgoingMessage.messageState == TSOutgoingMessageStateAttemptingOut; +} + +- (OWSMessagesBubbleImageFactory *)bubbleFactory +{ + return [OWSMessagesBubbleImageFactory shared]; +} + +- (void)prepareForReuse +{ + [NSLayoutConstraint deactivateConstraints:self.viewConstraints]; + self.viewConstraints = [NSMutableArray new]; + + [self.bodyTextView removeFromSuperview]; + self.bodyTextView.text = nil; + self.bodyTextView.hidden = YES; + + self.bubbleView.bubbleColor = nil; + [self.bubbleView clearPartnerViews]; + + for (UIView *subview in self.bubbleView.subviews) { + [subview removeFromSuperview]; + } + + if (self.unloadCellContentBlock) { + self.unloadCellContentBlock(); + } + self.loadCellContentBlock = nil; + self.unloadCellContentBlock = nil; + + [self.lastBodyMediaView removeFromSuperview]; + self.lastBodyMediaView = nil; + + [self.lastQuotedMessageView removeFromSuperview]; + self.lastQuotedMessageView = nil; +} + +#pragma mark - Gestures + +- (OWSMessageGestureLocation)gestureLocationForLocation:(CGPoint)locationInMessageBubble +{ + if (self.lastQuotedMessageView) { + // Treat this as a "quoted reply" gesture if: + // + // * There is a "quoted reply" view. + // * The gesture occured within or above the "quoted reply" view. + CGPoint location = [self convertPoint:locationInMessageBubble toView:self.lastQuotedMessageView]; + if (location.y <= self.lastQuotedMessageView.height) { + return OWSMessageGestureLocation_QuotedReply; + } + } + + if (self.lastBodyMediaView) { + // Treat this as a "body media" gesture if: + // + // * There is a "body media" view. + // * The gesture occured within or above the "body media" view. + CGPoint location = [self convertPoint:locationInMessageBubble toView:self.lastBodyMediaView]; + if (location.y <= self.lastBodyMediaView.height) { + return OWSMessageGestureLocation_Media; + } + } + + if (self.hasTapForMore) { + return OWSMessageGestureLocation_OversizeText; + } + + return OWSMessageGestureLocation_Default; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m index fb116ea95..083a811d6 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m @@ -3,22 +3,26 @@ // #import "OWSMessageCell.h" -#import "AttachmentSharing.h" -#import "AttachmentUploadView.h" -#import "ConversationViewItem.h" -#import "NSAttributedString+OWS.h" -#import "OWSAudioMessageView.h" -#import "OWSBubbleStrokeView.h" -#import "OWSBubbleView.h" +#import "OWSMessageBubbleView.h" + +//#import "AttachmentSharing.h" +//#import "AttachmentUploadView.h" +//#import "ConversationViewItem.h" +//#import "NSAttributedString+OWS.h" +//#import "OWSAudioMessageView.h" +//#import "OWSBubbleStrokeView.h" +//#import "OWSBubbleView.h" #import "OWSExpirationTimerView.h" -#import "OWSGenericAttachmentView.h" -#import "OWSMessageTextView.h" -#import "OWSQuotedMessageView.h" + +//#import "OWSGenericAttachmentView.h" +//#import "OWSMessageTextView.h" +//#import "OWSQuotedMessageView.h" #import "Signal-Swift.h" -#import "UIColor+OWS.h" -#import -#import -#import + +//#import "UIColor+OWS.h" +//#import +//#import +//#import NS_ASSUME_NONNULL_BEGIN @@ -35,23 +39,13 @@ NS_ASSUME_NONNULL_BEGIN // * footerView (below message) // * failedSendBadgeView ("trailing" beside message) -@property (nonatomic) OWSBubbleView *bubbleView; - +@property (nonatomic) OWSMessageBubbleView *messageBubbleView; @property (nonatomic) UILabel *dateHeaderLabel; -@property (nonatomic) OWSMessageTextView *bodyTextView; @property (nonatomic, nullable) UIImageView *failedSendBadgeView; @property (nonatomic) UIView *footerView; @property (nonatomic) UILabel *footerLabel; @property (nonatomic, nullable) OWSExpirationTimerView *expirationTimerView; -@property (nonatomic, nullable) UIView *lastBodyMediaView; - -// Should lazy-load expensive view contents (images, etc.). -// Should do nothing if view is already loaded. -@property (nonatomic, nullable) dispatch_block_t loadCellContentBlock; -// Should unload all expensive view contents (images, etc.). -@property (nonatomic, nullable) dispatch_block_t unloadCellContentBlock; - @property (nonatomic, nullable) NSMutableArray *viewConstraints; @property (nonatomic) BOOL isPresentingMenuController; @@ -71,16 +65,15 @@ NS_ASSUME_NONNULL_BEGIN - (void)commontInit { - OWSAssert(!self.bodyTextView); + OWSAssert(!self.messageBubbleView); _viewConstraints = [NSMutableArray new]; self.layoutMargins = UIEdgeInsetsZero; self.contentView.layoutMargins = UIEdgeInsetsZero; - self.bubbleView = [OWSBubbleView new]; - self.bubbleView.layoutMargins = UIEdgeInsetsZero; - [self.contentView addSubview:self.bubbleView]; + self.messageBubbleView = [OWSMessageBubbleView new]; + [self.contentView addSubview:self.messageBubbleView]; self.footerView = [UIView containerView]; [self.contentView addSubview:self.footerView]; @@ -91,23 +84,17 @@ NS_ASSUME_NONNULL_BEGIN self.dateHeaderLabel.textColor = [UIColor lightGrayColor]; [self.contentView addSubview:self.dateHeaderLabel]; - self.bodyTextView = [self newTextView]; - // Setting dataDetectorTypes is expensive. Do it just once. - self.bodyTextView.dataDetectorTypes - = (UIDataDetectorTypeLink | UIDataDetectorTypeAddress | UIDataDetectorTypeCalendarEvent); - 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.bodyTextView.hidden = YES; self.dateHeaderLabel.hidden = YES; self.footerLabel.hidden = YES; - [self.bubbleView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.dateHeaderLabel]; - [self.footerView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.bubbleView]; + [self.messageBubbleView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.dateHeaderLabel]; + [self.footerView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.messageBubbleView]; [self.footerView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; [self.footerView autoPinWidthToSuperview]; @@ -128,49 +115,11 @@ NS_ASSUME_NONNULL_BEGIN [self addGestureRecognizer:panGesture]; } -- (OWSMessageTextView *)newTextView -{ - OWSMessageTextView *textView = [OWSMessageTextView new]; - textView.backgroundColor = [UIColor clearColor]; - textView.opaque = NO; - textView.editable = NO; - textView.selectable = YES; - textView.textContainerInset = UIEdgeInsetsZero; - textView.contentInset = UIEdgeInsetsZero; - textView.textContainer.lineFragmentPadding = 0; - textView.scrollEnabled = NO; - return textView; -} - + (NSString *)cellReuseIdentifier { return NSStringFromClass([self class]); } -- (UIFont *)textMessageFont -{ - OWSAssert(DisplayableText.kMaxJumbomojiCount == 5); - - CGFloat basePointSize = [UIFont ows_dynamicTypeBodyFont].pointSize; - switch (self.displayableBodyText.jumbomojiCount) { - case 0: - break; - case 1: - return [UIFont ows_regularFontWithSize:basePointSize + 18.f]; - case 2: - return [UIFont ows_regularFontWithSize:basePointSize + 12.f]; - case 3: - case 4: - case 5: - return [UIFont ows_regularFontWithSize:basePointSize + 6.f]; - default: - OWSFail(@"%@ Unexpected jumbomoji count: %zd", self.logTag, self.displayableBodyText.jumbomojiCount); - break; - } - - return [UIFont ows_dynamicTypeBodyFont]; -} - - (BOOL)shouldHaveFailedSendBadge { if (![self.viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) { @@ -193,6 +142,29 @@ NS_ASSUME_NONNULL_BEGIN return 20.f; } +//#pragma mark - Accessors +// +//- (void)setViewItem:(nullable ConversationViewItem *)viewItem +//{ +// OWSAssert(self.messageBubbleView); +// +// _viewItem = viewItem; +// +// self.messageBubbleView.viewItem = viewItem; +//} +// +//- (void)setContentWidth:(int)contentWidth { +// OWSAssert(self.messageBubbleView); +// +// _contentWidth = contentWidth; +// +// self.messageBubbleView.contentWidth = contentWidth; +//} + +#pragma mark - Convenience Accessors + +// TODO: Remove as many of these convenience methods as possible. + - (OWSMessageCellType)cellType { return self.viewItem.messageCellType; @@ -304,6 +276,16 @@ NS_ASSUME_NONNULL_BEGIN } } +- (BOOL)isIncoming +{ + return self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage; +} + +- (BOOL)isOutgoing +{ + return self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage; +} + #pragma mark - Load - (void)loadForDisplay @@ -312,14 +294,13 @@ NS_ASSUME_NONNULL_BEGIN OWSAssert(self.viewItem.interaction); OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); OWSAssert(self.contentWidth > 0); + OWSAssert(self.messageBubbleView); - CGSize bodyMediaContentSize = [self bodyMediaSizeForContentWidth:self.contentWidth]; - CGSize bodyTextContentSize = [self bodyTextSizeForContentWidth:self.contentWidth includeMargins:NO]; - - // TODO: We might not need to hide it. - self.bubbleView.hidden = NO; - self.bubbleView.isOutgoing = self.isOutgoing; - self.bubbleView.hideTail = self.viewItem.shouldHideBubbleTail; + self.messageBubbleView.viewItem = self.viewItem; + self.messageBubbleView.contentWidth = self.contentWidth; + self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache; + [self.messageBubbleView configureViews]; + [self.messageBubbleView loadContent]; if (self.shouldHaveFailedSendBadge) { self.failedSendBadgeView = [UIImageView new]; @@ -329,268 +310,34 @@ NS_ASSUME_NONNULL_BEGIN [self.contentView addSubview:self.failedSendBadgeView]; [self.viewConstraints addObjectsFromArray:@[ - [self.bubbleView autoPinLeadingToSuperviewMargin], - [self.failedSendBadgeView autoPinLeadingToTrailingEdgeOfView:self.bubbleView], - [self.failedSendBadgeView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.bubbleView], + [self.messageBubbleView autoPinLeadingToSuperviewMargin], + [self.failedSendBadgeView autoPinLeadingToTrailingEdgeOfView:self.messageBubbleView], + [self.failedSendBadgeView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.messageBubbleView], [self.failedSendBadgeView autoPinTrailingToSuperviewMargin], [self.failedSendBadgeView autoSetDimension:ALDimensionWidth toSize:self.failedSendBadgeSize], [self.failedSendBadgeView autoSetDimension:ALDimensionHeight toSize:self.failedSendBadgeSize], ]]; } else { [self.viewConstraints addObjectsFromArray:@[ - [self.bubbleView autoPinLeadingToSuperviewMargin], - [self.bubbleView autoPinTrailingToSuperviewMargin], + [self.messageBubbleView autoPinLeadingToSuperviewMargin], + [self.messageBubbleView autoPinTrailingToSuperviewMargin], ]]; } - if ([self.viewItem.interaction isKindOfClass:[TSMessage class]] && self.hasNonImageBodyContent) { - TSMessage *message = (TSMessage *)self.viewItem.interaction; - self.bubbleView.bubbleColor = [self.bubbleFactory bubbleColorWithMessage:message]; - } else { - // Media-only messages should have no background color; they will fill the bubble's bounds - // and we don't want artifacts at the edges. - self.bubbleView.bubbleColor = nil; - } - [self updateDateHeader]; [self updateFooter]; - - UIView *_Nullable lastSubview = nil; - CGFloat bottomMargin = 0; - - if (self.isQuotedReply) { - OWSAssert(!lastSubview); - - TSMessage *message = (TSMessage *)self.viewItem.interaction; - OWSQuotedMessageView *quotedMessageView = [OWSQuotedMessageView - quotedMessageViewForConversation:message.quotedMessage - displayableQuotedText:(self.viewItem.hasQuotedText ? self.viewItem.displayableQuotedText : nil)]; - [quotedMessageView createContents]; - [self.bubbleView addSubview:quotedMessageView]; - - CGFloat bubbleLeadingMargin = (self.isIncoming ? kBubbleThornSideInset : 0.f); - CGFloat bubbleTrailingMargin = (self.isIncoming ? 0.f : kBubbleThornSideInset); - [self.viewConstraints addObjectsFromArray:@[ - [quotedMessageView autoPinLeadingToSuperviewMarginWithInset:bubbleLeadingMargin], - [quotedMessageView autoPinTrailingToSuperviewMarginWithInset:bubbleTrailingMargin], - ]]; - - if (lastSubview) { - [self.viewConstraints - addObject:[quotedMessageView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview]]; - } else { - [self.viewConstraints addObject:[quotedMessageView autoPinEdgeToSuperviewEdge:ALEdgeTop]]; - } - lastSubview = quotedMessageView; - bottomMargin = 0; - - [self.bubbleView addPartnerView:quotedMessageView.boundsStrokeView]; - } - - UIView *_Nullable bodyMediaView = nil; - BOOL bodyMediaViewHasGreedyWidth = NO; - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextMessage: - case OWSMessageCellType_OversizeTextMessage: - break; - case OWSMessageCellType_StillImage: - OWSAssert(self.viewItem.attachmentStream); - bodyMediaView = [self loadViewForStillImage]; - break; - case OWSMessageCellType_AnimatedImage: - OWSAssert(self.viewItem.attachmentStream); - bodyMediaView = [self loadViewForAnimatedImage]; - break; - case OWSMessageCellType_Video: - OWSAssert(self.viewItem.attachmentStream); - bodyMediaView = [self loadViewForVideo]; - break; - case OWSMessageCellType_Audio: - OWSAssert(self.viewItem.attachmentStream); - bodyMediaView = [self loadViewForAudio]; - bodyMediaViewHasGreedyWidth = YES; - break; - case OWSMessageCellType_GenericAttachment: - bodyMediaView = [self loadViewForGenericAttachment]; - bodyMediaViewHasGreedyWidth = YES; - break; - case OWSMessageCellType_DownloadingAttachment: - bodyMediaView = [self loadViewForDownloadingAttachment]; - bodyMediaViewHasGreedyWidth = YES; - break; - } - - if (bodyMediaView) { - OWSAssert(self.loadCellContentBlock); - OWSAssert(self.unloadCellContentBlock); - OWSAssert(!lastSubview); - - bodyMediaView.clipsToBounds = YES; - - self.lastBodyMediaView = bodyMediaView; - bodyMediaView.userInteractionEnabled = NO; - if (self.isMediaBeingSent) { - bodyMediaView.layer.opacity = 0.75f; - } - - [self.bubbleView addSubview:bodyMediaView]; - // This layout can lead to extreme cropping of media content, - // e.g. a very tall portrait image + long caption. The media - // view will have "max width", so the image will be cropped to - // roughly a square. - // TODO: Myles is considering alternatives. - [self.viewConstraints addObjectsFromArray:@[ - [bodyMediaView autoPinLeadingToSuperviewMarginWithInset:0], - [bodyMediaView autoPinTrailingToSuperviewMarginWithInset:0], - ]]; - // We need constraints to control the vertical sizing of media and text views, but we use - // lower priority so that when a message only contains media it uses the exact bounds of - // the message view. - [NSLayoutConstraint - autoSetPriority:UILayoutPriorityDefaultLow - forConstraints:^{ - [self.viewConstraints - addObject:[bodyMediaView autoSetDimension:ALDimensionHeight toSize:bodyMediaContentSize.height]]; - }]; - - if (lastSubview) { - [self.viewConstraints - addObject:[bodyMediaView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview withOffset:0]]; - } else { - [self.viewConstraints addObject:[bodyMediaView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:0]]; - } - lastSubview = bodyMediaView; - bottomMargin = 0; - - BOOL shouldStrokeMediaView = [bodyMediaView isKindOfClass:[UIImageView class]]; - if (shouldStrokeMediaView) { - OWSBubbleStrokeView *bubbleStrokeView = [OWSBubbleStrokeView new]; - bubbleStrokeView.strokeThickness = 1.f; - bubbleStrokeView.strokeColor = [UIColor colorWithWhite:0.f alpha:0.1f]; - - [self.bubbleView addSubview:bubbleStrokeView]; - [bubbleStrokeView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:bodyMediaView]; - [bubbleStrokeView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:bodyMediaView]; - [bubbleStrokeView autoPinEdge:ALEdgeLeft toEdge:ALEdgeLeft ofView:bodyMediaView]; - [bubbleStrokeView autoPinEdge:ALEdgeRight toEdge:ALEdgeRight ofView:bodyMediaView]; - - [self.bubbleView addPartnerView:bubbleStrokeView]; - } - } - - OWSMessageTextView *_Nullable bodyTextView = nil; - // We render malformed messages as "empty text" messages, - // so create a text view if there is no body media view. - if (self.hasBodyText || !bodyMediaView) { - bodyTextView = [self configureBodyTextView]; - } - if (bodyTextView) { - [self.bubbleView addSubview:bodyTextView]; - [self.viewConstraints addObjectsFromArray:@[ - [bodyTextView autoPinLeadingToSuperviewMarginWithInset:self.textLeadingMargin], - [bodyTextView autoPinTrailingToSuperviewMarginWithInset:self.textTrailingMargin], - ]]; - // We need constraints to control the vertical sizing of media and text views, but we use - // lower priority so that when a message only contains media it uses the exact bounds of - // the message view. - [NSLayoutConstraint - autoSetPriority:UILayoutPriorityDefaultLow - forConstraints:^{ - [self.viewConstraints - addObject:[bodyTextView autoSetDimension:ALDimensionHeight toSize:bodyTextContentSize.height]]; - }]; - if (lastSubview) { - [self.viewConstraints addObject:[bodyTextView autoPinEdge:ALEdgeTop - toEdge:ALEdgeBottom - ofView:lastSubview - withOffset:self.textTopMargin]]; - } else { - [self.viewConstraints - addObject:[bodyTextView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:self.textTopMargin]]; - } - lastSubview = bodyTextView; - bottomMargin = self.textBottomMargin; - } - - UIView *_Nullable tapForMoreLabel = [self createTapForMoreLabelIfNecessary]; - if (tapForMoreLabel) { - OWSAssert(lastSubview); - OWSAssert(lastSubview == bodyTextView); - [self.bubbleView addSubview:tapForMoreLabel]; - [self.viewConstraints addObjectsFromArray:@[ - [tapForMoreLabel autoPinLeadingToSuperviewMarginWithInset:self.textLeadingMargin], - [tapForMoreLabel autoPinTrailingToSuperviewMarginWithInset:self.textTrailingMargin], - [tapForMoreLabel autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastSubview], - [tapForMoreLabel autoSetDimension:ALDimensionHeight toSize:self.tapForMoreHeight], - ]]; - lastSubview = tapForMoreLabel; - bottomMargin = self.textBottomMargin; - } - - OWSAssert(lastSubview); - [self.viewConstraints addObjectsFromArray:@[ - [lastSubview autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:bottomMargin], - ]]; - - [self ensureMediaLoadState]; -} - -// We now eagerly create our view hierarchy (to do this exactly once per cell usage) -// but lazy-load any expensive media (photo, gif, etc.) used in those views. Note that -// this lazy-load can fail, in which case we modify the view hierarchy to use an "error" -// state. The didCellMediaFailToLoad reflects media load fails. -- (nullable id)tryToLoadCellMedia:(nullable id (^)(void))loadCellMediaBlock - mediaView:(UIView *)mediaView - cacheKey:(NSString *)cacheKey - shouldSkipCache:(BOOL)shouldSkipCache -{ - OWSAssert(self.attachmentStream); - OWSAssert(mediaView); - OWSAssert(cacheKey); - - if (self.viewItem.didCellMediaFailToLoad) { - return nil; - } - - NSCache *cellMediaCache = self.delegate.cellMediaCache; - OWSAssert(cellMediaCache); - - id _Nullable cellMedia = [cellMediaCache objectForKey:cacheKey]; - if (cellMedia) { - DDLogVerbose(@"%@ cell media cache hit", self.logTag); - return cellMedia; - } - cellMedia = loadCellMediaBlock(); - if (cellMedia) { - DDLogVerbose(@"%@ cell media cache miss", self.logTag); - if (!shouldSkipCache) { - [cellMediaCache setObject:cellMedia forKey:cacheKey]; - } - } else { - DDLogError(@"%@ Failed to load cell media: %@", [self logTag], [self.attachmentStream mediaURL]); - self.viewItem.didCellMediaFailToLoad = YES; - // TODO: Do we need to hide/remove the media view? - [self showAttachmentErrorViewWithMediaView:mediaView]; - } - return cellMedia; } // * If cell is visible, lazy-load (expensive) view contents. // * If cell is not visible, eagerly unload view contents. - (void)ensureMediaLoadState { + OWSAssert(self.messageBubbleView); + if (!self.isCellVisible) { - // Eagerly unload. - if (self.unloadCellContentBlock) { - self.unloadCellContentBlock(); - } - return; + [self.messageBubbleView unloadContent]; } else { - // Lazy load. - if (self.loadCellContentBlock) { - self.loadCellContentBlock(); - } + [self.messageBubbleView loadContent]; } } @@ -760,473 +507,30 @@ NS_ASSUME_NONNULL_BEGIN return [UIFont systemFontOfSize:12.0f]; } -- (OWSMessageTextView *)configureBodyTextView -{ - OWSAssert(self.hasBodyText); - - BOOL shouldIgnoreEvents = NO; - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - // Ignore taps on links in outgoing messages that haven't been sent yet, as - // this interferes with "tap to retry". - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - shouldIgnoreEvents = outgoingMessage.messageState != TSOutgoingMessageStateSentToService; - } - [self.class loadForTextDisplay:self.bodyTextView - text:self.displayableBodyText.displayText - textColor:self.bodyTextColor - font:self.textMessageFont - shouldIgnoreEvents:shouldIgnoreEvents]; - return self.bodyTextView; -} - -+ (void)loadForTextDisplay:(OWSMessageTextView *)textView - text:(NSString *)text - textColor:(UIColor *)textColor - font:(UIFont *)font - shouldIgnoreEvents:(BOOL)shouldIgnoreEvents -{ - textView.hidden = NO; - textView.text = text; - textView.textColor = textColor; - - // Honor dynamic type in the message bodies. - textView.font = font; - textView.linkTextAttributes = @{ - NSForegroundColorAttributeName : textColor, - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle | NSUnderlinePatternSolid) - }; - textView.shouldIgnoreEvents = shouldIgnoreEvents; -} - -- (BOOL)hasTapForMore -{ - if (!self.hasBodyText) { - return NO; - } else if (!self.displayableBodyText.isTextTruncated) { - return NO; - } else { - return YES; - } -} - -- (nullable UIView *)createTapForMoreLabelIfNecessary -{ - if (!self.hasTapForMore) { - return nil; - } - - UILabel *tapForMoreLabel = [UILabel new]; - tapForMoreLabel.text = NSLocalizedString(@"CONVERSATION_VIEW_OVERSIZE_TEXT_TAP_FOR_MORE", - @"Indicator on truncated text messages that they can be tapped to see the entire text message."); - tapForMoreLabel.font = [self tapForMoreFont]; - tapForMoreLabel.textColor = [self.bodyTextColor colorWithAlphaComponent:0.85]; - tapForMoreLabel.textAlignment = [tapForMoreLabel textAlignmentUnnatural]; - - return tapForMoreLabel; -} - -- (UIView *)loadViewForStillImage -{ - OWSAssert(self.attachmentStream); - OWSAssert([self.attachmentStream isImage]); - - UIImageView *stillImageView = [UIImageView new]; - // We need to specify a contentMode since the size of the image - // might not match the aspect ratio of the view. - stillImageView.contentMode = UIViewContentModeScaleAspectFill; - // Use trilinear filters for better scaling quality at - // some performance cost. - stillImageView.layer.minificationFilter = kCAFilterTrilinear; - stillImageView.layer.magnificationFilter = kCAFilterTrilinear; - [self addAttachmentUploadViewIfNecessary:stillImageView]; - - __weak OWSMessageCell *weakSelf = self; - self.loadCellContentBlock = ^{ - OWSMessageCell *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); - if (stillImageView.image) { - return; - } - // Don't cache large still images. - // - // TODO: Don't use full size images in the message cells. - const NSUInteger kMaxCachableSize = 1024 * 1024; - BOOL shouldSkipCache = - [OWSFileSystem fileSizeOfPath:strongSelf.attachmentStream.filePath].unsignedIntegerValue < kMaxCachableSize; - stillImageView.image = [strongSelf tryToLoadCellMedia:^{ - OWSCAssert([strongSelf.attachmentStream isImage]); - return strongSelf.attachmentStream.image; - } - mediaView:stillImageView - cacheKey:strongSelf.attachmentStream.uniqueId - shouldSkipCache:shouldSkipCache]; - }; - self.unloadCellContentBlock = ^{ - OWSMessageCell *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); - stillImageView.image = nil; - }; - - return stillImageView; -} - -- (UIView *)loadViewForAnimatedImage -{ - OWSAssert(self.attachmentStream); - OWSAssert([self.attachmentStream isAnimated]); - - YYAnimatedImageView *animatedImageView = [[YYAnimatedImageView alloc] init]; - // We need to specify a contentMode since the size of the image - // might not match the aspect ratio of the view. - animatedImageView.contentMode = UIViewContentModeScaleAspectFill; - [self addAttachmentUploadViewIfNecessary:animatedImageView]; - - __weak OWSMessageCell *weakSelf = self; - self.loadCellContentBlock = ^{ - OWSMessageCell *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - OWSCAssert(strongSelf.lastBodyMediaView == animatedImageView); - if (animatedImageView.image) { - return; - } - animatedImageView.image = [strongSelf tryToLoadCellMedia:^{ - OWSCAssert([strongSelf.attachmentStream isAnimated]); - - NSString *_Nullable filePath = [strongSelf.attachmentStream filePath]; - YYImage *_Nullable animatedImage = nil; - if (filePath && [NSData ows_isValidImageAtPath:filePath]) { - animatedImage = [YYImage imageWithContentsOfFile:filePath]; - } - return animatedImage; - } - mediaView:animatedImageView - cacheKey:strongSelf.attachmentStream.uniqueId - shouldSkipCache:NO]; - }; - self.unloadCellContentBlock = ^{ - OWSMessageCell *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - OWSCAssert(strongSelf.lastBodyMediaView == animatedImageView); - animatedImageView.image = nil; - }; - - return animatedImageView; -} - -- (UIView *)loadViewForAudio -{ - OWSAssert(self.attachmentStream); - OWSAssert([self.attachmentStream isAudio]); - - OWSAudioMessageView *audioMessageView = [[OWSAudioMessageView alloc] initWithAttachment:self.attachmentStream - isIncoming:self.isIncoming - viewItem:self.viewItem]; - self.viewItem.lastAudioMessageView = audioMessageView; - [audioMessageView createContents]; - [self addAttachmentUploadViewIfNecessary:audioMessageView]; - - self.loadCellContentBlock = ^{ - // Do nothing. - }; - self.unloadCellContentBlock = ^{ - // Do nothing. - }; - - return audioMessageView; -} - -- (UIView *)loadViewForVideo -{ - OWSAssert(self.attachmentStream); - OWSAssert([self.attachmentStream isVideo]); - - UIImageView *stillImageView = [UIImageView new]; - // We need to specify a contentMode since the size of the image - // might not match the aspect ratio of the view. - stillImageView.contentMode = UIViewContentModeScaleAspectFill; - // Use trilinear filters for better scaling quality at - // some performance cost. - stillImageView.layer.minificationFilter = kCAFilterTrilinear; - stillImageView.layer.magnificationFilter = kCAFilterTrilinear; - - UIImage *videoPlayIcon = [UIImage imageNamed:@"play_button"]; - UIImageView *videoPlayButton = [[UIImageView alloc] initWithImage:videoPlayIcon]; - [stillImageView addSubview:videoPlayButton]; - [videoPlayButton autoCenterInSuperview]; - [self addAttachmentUploadViewIfNecessary:stillImageView - attachmentStateCallback:^(BOOL isAttachmentReady) { - videoPlayButton.hidden = !isAttachmentReady; - }]; - - __weak OWSMessageCell *weakSelf = self; - self.loadCellContentBlock = ^{ - OWSMessageCell *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); - if (stillImageView.image) { - return; - } - stillImageView.image = [strongSelf tryToLoadCellMedia:^{ - OWSCAssert([strongSelf.attachmentStream isVideo]); - - return strongSelf.attachmentStream.image; - } - mediaView:stillImageView - cacheKey:strongSelf.attachmentStream.uniqueId - shouldSkipCache:NO]; - }; - self.unloadCellContentBlock = ^{ - OWSMessageCell *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - OWSCAssert(strongSelf.lastBodyMediaView == stillImageView); - stillImageView.image = nil; - }; - - return stillImageView; -} - -- (UIView *)loadViewForGenericAttachment -{ - OWSAssert(self.viewItem.attachmentStream); - OWSGenericAttachmentView *attachmentView = - [[OWSGenericAttachmentView alloc] initWithAttachment:self.attachmentStream isIncoming:self.isIncoming]; - [attachmentView createContents]; - [self addAttachmentUploadViewIfNecessary:attachmentView]; - - self.loadCellContentBlock = ^{ - // Do nothing. - }; - self.unloadCellContentBlock = ^{ - // Do nothing. - }; - - return attachmentView; -} - -- (UIView *)loadViewForDownloadingAttachment -{ - OWSAssert(self.attachmentPointer); - - UIView *customView = [UIView new]; - switch (self.attachmentPointer.state) { - case TSAttachmentPointerStateEnqueued: - customView.backgroundColor - = (self.isIncoming ? [UIColor jsq_messageBubbleLightGrayColor] : [UIColor ows_fadedBlueColor]); - break; - case TSAttachmentPointerStateDownloading: - customView.backgroundColor - = (self.isIncoming ? [UIColor jsq_messageBubbleLightGrayColor] : [UIColor ows_fadedBlueColor]); - break; - case TSAttachmentPointerStateFailed: - customView.backgroundColor = [UIColor grayColor]; - break; - } - - AttachmentPointerView *attachmentPointerView = - [[AttachmentPointerView alloc] initWithAttachmentPointer:self.attachmentPointer isIncoming:self.isIncoming]; - [customView addSubview:attachmentPointerView]; - [attachmentPointerView autoPinWidthToSuperviewWithMargin:20.f]; - [attachmentPointerView autoVCenterInSuperview]; - - self.loadCellContentBlock = ^{ - // Do nothing. - }; - self.unloadCellContentBlock = ^{ - // Do nothing. - }; - - return customView; -} - -- (void)addAttachmentUploadViewIfNecessary:(UIView *)attachmentView -{ - [self addAttachmentUploadViewIfNecessary:attachmentView - attachmentStateCallback:^(BOOL isAttachmentReady){ - }]; -} - -- (void)addAttachmentUploadViewIfNecessary:(UIView *)attachmentView - attachmentStateCallback:(AttachmentStateBlock)attachmentStateCallback -{ - OWSAssert(attachmentView); - OWSAssert(attachmentStateCallback); - OWSAssert(self.attachmentStream); - - if (self.isOutgoing) { - if (!self.attachmentStream.isUploaded) { - AttachmentUploadView *attachmentUploadView = - [[AttachmentUploadView alloc] initWithAttachment:self.attachmentStream - attachmentStateCallback:attachmentStateCallback]; - [attachmentView addSubview:attachmentUploadView]; - [attachmentUploadView autoPinToSuperviewEdges]; - } - } -} - -- (void)showAttachmentErrorViewWithMediaView:(UIView *)mediaView -{ - OWSAssert(mediaView); - - // TODO: We could do a better job of indicating that the media could not be loaded. - UIView *errorView = [UIView new]; - errorView.backgroundColor = [UIColor colorWithWhite:0.85f alpha:1.f]; - errorView.userInteractionEnabled = NO; - [mediaView addSubview:errorView]; - [errorView autoPinEdgesToSuperviewEdges]; -} - #pragma mark - Measurement -// Size of "message body" text, not quoted reply text. -- (CGSize)bodyTextSizeForContentWidth:(int)contentWidth includeMargins:(BOOL)includeMargins -{ - if (!self.hasBodyText) { - return CGSizeZero; - } - - BOOL isRTL = self.isRTL; - CGFloat leftMargin = isRTL ? self.textTrailingMargin : self.textLeadingMargin; - CGFloat rightMargin = isRTL ? self.textLeadingMargin : self.textTrailingMargin; - - const int maxMessageWidth = [self maxMessageWidthForContentWidth:contentWidth]; - const int maxTextWidth = (int)floor(maxMessageWidth - (leftMargin + rightMargin)); - - OWSMessageTextView *bodyTextView = [self configureBodyTextView]; - CGSize textSize = CGSizeCeil([bodyTextView sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]); - textSize.width = MIN(textSize.width, maxTextWidth); - CGSize result = textSize; - - if (includeMargins) { - result.width += leftMargin + rightMargin; - result.height += self.textTopMargin + self.textBottomMargin; - } - - return CGSizeCeil(result); -} - -- (CGSize)bodyMediaSizeForContentWidth:(int)contentWidth -{ - const int maxMessageWidth = [self maxMessageWidthForContentWidth:contentWidth]; - - switch (self.cellType) { - case OWSMessageCellType_Unknown: - case OWSMessageCellType_TextMessage: - case OWSMessageCellType_OversizeTextMessage: { - return CGSizeZero; - } - case OWSMessageCellType_StillImage: - case OWSMessageCellType_AnimatedImage: - case OWSMessageCellType_Video: { - OWSAssert(self.mediaSize.width > 0); - OWSAssert(self.mediaSize.height > 0); - - // TODO: Adjust this behavior. - - CGFloat contentAspectRatio = self.mediaSize.width / self.mediaSize.height; - // Clamp the aspect ratio so that very thin/wide content is presented - // in a reasonable way. - const CGFloat minAspectRatio = 0.35f; - const CGFloat maxAspectRatio = 1 / minAspectRatio; - contentAspectRatio = MAX(minAspectRatio, MIN(maxAspectRatio, contentAspectRatio)); - - const CGFloat maxMediaWidth = maxMessageWidth; - const CGFloat maxMediaHeight = maxMessageWidth; - CGFloat mediaWidth = maxMediaHeight * contentAspectRatio; - CGFloat mediaHeight = maxMediaHeight; - if (mediaWidth > maxMediaWidth) { - mediaWidth = maxMediaWidth; - mediaHeight = maxMediaWidth / contentAspectRatio; - } - - // We don't want to blow up small images unnecessarily. - const CGFloat kMinimumSize = 150.f; - CGFloat shortSrcDimension = MIN(self.mediaSize.width, self.mediaSize.height); - CGFloat shortDstDimension = MIN(mediaWidth, mediaHeight); - if (shortDstDimension > kMinimumSize && shortDstDimension > shortSrcDimension) { - CGFloat factor = kMinimumSize / shortDstDimension; - mediaWidth *= factor; - mediaHeight *= factor; - } - - return CGSizeRound(CGSizeMake(mediaWidth, mediaHeight)); - } - case OWSMessageCellType_Audio: - return CGSizeMake(maxMessageWidth, OWSAudioMessageView.bubbleHeight); - case OWSMessageCellType_GenericAttachment: - return CGSizeMake(maxMessageWidth, [OWSGenericAttachmentView bubbleHeight]); - case OWSMessageCellType_DownloadingAttachment: - return CGSizeMake(200, 90); - } -} - -- (int)maxMessageWidthForContentWidth:(int)contentWidth -{ - return (int)floor(contentWidth * 0.8f); -} - -- (CGSize)quotedMessageSizeForViewWidth:(int)viewWidth - contentWidth:(int)contentWidth - includeMargins:(BOOL)includeMargins -{ - OWSAssert(self.viewItem); - OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); - - if (!self.isQuotedReply) { - return CGSizeZero; - } - - TSMessage *message = (TSMessage *)self.viewItem.interaction; - OWSQuotedMessageView *quotedMessageView = [OWSQuotedMessageView - quotedMessageViewForConversation:message.quotedMessage - displayableQuotedText:(self.hasQuotedText ? self.viewItem.displayableQuotedText : nil)]; - const int maxMessageWidth = [self maxMessageWidthForContentWidth:contentWidth]; - CGSize result = [quotedMessageView sizeForMaxWidth:maxMessageWidth - kBubbleThornSideInset]; - result.width += kBubbleThornSideInset; - - return result; -} +//- (int)maxMessageWidthForContentWidth:(int)contentWidth +//{ +// return (int)floor(contentWidth * 0.8f); +//} - (CGSize)cellSizeForViewWidth:(int)viewWidth contentWidth:(int)contentWidth { OWSAssert(self.viewItem); OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]); + OWSAssert(self.messageBubbleView); - CGSize cellSize = CGSizeZero; + self.messageBubbleView.viewItem = self.viewItem; + self.messageBubbleView.contentWidth = self.contentWidth; + self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache; + CGSize messageBubbleSize = [self.messageBubbleView sizeForViewWidth:viewWidth contentWidth:contentWidth]; - CGSize quotedMessageSize = - [self quotedMessageSizeForViewWidth:viewWidth contentWidth:contentWidth includeMargins:YES]; - cellSize.width = MAX(cellSize.width, quotedMessageSize.width); - cellSize.height += quotedMessageSize.height; - - CGSize mediaContentSize = [self bodyMediaSizeForContentWidth:contentWidth]; - cellSize.width = MAX(cellSize.width, mediaContentSize.width); - cellSize.height += mediaContentSize.height; - - CGSize textContentSize = [self bodyTextSizeForContentWidth:contentWidth includeMargins:YES]; - cellSize.width = MAX(cellSize.width, textContentSize.width); - cellSize.height += textContentSize.height; + CGSize cellSize = messageBubbleSize; OWSAssert(cellSize.width > 0 && cellSize.height > 0); cellSize.height += self.dateHeaderHeight; cellSize.height += self.footerHeight; - if (self.hasTapForMore) { - cellSize.height += self.tapForMoreHeight; - } if (self.shouldHaveFailedSendBadge) { cellSize.width += self.failedSendBadgeSize; @@ -1247,80 +551,60 @@ NS_ASSUME_NONNULL_BEGIN } } -- (UIFont *)tapForMoreFont -{ - return [UIFont ows_regularFontWithSize:12.f]; -} - -- (CGFloat)tapForMoreHeight -{ - return (CGFloat)ceil([self tapForMoreFont].lineHeight * 1.25); -} - #pragma mark - -- (BOOL)isIncoming -{ - return self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage; -} - -- (BOOL)isOutgoing -{ - return self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage; -} - -- (CGFloat)textLeadingMargin -{ - CGFloat result = kBubbleTextHInset; - if (self.isIncoming) { - result += kBubbleThornSideInset; - } - return result; -} - -- (CGFloat)textTrailingMargin -{ - CGFloat result = kBubbleTextHInset; - if (!self.isIncoming) { - result += kBubbleThornSideInset; - } - return result; -} - -- (CGFloat)textTopMargin -{ - return kBubbleTextVInset; -} - -- (CGFloat)textBottomMargin -{ - return kBubbleTextVInset + kBubbleThornVInset; -} - -- (UIColor *)bodyTextColor -{ - return self.isIncoming ? [UIColor blackColor] : [UIColor whiteColor]; -} - -- (BOOL)isMediaBeingSent -{ - if (self.isIncoming) { - return NO; - } - if (self.cellType == OWSMessageCellType_DownloadingAttachment) { - return NO; - } - if (!self.attachmentStream) { - return NO; - } - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - return outgoingMessage.messageState == TSOutgoingMessageStateAttemptingOut; -} - -- (OWSMessagesBubbleImageFactory *)bubbleFactory -{ - return [OWSMessagesBubbleImageFactory shared]; -} +//- (CGFloat)textLeadingMargin +//{ +// CGFloat result = kBubbleTextHInset; +// if (self.isIncoming) { +// result += kBubbleThornSideInset; +// } +// return result; +//} +// +//- (CGFloat)textTrailingMargin +//{ +// CGFloat result = kBubbleTextHInset; +// if (!self.isIncoming) { +// result += kBubbleThornSideInset; +// } +// return result; +//} +// +//- (CGFloat)textTopMargin +//{ +// return kBubbleTextVInset; +//} +// +//- (CGFloat)textBottomMargin +//{ +// return kBubbleTextVInset + kBubbleThornVInset; +//} +// +//- (UIColor *)bodyTextColor +//{ +// return self.isIncoming ? [UIColor blackColor] : [UIColor whiteColor]; +//} +// +//- (BOOL)isMediaBeingSent +//{ +// if (self.isIncoming) { +// return NO; +// } +// if (self.cellType == OWSMessageCellType_DownloadingAttachment) { +// return NO; +// } +// if (!self.attachmentStream) { +// return NO; +// } +// TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; +// return outgoingMessage.messageState == TSOutgoingMessageStateAttemptingOut; +//} +// +//- (OWSMessagesBubbleImageFactory *)bubbleFactory +//{ +// return [OWSMessagesBubbleImageFactory shared]; +//} - (void)prepareForReuse { @@ -1329,37 +613,20 @@ NS_ASSUME_NONNULL_BEGIN [NSLayoutConstraint deactivateConstraints:self.viewConstraints]; self.viewConstraints = [NSMutableArray new]; + [self.messageBubbleView prepareForReuse]; + [self.messageBubbleView unloadContent]; + self.dateHeaderLabel.text = nil; self.dateHeaderLabel.hidden = YES; - [self.bodyTextView removeFromSuperview]; - self.bodyTextView.text = nil; - self.bodyTextView.hidden = YES; [self.failedSendBadgeView removeFromSuperview]; self.failedSendBadgeView = nil; self.footerLabel.text = nil; self.footerLabel.hidden = YES; - self.bubbleView.hidden = YES; - self.bubbleView.bubbleColor = nil; - [self.bubbleView clearPartnerViews]; - - for (UIView *subview in self.bubbleView.subviews) { - [subview removeFromSuperview]; - } - - if (self.unloadCellContentBlock) { - self.unloadCellContentBlock(); - } - self.loadCellContentBlock = nil; - self.unloadCellContentBlock = nil; - [self.expirationTimerView clearAnimations]; [self.expirationTimerView removeFromSuperview]; self.expirationTimerView = nil; - [self.lastBodyMediaView removeFromSuperview]; - self.lastBodyMediaView = nil; - [self hideMenuControllerIfNecessary]; } @@ -1400,30 +667,6 @@ NS_ASSUME_NONNULL_BEGIN return; } - if (self.lastBodyMediaView) { - // Treat this as a "body media" gesture if: - // - // * There is a "body media" view. - // * The gesture occured within or above the "body media" view. - CGPoint location = [sender locationInView:self.lastBodyMediaView]; - if (location.y <= self.lastBodyMediaView.height) { - [self handleMediaTapGesture:sender]; - return; - } - } - - [self handleTextTapGesture:sender]; -} - -- (void)handleTextTapGesture:(UITapGestureRecognizer *)sender -{ - OWSAssert(self.delegate); - - if (sender.state != UIGestureRecognizerStateRecognized) { - DDLogVerbose(@"%@ Ignoring tap on message: %@", self.logTag, self.viewItem.interaction.debugDescription); - return; - } - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; if (outgoingMessage.messageState == TSOutgoingMessageStateUnsent) { @@ -1435,62 +678,52 @@ NS_ASSUME_NONNULL_BEGIN } } - if (self.hasTapForMore) { - [self.delegate didTapTruncatedTextMessage:self.viewItem]; - return; + CGPoint locationInMessageBubble = [sender locationInView:self.messageBubbleView]; + switch ([self.messageBubbleView gestureLocationForLocation:locationInMessageBubble]) { + case OWSMessageGestureLocation_Default: + [self.delegate didTapTruncatedTextMessage:self.viewItem]; + return; + case OWSMessageGestureLocation_OversizeText: + [self.delegate didTapTruncatedTextMessage:self.viewItem]; + return; + case OWSMessageGestureLocation_Media: + [self handleMediaTapGesture]; + break; + case OWSMessageGestureLocation_QuotedReply: + // TODO: + break; } } -- (void)handleMediaTapGesture:(UITapGestureRecognizer *)sender +- (void)handleMediaTapGesture { OWSAssert(self.delegate); - if (sender.state != UIGestureRecognizerStateRecognized) { - DDLogVerbose(@"%@ Ignoring tap on message: %@", self.logTag, self.viewItem.interaction.debugDescription); - return; - } - - if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { - TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; - if (outgoingMessage.messageState == TSOutgoingMessageStateUnsent) { - [self.delegate didTapFailedOutgoingMessage:outgoingMessage]; - return; - } else if (outgoingMessage.messageState == TSOutgoingMessageStateAttemptingOut) { - // Ignore taps on outgoing messages being sent. - return; - } - } - switch (self.cellType) { case OWSMessageCellType_Unknown: - break; case OWSMessageCellType_TextMessage: case OWSMessageCellType_OversizeTextMessage: - if (self.hasTapForMore) { - [self.delegate didTapTruncatedTextMessage:self.viewItem]; - return; - } break; case OWSMessageCellType_StillImage: - OWSAssert(self.lastBodyMediaView); + OWSAssert(self.messageBubbleView.lastBodyMediaView); [self.delegate didTapImageViewItem:self.viewItem attachmentStream:self.attachmentStream - imageView:self.lastBodyMediaView]; + imageView:self.messageBubbleView.lastBodyMediaView]; break; case OWSMessageCellType_AnimatedImage: - OWSAssert(self.lastBodyMediaView); + OWSAssert(self.messageBubbleView.lastBodyMediaView); [self.delegate didTapImageViewItem:self.viewItem attachmentStream:self.attachmentStream - imageView:self.lastBodyMediaView]; + imageView:self.messageBubbleView.lastBodyMediaView]; break; case OWSMessageCellType_Audio: [self.delegate didTapAudioViewItem:self.viewItem attachmentStream:self.attachmentStream]; return; case OWSMessageCellType_Video: - OWSAssert(self.lastBodyMediaView); + OWSAssert(self.messageBubbleView.lastBodyMediaView); [self.delegate didTapVideoViewItem:self.viewItem attachmentStream:self.attachmentStream - imageView:self.lastBodyMediaView]; + imageView:self.messageBubbleView.lastBodyMediaView]; return; case OWSMessageCellType_GenericAttachment: [AttachmentSharing showShareUIForAttachment:self.attachmentStream]; @@ -1513,40 +746,33 @@ NS_ASSUME_NONNULL_BEGIN return; } - if (self.lastBodyMediaView) { - // Treat this as a "body media" gesture if: - // - // * There is a "body media" view. - // * The gesture occured within or above the "body media" view. - CGPoint location = [sender locationInView:self.lastBodyMediaView]; - if (location.y <= self.lastBodyMediaView.height) { - [self handleMediaLongPressGesture:sender]; + if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) { + TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; + if (outgoingMessage.messageState == TSOutgoingMessageStateUnsent) { + // Ignore long press on unsent messages. + return; + } else if (outgoingMessage.messageState == TSOutgoingMessageStateAttemptingOut) { + // Ignore long press on outgoing messages being sent. return; } } - [self handleTextLongPressGesture:sender]; -} - -- (void)handleTextLongPressGesture:(UILongPressGestureRecognizer *)sender -{ - OWSAssert(self.delegate); - - // We "eagerly" respond when the long press begins, not when it ends. - if (sender.state == UIGestureRecognizerStateBegan) { - CGPoint location = [sender locationInView:self]; - [self showTextMenuController:location]; - } -} - -- (void)handleMediaLongPressGesture:(UILongPressGestureRecognizer *)sender -{ - OWSAssert(self.delegate); - - // We "eagerly" respond when the long press begins, not when it ends. - if (sender.state == UIGestureRecognizerStateBegan) { - CGPoint location = [sender locationInView:self]; - [self showMediaMenuController:location]; + CGPoint locationInMessageBubble = [sender locationInView:self.messageBubbleView]; + switch ([self.messageBubbleView gestureLocationForLocation:locationInMessageBubble]) { + case OWSMessageGestureLocation_Default: + case OWSMessageGestureLocation_OversizeText: { + CGPoint location = [sender locationInView:self]; + [self showTextMenuController:location]; + break; + } + case OWSMessageGestureLocation_Media: { + CGPoint location = [sender locationInView:self]; + [self showMediaMenuController:location]; + break; + } + case OWSMessageGestureLocation_QuotedReply: + // TODO: + break; } } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 8cad36aaa..61b4cf186 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -2299,6 +2299,14 @@ typedef enum : NSUInteger { - (void)scrollDownButtonTapped { +#ifdef DEBUG + CGPoint contentOffset = self.collectionView.contentOffset; + contentOffset.y += self.collectionView.height + - (self.collectionView.contentInset.top + self.collectionView.contentInset.bottom); + [self.collectionView setContentOffset:contentOffset animated:NO]; + return; +#endif + NSIndexPath *indexPathOfUnreadMessagesIndicator = [self indexPathOfUnreadMessagesIndicator]; if (indexPathOfUnreadMessagesIndicator != nil) { NSInteger unreadRow = indexPathOfUnreadMessagesIndicator.row; diff --git a/Signal/src/ViewControllers/HomeViewController.m b/Signal/src/ViewControllers/HomeViewController.m index 344c90819..0c4af11c7 100644 --- a/Signal/src/ViewControllers/HomeViewController.m +++ b/Signal/src/ViewControllers/HomeViewController.m @@ -284,6 +284,15 @@ typedef NS_ENUM(NSInteger, CellState) { kArchiveState, kInboxState }; } [self updateBarButtonItems]; + + dispatch_async(dispatch_get_main_queue(), ^{ + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; + TSThread *thread = [self threadForIndexPath:indexPath]; + if (!thread) { + return; + } + [self presentThread:thread keyboardOnViewAppearing:NO callOnViewAppearing:NO]; + }); } - (void)viewDidAppear:(BOOL)animated