session-ios/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m

1092 lines
38 KiB
Mathematica
Raw Normal View History

2018-04-05 19:19:13 +02:00
//
// 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 <SignalMessaging/UIView+OWS.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageBubbleView ()
@property (nonatomic) OWSBubbleView *bubbleView;
@property (nonatomic) OWSMessageTextView *bodyTextView;
2018-04-06 18:34:50 +02:00
@property (nonatomic, nullable) UIView *quotedMessageView;
2018-04-05 19:19:13 +02:00
2018-04-06 18:34:50 +02:00
@property (nonatomic, nullable) UIView *bodyMediaView;
2018-04-05 19:19:13 +02:00
// 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<NSLayoutConstraint *> *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
- (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)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 quotedMessageContentSize = [self quotedMessageSizeForContentWidth:self.contentWidth includeMargins:NO];
2018-04-05 19:19:13 +02:00
CGSize bodyMediaContentSize = [self bodyMediaSizeForContentWidth:self.contentWidth];
CGSize bodyTextContentSize = [self bodyTextSizeForContentWidth:self.contentWidth includeMargins:NO];
self.bubbleView.isOutgoing = self.isOutgoing;
2018-04-06 18:45:16 +02:00
self.bubbleView.hideTail = self.viewItem.shouldHideBubbleTail && !self.alwaysShowBubbleTail;
2018-04-05 19:19:13 +02:00
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)];
2018-04-06 18:34:50 +02:00
self.quotedMessageView = quotedMessageView;
2018-04-05 19:19:13 +02:00
[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],
]];
// We need constraints to control the vertical sizing of bubble content 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:[quotedMessageView autoSetDimension:ALDimensionHeight
toSize:quotedMessageContentSize.height]];
}];
2018-04-05 19:19:13 +02:00
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);
bodyMediaView.clipsToBounds = YES;
2018-04-06 18:34:50 +02:00
self.bodyMediaView = bodyMediaView;
2018-04-05 19:19:13 +02:00
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 bubble content views, but we use
2018-04-05 19:19:13 +02:00
// 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 bubble content views, but we use
2018-04-05 19:19:13 +02:00
// 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;
[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;
}
2018-04-06 18:34:50 +02:00
OWSCAssert(strongSelf.bodyMediaView == stillImageView);
2018-04-05 19:19:13 +02:00
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;
}
2018-04-06 18:34:50 +02:00
OWSCAssert(strongSelf.bodyMediaView == stillImageView);
2018-04-05 19:19:13 +02:00
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;
}
2018-04-06 18:34:50 +02:00
OWSCAssert(strongSelf.bodyMediaView == animatedImageView);
2018-04-05 19:19:13 +02:00
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;
}
2018-04-06 18:34:50 +02:00
OWSCAssert(strongSelf.bodyMediaView == animatedImageView);
2018-04-05 19:19:13 +02:00
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;
}
2018-04-06 18:34:50 +02:00
OWSCAssert(strongSelf.bodyMediaView == stillImageView);
2018-04-05 19:19:13 +02:00
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;
}
2018-04-06 18:34:50 +02:00
OWSCAssert(strongSelf.bodyMediaView == stillImageView);
2018-04-05 19:19:13 +02:00
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)quotedMessageSizeForContentWidth:(int)contentWidth includeMargins:(BOOL)includeMargins
2018-04-05 19:19:13 +02:00
{
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];
if (includeMargins) {
result.width += kBubbleThornSideInset;
}
2018-04-05 19:19:13 +02:00
return result;
}
- (CGSize)sizeForContentWidth:(int)contentWidth
2018-04-05 19:19:13 +02:00
{
OWSAssert(self.viewItem);
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
CGSize cellSize = CGSizeZero;
CGSize quotedMessageSize = [self quotedMessageSizeForContentWidth:contentWidth includeMargins:YES];
2018-04-05 19:19:13 +02:00
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;
2018-04-06 18:34:50 +02:00
[self.bodyMediaView removeFromSuperview];
self.bodyMediaView = nil;
2018-04-05 19:19:13 +02:00
2018-04-06 18:34:50 +02:00
[self.quotedMessageView removeFromSuperview];
self.quotedMessageView = nil;
2018-04-05 19:19:13 +02:00
}
#pragma mark - Gestures
- (OWSMessageGestureLocation)gestureLocationForLocation:(CGPoint)locationInMessageBubble
{
2018-04-06 18:34:50 +02:00
if (self.quotedMessageView) {
2018-04-05 19:19:13 +02:00
// Treat this as a "quoted reply" gesture if:
//
// * There is a "quoted reply" view.
// * The gesture occured within or above the "quoted reply" view.
2018-04-06 18:34:50 +02:00
CGPoint location = [self convertPoint:locationInMessageBubble toView:self.quotedMessageView];
if (location.y <= self.quotedMessageView.height) {
2018-04-05 19:19:13 +02:00
return OWSMessageGestureLocation_QuotedReply;
}
}
2018-04-06 18:34:50 +02:00
if (self.bodyMediaView) {
2018-04-05 19:19:13 +02:00
// Treat this as a "body media" gesture if:
//
// * There is a "body media" view.
// * The gesture occured within or above the "body media" view.
2018-04-06 18:34:50 +02:00
CGPoint location = [self convertPoint:locationInMessageBubble toView:self.bodyMediaView];
if (location.y <= self.bodyMediaView.height) {
2018-04-05 19:19:13 +02:00
return OWSMessageGestureLocation_Media;
}
}
if (self.hasTapForMore) {
return OWSMessageGestureLocation_OversizeText;
}
return OWSMessageGestureLocation_Default;
}
@end
NS_ASSUME_NONNULL_END