1532 lines
62 KiB
Objective-C
1532 lines
62 KiB
Objective-C
/*
|
|
Copyright 2018-2024 New Vector Ltd.
|
|
Copyright 2017 Vector Creations Ltd
|
|
Copyright 2015 OpenMarket Ltd
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
#import "MXKRoomBubbleTableViewCell.h"
|
|
|
|
#import "MXKImageView.h"
|
|
#import "MXKPieChartView.h"
|
|
#import "MXKRoomBubbleCellData.h"
|
|
#import "MXKTools.h"
|
|
|
|
#import "MXKConstants.h"
|
|
|
|
#import "NSBundle+MatrixKit.h"
|
|
#import "MXRoom+Sync.h"
|
|
#import "MXKMessageTextView.h"
|
|
|
|
#import "GeneratedInterface-Swift.h"
|
|
|
|
#pragma mark - Constant definitions
|
|
NSString *const kMXKRoomBubbleCellTapOnMessageTextView = @"kMXKRoomBubbleCellTapOnMessageTextView";
|
|
NSString *const kMXKRoomBubbleCellTapOnSenderNameLabel = @"kMXKRoomBubbleCellTapOnSenderNameLabel";
|
|
NSString *const kMXKRoomBubbleCellTapOnAvatarView = @"kMXKRoomBubbleCellTapOnAvatarView";
|
|
NSString *const kMXKRoomBubbleCellTapOnDateTimeContainer = @"kMXKRoomBubbleCellTapOnDateTimeContainer";
|
|
NSString *const kMXKRoomBubbleCellTapOnAttachmentView = @"kMXKRoomBubbleCellTapOnAttachmentView";
|
|
NSString *const kMXKRoomBubbleCellTapOnOverlayContainer = @"kMXKRoomBubbleCellTapOnOverlayContainer";
|
|
NSString *const kMXKRoomBubbleCellTapOnContentView = @"kMXKRoomBubbleCellTapOnContentView";
|
|
|
|
|
|
NSString *const kMXKRoomBubbleCellUnsentButtonPressed = @"kMXKRoomBubbleCellUnsentButtonPressed";
|
|
NSString *const kMXKRoomBubbleCellStopShareButtonPressed = @"kMXKRoomBubbleCellStopShareButtonPressed";
|
|
NSString *const kMXKRoomBubbleCellRetryShareButtonPressed = @"kMXKRoomBubbleCellRetryShareButtonPressed";
|
|
|
|
NSString *const kMXKRoomBubbleCellLongPressOnEvent = @"kMXKRoomBubbleCellLongPressOnEvent";
|
|
NSString *const kMXKRoomBubbleCellLongPressOnProgressView = @"kMXKRoomBubbleCellLongPressOnProgressView";
|
|
NSString *const kMXKRoomBubbleCellLongPressOnAvatarView = @"kMXKRoomBubbleCellLongPressOnAvatarView";
|
|
NSString *const kMXKRoomBubbleCellShouldInteractWithURL = @"kMXKRoomBubbleCellShouldInteractWithURL";
|
|
|
|
NSString *const kMXKRoomBubbleCellUserIdKey = @"kMXKRoomBubbleCellUserIdKey";
|
|
NSString *const kMXKRoomBubbleCellEventKey = @"kMXKRoomBubbleCellEventKey";
|
|
NSString *const kMXKRoomBubbleCellEventIdKey = @"kMXKRoomBubbleCellEventIdKey";
|
|
NSString *const kMXKRoomBubbleCellReceiptsContainerKey = @"kMXKRoomBubbleCellReceiptsContainerKey";
|
|
NSString *const kMXKRoomBubbleCellUrl = @"kMXKRoomBubbleCellUrl";
|
|
NSString *const kMXKRoomBubbleCellUrlItemInteraction = @"kMXKRoomBubbleCellUrlItemInteraction";
|
|
|
|
static BOOL _disableLongPressGestureOnEvent;
|
|
|
|
@interface MXKRoomBubbleTableViewCell () <UIGestureRecognizerDelegate>
|
|
{
|
|
// The list of UIViews used to fix the display of side borders for HTML blockquotes
|
|
NSMutableArray<UIView*> *htmlBlockquoteSideBorderViews;
|
|
}
|
|
|
|
@property (nonatomic, weak) UIView *messageTextBackgroundView;
|
|
@property (nonatomic) double attachmentViewBottomConstraintDefaultConstant;
|
|
|
|
@end
|
|
|
|
@implementation MXKRoomBubbleTableViewCell
|
|
@synthesize delegate, bubbleData, readReceiptsAlignment;
|
|
@synthesize mxkCellData;
|
|
|
|
+ (instancetype)roomBubbleTableViewCell
|
|
{
|
|
MXKRoomBubbleTableViewCell *instance = nil;
|
|
|
|
// Check whether a xib is defined
|
|
if ([[self class] nib])
|
|
{
|
|
@try {
|
|
instance = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject;
|
|
}
|
|
@catch (NSException *exception) {
|
|
}
|
|
}
|
|
|
|
if (!instance)
|
|
{
|
|
instance = [[self alloc] init];
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
+ (void)disableLongPressGestureOnEvent:(BOOL)disable
|
|
{
|
|
_disableLongPressGestureOnEvent = disable;
|
|
}
|
|
|
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier
|
|
{
|
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
|
if (self)
|
|
{
|
|
[self finalizeInit];
|
|
}
|
|
return self;
|
|
}
|
|
- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder
|
|
{
|
|
self = [super initWithCoder:aDecoder];
|
|
if (self)
|
|
{
|
|
[self finalizeInit];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)finalizeInit
|
|
{
|
|
self.readReceiptsAlignment = ReadReceiptAlignmentLeft;
|
|
_allTextHighlighted = NO;
|
|
_isAutoAnimatedGif = NO;
|
|
_tmpSubviews = [NSMutableArray array];
|
|
_isTextViewNeedsPositioningVerticalSpace = YES;
|
|
}
|
|
|
|
- (void)awakeFromNib
|
|
{
|
|
[super awakeFromNib];
|
|
|
|
[self setupViews];
|
|
}
|
|
|
|
- (void)setupViews
|
|
{
|
|
[self setupSenderNameLabel];
|
|
|
|
[self setupAvatarView];
|
|
|
|
[self setupMessageTextView];
|
|
|
|
if (self.playIconView)
|
|
{
|
|
self.playIconView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"play"];
|
|
}
|
|
|
|
if (self.bubbleOverlayContainer)
|
|
{
|
|
// Add tap recognizer on overlay container
|
|
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onOverlayTap:)];
|
|
[tapGesture setNumberOfTouchesRequired:1];
|
|
[tapGesture setNumberOfTapsRequired:1];
|
|
[tapGesture setDelegate:self];
|
|
[self.bubbleOverlayContainer addGestureRecognizer:tapGesture];
|
|
}
|
|
|
|
// Listen to content view tap by default
|
|
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onContentViewTap:)];
|
|
[tapGesture setNumberOfTouchesRequired:1];
|
|
[tapGesture setNumberOfTapsRequired:1];
|
|
[tapGesture setDelegate:self];
|
|
[self.contentView addGestureRecognizer:tapGesture];
|
|
|
|
if (_disableLongPressGestureOnEvent == NO)
|
|
{
|
|
// Add a long gesture recognizer on text view (in order to display for example the event details)
|
|
UILongPressGestureRecognizer *longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)];
|
|
longPressGestureRecognizer.delegate = self;
|
|
[self.contentView addGestureRecognizer:longPressGestureRecognizer];
|
|
}
|
|
|
|
[self setupConstraintsConstantDefaultValues];
|
|
}
|
|
|
|
- (void)setupSenderNameLabel
|
|
{
|
|
if (!self.userNameLabel)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Listen to name tap
|
|
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onSenderNameTap:)];
|
|
[tapGesture setNumberOfTouchesRequired:1];
|
|
[tapGesture setNumberOfTapsRequired:1];
|
|
[tapGesture setDelegate:self];
|
|
|
|
if (self.userNameTapGestureMaskView)
|
|
{
|
|
[self.userNameTapGestureMaskView addGestureRecognizer:tapGesture];
|
|
}
|
|
else
|
|
{
|
|
[self.userNameLabel addGestureRecognizer:tapGesture];
|
|
self.userNameLabel.userInteractionEnabled = YES;
|
|
}
|
|
}
|
|
|
|
- (void)setupAvatarView
|
|
{
|
|
if (!self.pictureView)
|
|
{
|
|
return;
|
|
}
|
|
|
|
self.pictureView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder;
|
|
|
|
// Listen to avatar tap
|
|
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAvatarTap:)];
|
|
[tapGesture setNumberOfTouchesRequired:1];
|
|
[tapGesture setNumberOfTapsRequired:1];
|
|
[tapGesture setDelegate:self];
|
|
[self.pictureView addGestureRecognizer:tapGesture];
|
|
self.pictureView.userInteractionEnabled = YES;
|
|
|
|
// Add a long gesture recognizer on avatar (in order to display for example the member details)
|
|
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)];
|
|
[self.pictureView addGestureRecognizer:longPress];
|
|
}
|
|
|
|
- (void)setupMessageTextView
|
|
{
|
|
if (!self.messageTextView)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Listen to textView tap
|
|
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onMessageTap:)];
|
|
[tapGesture setNumberOfTouchesRequired:1];
|
|
[tapGesture setNumberOfTapsRequired:1];
|
|
[tapGesture setDelegate:self];
|
|
[self.messageTextView addGestureRecognizer:tapGesture];
|
|
self.messageTextView.userInteractionEnabled = YES;
|
|
self.messageTextView.clipsToBounds = NO;
|
|
|
|
// Recognise and make tappable phone numbers, address, etc.
|
|
self.messageTextView.dataDetectorTypes = UIDataDetectorTypeAll;
|
|
|
|
// Listen to link click
|
|
self.messageTextView.delegate = self;
|
|
|
|
[self setupMessageTextViewLongPressGesture];
|
|
}
|
|
|
|
- (void)setupMessageTextViewLongPressGesture
|
|
{
|
|
if (_disableLongPressGestureOnEvent)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Add a long gesture recognizer on text view (in order to display for example the event details)
|
|
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)];
|
|
longPress.delegate = self;
|
|
|
|
// MXKMessageTextView does not catch touches outside of links. Add a background view to handle long touch.
|
|
if ([self.messageTextView isKindOfClass:[MXKMessageTextView class]])
|
|
{
|
|
UIView *messageTextBackgroundView = [[UIView alloc] initWithFrame:self.messageTextView.frame];
|
|
messageTextBackgroundView.backgroundColor = [UIColor clearColor];
|
|
[self.contentView insertSubview:messageTextBackgroundView belowSubview:self.messageTextView];
|
|
messageTextBackgroundView.translatesAutoresizingMaskIntoConstraints = NO;
|
|
[messageTextBackgroundView.leftAnchor constraintEqualToAnchor:self.messageTextView.leftAnchor].active = YES;
|
|
[messageTextBackgroundView.rightAnchor constraintEqualToAnchor:self.messageTextView.rightAnchor].active = YES;
|
|
[messageTextBackgroundView.topAnchor constraintEqualToAnchor:self.messageTextView.topAnchor].active = YES;
|
|
[messageTextBackgroundView.bottomAnchor constraintEqualToAnchor:self.messageTextView.bottomAnchor].active = YES;
|
|
|
|
[messageTextBackgroundView addGestureRecognizer:longPress];
|
|
|
|
self.messageTextBackgroundView = messageTextBackgroundView;
|
|
}
|
|
else
|
|
{
|
|
[self.messageTextView addGestureRecognizer:longPress];
|
|
}
|
|
}
|
|
|
|
- (void)customizeTableViewCellRendering
|
|
{
|
|
[super customizeTableViewCellRendering];
|
|
|
|
// Clear the default background color of a MXKImageView instance
|
|
self.pictureView.defaultBackgroundColor = [UIColor clearColor];
|
|
}
|
|
|
|
- (void)layoutSubviews
|
|
{
|
|
[super layoutSubviews];
|
|
|
|
if (self.pictureView)
|
|
{
|
|
// Round image view
|
|
[self.pictureView.layer setCornerRadius:self.pictureView.frame.size.width / 2];
|
|
self.pictureView.clipsToBounds = YES;
|
|
}
|
|
}
|
|
|
|
/**
|
|
Manually add a side border for HTML blockquotes.
|
|
|
|
@discussion
|
|
`NSAttributedString` and `UITextView` classes do not support it natively. This
|
|
method add an `UIView` to the `UITextView` that implements this border.
|
|
|
|
@param canRetry YES if the method can retry later if the UI is not yet ready.
|
|
*/
|
|
- (void)fixHTMLBlockQuoteRendering:(BOOL)canRetry
|
|
{
|
|
if (self.messageTextView && htmlBlockquoteSideBorderViews.count == 0)
|
|
{
|
|
__weak typeof(self) weakSelf = self;
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
|
|
if (weakSelf)
|
|
{
|
|
typeof(self) self = weakSelf;
|
|
[MXKTools enumerateMarkedBlockquotesInAttributedString:self.messageTextView.attributedText
|
|
usingBlock:^(NSRange range, BOOL *stop)
|
|
{
|
|
// Compute the UITextRange of the blockquote
|
|
UITextPosition *beginning = self.messageTextView.beginningOfDocument;
|
|
UITextPosition *start = [self.messageTextView positionFromPosition:beginning offset:range.location];
|
|
UITextPosition *end = [self.messageTextView positionFromPosition:start offset:range.length];
|
|
UITextRange *textRange = [self.messageTextView textRangeFromPosition:start toPosition:end];
|
|
|
|
// Get the rect area of this blockquote within the cell
|
|
// There can be several rects in case of multilines. Hence, the merge
|
|
NSArray<UITextSelectionRect*> *array = [self.messageTextView selectionRectsForRange:textRange];
|
|
CGRect textRect = CGRectNull;
|
|
for (UITextSelectionRect *rect in array)
|
|
{
|
|
if (rect.rect.size.width)
|
|
{
|
|
textRect = CGRectUnion(textRect, rect.rect);
|
|
}
|
|
}
|
|
|
|
if (!CGRectIsNull(textRect))
|
|
{
|
|
// Add a left border with a height that covers all the blockquote block height
|
|
// TODO: Manage RTL language
|
|
UIView *sideBorderView = [[UIView alloc] initWithFrame:CGRectMake(5, textRect.origin.y, 4, textRect.size.height)];
|
|
sideBorderView.backgroundColor = self.bubbleData.eventFormatter.htmlBlockquoteBorderColor;
|
|
[sideBorderView setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
|
|
[self.messageTextView addSubview:sideBorderView];
|
|
|
|
if (!self->htmlBlockquoteSideBorderViews)
|
|
{
|
|
self->htmlBlockquoteSideBorderViews = [NSMutableArray array];
|
|
}
|
|
|
|
[self->htmlBlockquoteSideBorderViews addObject:sideBorderView];
|
|
}
|
|
else if (canRetry)
|
|
{
|
|
// Have not found rect area that corresponds to the blockquote
|
|
// Try again later when the UI is more ready. Try it only once
|
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
|
[self fixHTMLBlockQuoteRendering:NO];
|
|
});
|
|
}
|
|
}];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
// remove any pending observers
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
|
|
delegate = nil;
|
|
}
|
|
|
|
- (UIImage*)picturePlaceholder
|
|
{
|
|
return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"];
|
|
}
|
|
|
|
- (void)setIsAutoAnimatedGif:(BOOL)isAutoAnimatedGif
|
|
{
|
|
_isAutoAnimatedGif = isAutoAnimatedGif;
|
|
|
|
[self renderGif];
|
|
}
|
|
|
|
- (void)setAllTextHighlighted:(BOOL)allTextHighlighted
|
|
{
|
|
_allTextHighlighted = allTextHighlighted;
|
|
|
|
if (self.messageTextView && bubbleData.textMessage.length != 0)
|
|
{
|
|
if (_allTextHighlighted)
|
|
{
|
|
NSMutableAttributedString *highlightedString = [[NSMutableAttributedString alloc] initWithAttributedString:self.suitableAttributedTextMessage];
|
|
UIColor *color = self.tintColor ? self.tintColor : [UIColor lightGrayColor];
|
|
[highlightedString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, highlightedString.length)];
|
|
self.messageTextView.attributedText = highlightedString;
|
|
}
|
|
else
|
|
{
|
|
self.messageTextView.attributedText = self.suitableAttributedTextMessage;
|
|
}
|
|
}
|
|
}
|
|
|
|
- (NSAttributedString *)suitableAttributedTextMessage
|
|
{
|
|
return self.isTextViewNeedsPositioningVerticalSpace ? bubbleData.attributedTextMessage : bubbleData.attributedTextMessageWithoutPositioningSpace;
|
|
}
|
|
|
|
- (void)highlightTextMessageForEvent:(NSString*)eventId
|
|
{
|
|
if (self.messageTextView)
|
|
{
|
|
if (eventId.length)
|
|
{
|
|
self.messageTextView.attributedText = [bubbleData attributedTextMessageWithHighlightedEvent:eventId tintColor:self.tintColor];
|
|
}
|
|
else
|
|
{
|
|
// Restore original string
|
|
self.messageTextView.attributedText = self.suitableAttributedTextMessage;
|
|
}
|
|
}
|
|
}
|
|
|
|
- (CGFloat)topPositionOfEvent:(NSString*)eventId
|
|
{
|
|
CGFloat topPositionOfEvent = 0;
|
|
|
|
// Retrieve the component that hosts the event
|
|
MXKRoomBubbleComponent *theComponent;
|
|
for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents)
|
|
{
|
|
if ([component.event.eventId isEqualToString:eventId])
|
|
{
|
|
theComponent = component;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (theComponent)
|
|
{
|
|
topPositionOfEvent = theComponent.position.y + self.msgTextViewTopConstraint.constant;
|
|
}
|
|
return topPositionOfEvent;
|
|
}
|
|
|
|
- (CGFloat)bottomPositionOfEvent:(NSString*)eventId
|
|
{
|
|
CGFloat bottomPositionOfEvent = self.frame.size.height - self.msgTextViewBottomConstraint.constant;
|
|
|
|
// Parse each component by the end of the array in order to compute the bottom position.
|
|
NSArray *bubbleComponents = bubbleData.bubbleComponents;
|
|
NSInteger index = bubbleComponents.count;
|
|
|
|
while (index --)
|
|
{
|
|
MXKRoomBubbleComponent *component = bubbleComponents[index];
|
|
if ([component.event.eventId isEqualToString:eventId])
|
|
{
|
|
break;
|
|
}
|
|
else
|
|
{
|
|
// Update the bottom position
|
|
bottomPositionOfEvent = component.position.y + self.msgTextViewTopConstraint.constant;
|
|
}
|
|
}
|
|
return bottomPositionOfEvent;
|
|
}
|
|
|
|
- (void)setSelected:(BOOL)selected animated:(BOOL)animated
|
|
{
|
|
[super setSelected:selected animated:animated];
|
|
|
|
// Configure the view for the selected state
|
|
}
|
|
|
|
- (void)render:(MXKCellData *)cellData
|
|
{
|
|
[self prepareRender:cellData];
|
|
|
|
if (bubbleData)
|
|
{
|
|
// Check conditions to display the message sender name
|
|
if (self.userNameLabel)
|
|
{
|
|
// Display sender's name except if the name appears in the displayed text (see emote and membership events)
|
|
if (bubbleData.shouldHideSenderName == NO)
|
|
{
|
|
self.userNameLabel.text = bubbleData.senderDisplayName;
|
|
self.userNameLabel.hidden = NO;
|
|
self.userNameTapGestureMaskView.userInteractionEnabled = YES;
|
|
}
|
|
else
|
|
{
|
|
self.userNameLabel.hidden = YES;
|
|
self.userNameTapGestureMaskView.userInteractionEnabled = NO;
|
|
}
|
|
}
|
|
|
|
// Check whether the sender's picture is actually displayed before loading it.
|
|
if (self.pictureView)
|
|
{
|
|
self.pictureView.enableInMemoryCache = YES;
|
|
// Consider here the sender avatar is stored unencrypted on Matrix media repo
|
|
[self.pictureView setImageURI:bubbleData.senderAvatarUrl
|
|
withType:nil
|
|
andImageOrientation:UIImageOrientationUp
|
|
toFitViewSize:self.pictureView.frame.size
|
|
withMethod:MXThumbnailingMethodCrop
|
|
previewImage:bubbleData.senderAvatarPlaceholder ? bubbleData.senderAvatarPlaceholder : self.picturePlaceholder
|
|
mediaManager:bubbleData.mxSession.mediaManager];
|
|
}
|
|
|
|
if (self.attachmentView && bubbleData.isAttachmentWithThumbnail)
|
|
{
|
|
// Set attached media folders
|
|
self.attachmentView.mediaFolder = bubbleData.roomId;
|
|
|
|
self.attachmentView.backgroundColor = [UIColor clearColor];
|
|
|
|
// Retrieve the suitable content size for the attachment thumbnail
|
|
CGSize contentSize = bubbleData.contentSize;
|
|
|
|
// Update image view frame in order to center loading wheel (if any)
|
|
CGRect frame = self.attachmentView.frame;
|
|
frame.size.width = contentSize.width;
|
|
frame.size.height = contentSize.height;
|
|
self.attachmentView.frame = frame;
|
|
|
|
// Set play icon visibility
|
|
self.playIconView.hidden = (bubbleData.attachment.type != MXKAttachmentTypeVideo);
|
|
|
|
// Hide by default file type icon
|
|
self.fileTypeIconView.hidden = YES;
|
|
|
|
// Display the attachment thumbnail
|
|
[self.attachmentView setAttachmentThumb:bubbleData.attachment];
|
|
|
|
if (bubbleData.attachment.contentURL)
|
|
{
|
|
// Add tap recognizer to open attachment
|
|
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAttachmentTap:)];
|
|
[tap setNumberOfTouchesRequired:1];
|
|
[tap setNumberOfTapsRequired:1];
|
|
[tap setDelegate:self];
|
|
[self.attachmentView addGestureRecognizer:tap];
|
|
}
|
|
|
|
[self startProgressUI];
|
|
|
|
// Adjust Attachment width constant
|
|
self.attachViewWidthConstraint.constant = contentSize.width;
|
|
|
|
// Add a long gesture recognizer on progressView to cancel the current operation (Note: only the download can be cancelled).
|
|
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)];
|
|
[self.progressView addGestureRecognizer:longPress];
|
|
|
|
if (_disableLongPressGestureOnEvent == NO)
|
|
{
|
|
// Add a long gesture recognizer on attachment view in order to display for example the event details
|
|
longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)];
|
|
[self.attachmentView addGestureRecognizer:longPress];
|
|
}
|
|
|
|
// Handle here the case of the attached gif
|
|
[self renderGif];
|
|
}
|
|
else if (self.messageTextView)
|
|
{
|
|
// Compute message content size
|
|
bubbleData.maxTextViewWidth = self.frame.size.width - (self.msgTextViewLeadingConstraint.constant + self.msgTextViewTrailingConstraint.constant);
|
|
CGSize contentSize = bubbleData.contentSize;
|
|
|
|
// Prepare displayed text message
|
|
NSAttributedString* newText = nil;
|
|
|
|
// Underline attached file name
|
|
if (self.isBubbleDataContainsFileAttachment)
|
|
{
|
|
NSMutableAttributedString *updatedText = [[NSMutableAttributedString alloc] initWithAttributedString:self.suitableAttributedTextMessage];
|
|
[updatedText addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:NSMakeRange(0, updatedText.length)];
|
|
|
|
newText = updatedText;
|
|
}
|
|
else
|
|
{
|
|
newText = self.suitableAttributedTextMessage;
|
|
}
|
|
|
|
// update the text only if it is required
|
|
// updating a text is quite long (even with the same text).
|
|
if (![self.messageTextView.attributedText isEqualToAttributedString:newText])
|
|
{
|
|
self.messageTextView.attributedText = newText;
|
|
|
|
if (bubbleData.displayFix & MXKRoomBubbleComponentDisplayFixHtmlBlockquote)
|
|
{
|
|
[self fixHTMLBlockQuoteRendering:YES];
|
|
}
|
|
}
|
|
|
|
// Update msgTextView width constraint to align correctly the text
|
|
if (self.msgTextViewWidthConstraint.constant != contentSize.width)
|
|
{
|
|
self.msgTextViewWidthConstraint.constant = contentSize.width;
|
|
}
|
|
}
|
|
|
|
// Check and update each component position (used to align timestamps label in front of events, and to handle tap gesture on events)
|
|
[bubbleData prepareBubbleComponentsPosition];
|
|
|
|
// Handle here timestamp display (only if a container has been defined)
|
|
if (self.bubbleInfoContainer)
|
|
{
|
|
if ((bubbleData.showBubbleDateTime && !bubbleData.useCustomDateTimeLabel)
|
|
|| (bubbleData.showBubbleReceipts && !bubbleData.useCustomReceipts))
|
|
{
|
|
// Add datetime label for each component
|
|
self.bubbleInfoContainer.hidden = NO;
|
|
|
|
// ensure that older subviews are removed
|
|
// They should be (they are removed when the is not anymore used).
|
|
// But, it seems that is not always true.
|
|
NSArray* views = [self.bubbleInfoContainer subviews];
|
|
for(UIView* view in views)
|
|
{
|
|
[view removeFromSuperview];
|
|
}
|
|
|
|
for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents)
|
|
{
|
|
if (component.event.sentState != MXEventSentStateFailed)
|
|
{
|
|
CGFloat timeLabelOffset = 0;
|
|
|
|
if (component.date && bubbleData.showBubbleDateTime && !bubbleData.useCustomDateTimeLabel)
|
|
{
|
|
UILabel *dateTimeLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, component.position.y, self.bubbleInfoContainer.frame.size.width , 15)];
|
|
|
|
dateTimeLabel.text = [bubbleData.eventFormatter dateStringFromDate:component.date withTime:YES];
|
|
if (bubbleData.isIncoming)
|
|
{
|
|
dateTimeLabel.textAlignment = NSTextAlignmentRight;
|
|
}
|
|
else
|
|
{
|
|
dateTimeLabel.textAlignment = NSTextAlignmentLeft;
|
|
}
|
|
dateTimeLabel.textColor = [UIColor lightGrayColor];
|
|
dateTimeLabel.font = [UIFont systemFontOfSize:11];
|
|
dateTimeLabel.adjustsFontSizeToFitWidth = YES;
|
|
dateTimeLabel.minimumScaleFactor = 0.6;
|
|
|
|
[dateTimeLabel setTranslatesAutoresizingMaskIntoConstraints:NO];
|
|
[self.bubbleInfoContainer addSubview:dateTimeLabel];
|
|
// Force dateTimeLabel in full width (to handle auto-layout in case of screen rotation)
|
|
NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel
|
|
attribute:NSLayoutAttributeLeading
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:self.bubbleInfoContainer
|
|
attribute:NSLayoutAttributeLeading
|
|
multiplier:1.0
|
|
constant:0];
|
|
NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel
|
|
attribute:NSLayoutAttributeTrailing
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:self.bubbleInfoContainer
|
|
attribute:NSLayoutAttributeTrailing
|
|
multiplier:1.0
|
|
constant:0];
|
|
// Vertical constraints are required for iOS > 8
|
|
NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel
|
|
attribute:NSLayoutAttributeTop
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:self.bubbleInfoContainer
|
|
attribute:NSLayoutAttributeTop
|
|
multiplier:1.0
|
|
constant:component.position.y];
|
|
NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel
|
|
attribute:NSLayoutAttributeHeight
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:nil
|
|
attribute:NSLayoutAttributeNotAnAttribute
|
|
multiplier:1.0
|
|
constant:15];
|
|
[NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, heightConstraint]];
|
|
|
|
timeLabelOffset += 15;
|
|
}
|
|
|
|
if (bubbleData.showBubbleReceipts && !bubbleData.useCustomReceipts)
|
|
{
|
|
NSMutableArray* roomMembers = nil;
|
|
NSMutableArray* placeholders = nil;
|
|
NSArray<MXReceiptData*> *receipts = bubbleData.readReceipts[component.event.eventId];
|
|
|
|
// Check whether some receipts are found
|
|
if (receipts.count)
|
|
{
|
|
MXRoom* room = [bubbleData.mxSession roomWithRoomId:bubbleData.roomId];
|
|
if (room)
|
|
{
|
|
// Retrieve the corresponding room members
|
|
roomMembers = [[NSMutableArray alloc] initWithCapacity:receipts.count];
|
|
placeholders = [[NSMutableArray alloc] initWithCapacity:receipts.count];
|
|
|
|
MXRoomMembers *stateRoomMembers = room.dangerousSyncState.members;
|
|
for (MXReceiptData* data in receipts)
|
|
{
|
|
MXRoomMember * roomMember = [stateRoomMembers memberWithUserId:data.userId];
|
|
if (roomMember)
|
|
{
|
|
[roomMembers addObject:roomMember];
|
|
[placeholders addObject:self.picturePlaceholder];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (roomMembers.count)
|
|
{
|
|
MXKReceiptSendersContainer* avatarsContainer = [[MXKReceiptSendersContainer alloc] initWithFrame:CGRectMake(0, component.position.y + timeLabelOffset, self.bubbleInfoContainer.frame.size.width , 15) andMediaManager:bubbleData.mxSession.mediaManager];
|
|
|
|
[avatarsContainer refreshReceiptSenders:roomMembers withPlaceHolders:placeholders andAlignment:self.readReceiptsAlignment];
|
|
|
|
[self.bubbleInfoContainer addSubview:avatarsContainer];
|
|
|
|
// Force dateTimeLabel in full width (to handle auto-layout in case of screen rotation)
|
|
NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer
|
|
attribute:NSLayoutAttributeLeading
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:self.bubbleInfoContainer
|
|
attribute:NSLayoutAttributeLeading
|
|
multiplier:1.0
|
|
constant:0];
|
|
NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer
|
|
attribute:NSLayoutAttributeTrailing
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:self.bubbleInfoContainer
|
|
attribute:NSLayoutAttributeTrailing
|
|
multiplier:1.0
|
|
constant:0];
|
|
// Vertical constraints are required for iOS > 8
|
|
NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer
|
|
attribute:NSLayoutAttributeTop
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:self.bubbleInfoContainer
|
|
attribute:NSLayoutAttributeTop
|
|
multiplier:1.0
|
|
constant:(component.position.y + timeLabelOffset)];
|
|
|
|
NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer
|
|
attribute:NSLayoutAttributeHeight
|
|
relatedBy:NSLayoutRelationEqual
|
|
toItem:nil
|
|
attribute:NSLayoutAttributeNotAnAttribute
|
|
multiplier:1.0
|
|
constant:15];
|
|
|
|
[NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, heightConstraint]];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
self.bubbleInfoContainer.hidden = YES;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)prepareRender:(MXKCellData *)cellData
|
|
{
|
|
// Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes
|
|
NSParameterAssert([cellData isKindOfClass:[MXKRoomBubbleCellData class]]);
|
|
|
|
bubbleData = (MXKRoomBubbleCellData*)cellData;
|
|
mxkCellData = cellData;
|
|
}
|
|
|
|
- (void)renderGif
|
|
{
|
|
if (self.attachmentView && bubbleData.attachment)
|
|
{
|
|
NSString *mimetype = nil;
|
|
if (bubbleData.attachment.thumbnailInfo)
|
|
{
|
|
mimetype = bubbleData.attachment.thumbnailInfo[@"mimetype"];
|
|
}
|
|
else if (bubbleData.attachment.contentInfo)
|
|
{
|
|
mimetype = bubbleData.attachment.contentInfo[@"mimetype"];
|
|
}
|
|
|
|
if ([mimetype isKindOfClass:[NSString class]] && [mimetype isEqualToString:@"image/gif"])
|
|
{
|
|
if (_isAutoAnimatedGif)
|
|
{
|
|
// Hide the file type icon, and the progress UI
|
|
self.fileTypeIconView.hidden = YES;
|
|
[self stopProgressUI];
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil];
|
|
|
|
// Animated gif is displayed in a webview added on the attachment view
|
|
self.attachmentWebView = [[WKWebView alloc] initWithFrame:self.attachmentView.bounds];
|
|
self.attachmentWebView.opaque = NO;
|
|
self.attachmentWebView.backgroundColor = [UIColor clearColor];
|
|
self.attachmentWebView.contentMode = UIViewContentModeScaleAspectFit;
|
|
self.attachmentWebView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin);
|
|
self.attachmentWebView.userInteractionEnabled = NO;
|
|
self.attachmentWebView.hidden = YES;
|
|
[self.attachmentView addSubview:self.attachmentWebView];
|
|
|
|
__weak WKWebView *weakAnimatedGifViewer = self.attachmentWebView;
|
|
__weak typeof(self) weakSelf = self;
|
|
|
|
void (^onDownloaded)(NSData *) = ^(NSData *data){
|
|
|
|
if (weakAnimatedGifViewer && weakAnimatedGifViewer.superview)
|
|
{
|
|
WKWebView *strongAnimatedGifViewer = weakAnimatedGifViewer;
|
|
strongAnimatedGifViewer.navigationDelegate = weakSelf;
|
|
[strongAnimatedGifViewer loadData:data MIMEType:@"image/gif" characterEncodingName:@"UTF-8" baseURL:[NSURL URLWithString:@"http://"]];
|
|
}
|
|
};
|
|
|
|
void (^onFailure)(NSError *) = ^(NSError *error){
|
|
|
|
MXLogDebug(@"[MXKRoomBubbleTableViewCell] gif download failed");
|
|
// Notify the end user
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error];
|
|
};
|
|
|
|
[bubbleData.attachment getAttachmentData:^(NSData *data) {
|
|
onDownloaded(data);
|
|
} failure:^(NSError *error) {
|
|
onFailure(error);
|
|
}];
|
|
}
|
|
else
|
|
{
|
|
self.fileTypeIconView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"filetype-gif"];
|
|
self.fileTypeIconView.hidden = NO;
|
|
|
|
// Check whether a download is in progress
|
|
[self startProgressUI];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
+ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth
|
|
{
|
|
// Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes
|
|
NSParameterAssert([cellData isKindOfClass:[MXKRoomBubbleCellData class]]);
|
|
|
|
MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData;
|
|
MXKRoomBubbleTableViewCell* cell = [self cellWithOriginalXib];
|
|
CGFloat rowHeight = cell.frame.size.height;
|
|
|
|
if (cell.attachmentView && bubbleData.isAttachmentWithThumbnail)
|
|
{
|
|
// retrieve the suggested image view height
|
|
rowHeight = bubbleData.contentSize.height;
|
|
|
|
// Check here the minimum height defined in cell view for text message
|
|
if (cell.attachViewMinHeightConstraint && rowHeight < cell.attachViewMinHeightConstraint.constant)
|
|
{
|
|
rowHeight = cell.attachViewMinHeightConstraint.constant;
|
|
}
|
|
|
|
// Finalize the row height by adding the vertical constraints.
|
|
rowHeight += cell.attachViewTopConstraint.constant + cell.attachViewBottomConstraint.constant;
|
|
}
|
|
else if (cell.messageTextView)
|
|
{
|
|
CGFloat maxTextViewWidth;
|
|
|
|
RoomTimelineConfiguration *timelineConfiguration = [RoomTimelineConfiguration shared];
|
|
|
|
id<RoomCellLayoutUpdating> cellLayoutUpdater = timelineConfiguration.currentStyle.cellLayoutUpdater;
|
|
|
|
// Handle updated text view layout if needed
|
|
if (cellLayoutUpdater)
|
|
{
|
|
maxTextViewWidth = [cellLayoutUpdater maximumTextViewWidthFor:cell cellData:cellData maximumCellWidth:maxWidth];
|
|
}
|
|
else
|
|
{
|
|
maxTextViewWidth = maxWidth - (cell.msgTextViewLeadingConstraint.constant + cell.msgTextViewTrailingConstraint.constant);
|
|
}
|
|
|
|
// Update maximum width available for the textview
|
|
bubbleData.maxTextViewWidth = maxTextViewWidth;
|
|
|
|
// Retrieve the suggested height of the message content
|
|
rowHeight = bubbleData.contentSize.height;
|
|
|
|
// Consider here the minimum height defined in cell view for text message
|
|
if (cell.msgTextViewMinHeightConstraint && rowHeight < cell.msgTextViewMinHeightConstraint.constant)
|
|
{
|
|
rowHeight = cell.msgTextViewMinHeightConstraint.constant;
|
|
}
|
|
|
|
// Finalize the row height by adding the top and bottom constraints of the message text view in cell
|
|
rowHeight += cell.msgTextViewTopConstraint.constant + cell.msgTextViewBottomConstraint.constant;
|
|
}
|
|
|
|
return rowHeight;
|
|
}
|
|
|
|
- (void)prepareForReuse
|
|
{
|
|
[super prepareForReuse];
|
|
|
|
bubbleData = nil;
|
|
delegate = nil;
|
|
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
|
|
self.readReceiptsAlignment = ReadReceiptAlignmentLeft;
|
|
|
|
_allTextHighlighted = NO;
|
|
_isAutoAnimatedGif = NO;
|
|
|
|
[self removeHTMLBlockquoteSideBorderViews];
|
|
[self removeTemporarySubviews];
|
|
[self cleanAttachmentView];
|
|
[self clearBubbleInfoContainer];
|
|
[self clearBubbleOverlayContainer];
|
|
[self resetConstraintsConstantToDefault];
|
|
[self clearAttachmentWebView];
|
|
|
|
[self didEndDisplay];
|
|
}
|
|
|
|
- (void)didEndDisplay
|
|
{
|
|
[self removeReadMarkerView];
|
|
[self cleanProgressView];
|
|
|
|
// TODO: Stop gif animation
|
|
}
|
|
|
|
- (BOOL)shouldInteractWithURL:(NSURL *)URL urlItemInteraction:(UITextItemInteraction)urlItemInteraction associatedEvent:(MXEvent*)associatedEvent
|
|
{
|
|
return [self shouldInteractWithURL:URL urlItemInteractionValue:@(urlItemInteraction) associatedEvent:associatedEvent];
|
|
}
|
|
|
|
- (BOOL)shouldInteractWithURL:(NSURL *)URL urlItemInteractionValue:(NSNumber*)urlItemInteractionValue associatedEvent:(MXEvent*)associatedEvent
|
|
{
|
|
NSMutableDictionary *userInfo = [@{
|
|
kMXKRoomBubbleCellUrl:URL,
|
|
kMXKRoomBubbleCellUrlItemInteraction:urlItemInteractionValue
|
|
} mutableCopy];
|
|
|
|
if (associatedEvent)
|
|
{
|
|
userInfo[kMXKRoomBubbleCellEventKey] = associatedEvent;
|
|
}
|
|
|
|
return [delegate cell:self shouldDoAction:kMXKRoomBubbleCellShouldInteractWithURL userInfo:userInfo defaultValue:YES];
|
|
}
|
|
|
|
- (BOOL)isBubbleDataContainsFileAttachment
|
|
{
|
|
return bubbleData.isAttachment;
|
|
}
|
|
|
|
- (MXKRoomBubbleComponent*)closestBubbleComponentForGestureRecognizer:(UIGestureRecognizer*)gestureRecognizer locationInView:(UIView*)view
|
|
{
|
|
CGPoint tapPoint = [gestureRecognizer locationInView:view];
|
|
MXKRoomBubbleComponent *tappedComponent;
|
|
|
|
if (tapPoint.y >= 0 && tapPoint.y <= view.frame.size.height)
|
|
{
|
|
tappedComponent = [self closestBubbleComponentAtPosition:tapPoint];
|
|
}
|
|
|
|
return tappedComponent;
|
|
}
|
|
|
|
- (MXKRoomBubbleComponent*)closestBubbleComponentAtPosition:(CGPoint)position
|
|
{
|
|
MXKRoomBubbleComponent *tappedComponent;
|
|
|
|
NSArray *bubbleComponents = bubbleData.bubbleComponents;
|
|
|
|
if (bubbleComponents.count == 1) {
|
|
return bubbleComponents.firstObject;
|
|
}
|
|
|
|
// The position check below fails for bubble data with a single component when message
|
|
// bubbles are enabled, thus the early bailout above
|
|
for (MXKRoomBubbleComponent *component in bubbleComponents)
|
|
{
|
|
// Ignore components without display (For example redacted event or state events)
|
|
if (!component.attributedTextMessage)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (component.position.y > position.y)
|
|
{
|
|
break;
|
|
}
|
|
|
|
tappedComponent = component;
|
|
}
|
|
|
|
return tappedComponent;
|
|
}
|
|
|
|
- (void)setupConstraintsConstantDefaultValues
|
|
{
|
|
self.attachmentViewBottomConstraintDefaultConstant = self.attachViewBottomConstraint.constant;
|
|
}
|
|
|
|
- (void)resetAttachmentViewBottomConstraintConstant
|
|
{
|
|
self.attachViewBottomConstraint.constant = self.attachmentViewBottomConstraintDefaultConstant;
|
|
}
|
|
|
|
- (void)resetConstraintsConstantToDefault
|
|
{
|
|
[self resetAttachmentViewBottomConstraintConstant];
|
|
}
|
|
|
|
- (void)addTemporarySubview:(UIView*)subview
|
|
{
|
|
if (!self.tmpSubviews)
|
|
{
|
|
self.tmpSubviews = [NSMutableArray new];
|
|
}
|
|
|
|
[self.tmpSubviews addObject:subview];
|
|
}
|
|
|
|
#pragma mark - Cleaning
|
|
|
|
- (void)removeHTMLBlockquoteSideBorderViews
|
|
{
|
|
for (UIView *sideBorder in htmlBlockquoteSideBorderViews)
|
|
{
|
|
[sideBorder removeFromSuperview];
|
|
}
|
|
[htmlBlockquoteSideBorderViews removeAllObjects];
|
|
htmlBlockquoteSideBorderViews = nil;
|
|
}
|
|
|
|
- (void)removeReadMarkerView
|
|
{
|
|
if (_readMarkerView)
|
|
{
|
|
[_readMarkerView removeFromSuperview];
|
|
_readMarkerView = nil;
|
|
_readMarkerViewTopConstraint = nil;
|
|
_readMarkerViewLeadingConstraint = nil;
|
|
_readMarkerViewTrailingConstraint = nil;
|
|
_readMarkerViewHeightConstraint = nil;
|
|
}
|
|
}
|
|
|
|
- (void)removeTemporarySubviews
|
|
{
|
|
// Remove temporary subviews
|
|
for (UIView *view in self.tmpSubviews)
|
|
{
|
|
[view removeFromSuperview];
|
|
}
|
|
[self.tmpSubviews removeAllObjects];
|
|
}
|
|
|
|
- (void)cleanAttachmentView
|
|
{
|
|
if (self.attachmentView)
|
|
{
|
|
// Remove all gesture recognizer
|
|
while (self.attachmentView.gestureRecognizers.count)
|
|
{
|
|
[self.attachmentView removeGestureRecognizer:self.attachmentView.gestureRecognizers[0]];
|
|
}
|
|
|
|
// Prevent the cell from displaying again the image in case of reuse.
|
|
self.attachmentView.image = nil;
|
|
}
|
|
}
|
|
|
|
- (void)clearBubbleInfoContainer
|
|
{
|
|
// Remove potential dateTime (or unsent) label(s)
|
|
if (self.bubbleInfoContainer && self.bubbleInfoContainer.subviews.count > 0)
|
|
{
|
|
NSArray* subviews = self.bubbleInfoContainer.subviews;
|
|
|
|
for (UIView *view in subviews)
|
|
{
|
|
[view removeFromSuperview];
|
|
}
|
|
}
|
|
self.bubbleInfoContainer.hidden = YES;
|
|
}
|
|
|
|
- (void)clearBubbleOverlayContainer
|
|
{
|
|
// Remove potential overlay subviews
|
|
if (self.bubbleOverlayContainer)
|
|
{
|
|
NSArray* subviews = self.bubbleOverlayContainer.subviews;
|
|
|
|
for (UIView *view in subviews)
|
|
{
|
|
[view removeFromSuperview];
|
|
}
|
|
|
|
self.bubbleOverlayContainer.hidden = YES;
|
|
}
|
|
}
|
|
|
|
- (void)cleanProgressView
|
|
{
|
|
if (self.progressView)
|
|
{
|
|
[self stopProgressUI];
|
|
|
|
// Remove long tap gesture on the progressView
|
|
while (self.progressView.gestureRecognizers.count)
|
|
{
|
|
[self.progressView removeGestureRecognizer:self.progressView.gestureRecognizers[0]];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)clearAttachmentWebView
|
|
{
|
|
if (_attachmentWebView)
|
|
{
|
|
[_attachmentWebView removeFromSuperview];
|
|
_attachmentWebView.navigationDelegate = nil;
|
|
_attachmentWebView = nil;
|
|
}
|
|
}
|
|
|
|
#pragma mark - Attachment progress handling
|
|
|
|
- (void)updateProgressUI:(NSDictionary*)statisticsDict
|
|
{
|
|
self.progressView.hidden = !statisticsDict;
|
|
|
|
NSNumber* downloadRate = [statisticsDict valueForKey:kMXMediaLoaderCurrentDataRateKey];
|
|
|
|
NSNumber* completedBytesCount = [statisticsDict valueForKey:kMXMediaLoaderCompletedBytesCountKey];
|
|
NSNumber* totalBytesCount = [statisticsDict valueForKey:kMXMediaLoaderTotalBytesCountKey];
|
|
|
|
NSMutableString* text = [[NSMutableString alloc] init];
|
|
|
|
if (completedBytesCount && totalBytesCount)
|
|
{
|
|
NSString* progressString = [NSString stringWithFormat:@"%@ / %@", [NSByteCountFormatter stringFromByteCount:completedBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile], [NSByteCountFormatter stringFromByteCount:totalBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]];
|
|
|
|
[text appendString:progressString];
|
|
}
|
|
|
|
if (downloadRate && downloadRate.longLongValue)
|
|
{
|
|
[text appendFormat:@"\n%@/s", [NSByteCountFormatter stringFromByteCount:downloadRate.longLongValue countStyle:NSByteCountFormatterCountStyleFile]];
|
|
|
|
if (completedBytesCount && totalBytesCount)
|
|
{
|
|
CGFloat remainimgTime = ((totalBytesCount.floatValue - completedBytesCount.floatValue)) / downloadRate.floatValue;
|
|
[text appendFormat:@"\n%@", [MXKTools formatSecondsInterval:remainimgTime]];
|
|
}
|
|
}
|
|
|
|
self.statsLabel.text = text;
|
|
|
|
NSNumber* progressNumber = [statisticsDict valueForKey:kMXMediaLoaderProgressValueKey];
|
|
|
|
if (progressNumber)
|
|
{
|
|
self.progressChartView.progress = progressNumber.floatValue;
|
|
}
|
|
}
|
|
|
|
- (void)onAttachmentLoaderStateChange:(NSNotification *)notif
|
|
{
|
|
MXMediaLoader *loader = (MXMediaLoader*)notif.object;
|
|
switch (loader.state) {
|
|
case MXMediaLoaderStateDownloadInProgress:
|
|
[self updateProgressUI:loader.statisticsDict];
|
|
break;
|
|
case MXMediaLoaderStateDownloadCompleted:
|
|
case MXMediaLoaderStateDownloadFailed:
|
|
case MXMediaLoaderStateCancelled:
|
|
[self stopProgressUI];
|
|
// remove the observer
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader];
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
- (void)startProgressUI
|
|
{
|
|
self.progressView.hidden = YES;
|
|
|
|
// there is an attachment URL
|
|
if (bubbleData.attachment.contentURL)
|
|
{
|
|
// remove any pending observers
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil];
|
|
|
|
// check if there is a download in progress
|
|
MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:bubbleData.attachment.downloadId];
|
|
if (loader)
|
|
{
|
|
// defines the text to display
|
|
[self updateProgressUI:loader.statisticsDict];
|
|
|
|
// anyway listen to the progress event
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(onAttachmentLoaderStateChange:)
|
|
name:kMXMediaLoaderStateDidChangeNotification
|
|
object:loader];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)stopProgressUI
|
|
{
|
|
self.progressView.hidden = YES;
|
|
|
|
// do not remove the observer here
|
|
// the download could restart without recomposing the cell
|
|
}
|
|
|
|
#pragma mark - Original Xib values
|
|
|
|
/**
|
|
`childClasses` hosts one instance of each child classes of `MXKRoomBubbleTableViewCell`.
|
|
The key is the child class name. The value, the instance.
|
|
*/
|
|
static NSMutableDictionary *childClasses;
|
|
|
|
+ (MXKRoomBubbleTableViewCell*)cellWithOriginalXib
|
|
{
|
|
MXKRoomBubbleTableViewCell *cellWithOriginalXib;
|
|
|
|
@synchronized(self)
|
|
{
|
|
if (childClasses == nil)
|
|
{
|
|
childClasses = [NSMutableDictionary dictionary];
|
|
}
|
|
|
|
// To save memory, use only one original instance per child class
|
|
cellWithOriginalXib = childClasses[NSStringFromClass(self.class)];
|
|
if (nil == cellWithOriginalXib)
|
|
{
|
|
cellWithOriginalXib = [self roomBubbleTableViewCell];
|
|
|
|
childClasses[NSStringFromClass(self.class)] = cellWithOriginalXib;
|
|
}
|
|
}
|
|
return cellWithOriginalXib;
|
|
}
|
|
|
|
#pragma mark - User actions
|
|
|
|
- (IBAction)onMessageTap:(UITapGestureRecognizer*)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
// Check whether the current displayed text corresponds to an attached file
|
|
// NOTE: This assumes that a cell with attachment has only one `MXKRoomBubbleComponent`
|
|
if (self.isBubbleDataContainsFileAttachment)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil];
|
|
}
|
|
else
|
|
{
|
|
NSURL *tappedUrl;
|
|
|
|
// Hyperlinks in UITextView does not respond instantly to touch.
|
|
// To overcome this, check manually if a link has been touched in UITextView when performing a quick tap.
|
|
// Otherwise UITextViewDelegate method `- (BOOL)textView:shouldInteractWithURL:inRange:interaction:` is still called for long press and force touch.
|
|
if ([sender.view isEqual:self.messageTextView])
|
|
{
|
|
UITextView *textView = self.messageTextView;
|
|
CGPoint tapLocation = [sender locationInView:textView];
|
|
tappedUrl = [textView urlForLinkAtLocation:tapLocation];
|
|
}
|
|
|
|
MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:sender.view];
|
|
MXEvent *tappedEvent = tappedComponent.event;
|
|
|
|
// If a link has been touched warn delegate immediately.
|
|
if (tappedUrl)
|
|
{
|
|
[self shouldInteractWithURL:tappedUrl urlItemInteraction:UITextItemInteractionInvokeDefaultAction associatedEvent:tappedEvent];
|
|
}
|
|
else
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnMessageTextView userInfo:(tappedEvent ? @{kMXKRoomBubbleCellEventKey:tappedEvent} : nil)];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
- (IBAction)onSenderNameTap:(UITapGestureRecognizer*)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnSenderNameLabel userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}];
|
|
}
|
|
}
|
|
|
|
- (IBAction)onAvatarTap:(UITapGestureRecognizer*)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAvatarView userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}];
|
|
}
|
|
}
|
|
|
|
- (IBAction)onAttachmentTap:(UITapGestureRecognizer*)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil];
|
|
}
|
|
}
|
|
|
|
- (IBAction)showHideDateTime:(id)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnDateTimeContainer userInfo:nil];
|
|
}
|
|
}
|
|
|
|
- (IBAction)onOverlayTap:(UITapGestureRecognizer*)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnOverlayContainer userInfo:nil];
|
|
}
|
|
}
|
|
|
|
- (IBAction)onContentViewTap:(UITapGestureRecognizer*)sender
|
|
{
|
|
if (delegate)
|
|
{
|
|
// Check whether a bubble component is displayed at the level of the tapped line.
|
|
MXKRoomBubbleComponent *tappedComponent = nil;
|
|
|
|
if (self.attachmentView)
|
|
{
|
|
// Check whether the user tapped on the side of the attachment.
|
|
tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:self.attachmentView];
|
|
}
|
|
else if (self.messageTextView)
|
|
{
|
|
// NOTE: A tap on messageTextView using `MXKMessageTextView` class fallback here if the user does not tap on a link.
|
|
|
|
// Use the same hack as `onMessageTap:`, check whether the current displayed text corresponds to an attached file
|
|
// NOTE: This assumes that a cell with attachment has only one `MXKRoomBubbleComponent`
|
|
if (self.isBubbleDataContainsFileAttachment)
|
|
{
|
|
// This assume that an attachment use one cell in the application using MatrixKit
|
|
// This condition is a fix to handle
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil];
|
|
}
|
|
else
|
|
{
|
|
// Check whether the user tapped in front of a text component.
|
|
tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:self.messageTextView];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
tappedComponent = [self.bubbleData getFirstBubbleComponentWithDisplay];
|
|
}
|
|
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnContentView userInfo:(tappedComponent ? @{kMXKRoomBubbleCellEventKey:tappedComponent.event} : nil)];
|
|
}
|
|
}
|
|
|
|
- (IBAction)onLongPressGesture:(UILongPressGestureRecognizer*)longPressGestureRecognizer
|
|
{
|
|
if (longPressGestureRecognizer.state == UIGestureRecognizerStateBegan && delegate)
|
|
{
|
|
UIView* view = longPressGestureRecognizer.view;
|
|
|
|
// Check the view on which long press has been detected
|
|
if (view == self.progressView)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnProgressView userInfo:nil];
|
|
}
|
|
else if (view == self.messageTextView || view == self.messageTextBackgroundView || view == self.attachmentView)
|
|
{
|
|
MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:view];
|
|
MXEvent *selectedEvent = tappedComponent.event;
|
|
|
|
if (selectedEvent)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnEvent userInfo:@{kMXKRoomBubbleCellEventKey:selectedEvent}];
|
|
}
|
|
}
|
|
else if (view == self.pictureView)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnAvatarView userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}];
|
|
}
|
|
else if (view == self.contentView)
|
|
{
|
|
// Check whether a bubble component is displayed at the level of the tapped line.
|
|
MXKRoomBubbleComponent *tappedComponent = nil;
|
|
|
|
if (self.attachmentView)
|
|
{
|
|
// Check whether the user tapped on the side of the attachment.
|
|
tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:self.attachmentView];
|
|
}
|
|
else if (self.messageTextView)
|
|
{
|
|
// Check whether the user tapped in front of a text component.
|
|
tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:self.messageTextView];
|
|
}
|
|
else
|
|
{
|
|
tappedComponent = [self.bubbleData getFirstBubbleComponentWithDisplay];
|
|
}
|
|
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnEvent userInfo:(tappedComponent ? @{kMXKRoomBubbleCellEventKey:tappedComponent.event} : nil)];
|
|
}
|
|
}
|
|
}
|
|
|
|
#pragma mark - UITextView delegate
|
|
|
|
- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction
|
|
{
|
|
BOOL shouldInteractWithURL = YES;
|
|
|
|
if (delegate && URL)
|
|
{
|
|
MXEvent *associatedEvent;
|
|
|
|
if ([textView isMemberOfClass:[MXKMessageTextView class]])
|
|
{
|
|
MXKMessageTextView *mxkMessageTextView = (MXKMessageTextView *)textView;
|
|
MXKRoomBubbleComponent *bubbleComponent = [self closestBubbleComponentAtPosition:mxkMessageTextView.lastHitTestLocation];
|
|
associatedEvent = bubbleComponent.event;
|
|
}
|
|
|
|
// Tapping a file attachment who's name triggers a data detector will try to open that URL.
|
|
// Detect this and instead map the interaction into a tap on the cell.
|
|
if (associatedEvent.isMediaAttachment)
|
|
{
|
|
[delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil];
|
|
return NO;
|
|
}
|
|
|
|
// Ask the delegate if iOS can open the link
|
|
shouldInteractWithURL = [self shouldInteractWithURL:URL urlItemInteraction:interaction associatedEvent:associatedEvent];
|
|
}
|
|
|
|
return shouldInteractWithURL;
|
|
}
|
|
|
|
#pragma mark - WKNavigationDelegate
|
|
|
|
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation
|
|
{
|
|
if (webView == _attachmentWebView && self.attachmentView)
|
|
{
|
|
// The attachment webview is ready to replace the attachment view.
|
|
_attachmentWebView.hidden = NO;
|
|
self.attachmentView.image = nil;
|
|
}
|
|
}
|
|
|
|
#pragma mark - UIGestureRecognizerDelegate
|
|
|
|
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
|
|
{
|
|
UIView *recognizerView = gestureRecognizer.view;
|
|
|
|
if ([recognizerView isDescendantOfView:self.contentView])
|
|
{
|
|
UIView *touchedView = touch.view;
|
|
|
|
if ([touchedView isKindOfClass:[UIButton class]])
|
|
{
|
|
return NO;
|
|
}
|
|
|
|
// Prevent gesture recognizer to be recognized by a custom view added to the cell contentView and with user interaction enabled
|
|
for (UIView *tmpSubview in self.tmpSubviews)
|
|
{
|
|
if (tmpSubview.isUserInteractionEnabled && [tmpSubview isDescendantOfView:self.contentView])
|
|
{
|
|
CGPoint touchedPoint = [touch locationInView:tmpSubview];
|
|
|
|
if (CGRectContainsPoint(tmpSubview.bounds, touchedPoint))
|
|
{
|
|
return NO;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prevent gesture recognizer to be recognized when user hits a link in a UITextView, let UITextViewDelegate handle links.
|
|
if ([touchedView isKindOfClass:[UITextView class]])
|
|
{
|
|
UITextView *textView = (UITextView*)touchedView;
|
|
CGPoint touchLocation = [touch locationInView:textView];
|
|
|
|
return [textView isThereALinkNearLocation:touchLocation] == NO;
|
|
}
|
|
}
|
|
|
|
return YES;
|
|
}
|
|
|
|
@end
|