996 lines
34 KiB
Objective-C
996 lines
34 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.
|
|
*/
|
|
|
|
#define MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH 192
|
|
|
|
#define MXKROOMBUBBLECELLDATA_DEFAULT_MAX_TEXTVIEW_WIDTH 200
|
|
|
|
@import MatrixSDK;
|
|
|
|
#import "MXKRoomBubbleCellData.h"
|
|
|
|
#import "MXKTools.h"
|
|
|
|
#import "GeneratedInterface-Swift.h"
|
|
|
|
@implementation MXKRoomBubbleCellData
|
|
@synthesize senderId, targetId, roomId, senderDisplayName, senderAvatarUrl, senderAvatarPlaceholder, targetDisplayName, targetAvatarUrl, targetAvatarPlaceholder, isEncryptedRoom, isPaginationFirstBubble, shouldHideSenderInformation, date, isIncoming, isAttachmentWithThumbnail, isAttachmentWithIcon, attachment;
|
|
@synthesize textMessage, attributedTextMessage, attributedTextMessageWithoutPositioningSpace;
|
|
@synthesize shouldHideSenderName, isTyping, showBubbleDateTime, showBubbleReceipts, useCustomDateTimeLabel, useCustomReceipts, useCustomUnsentButton, hasNoDisplay;
|
|
@synthesize tag;
|
|
@synthesize collapsable, collapsed, collapsedAttributedTextMessage, prevCollapsableCellData, nextCollapsableCellData, collapseState;
|
|
|
|
#pragma mark - MXKRoomBubbleCellDataStoring
|
|
|
|
- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)roomState andRoomDataSource:(MXKRoomDataSource *)roomDataSource
|
|
{
|
|
self = [self init];
|
|
if (self)
|
|
{
|
|
self->roomDataSource = roomDataSource;
|
|
|
|
// Initialize read receipts
|
|
self.readReceipts = [NSMutableDictionary dictionary];
|
|
|
|
// Create the bubble component based on matrix event
|
|
MXKRoomBubbleComponent *firstComponent = [[MXKRoomBubbleComponent alloc] initWithEvent:event
|
|
roomState:roomState
|
|
andLatestRoomState:roomDataSource.roomState
|
|
eventFormatter:roomDataSource.eventFormatter
|
|
session:roomDataSource.mxSession];
|
|
if (firstComponent)
|
|
{
|
|
bubbleComponents = [NSMutableArray array];
|
|
[bubbleComponents addObject:firstComponent];
|
|
|
|
senderId = event.sender;
|
|
if ([event.type isEqualToString:kMXEventTypeStringRoomMember])
|
|
{
|
|
MXRoomMemberEventContent *content = [MXRoomMemberEventContent modelFromJSON:event.content];
|
|
if (![content.membership isEqualToString:kMXMembershipStringJoin])
|
|
{
|
|
targetId = event.stateKey;
|
|
}
|
|
else
|
|
{
|
|
targetId = event.sender;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
targetId = nil;
|
|
}
|
|
roomId = roomDataSource.roomId;
|
|
|
|
// If `roomScreenUseOnlyLatestUserAvatarAndName`is enabled, the avatar and name are
|
|
// displayed from the latest room state perspective rather than the historical.
|
|
MXRoomState *latestRoomState = roomDataSource.roomState;
|
|
MXRoomState *displayRoomState = RiotSettings.shared.roomScreenUseOnlyLatestUserAvatarAndName ? latestRoomState : roomState;
|
|
[self setRoomState:displayRoomState];
|
|
senderAvatarPlaceholder = nil;
|
|
targetAvatarPlaceholder = nil;
|
|
|
|
// Encryption status should always rely on the `MXRoomState`
|
|
// from the event rather than the latest.
|
|
isEncryptedRoom = roomState.isEncrypted;
|
|
isIncoming = ([event.sender isEqualToString:roomDataSource.mxSession.myUser.userId] == NO);
|
|
|
|
// Check attachment if any
|
|
if ([roomDataSource.eventFormatter isSupportedAttachment:event])
|
|
{
|
|
// Note: event.eventType is equal here to MXEventTypeRoomMessage or MXEventTypeSticker
|
|
attachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager];
|
|
if (attachment && attachment.type == MXKAttachmentTypeImage)
|
|
{
|
|
// Check the current thumbnail orientation. Rotate the current content size (if need)
|
|
if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight)
|
|
{
|
|
_contentSize = CGSizeMake(_contentSize.height, _contentSize.width);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Report the attributed string (This will initialize _contentSize attribute)
|
|
self.attributedTextMessage = firstComponent.attributedTextMessage;
|
|
|
|
// Initialize rendering attributes
|
|
_maxTextViewWidth = MXKROOMBUBBLECELLDATA_DEFAULT_MAX_TEXTVIEW_WIDTH;
|
|
}
|
|
else
|
|
{
|
|
// Ignore this event
|
|
self = nil;
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
roomDataSource = nil;
|
|
bubbleComponents = nil;
|
|
}
|
|
|
|
- (void)refreshProfilesIfNeeded:(MXRoomState *)latestRoomState
|
|
{
|
|
if (RiotSettings.shared.roomScreenUseOnlyLatestUserAvatarAndName)
|
|
{
|
|
[self setRoomState:latestRoomState];
|
|
}
|
|
}
|
|
|
|
/**
|
|
Sets the `MXRoomState` for a buble cell. This allows to adapt the display
|
|
of a cell with a different room state than its historical. This won't update critical
|
|
flag/status, such as `isEncryptedRoom`.
|
|
|
|
@param roomState the `MXRoomState` to use for this cell.
|
|
*/
|
|
- (void)setRoomState:(MXRoomState *)roomState;
|
|
{
|
|
MXEvent* firstEvent = self.events.firstObject;
|
|
|
|
if (firstEvent == nil || roomState == nil)
|
|
{
|
|
return;
|
|
}
|
|
|
|
senderDisplayName = [roomDataSource.eventFormatter senderDisplayNameForEvent:firstEvent
|
|
withRoomState:roomState];
|
|
senderAvatarUrl = [roomDataSource.eventFormatter senderAvatarUrlForEvent:firstEvent
|
|
withRoomState:roomState];
|
|
targetDisplayName = [roomDataSource.eventFormatter targetDisplayNameForEvent:firstEvent
|
|
withRoomState:roomState];
|
|
targetAvatarUrl = [roomDataSource.eventFormatter targetAvatarUrlForEvent:firstEvent
|
|
withRoomState:roomState];
|
|
}
|
|
|
|
- (NSUInteger)updateEvent:(NSString *)eventId withEvent:(MXEvent *)event
|
|
{
|
|
NSUInteger count = 0;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
// Retrieve the component storing the event and update it
|
|
for (NSUInteger index = 0; index < bubbleComponents.count; index++)
|
|
{
|
|
MXKRoomBubbleComponent *roomBubbleComponent = [bubbleComponents objectAtIndex:index];
|
|
if ([roomBubbleComponent.event.eventId isEqualToString:eventId])
|
|
{
|
|
[roomBubbleComponent updateWithEvent:event
|
|
roomState:roomDataSource.roomState
|
|
andLatestRoomState:nil
|
|
session:self.mxSession];
|
|
if (!roomBubbleComponent.textMessage.length)
|
|
{
|
|
[bubbleComponents removeObjectAtIndex:index];
|
|
}
|
|
// Indicate that the text message layout should be recomputed.
|
|
[self invalidateTextLayout];
|
|
|
|
// Handle here attachment update.
|
|
// For example: the case of update of attachment event happens when an echo is replaced by its true event
|
|
// received back by the events stream.
|
|
if (attachment)
|
|
{
|
|
// Check the current content url, to update it with the actual one
|
|
// Retrieve content url/info
|
|
NSString *eventContentURL = event.content[@"url"];
|
|
if (event.content[@"file"][@"url"])
|
|
{
|
|
eventContentURL = event.content[@"file"][@"url"];
|
|
}
|
|
|
|
if (!eventContentURL.length)
|
|
{
|
|
// The attachment has been redacted.
|
|
attachment = nil;
|
|
_contentSize = CGSizeZero;
|
|
}
|
|
else if (![attachment.eventId isEqualToString:event.eventId] || ![attachment.contentURL isEqualToString:eventContentURL])
|
|
{
|
|
MXKAttachment *updatedAttachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager];
|
|
|
|
// Sanity check on attachment type
|
|
if (updatedAttachment && attachment.type == updatedAttachment.type)
|
|
{
|
|
// Re-use the current image as preview to prevent the cell from flashing
|
|
updatedAttachment.previewImage = [attachment getCachedThumbnail];
|
|
if (!updatedAttachment.previewImage && attachment.type == MXKAttachmentTypeImage)
|
|
{
|
|
updatedAttachment.previewImage = [MXMediaManager loadPictureFromFilePath:attachment.cacheFilePath];
|
|
}
|
|
|
|
// Clean the cache by removing the useless data
|
|
if (![updatedAttachment.cacheFilePath isEqualToString:attachment.cacheFilePath])
|
|
{
|
|
[[NSFileManager defaultManager] removeItemAtPath:attachment.cacheFilePath error:nil];
|
|
}
|
|
if (![updatedAttachment.thumbnailCachePath isEqualToString:attachment.thumbnailCachePath])
|
|
{
|
|
[[NSFileManager defaultManager] removeItemAtPath:attachment.thumbnailCachePath error:nil];
|
|
}
|
|
|
|
// Update the current attachment description
|
|
attachment = updatedAttachment;
|
|
|
|
if (attachment.type == MXKAttachmentTypeImage)
|
|
{
|
|
// Reset content size
|
|
_contentSize = CGSizeZero;
|
|
|
|
// Check the current thumbnail orientation. Rotate the current content size (if need)
|
|
if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight)
|
|
{
|
|
_contentSize = CGSizeMake(_contentSize.height, _contentSize.width);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
MXLogDebug(@"[MXKRoomBubbleCellData] updateEvent: Warning: Does not support change of attachment type");
|
|
}
|
|
}
|
|
}
|
|
else if ([roomDataSource.eventFormatter isSupportedAttachment:event])
|
|
{
|
|
// The event is updated to an event with attachement
|
|
attachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager];
|
|
if (attachment && attachment.type == MXKAttachmentTypeImage)
|
|
{
|
|
// Check the current thumbnail orientation. Rotate the current content size (if need)
|
|
if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight)
|
|
{
|
|
_contentSize = CGSizeMake(_contentSize.height, _contentSize.width);
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
count = bubbleComponents.count;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
- (NSUInteger)removeEvent:(NSString *)eventId
|
|
{
|
|
NSUInteger count = 0;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents)
|
|
{
|
|
if ([roomBubbleComponent.event.eventId isEqualToString:eventId])
|
|
{
|
|
[bubbleComponents removeObject:roomBubbleComponent];
|
|
|
|
// Indicate that the text message layout should be recomputed.
|
|
[self invalidateTextLayout];
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
count = bubbleComponents.count;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
- (NSUInteger)removeEventsFromEvent:(NSString*)eventId removedEvents:(NSArray<MXEvent*>**)removedEvents;
|
|
{
|
|
NSMutableArray *cuttedEvents = [NSMutableArray array];
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
NSInteger componentIndex = [self bubbleComponentIndexForEventId:eventId];
|
|
|
|
if (NSNotFound != componentIndex)
|
|
{
|
|
NSArray *newBubbleComponents = [bubbleComponents subarrayWithRange:NSMakeRange(0, componentIndex)];
|
|
|
|
for (NSUInteger i = componentIndex; i < bubbleComponents.count; i++)
|
|
{
|
|
MXKRoomBubbleComponent *roomBubbleComponent = bubbleComponents[i];
|
|
[cuttedEvents addObject:roomBubbleComponent.event];
|
|
}
|
|
|
|
bubbleComponents = [NSMutableArray arrayWithArray:newBubbleComponents];
|
|
|
|
// Indicate that the text message layout should be recomputed.
|
|
[self invalidateTextLayout];
|
|
}
|
|
}
|
|
|
|
*removedEvents = cuttedEvents;
|
|
return bubbleComponents.count;
|
|
}
|
|
|
|
- (BOOL)hasSameSenderAsBubbleCellData:(id<MXKRoomBubbleCellDataStoring>)bubbleCellData
|
|
{
|
|
// Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes
|
|
NSParameterAssert([bubbleCellData isKindOfClass:[MXKRoomBubbleCellData class]]);
|
|
|
|
// NOTE: Same sender means here same id, same display name and same avatar
|
|
|
|
// Check first user id
|
|
if ([senderId isEqualToString:bubbleCellData.senderId] == NO)
|
|
{
|
|
return NO;
|
|
}
|
|
// Check sender name
|
|
if ((senderDisplayName.length || bubbleCellData.senderDisplayName.length) && ([senderDisplayName isEqualToString:bubbleCellData.senderDisplayName] == NO))
|
|
{
|
|
return NO;
|
|
}
|
|
// Check avatar url
|
|
if ((senderAvatarUrl.length || bubbleCellData.senderAvatarUrl.length) && ([senderAvatarUrl isEqualToString:bubbleCellData.senderAvatarUrl] == NO))
|
|
{
|
|
return NO;
|
|
}
|
|
|
|
return YES;
|
|
}
|
|
|
|
- (MXKRoomBubbleComponent*) getFirstBubbleComponent
|
|
{
|
|
MXKRoomBubbleComponent* first = nil;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
if (bubbleComponents.count)
|
|
{
|
|
first = [bubbleComponents firstObject];
|
|
}
|
|
}
|
|
|
|
return first;
|
|
}
|
|
|
|
- (MXKRoomBubbleComponent*)getFirstBubbleComponentWithDisplay
|
|
{
|
|
// Look for the first component which is actually displayed (some event are ignored in room history display).
|
|
MXKRoomBubbleComponent* first = nil;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *component in bubbleComponents)
|
|
{
|
|
if (component.attributedTextMessage)
|
|
{
|
|
first = component;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return first;
|
|
}
|
|
|
|
- (MXKRoomBubbleComponent*)getLastBubbleComponentWithDisplay
|
|
{
|
|
// Look for the first component which is actually displayed (some event are ignored in room history display).
|
|
MXKRoomBubbleComponent* lastVisibleComponent = nil;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *component in bubbleComponents.reverseObjectEnumerator)
|
|
{
|
|
if (component.attributedTextMessage)
|
|
{
|
|
lastVisibleComponent = component;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return lastVisibleComponent;
|
|
}
|
|
|
|
- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor
|
|
{
|
|
NSAttributedString *customAttributedTextMsg;
|
|
|
|
// By default only one component is supported, consider here the first component
|
|
MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent];
|
|
|
|
if (firstComponent)
|
|
{
|
|
customAttributedTextMsg = firstComponent.attributedTextMessage;
|
|
|
|
// Sanity check
|
|
if (customAttributedTextMsg && [firstComponent.event.eventId isEqualToString:eventId])
|
|
{
|
|
NSMutableAttributedString *customComponentString = [[NSMutableAttributedString alloc] initWithAttributedString:customAttributedTextMsg];
|
|
UIColor *color = tintColor ? tintColor : [UIColor lightGrayColor];
|
|
[customComponentString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, customComponentString.length)];
|
|
customAttributedTextMsg = customComponentString;
|
|
}
|
|
}
|
|
|
|
return customAttributedTextMsg;
|
|
}
|
|
|
|
- (void)highlightPatternInTextMessage:(NSString*)pattern
|
|
withBackgroundColor:(UIColor *)backgroundColor
|
|
foregroundColor:(UIColor*)foregroundColor
|
|
andFont:(UIFont*)patternFont
|
|
{
|
|
highlightedPattern = pattern;
|
|
highlightedPatternBackgroundColor = backgroundColor;
|
|
highlightedPatternForegroundColor = foregroundColor;
|
|
highlightedPatternFont = patternFont;
|
|
|
|
// Indicate that the text message layout should be recomputed.
|
|
[self invalidateTextLayout];
|
|
}
|
|
|
|
- (void)setShouldHideSenderInformation:(BOOL)inShouldHideSenderInformation
|
|
{
|
|
shouldHideSenderInformation = inShouldHideSenderInformation;
|
|
}
|
|
|
|
- (BOOL)hasThreadRoot
|
|
{
|
|
@synchronized (bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *component in bubbleComponents)
|
|
{
|
|
if (component.thread)
|
|
{
|
|
return YES;
|
|
}
|
|
}
|
|
}
|
|
|
|
return NO;
|
|
}
|
|
|
|
#pragma mark -
|
|
|
|
- (void)invalidateTextLayout
|
|
{
|
|
self.attributedTextMessage = nil;
|
|
}
|
|
|
|
- (void)prepareBubbleComponentsPosition
|
|
{
|
|
// Consider here only the first component if any
|
|
MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent];
|
|
|
|
if (firstComponent)
|
|
{
|
|
CGFloat positionY = (attachment == nil || attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeAudio || attachment.type == MXKAttachmentTypeVoiceMessage) ? MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET : 0;
|
|
firstComponent.position = CGPointMake(0, positionY);
|
|
}
|
|
}
|
|
|
|
- (NSInteger)bubbleComponentIndexForEventId:(NSString *)eventId
|
|
{
|
|
return [self.bubbleComponents indexOfObjectPassingTest:^BOOL(MXKRoomBubbleComponent * _Nonnull bubbleComponent, NSUInteger idx, BOOL * _Nonnull stop) {
|
|
if ([bubbleComponent.event.eventId isEqualToString:eventId])
|
|
{
|
|
*stop = YES;
|
|
return YES;
|
|
}
|
|
return NO;
|
|
}];
|
|
}
|
|
|
|
#pragma mark - Text measuring
|
|
|
|
// Return the raw height of the provided text by removing any margin
|
|
- (CGFloat)rawTextHeight: (NSAttributedString*)attributedText
|
|
{
|
|
return [self rawTextHeight:attributedText withMaxWidth:_maxTextViewWidth];
|
|
}
|
|
|
|
// Return the raw height of the provided text by removing any vertical margin/inset and constraining the width.
|
|
- (CGFloat)rawTextHeight: (NSAttributedString*)attributedText withMaxWidth:(CGFloat)maxTextViewWidth
|
|
{
|
|
__block CGSize textSize;
|
|
if ([NSThread currentThread] != [NSThread mainThread])
|
|
{
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
textSize = [self textContentSize:attributedText removeVerticalInset:YES maxTextViewWidth:maxTextViewWidth];
|
|
});
|
|
}
|
|
else
|
|
{
|
|
textSize = [self textContentSize:attributedText removeVerticalInset:YES maxTextViewWidth:maxTextViewWidth];
|
|
}
|
|
|
|
return textSize.height;
|
|
}
|
|
|
|
- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset
|
|
{
|
|
return [self textContentSize:attributedText removeVerticalInset:removeVerticalInset maxTextViewWidth:_maxTextViewWidth];
|
|
}
|
|
|
|
- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset maxTextViewWidth:(CGFloat)maxTextViewWidth
|
|
{
|
|
static UITextView* measurementTextView = nil;
|
|
static UITextView* measurementTextViewWithoutInset = nil;
|
|
|
|
if (attributedText.length)
|
|
{
|
|
if (!measurementTextView)
|
|
{
|
|
measurementTextView = [[UITextView alloc] init];
|
|
|
|
measurementTextViewWithoutInset = [[UITextView alloc] init];
|
|
// Remove the container inset: this operation impacts only the vertical margin.
|
|
// Note: consider textContainer.lineFragmentPadding to remove horizontal margin
|
|
measurementTextViewWithoutInset.textContainerInset = UIEdgeInsetsZero;
|
|
}
|
|
|
|
// Select the right text view for measurement
|
|
UITextView *selectedTextView = (removeVerticalInset ? measurementTextViewWithoutInset : measurementTextView);
|
|
|
|
selectedTextView.frame = CGRectMake(0, 0, maxTextViewWidth, 0);
|
|
selectedTextView.attributedText = attributedText;
|
|
|
|
// Force the layout manager to layout the text, fixes problems starting iOS 16
|
|
[selectedTextView.layoutManager ensureLayoutForTextContainer:selectedTextView.textContainer];
|
|
|
|
CGSize size = [selectedTextView sizeThatFits:selectedTextView.frame.size];
|
|
|
|
// Manage the case where a string attribute has a single paragraph with a left indent
|
|
// In this case, [UITextView sizeThatFits] ignores the indent and return the width
|
|
// of the text only.
|
|
// So, add this indent afterwards
|
|
NSRange textRange = NSMakeRange(0, attributedText.length);
|
|
NSRange longestEffectiveRange;
|
|
NSParagraphStyle *paragraphStyle = [attributedText attribute:NSParagraphStyleAttributeName atIndex:0 longestEffectiveRange:&longestEffectiveRange inRange:textRange];
|
|
|
|
if (NSEqualRanges(textRange, longestEffectiveRange))
|
|
{
|
|
size.width = size.width + paragraphStyle.headIndent;
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
return CGSizeZero;
|
|
}
|
|
|
|
#pragma mark - Properties
|
|
|
|
- (MXSession*)mxSession
|
|
{
|
|
return roomDataSource.mxSession;
|
|
}
|
|
|
|
- (NSArray*)bubbleComponents
|
|
{
|
|
NSArray* copy;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
copy = [bubbleComponents copy];
|
|
}
|
|
|
|
return copy;
|
|
}
|
|
|
|
- (NSString*)textMessage
|
|
{
|
|
return self.attributedTextMessage.string;
|
|
}
|
|
|
|
- (void)setAttributedTextMessage:(NSAttributedString *)inAttributedTextMessage
|
|
{
|
|
attributedTextMessage = inAttributedTextMessage;
|
|
|
|
if (attributedTextMessage.length && highlightedPattern)
|
|
{
|
|
[self highlightPattern];
|
|
}
|
|
|
|
// Reset content size
|
|
_contentSize = CGSizeZero;
|
|
}
|
|
|
|
- (NSAttributedString*)attributedTextMessage
|
|
{
|
|
if (self.hasAttributedTextMessage && !attributedTextMessage.length)
|
|
{
|
|
// By default only one component is supported, consider here the first component
|
|
MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent];
|
|
|
|
if (firstComponent)
|
|
{
|
|
attributedTextMessage = firstComponent.attributedTextMessage;
|
|
|
|
if (attributedTextMessage.length && highlightedPattern)
|
|
{
|
|
[self highlightPattern];
|
|
}
|
|
}
|
|
}
|
|
|
|
return attributedTextMessage;
|
|
}
|
|
|
|
- (BOOL)hasAttributedTextMessage
|
|
{
|
|
// Determine if the event formatter will return at least one string for the events in this cell.
|
|
// No string means that the event formatter has been configured so that it did not accept all events
|
|
// of the cell.
|
|
BOOL hasAttributedTextMessage = NO;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents)
|
|
{
|
|
if (roomBubbleComponent.attributedTextMessage)
|
|
{
|
|
hasAttributedTextMessage = YES;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return hasAttributedTextMessage;
|
|
}
|
|
|
|
- (BOOL)hasLink
|
|
{
|
|
@synchronized (bubbleComponents) {
|
|
for (MXKRoomBubbleComponent *component in bubbleComponents)
|
|
{
|
|
if (component.link)
|
|
{
|
|
return YES;
|
|
}
|
|
}
|
|
}
|
|
|
|
return NO;
|
|
}
|
|
|
|
- (MXKRoomBubbleComponentDisplayFix)displayFix
|
|
{
|
|
MXKRoomBubbleComponentDisplayFix displayFix = MXKRoomBubbleComponentDisplayFixNone;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *component in self.bubbleComponents)
|
|
{
|
|
displayFix |= component.displayFix;
|
|
}
|
|
}
|
|
return displayFix;
|
|
}
|
|
|
|
- (BOOL)shouldHideSenderName
|
|
{
|
|
BOOL res = NO;
|
|
|
|
MXKRoomBubbleComponent *firstDisplayedComponent = [self getFirstBubbleComponentWithDisplay];
|
|
NSString *senderDisplayName = self.senderDisplayName;
|
|
|
|
if (firstDisplayedComponent)
|
|
{
|
|
res = (firstDisplayedComponent.event.isEmote || (firstDisplayedComponent.event.isState && senderDisplayName && [firstDisplayedComponent.textMessage hasPrefix:senderDisplayName]));
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
- (BOOL)canInvitePeople
|
|
{
|
|
NSInteger requiredLevel = roomDataSource.roomState.powerLevels.invite;
|
|
NSInteger myLevel = [roomDataSource.roomState.powerLevels powerLevelOfUserWithUserID:roomDataSource.mxSession.myUserId];
|
|
return myLevel >= requiredLevel;
|
|
}
|
|
|
|
- (NSArray*)events
|
|
{
|
|
NSMutableArray* eventsArray;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
eventsArray = [NSMutableArray arrayWithCapacity:bubbleComponents.count];
|
|
for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents)
|
|
{
|
|
if (roomBubbleComponent.event)
|
|
{
|
|
[eventsArray addObject:roomBubbleComponent.event];
|
|
}
|
|
}
|
|
}
|
|
return eventsArray;
|
|
}
|
|
|
|
- (NSDate*)date
|
|
{
|
|
MXKRoomBubbleComponent *firstDisplayedComponent = [self getFirstBubbleComponentWithDisplay];
|
|
|
|
if (firstDisplayedComponent)
|
|
{
|
|
return firstDisplayedComponent.date;
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
- (BOOL)hasNoDisplay
|
|
{
|
|
BOOL noDisplay = YES;
|
|
|
|
// Check whether at least one component has a string description.
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
if (self.collapsed)
|
|
{
|
|
// Collapsed cells have no display except their cell header
|
|
noDisplay = !self.collapsedAttributedTextMessage;
|
|
}
|
|
else
|
|
{
|
|
for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents)
|
|
{
|
|
if (roomBubbleComponent.attributedTextMessage)
|
|
{
|
|
noDisplay = NO;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (noDisplay && !attachment);
|
|
}
|
|
|
|
- (BOOL)isAttachmentWithThumbnail
|
|
{
|
|
return (attachment && (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo || attachment.type == MXKAttachmentTypeSticker));
|
|
}
|
|
|
|
- (BOOL)isAttachmentWithIcon
|
|
{
|
|
// Not supported yet (TODO for audio, file).
|
|
return NO;
|
|
}
|
|
|
|
- (BOOL)isAttachment
|
|
{
|
|
if (!self.attachment)
|
|
{
|
|
return NO;
|
|
}
|
|
|
|
if (!attachment.contentURL || !attachment.contentInfo) {
|
|
return NO;
|
|
}
|
|
|
|
switch (self.attachment.type) {
|
|
case MXKAttachmentTypeFile:
|
|
case MXKAttachmentTypeAudio:
|
|
case MXKAttachmentTypeVoiceMessage:
|
|
return YES;
|
|
default:
|
|
return NO;
|
|
}
|
|
}
|
|
|
|
- (void)setMaxTextViewWidth:(CGFloat)inMaxTextViewWidth
|
|
{
|
|
// Check change
|
|
if (inMaxTextViewWidth != _maxTextViewWidth)
|
|
{
|
|
_maxTextViewWidth = inMaxTextViewWidth;
|
|
// Reset content size
|
|
_contentSize = CGSizeZero;
|
|
}
|
|
}
|
|
|
|
- (CGSize)contentSize
|
|
{
|
|
if (CGSizeEqualToSize(_contentSize, CGSizeZero))
|
|
{
|
|
if (attachment == nil)
|
|
{
|
|
// Here the bubble is a text message
|
|
if ([NSThread currentThread] != [NSThread mainThread])
|
|
{
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
self->_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO];
|
|
});
|
|
}
|
|
else
|
|
{
|
|
_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO];
|
|
}
|
|
}
|
|
else if (self.isAttachmentWithThumbnail)
|
|
{
|
|
CGFloat width, height;
|
|
|
|
// Set default content size
|
|
width = height = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH;
|
|
|
|
if (attachment.thumbnailInfo || attachment.contentInfo)
|
|
{
|
|
if (attachment.thumbnailInfo && attachment.thumbnailInfo[@"w"] && attachment.thumbnailInfo[@"h"])
|
|
{
|
|
width = [attachment.thumbnailInfo[@"w"] integerValue];
|
|
height = [attachment.thumbnailInfo[@"h"] integerValue];
|
|
}
|
|
else if (attachment.contentInfo[@"w"] && attachment.contentInfo[@"h"])
|
|
{
|
|
width = [attachment.contentInfo[@"w"] integerValue];
|
|
height = [attachment.contentInfo[@"h"] integerValue];
|
|
}
|
|
|
|
if (width > MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH || height > MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH)
|
|
{
|
|
if (width > height)
|
|
{
|
|
height = (height * MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) / width;
|
|
height = floorf(height / 2) * 2;
|
|
width = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH;
|
|
}
|
|
else
|
|
{
|
|
width = (width * MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) / height;
|
|
width = floorf(width / 2) * 2;
|
|
height = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check here thumbnail orientation
|
|
if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight)
|
|
{
|
|
_contentSize = CGSizeMake(height, width);
|
|
}
|
|
else
|
|
{
|
|
_contentSize = CGSizeMake(width, height);
|
|
}
|
|
}
|
|
else if (attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeAudio)
|
|
{
|
|
// Presently we displayed only the file name for attached file (no icon yet)
|
|
// Return suitable content size of a text view to display the file name (available in text message).
|
|
if ([NSThread currentThread] != [NSThread mainThread])
|
|
{
|
|
dispatch_sync(dispatch_get_main_queue(), ^{
|
|
self->_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO];
|
|
});
|
|
}
|
|
else
|
|
{
|
|
_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_contentSize = CGSizeMake(40, 40);
|
|
}
|
|
}
|
|
return _contentSize;
|
|
}
|
|
|
|
- (MXKEventFormatter *)eventFormatter
|
|
{
|
|
MXKRoomBubbleComponent *firstComponent = [bubbleComponents firstObject];
|
|
|
|
// Retrieve event formatter from the first component
|
|
if (firstComponent)
|
|
{
|
|
return firstComponent.eventFormatter;
|
|
}
|
|
|
|
return nil;
|
|
}
|
|
|
|
- (BOOL)showAntivirusScanStatus
|
|
{
|
|
MXKRoomBubbleComponent *firstBubbleComponent = self.bubbleComponents.firstObject;
|
|
|
|
if (self.attachment == nil || firstBubbleComponent == nil)
|
|
{
|
|
return NO;
|
|
}
|
|
|
|
MXEventScan *eventScan = firstBubbleComponent.eventScan;
|
|
|
|
return eventScan != nil && eventScan.antivirusScanStatus != MXAntivirusScanStatusTrusted;
|
|
}
|
|
|
|
- (BOOL)containsBubbleComponentWithEncryptionBadge
|
|
{
|
|
BOOL containsBubbleComponentWithEncryptionBadge = NO;
|
|
|
|
@synchronized(bubbleComponents)
|
|
{
|
|
for (MXKRoomBubbleComponent *component in bubbleComponents)
|
|
{
|
|
if (component.encryptionDecoration != EventEncryptionDecorationNone)
|
|
{
|
|
containsBubbleComponentWithEncryptionBadge = YES;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return containsBubbleComponentWithEncryptionBadge;
|
|
}
|
|
|
|
#pragma mark - Bubble collapsing
|
|
|
|
- (BOOL)collapseWith:(id<MXKRoomBubbleCellDataStoring>)cellData
|
|
{
|
|
// NO by default
|
|
return NO;
|
|
}
|
|
|
|
#pragma mark - Internals
|
|
|
|
- (void)highlightPattern
|
|
{
|
|
NSMutableAttributedString *customAttributedTextMsg = nil;
|
|
|
|
NSString *currentTextMessage = self.textMessage;
|
|
NSRange range = [currentTextMessage rangeOfString:highlightedPattern options:NSCaseInsensitiveSearch];
|
|
|
|
if (range.location != NSNotFound)
|
|
{
|
|
customAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedTextMessage];
|
|
|
|
while (range.location != NSNotFound)
|
|
{
|
|
if (highlightedPatternBackgroundColor)
|
|
{
|
|
// Update background color
|
|
[customAttributedTextMsg addAttribute:NSBackgroundColorAttributeName value:highlightedPatternBackgroundColor range:range];
|
|
}
|
|
|
|
if (highlightedPatternForegroundColor)
|
|
{
|
|
// Update text color
|
|
[customAttributedTextMsg addAttribute:NSForegroundColorAttributeName value:highlightedPatternForegroundColor range:range];
|
|
}
|
|
|
|
if (highlightedPatternFont)
|
|
{
|
|
// Update text font
|
|
[customAttributedTextMsg addAttribute:NSFontAttributeName value:highlightedPatternFont range:range];
|
|
}
|
|
|
|
// Look for the next pattern occurrence
|
|
range.location += range.length;
|
|
if (range.location < currentTextMessage.length)
|
|
{
|
|
range.length = currentTextMessage.length - range.location;
|
|
range = [currentTextMessage rangeOfString:highlightedPattern options:NSCaseInsensitiveSearch range:range];
|
|
}
|
|
else
|
|
{
|
|
range.location = NSNotFound;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (customAttributedTextMsg)
|
|
{
|
|
// Update resulting message body
|
|
attributedTextMessage = customAttributedTextMsg;
|
|
}
|
|
}
|
|
|
|
@end
|