session-ios/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m
Michael Kirk 56e5abb2c3 Format date breaks to spec
// FREEBIE
2018-07-10 11:39:56 -06:00

665 lines
22 KiB
Objective-C

//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSMessageCell.h"
#import "OWSContactAvatarBuilder.h"
#import "OWSMessageBubbleView.h"
#import "Signal-Swift.h"
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageCell ()
// The nullable properties are created as needed.
// The non-nullable properties are so frequently used that it's easier
// to always keep one around.
@property (nonatomic) OWSMessageBubbleView *messageBubbleView;
@property (nonatomic) UIStackView *dateHeaderView;
@property (nonatomic) UILabel *dateHeaderLabel;
@property (nonatomic) AvatarImageView *avatarView;
@property (nonatomic, nullable) UIImageView *sendFailureBadgeView;
@property (nonatomic, nullable) NSMutableArray<NSLayoutConstraint *> *viewConstraints;
@property (nonatomic) BOOL isPresentingMenuController;
@end
#pragma mark -
@implementation OWSMessageCell
// `[UIView init]` invokes `[self initWithFrame:...]`.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self commontInit];
}
return self;
}
- (void)commontInit
{
// Ensure only called once.
OWSAssert(!self.messageBubbleView);
self.layoutMargins = UIEdgeInsetsZero;
self.contentView.layoutMargins = UIEdgeInsetsZero;
_viewConstraints = [NSMutableArray new];
self.messageBubbleView = [OWSMessageBubbleView new];
[self.contentView addSubview:self.messageBubbleView];
self.dateHeaderLabel = [UILabel new];
self.dateHeaderLabel.font = self.dateHeaderFont;
self.dateHeaderLabel.textAlignment = NSTextAlignmentCenter;
self.dateHeaderLabel.textColor = [UIColor ows_light60Color];
self.dateHeaderView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.dateHeaderLabel,
]];
self.dateHeaderView.axis = NSTextLayoutOrientationVertical;
self.avatarView = [[AvatarImageView alloc] init];
[self.avatarView autoSetDimension:ALDimensionWidth toSize:self.avatarSize];
[self.avatarView autoSetDimension:ALDimensionHeight toSize:self.avatarSize];
[self.messageBubbleView autoPinBottomToSuperviewMarginWithInset:0];
self.contentView.userInteractionEnabled = YES;
UITapGestureRecognizer *tap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)];
[self addGestureRecognizer:tap];
UILongPressGestureRecognizer *longPress =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGesture:)];
[self.contentView addGestureRecognizer:longPress];
PanDirectionGestureRecognizer *panGesture = [[PanDirectionGestureRecognizer alloc]
initWithDirection:(CurrentAppContext().isRTL ? PanDirectionLeft : PanDirectionRight)
target:self
action:@selector(handlePanGesture:)];
[self addGestureRecognizer:panGesture];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)setConversationStyle:(nullable ConversationStyle *)conversationStyle
{
[super setConversationStyle:conversationStyle];
self.messageBubbleView.conversationStyle = conversationStyle;
}
+ (NSString *)cellReuseIdentifier
{
return NSStringFromClass([self class]);
}
#pragma mark - Convenience Accessors
- (OWSMessageCellType)cellType
{
return self.viewItem.messageCellType;
}
- (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;
}
- (BOOL)shouldHaveSendFailureBadge
{
if (![self.viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) {
return NO;
}
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction;
return outgoingMessage.messageState == TSOutgoingMessageStateFailed;
}
#pragma mark - Load
- (void)loadForDisplayWithTransaction:(YapDatabaseReadTransaction *)transaction
{
OWSAssert(self.conversationStyle);
OWSAssert(self.viewItem);
OWSAssert(self.viewItem.interaction);
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
OWSAssert(self.messageBubbleView);
self.messageBubbleView.viewItem = self.viewItem;
self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache;
[self.messageBubbleView configureViews];
[self.messageBubbleView loadContent];
// Update label fonts to honor dynamic type size.
self.dateHeaderLabel.font = self.dateHeaderFont;
if (self.isIncoming) {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.gutterLeading],
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.gutterTrailing
relation:NSLayoutRelationGreaterThanOrEqual],
]];
} else {
if (self.shouldHaveSendFailureBadge) {
self.sendFailureBadgeView = [UIImageView new];
self.sendFailureBadgeView.image =
[self.sendFailureBadge imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
self.sendFailureBadgeView.tintColor = [UIColor ows_destructiveRedColor];
[self.contentView addSubview:self.sendFailureBadgeView];
CGFloat sendFailureBadgeBottomMargin
= round(self.conversationStyle.lastTextLineAxis - self.sendFailureBadgeSize * 0.5f);
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.gutterLeading
relation:NSLayoutRelationGreaterThanOrEqual],
[self.sendFailureBadgeView autoPinLeadingToTrailingEdgeOfView:self.messageBubbleView
offset:self.sendFailureBadgeSpacing],
// V-align the "send failure" badge with the
// last line of the text (if any, or where it
// would be).
[self.messageBubbleView autoPinEdge:ALEdgeBottom
toEdge:ALEdgeBottom
ofView:self.sendFailureBadgeView
withOffset:sendFailureBadgeBottomMargin],
[self.sendFailureBadgeView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.errorGutterTrailing],
[self.sendFailureBadgeView autoSetDimension:ALDimensionWidth toSize:self.sendFailureBadgeSize],
[self.sendFailureBadgeView autoSetDimension:ALDimensionHeight toSize:self.sendFailureBadgeSize],
]];
} else {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.gutterLeading
relation:NSLayoutRelationGreaterThanOrEqual],
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.gutterTrailing],
]];
}
}
[self updateDateHeader];
if ([self updateAvatarView]) {
CGFloat avatarBottomMargin = round(self.conversationStyle.lastTextLineAxis - self.avatarSize * 0.5f);
[self.viewConstraints addObjectsFromArray:@[
// V-align the "group sender" avatar with the
// last line of the text (if any, or where it
// would be).
[self.messageBubbleView autoPinLeadingToTrailingEdgeOfView:self.avatarView offset:8],
[self.messageBubbleView autoPinEdge:ALEdgeBottom
toEdge:ALEdgeBottom
ofView:self.avatarView
withOffset:avatarBottomMargin],
]];
}
}
- (UIImage *)sendFailureBadge
{
UIImage *image = [UIImage imageNamed:@"message_status_failed_large"];
OWSAssert(image);
OWSAssert(image.size.width == self.sendFailureBadgeSize && image.size.height == self.sendFailureBadgeSize);
return image;
}
- (CGFloat)sendFailureBadgeSize
{
return 20.f;
}
- (CGFloat)sendFailureBadgeSpacing
{
return 8.f;
}
// * 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) {
[self.messageBubbleView unloadContent];
} else {
[self.messageBubbleView loadContent];
}
}
- (void)updateDateHeader
{
OWSAssert(self.conversationStyle);
if (self.viewItem.shouldShowDate) {
NSDate *date = self.viewItem.interaction.dateForSorting;
NSString *dateString = [DateUtil formatDateForConversationDateBreaks:date];
self.dateHeaderLabel.font = self.dateHeaderFont;
self.dateHeaderLabel.textColor = UIColor.lightGrayColor;
self.dateHeaderLabel.text = dateString;
[self.contentView addSubview:self.dateHeaderView];
[self.viewConstraints addObjectsFromArray:@[
[self.dateHeaderView
autoPinLeadingToSuperviewMarginWithInset:self.conversationStyle.fullWidthGutterLeading],
[self.dateHeaderView
autoPinTrailingToSuperviewMarginWithInset:self.conversationStyle.fullWidthGutterTrailing],
[self.dateHeaderView autoPinEdgeToSuperviewEdge:ALEdgeTop],
// DO NOT pin to the bottom of dateHeaderView.
//
// Being a UIStackView, it doesn't reflect the spacing below the date
// header contents. Instead pin using dateHeaderHeight which includes
// the spacing.
[self.messageBubbleView autoPinEdge:ALEdgeTop
toEdge:ALEdgeTop
ofView:self.dateHeaderView
withOffset:self.dateHeaderHeight],
]];
} else {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTop],
]];
}
}
- (UIFont *)dateHeaderFont
{
return UIFont.ows_dynamicTypeCaption1Font;
}
#pragma mark - Avatar
// Returns YES IFF the avatar view is appropriate and configured.
- (BOOL)updateAvatarView
{
if (!self.viewItem.shouldShowSenderAvatar) {
return NO;
}
if (!self.viewItem.isGroupThread) {
OWSFail(@"%@ not a group thread.", self.logTag);
return NO;
}
if (self.viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) {
OWSFail(@"%@ not an incoming message.", self.logTag);
return NO;
}
OWSContactsManager *contactsManager = self.delegate.contactsManager;
if (contactsManager == nil) {
OWSFail(@"%@ contactsManager should not be nil", self.logTag);
return NO;
}
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction;
OWSAvatarBuilder *avatarBuilder = [[OWSContactAvatarBuilder alloc] initWithSignalId:incomingMessage.authorId
color:self.conversationStyle.primaryColor
diameter:self.avatarSize
contactsManager:contactsManager];
self.avatarView.image = [avatarBuilder build];
[self.contentView addSubview:self.avatarView];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(otherUsersProfileDidChange:)
name:kNSNotificationName_OtherUsersProfileDidChange
object:nil];
return YES;
}
- (NSUInteger)avatarSize
{
return 36.f;
}
- (void)otherUsersProfileDidChange:(NSNotification *)notification
{
OWSAssertIsOnMainThread();
if (!self.viewItem.shouldShowSenderAvatar) {
return;
}
if (!self.viewItem.isGroupThread) {
OWSFail(@"%@ not a group thread.", self.logTag);
return;
}
if (self.viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) {
OWSFail(@"%@ not an incoming message.", self.logTag);
return;
}
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
if (recipientId.length == 0) {
return;
}
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction;
if (![incomingMessage.authorId isEqualToString:recipientId]) {
return;
}
[self updateAvatarView];
}
#pragma mark - Measurement
- (CGSize)cellSizeWithTransaction:(YapDatabaseReadTransaction *)transaction
{
OWSAssert(self.conversationStyle);
OWSAssert(self.conversationStyle.viewWidth > 0);
OWSAssert(self.viewItem);
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
OWSAssert(self.messageBubbleView);
self.messageBubbleView.viewItem = self.viewItem;
self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache;
CGSize messageBubbleSize = [self.messageBubbleView measureSize];
CGSize cellSize = messageBubbleSize;
OWSAssert(cellSize.width > 0 && cellSize.height > 0);
cellSize.height += self.dateHeaderHeight;
if (self.shouldHaveSendFailureBadge) {
cellSize.width += self.sendFailureBadgeSize + self.sendFailureBadgeSpacing;
}
cellSize = CGSizeCeil(cellSize);
return cellSize;
}
- (CGFloat)dateHeaderStrokeThickness
{
return CGHairlineWidth();
}
- (CGFloat)dateHeaderBottomMargin
{
return 20.f;
}
- (CGFloat)dateHeaderHeight
{
if (self.viewItem.shouldShowDate) {
CGFloat textHeight = self.dateHeaderFont.capHeight;
return (CGFloat)ceil(self.dateHeaderStrokeThickness + textHeight + self.dateHeaderBottomMargin);
} else {
return 0.f;
}
}
#pragma mark - Reuse
- (void)prepareForReuse
{
[super prepareForReuse];
[NSLayoutConstraint deactivateConstraints:self.viewConstraints];
self.viewConstraints = [NSMutableArray new];
[self.messageBubbleView prepareForReuse];
[self.messageBubbleView unloadContent];
[self.dateHeaderView removeFromSuperview];
self.avatarView.image = nil;
[self.avatarView removeFromSuperview];
[self.sendFailureBadgeView removeFromSuperview];
self.sendFailureBadgeView = nil;
[self hideMenuControllerIfNecessary];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Notifications
- (void)setIsCellVisible:(BOOL)isCellVisible {
BOOL didChange = self.isCellVisible != isCellVisible;
[super setIsCellVisible:isCellVisible];
if (!didChange) {
return;
}
[self ensureMediaLoadState];
if (!isCellVisible) {
[self hideMenuControllerIfNecessary];
}
}
#pragma mark - Gesture recognizers
- (void)handleTapGesture:(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 == TSOutgoingMessageStateFailed) {
[self.delegate didTapFailedOutgoingMessage:outgoingMessage];
return;
} else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) {
// Ignore taps on outgoing messages being sent.
return;
}
}
[self.messageBubbleView handleTapGesture:sender];
}
- (void)handleLongPressGesture:(UILongPressGestureRecognizer *)sender
{
OWSAssert(self.delegate);
if (sender.state != UIGestureRecognizerStateBegan) {
return;
}
if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction;
if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) {
// Ignore long press on unsent messages.
return;
} else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) {
// Ignore long press on outgoing messages being sent.
return;
}
}
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: {
CGPoint location = [sender locationInView:self];
[self showDefaultMenuController:location];
break;
}
}
}
- (void)handlePanGesture:(UIPanGestureRecognizer *)panRecognizer
{
OWSAssert(self.delegate);
[self.delegate didPanWithGestureRecognizer:panRecognizer viewItem:self.viewItem];
}
#pragma mark - UIMenuController
- (void)showTextMenuController:(CGPoint)fromLocation
{
[self showMenuController:fromLocation menuItems:self.viewItem.textMenuControllerItems];
}
- (void)showMediaMenuController:(CGPoint)fromLocation
{
[self showMenuController:fromLocation menuItems:self.viewItem.mediaMenuControllerItems];
}
- (void)showDefaultMenuController:(CGPoint)fromLocation
{
[self showMenuController:fromLocation menuItems:self.viewItem.defaultMenuControllerItems];
}
- (void)showMenuController:(CGPoint)fromLocation menuItems:(NSArray *)menuItems
{
if (menuItems.count < 1) {
OWSFail(@"%@ No menu items to present.", self.logTag);
return;
}
// We don't want taps on messages to hide the keyboard,
// so we only let messages become first responder
// while they are trying to present the menu controller.
self.isPresentingMenuController = YES;
[self becomeFirstResponder];
if ([UIMenuController sharedMenuController].isMenuVisible) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
// We use custom action selectors so that we can control
// the ordering of the actions in the menu.
[UIMenuController sharedMenuController].menuItems = menuItems;
CGRect targetRect = CGRectMake(fromLocation.x, fromLocation.y, 1, 1);
[[UIMenuController sharedMenuController] setTargetRect:targetRect inView:self];
[[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
return [self.viewItem canPerformAction:action];
}
- (void)copyTextAction:(nullable id)sender
{
[self.viewItem copyTextAction];
}
- (void)copyMediaAction:(nullable id)sender
{
[self.viewItem copyMediaAction];
}
- (void)shareTextAction:(nullable id)sender
{
[self.viewItem shareTextAction];
}
- (void)shareMediaAction:(nullable id)sender
{
[self.viewItem shareMediaAction];
}
- (void)saveMediaAction:(nullable id)sender
{
[self.viewItem saveMediaAction];
}
- (void)deleteAction:(nullable id)sender
{
[self.viewItem deleteAction];
}
- (void)metadataAction:(nullable id)sender
{
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
[self.delegate showMetadataViewForViewItem:self.viewItem];
}
- (void)replyAction:(nullable id)sender
{
OWSAssert([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
[self.delegate conversationCell:self didTapReplyForViewItem:self.viewItem];
}
- (BOOL)canBecomeFirstResponder
{
return self.isPresentingMenuController;
}
- (void)didHideMenuController:(NSNotification *)notification
{
self.isPresentingMenuController = NO;
}
- (void)setIsPresentingMenuController:(BOOL)isPresentingMenuController
{
if (_isPresentingMenuController == isPresentingMenuController) {
return;
}
_isPresentingMenuController = isPresentingMenuController;
if (isPresentingMenuController) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didHideMenuController:)
name:UIMenuControllerDidHideMenuNotification
object:nil];
} else {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIMenuControllerDidHideMenuNotification
object:nil];
}
}
- (void)hideMenuControllerIfNecessary
{
if (self.isPresentingMenuController) {
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:NO];
}
self.isPresentingMenuController = NO;
}
@end
NS_ASSUME_NONNULL_END