element-ios/Riot/Modules/Room/CellData/RoomBubbleCellData.m

1507 lines
54 KiB
Objective-C

/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2015 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
#import "RoomBubbleCellData.h"
#import "EventFormatter.h"
#import "AvatarGenerator.h"
#import "Tools.h"
#import "RoomReactionsViewSizer.h"
#import "GeneratedInterface-Swift.h"
static NSAttributedString *timestampVerticalWhitespace = nil;
NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotification";
@interface RoomBubbleCellData()
@property(nonatomic, readonly) BOOL addVerticalWhitespaceForSelectedComponentTimestamp;
@property(nonatomic, readwrite) CGFloat additionalContentHeight;
@property(nonatomic) BOOL shouldUpdateAdditionalContentHeight;
// Flags to "Show All" reactions for an event
@property(nonatomic) NSMutableSet<NSString* /* eventId */> *eventsToShowAllReactions;
@end
@implementation RoomBubbleCellData
- (BOOL)addVerticalWhitespaceForSelectedComponentTimestamp
{
return self.showTimestampForSelectedComponent && !self.displayTimestampForSelectedComponentOnLeftWhenPossible;
}
#pragma mark - Override MXKRoomBubbleCellData
- (instancetype)init
{
self = [super init];
if (self)
{
_eventsToShowAllReactions = [NSMutableSet set];
_componentIndexOfSentMessageTick = -1;
}
return self;
}
- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)roomState andRoomDataSource:(MXKRoomDataSource *)roomDataSource
{
self = [super initWithEvent:event andRoomState:roomState andRoomDataSource:roomDataSource];
if (self)
{
self.displayTimestampForSelectedComponentOnLeftWhenPossible = YES;
switch (event.eventType)
{
case MXEventTypeRoomMember:
{
// Membership events have their own cell type
self.tag = RoomBubbleCellDataTagMembership;
// Membership events can be collapsed together
self.collapsable = YES;
// Collapse them by default
self.collapsed = YES;
// find the room create event in stateEvents
MXEvent *roomCreateEvent = [roomState.stateEvents filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"wireType == %@", kMXEventTypeStringRoomCreate]].firstObject;
NSString *creatorUserId = [MXRoomCreateContent modelFromJSON:roomCreateEvent.content].creatorUserId;
if (creatorUserId)
{
MXRoomMemberEventContent *content = [MXRoomMemberEventContent modelFromJSON:event.content];
if ([kMXMembershipStringJoin isEqualToString:content.membership] &&
[creatorUserId isEqualToString:event.sender])
{
// join event of the room creator
// group it with room creation events
self.tag = RoomBubbleCellDataTagRoomCreateConfiguration;
}
}
}
break;
case MXEventTypeRoomCreate:
{
MXRoomCreateContent *createContent = [MXRoomCreateContent modelFromJSON:event.content];
if (createContent.roomPredecessorInfo)
{
self.tag = RoomBubbleCellDataTagRoomCreateWithPredecessor;
}
else
{
self.tag = RoomBubbleCellDataTagRoomCreationIntro;
}
// Membership events can be collapsed together
self.collapsable = YES;
// Collapse them by default
self.collapsed = YES;
}
break;
case MXEventTypeRoomTopic:
case MXEventTypeRoomName:
case MXEventTypeRoomEncryption:
case MXEventTypeRoomHistoryVisibility:
case MXEventTypeRoomGuestAccess:
case MXEventTypeRoomAvatar:
case MXEventTypeRoomJoinRules:
{
self.tag = RoomBubbleCellDataTagRoomCreateConfiguration;
// Membership events can be collapsed together
self.collapsable = YES;
// Collapse them by default
self.collapsed = YES;
}
break;
case MXEventTypeCallInvite:
case MXEventTypeCallAnswer:
case MXEventTypeCallHangup:
case MXEventTypeCallReject:
{
self.tag = RoomBubbleCellDataTagCall;
// Call events can be collapsed together
self.collapsable = YES;
// Collapse them by default
self.collapsed = YES;
// Show timestamps always on right
self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO;
break;
}
case MXEventTypeCallNotify:
{
self.tag = RoomBubbleCellDataTagRTCCallNotify;
self.collapsable = NO;
self.collapsed = NO;
self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO;
break;
}
case MXEventTypePollStart:
case MXEventTypePollEnd:
{
self.tag = RoomBubbleCellDataTagPoll;
self.collapsable = NO;
self.collapsed = NO;
break;
}
case MXEventTypeBeaconInfo:
{
self.tag = RoomBubbleCellDataTagLiveLocation;
self.collapsable = NO;
self.collapsed = NO;
[self updateBeaconInfoSummaryWithId:event.eventId andEvent:event];
break;
}
case MXEventTypeCustom:
{
if ([event.type isEqualToString:kWidgetMatrixEventTypeString]
|| [event.type isEqualToString:kWidgetModularEventTypeString])
{
Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:roomDataSource.mxSession];
if ([widget.type isEqualToString:kWidgetTypeJitsiV1] ||
[widget.type isEqualToString:kWidgetTypeJitsiV2])
{
self.tag = RoomBubbleCellDataTagGroupCall;
// Show timestamps always on right
self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO;
}
}
else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType])
{
VoiceBroadcastInfo *voiceBroadcastInfo = [VoiceBroadcastInfo modelFromJSON: event.content];
// Check if the state event corresponds to the beginning of a voice broadcast
if ([VoiceBroadcastInfo isStartedFor:voiceBroadcastInfo.state])
{
// Retrieve the most recent voice broadcast info.
MXEvent *lastVoiceBroadcastInfoEvent = [roomDataSource.roomState stateEventsWithType:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType].lastObject;
if (event.originServerTs > lastVoiceBroadcastInfoEvent.originServerTs)
{
lastVoiceBroadcastInfoEvent = event;
}
VoiceBroadcastInfo *lastVoiceBroadcastInfo = [VoiceBroadcastInfo modelFromJSON: lastVoiceBroadcastInfoEvent.content];
// Handle the specific case where the state event is a started voice broadcast (the voiceBroadcastId is the event id itself).
if (!lastVoiceBroadcastInfo.voiceBroadcastId)
{
lastVoiceBroadcastInfo.voiceBroadcastId = lastVoiceBroadcastInfoEvent.eventId;
}
// Check if the voice broadcast is still alive.
if ([lastVoiceBroadcastInfo.voiceBroadcastId isEqualToString:event.eventId] && ![VoiceBroadcastInfo isStoppedFor:lastVoiceBroadcastInfo.state])
{
// Check whether this broadcast is sent from the currrent session to display it with the recorder view or not.
if ([event.stateKey isEqualToString:self.mxSession.myUserId] &&
[voiceBroadcastInfo.deviceId isEqualToString:self.mxSession.myDeviceId])
{
self.tag = RoomBubbleCellDataTagVoiceBroadcastRecord;
}
else
{
self.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback;
}
self.voiceBroadcastState = lastVoiceBroadcastInfo.state;
}
else
{
self.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback;
self.voiceBroadcastState = VoiceBroadcastInfo.stoppedValue;
}
}
else
{
self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay;
if ([VoiceBroadcastInfo isStoppedFor:voiceBroadcastInfo.state])
{
// This state event corresponds to the end of a voice broadcast
// Force the tag of the potential cellData which corresponds to the started event to switch the display from recorder to listener
RoomBubbleCellData *bubbleData = [roomDataSource cellDataOfEventWithEventId:voiceBroadcastInfo.voiceBroadcastId];
bubbleData.tag = RoomBubbleCellDataTagVoiceBroadcastPlayback;
bubbleData.voiceBroadcastState = VoiceBroadcastInfo.stoppedValue;
}
}
self.collapsable = NO;
self.collapsed = NO;
break;
}
break;
}
case MXEventTypeRoomMessage:
{
if (event.location)
{
self.tag = RoomBubbleCellDataTagLocation;
self.collapsable = NO;
self.collapsed = NO;
}
else if (event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType])
{
self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay;
self.collapsable = NO;
self.collapsed = NO;
}
break;
}
default:
break;
}
[self keyVerificationDidUpdate];
// Increase maximum number of components
self.maxComponentCount = 20;
// Indicate that the text message layout should be recomputed.
[self invalidateTextLayout];
// Load a url preview if necessary.
[self refreshURLPreviewForEventId:event.eventId];
}
return self;
}
- (NSUInteger)updateEvent:(NSString *)eventId withEvent:(MXEvent *)event
{
NSUInteger retVal = [super updateEvent:eventId withEvent:event];
// Update any URL preview data as necessary.
[self refreshURLPreviewForEventId:event.eventId];
if (self.tag == RoomBubbleCellDataTagLiveLocation)
{
[self updateBeaconInfoSummaryWithId:eventId andEvent:event];
}
// Handle here the case where an audio chunk of a voice broadcast have been decrypted with delay
// We take the opportunity of this update to disable the display of this chunk in the room timeline
if (event.eventType == MXEventTypeRoomMessage && event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType]) {
self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay;
self.collapsable = NO;
self.collapsed = NO;
}
return retVal;
}
- (void)prepareBubbleComponentsPosition
{
if (shouldUpdateComponentsPosition)
{
// The bubble layout depends on the room read receipts which must be retrieved on the main thread to prevent us from race conditions.
// Check here the current thread, this is just a sanity check because this method is called during the rendering step
// which takes place on the main thread.
if ([NSThread currentThread] != [NSThread mainThread])
{
MXLogDebug(@"[RoomBubbleCellData] prepareBubbleComponentsPosition called on wrong thread");
dispatch_sync(dispatch_get_main_queue(), ^{
[self refreshBubbleComponentsPosition];
});
}
else
{
[self refreshBubbleComponentsPosition];
}
shouldUpdateComponentsPosition = NO;
}
[self updateAdditionalContentHeightIfNeeded];
}
- (NSAttributedString*)attributedTextMessage
{
[self buildAttributedStringIfNeeded];
return attributedTextMessage;
}
- (NSAttributedString*)attributedTextMessageWithoutPositioningSpace
{
[self buildAttributedStringIfNeeded];
return attributedTextMessageWithoutPositioningSpace;
}
- (BOOL)hasNoDisplay
{
BOOL hasNoDisplay = YES;
switch (self.tag)
{
case RoomBubbleCellDataTagKeyVerificationNoDisplay:
hasNoDisplay = YES;
break;
case RoomBubbleCellDataTagRoomCreationIntro:
hasNoDisplay = NO;
break;
case RoomBubbleCellDataTagPoll:
if (!self.events.lastObject.isEditEvent)
{
hasNoDisplay = NO;
}
break;
case RoomBubbleCellDataTagLocation:
hasNoDisplay = NO;
break;
case RoomBubbleCellDataTagLiveLocation:
// Show the cell only if the summary exists
if (self.beaconInfoSummary)
{
hasNoDisplay = NO;
}
break;
case RoomBubbleCellDataTagVoiceBroadcastRecord:
case RoomBubbleCellDataTagVoiceBroadcastPlayback:
hasNoDisplay = NO;
break;
case RoomBubbleCellDataTagVoiceBroadcastNoDisplay:
break;
case RoomBubbleCellDataTagRTCCallNotify:
{
hasNoDisplay = NO;
break;
}
default:
hasNoDisplay = [super hasNoDisplay];
break;
}
return hasNoDisplay;
}
- (BOOL)hasThreadRoot
{
if (!RiotSettings.shared.enableThreads)
{
// do not consider this cell data if threads not enabled in the timeline
return NO;
}
if (roomDataSource.threadId)
{
// do not consider this cell data if in a thread view
return NO;
}
return super.hasThreadRoot;
}
- (BOOL)mergeWithBubbleCellData:(id<MXKRoomBubbleCellDataStoring>)bubbleCellData
{
RoomTimelineConfiguration *timelineConfiguration = [RoomTimelineConfiguration shared];
if (NO == [timelineConfiguration.currentStyle canMergeWithCellData:bubbleCellData into:self]) {
return NO;
}
return [super mergeWithBubbleCellData:bubbleCellData];
}
#pragma mark - Bubble collapsing
- (BOOL)collapseWith:(id<MXKRoomBubbleCellDataStoring>)cellData
{
if (self.tag == RoomBubbleCellDataTagMembership
&& cellData.tag == RoomBubbleCellDataTagMembership)
{
// For now, do not merge VoIP conference events
if (![MXCallManager isConferenceUser:cellData.events.firstObject.stateKey])
{
// Keep a pagination between events of different days
NSString *bubbleDateString = [roomDataSource.eventFormatter dateStringFromDate:self.date withTime:NO];
NSString *eventDateString = [roomDataSource.eventFormatter dateStringFromDate:((RoomBubbleCellData*)cellData).date withTime:NO];
if (bubbleDateString && eventDateString && [bubbleDateString isEqualToString:eventDateString])
{
return YES;
}
}
return NO;
}
else if (self.tag == RoomBubbleCellDataTagRoomCreateConfiguration && cellData.tag == RoomBubbleCellDataTagRoomCreateConfiguration)
{
return YES;
}
else if (self.tag == RoomBubbleCellDataTagCall && cellData.tag == RoomBubbleCellDataTagCall)
{
// Check if the same call
MXEvent * event1 = self.events.firstObject;
MXCallEventContent *eventContent1 = [MXCallEventContent modelFromJSON:event1.content];
MXEvent * event2 = cellData.events.firstObject;
MXCallEventContent *eventContent2 = [MXCallEventContent modelFromJSON:event2.content];
return [eventContent1.callId isEqualToString:eventContent2.callId];
}
if (self.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor || cellData.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor)
{
return NO;
}
return [super collapseWith:cellData];
}
- (void)setCollapsed:(BOOL)collapsed
{
if (collapsed != self.collapsed)
{
super.collapsed = collapsed;
// Refresh only cells series header
if (self.collapsedAttributedTextMessage && self.nextCollapsableCellData)
{
[self invalidateTextLayout];
}
}
}
#pragma mark -
- (void)invalidateLayout
{
[self invalidateTextLayout];
[self setNeedsUpdateAdditionalContentHeight];
}
- (void)buildAttributedString
{
// CAUTION: This method must be called on the main thread.
// Return the collapsed string only for cells series header
if (self.collapsed && self.collapsedAttributedTextMessage && self.nextCollapsableCellData)
{
NSAttributedString *attributedString = super.collapsedAttributedTextMessage;
self.attributedTextMessage = attributedString;
self.attributedTextMessageWithoutPositioningSpace = attributedString;
return;
}
NSMutableAttributedString *currentAttributedTextMsg;
NSMutableAttributedString *currentAttributedTextMsgWithoutVertSpace = [NSMutableAttributedString new];
NSInteger selectedComponentIndex = self.selectedComponentIndex;
NSInteger lastMessageIndex = self.containsLastMessage ? self.mostRecentComponentIndex : NSNotFound;
MXKRoomBubbleComponent *component;
NSAttributedString *componentString;
NSUInteger index = 0;
for (; index < bubbleComponents.count; index++)
{
component = bubbleComponents[index];
componentString = component.attributedTextMessage;
if (componentString)
{
// Check whether another component than this one is selected
// Note: When a component is selected, it is highlighted by applying an alpha on other components.
if (selectedComponentIndex != NSNotFound && selectedComponentIndex != index && componentString.length)
{
// Apply alpha to blur this component
componentString = [componentString withTextColorAlpha:.2];
if (@available(iOS 15.0, *)) {
[PillsFormatter setPillAlpha:.2 inAttributedString:componentString];
}
}
else if (@available(iOS 15.0, *))
{
// PillTextAttachment are not created again every time, we have to set alpha back to standard if needed.
[PillsFormatter setPillAlpha:1.f inAttributedString:componentString];
}
// Check whether the timestamp is displayed for this component, and check whether a vertical whitespace is required
if (((selectedComponentIndex == index && self.addVerticalWhitespaceForSelectedComponentTimestamp) || lastMessageIndex == index) && (self.shouldHideSenderInformation || self.shouldHideSenderName))
{
currentAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:[RoomBubbleCellData timestampVerticalWhitespace]];
[currentAttributedTextMsg appendAttributedString:componentString];
[currentAttributedTextMsgWithoutVertSpace appendAttributedString:componentString];
}
else
{
// Init attributed string with the first text component
currentAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:componentString];
[currentAttributedTextMsgWithoutVertSpace appendAttributedString:componentString];
}
[self addVerticalWhitespaceToString:currentAttributedTextMsg forEvent:component.event.eventId];
// The first non empty component has been handled.
break;
}
}
for (index++; index < bubbleComponents.count; index++)
{
component = bubbleComponents[index];
componentString = component.attributedTextMessage;
if (componentString)
{
[currentAttributedTextMsg appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]];
// Check whether another component than this one is selected
if (selectedComponentIndex != NSNotFound && selectedComponentIndex != index && componentString.length)
{
// Apply alpha to blur this component
componentString = [componentString withTextColorAlpha:.2];
if (@available(iOS 15.0, *)) {
[PillsFormatter setPillAlpha:.2 inAttributedString:componentString];
}
}
else if (@available(iOS 15.0, *))
{
// PillTextAttachment are not created again every time, we have to set alpha back to standard if needed.
[PillsFormatter setPillAlpha:1.f inAttributedString:componentString];
}
// Check whether the timestamp is displayed
if ((selectedComponentIndex == index && self.addVerticalWhitespaceForSelectedComponentTimestamp) || lastMessageIndex == index)
{
[currentAttributedTextMsg appendAttributedString:[RoomBubbleCellData timestampVerticalWhitespace]];
}
// Append attributed text
[currentAttributedTextMsg appendAttributedString:componentString];
[self addVerticalWhitespaceToString:currentAttributedTextMsg forEvent:component.event.eventId];
[currentAttributedTextMsgWithoutVertSpace appendAttributedString:componentString];
}
}
// With bubbles the text is truncated with quote messages containing vertical border view
// Add horizontal space to fix the issue
if (self.displayFix & MXKRoomBubbleComponentDisplayFixHtmlBlockquote)
{
[currentAttributedTextMsgWithoutVertSpace appendString:@" "];
}
self.attributedTextMessage = currentAttributedTextMsg;
self.attributedTextMessageWithoutPositioningSpace = currentAttributedTextMsgWithoutVertSpace;
}
- (void)buildAttributedStringIfNeeded
{
@synchronized(bubbleComponents)
{
if (self.hasAttributedTextMessage && !attributedTextMessage.length)
{
// Attributed text message depends on the room read receipts which must be retrieved on the main thread to prevent us from race conditions.
// Check here the current thread, this is just a sanity check because the attributed text message
// is requested during the rendering step which takes place on the main thread.
if ([NSThread currentThread] != [NSThread mainThread])
{
MXLogDebug(@"[RoomBubbleCellData] attributedTextMessage called on wrong thread");
dispatch_sync(dispatch_get_main_queue(), ^{
[self buildAttributedString];
});
}
else
{
[self buildAttributedString];
}
}
}
}
- (NSInteger)firstVisibleComponentIndex
{
__block NSInteger firstVisibleComponentIndex = NSNotFound;
MXEvent *firstEvent = self.events.firstObject;
BOOL isPoll = firstEvent.isTimelinePollEvent;
BOOL isVoiceBroadcast = (firstEvent.eventType == MXEventTypeCustom && [firstEvent.type isEqualToString: VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]);
if ((isPoll || self.attachment || isVoiceBroadcast) && self.bubbleComponents.count)
{
firstVisibleComponentIndex = 0;
}
else
{
[self.bubbleComponents enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
MXKRoomBubbleComponent *component = (MXKRoomBubbleComponent*)obj;
if (component.attributedTextMessage)
{
firstVisibleComponentIndex = idx;
*stop = YES;
}
}];
}
return firstVisibleComponentIndex;
}
- (void)refreshBubbleComponentsPosition
{
// CAUTION: This method must be called on the main thread.
@synchronized(bubbleComponents)
{
NSInteger bubbleComponentsCount = bubbleComponents.count;
// Check whether there is at least one component.
if (bubbleComponentsCount)
{
// Set position of the first component
CGFloat positionY = (self.attachment == nil || self.attachment.type == MXKAttachmentTypeFile || self.attachment.type == MXKAttachmentTypeAudio) ? MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET : 0;
MXKRoomBubbleComponent *component;
NSUInteger index = 0;
// Use same position for first components without render (redacted)
for (; index < bubbleComponentsCount; index++)
{
// Compute the vertical position for next component
component = bubbleComponents[index];
component.position = CGPointMake(0, positionY);
if (component.attributedTextMessage)
{
break;
}
}
// Check whether the position of other components need to be refreshed
if (!self.attachment && index < bubbleComponentsCount)
{
NSMutableAttributedString *attributedString = [NSMutableAttributedString new];
NSInteger selectedComponentIndex = self.selectedComponentIndex;
NSInteger lastMessageIndex = self.containsLastMessage ? self.mostRecentComponentIndex : NSNotFound;
NSInteger visibleMessageIndex = 0;
for (; index < bubbleComponentsCount; index++)
{
// Compute the vertical position for next component
component = bubbleComponents[index];
if (component.attributedTextMessage)
{
// Prepare its attributed string by considering potential vertical margin required to display timestamp.
NSAttributedString *componentString = component.attributedTextMessage;
// Check whether the timestamp is displayed for this component, and check whether a vertical whitespace is required
if (((selectedComponentIndex == index && self.addVerticalWhitespaceForSelectedComponentTimestamp) || lastMessageIndex == index)
&& !(visibleMessageIndex == 0 && !(self.shouldHideSenderInformation || self.shouldHideSenderName)))
{
[attributedString appendAttributedString:[RoomBubbleCellData timestampVerticalWhitespace]];
}
// Append this attributed string.
[attributedString appendAttributedString:componentString];
// Compute the height of the resulting string.
CGFloat cumulatedHeight = [self rawTextHeight:attributedString];
// Deduce the position of the beginning of this component.
positionY = MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET + (cumulatedHeight - [self rawTextHeight:componentString]);
component.position = CGPointMake(0, positionY);
// Vertical whitespace is added in case of read receipts or reactions
[self addVerticalWhitespaceToString:attributedString forEvent:component.event.eventId];
[attributedString appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]];
visibleMessageIndex++;
}
else
{
component.position = CGPointMake(0, positionY);
}
}
}
}
}
}
- (void)addVerticalWhitespaceToString:(NSMutableAttributedString *)attributedString forEvent:(NSString *)eventId
{
CGFloat additionalVerticalHeight = 0;
// Add vertical whitespace in case of a url preview.
additionalVerticalHeight+= [self urlPreviewHeightForEventId:eventId];
// Add vertical whitespace in case of reactions.
additionalVerticalHeight+= [self reactionHeightForEventId:eventId];
// Add vertical whitespace in case of a thread root
additionalVerticalHeight+= [self threadSummaryViewHeightForEventId:eventId];
// Add vertical whitespace in case of from a thread
additionalVerticalHeight+= [self fromAThreadViewHeightForEventId:eventId];
// Add vertical whitespace in case of read receipts.
additionalVerticalHeight+= [self readReceiptHeightForEventId:eventId];
if (additionalVerticalHeight)
{
[attributedString appendAttributedString:[RoomBubbleCellData verticalWhitespaceForHeight: additionalVerticalHeight]];
}
}
- (CGFloat)computeAdditionalHeight
{
CGFloat height = 0;
for (MXKRoomBubbleComponent *bubbleComponent in self.bubbleComponents)
{
NSString *eventId = bubbleComponent.event.eventId;
height+= [self urlPreviewHeightForEventId:eventId];
height+= [self reactionHeightForEventId:eventId];
height+= [self threadSummaryViewHeightForEventId:eventId];
height+= [self fromAThreadViewHeightForEventId:eventId];
height+= [self readReceiptHeightForEventId:eventId];
}
return height;
}
- (void)updateAdditionalContentHeightIfNeeded;
{
if (self.shouldUpdateAdditionalContentHeight)
{
void(^updateAdditionalHeight)(void) = ^() {
self.additionalContentHeight = [self computeAdditionalHeight];
};
// The additional height depends on the room read receipts and reactions view which must be calculated on the main thread.
// Check here the current thread, this is just a sanity check because this method is called during the rendering step
// which takes place on the main thread.
if ([NSThread currentThread] != [NSThread mainThread])
{
MXLogDebug(@"[RoomBubbleCellData] prepareBubbleComponentsPosition called on wrong thread");
dispatch_sync(dispatch_get_main_queue(), ^{
updateAdditionalHeight();
});
}
else
{
updateAdditionalHeight();
}
self.shouldUpdateAdditionalContentHeight = NO;
}
}
- (void)setNeedsUpdateAdditionalContentHeight
{
self.shouldUpdateAdditionalContentHeight = YES;
}
- (CGFloat)threadSummaryViewHeightForEventId:(NSString*)eventId
{
if (!RiotSettings.shared.enableThreads)
{
// do not show thread summary view if threads not enabled in the timeline
return 0;
}
if (roomDataSource.threadId)
{
// do not show thread summary view on threads
return 0;
}
NSInteger index = [self bubbleComponentIndexForEventId:eventId];
if (index == NSNotFound)
{
return 0;
}
MXKRoomBubbleComponent *component = self.bubbleComponents[index];
if (!component.thread)
{
// component is not a thread root
return 0;
}
return PlainRoomCellLayoutConstants.threadSummaryViewTopMargin +
[ThreadSummaryView contentViewHeightForThread:component.thread fitting:self.maxTextViewWidth];
}
- (CGFloat)fromAThreadViewHeightForEventId:(NSString*)eventId
{
if (!RiotSettings.shared.enableThreads)
{
// do not show from a thread view if threads not enabled
return 0;
}
if (roomDataSource.threadId)
{
// do not show from a thread view on threads
return 0;
}
NSInteger index = [self bubbleComponentIndexForEventId:eventId];
if (index == NSNotFound)
{
return 0;
}
MXKRoomBubbleComponent *component = self.bubbleComponents[index];
if (!component.event.isInThread)
{
// event is not in a thread
return 0;
}
return PlainRoomCellLayoutConstants.fromAThreadViewTopMargin +
[FromAThreadView contentViewHeightForEvent:component.event fitting:self.maxTextViewWidth];
}
- (CGFloat)urlPreviewHeightForEventId:(NSString*)eventId
{
MXKRoomBubbleComponent *component = [self bubbleComponentWithLinkForEventId:eventId];
if (!component.showURLPreview)
{
return 0;
}
return PlainRoomCellLayoutConstants.urlPreviewViewTopMargin + [URLPreviewView contentViewHeightFor:component.urlPreviewData
fitting:self.maxTextViewWidth];
}
- (CGFloat)reactionHeightForEventId:(NSString*)eventId
{
CGFloat height = 0;
NSUInteger reactionCount = self.reactions[eventId].reactions.count;
MXAggregatedReactions *aggregatedReactions = self.reactions[eventId];
if (reactionCount)
{
CGFloat reactionsViewWidth = self.maxTextViewWidth - 4;
static RoomReactionsViewSizer *reactionsViewSizer;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
reactionsViewSizer = [RoomReactionsViewSizer new];
});
BOOL showAllReactions = [self.eventsToShowAllReactions containsObject:eventId];
RoomReactionsViewModel *viewModel = [[RoomReactionsViewModel alloc] initWithAggregatedReactions:aggregatedReactions eventId:eventId showAll:showAllReactions];
height = [reactionsViewSizer heightForViewModel:viewModel fittingWidth:reactionsViewWidth] + PlainRoomCellLayoutConstants.reactionsViewTopMargin;
}
return height;
}
- (CGFloat)readReceiptHeightForEventId:(NSString*)eventId
{
CGFloat height = 0;
if (self.readReceipts[eventId].count)
{
height = PlainRoomCellLayoutConstants.readReceiptsViewHeight + PlainRoomCellLayoutConstants.readReceiptsViewTopMargin;
}
return height;
}
- (void)setContainsLastMessage:(BOOL)containsLastMessage
{
// Check whether there is something to do
if (_containsLastMessage || containsLastMessage)
{
// Update flag
_containsLastMessage = containsLastMessage;
// Indicate that the text message layout should be recomputed.
[self invalidateTextLayout];
}
}
- (void)setSelectedEventId:(NSString *)selectedEventId
{
// Check whether there is something to do
if (_selectedEventId || selectedEventId.length)
{
// Update flag
_selectedEventId = selectedEventId;
// Indicate that the text message layout should be recomputed.
[self invalidateTextLayout];
}
}
- (NSInteger)oldestComponentIndex
{
// Update the related component index
NSInteger oldestComponentIndex = NSNotFound;
NSArray *components = self.bubbleComponents;
NSInteger index = 0;
while (index < components.count)
{
MXKRoomBubbleComponent *component = components[index];
if (component.attributedTextMessage && component.date)
{
oldestComponentIndex = index;
break;
}
index++;
}
return oldestComponentIndex;
}
- (NSInteger)mostRecentComponentIndex
{
// Update the related component index
NSInteger mostRecentComponentIndex = NSNotFound;
NSArray *components = self.bubbleComponents;
NSInteger index = components.count;
while (index--)
{
MXKRoomBubbleComponent *component = components[index];
if (component.attributedTextMessage && component.date)
{
mostRecentComponentIndex = index;
break;
}
}
return mostRecentComponentIndex;
}
- (NSInteger)selectedComponentIndex
{
// Update the related component index
NSInteger selectedComponentIndex = NSNotFound;
if (_selectedEventId)
{
NSArray *components = self.bubbleComponents;
NSInteger index = components.count;
while (index--)
{
MXKRoomBubbleComponent *component = components[index];
if ([component.event.eventId isEqualToString:_selectedEventId])
{
selectedComponentIndex = index;
break;
}
}
}
return selectedComponentIndex;
}
- (MXKRoomBubbleComponent *)bubbleComponentWithLinkForEventId:(NSString *)eventId
{
NSInteger index = [self bubbleComponentIndexForEventId:eventId];
if (index == NSNotFound)
{
return nil;
}
MXKRoomBubbleComponent *component = self.bubbleComponents[index];
if (!component.link)
{
return nil;
}
return component;
}
#pragma mark -
+ (NSAttributedString *)timestampVerticalWhitespace
{
@synchronized(self)
{
if (timestampVerticalWhitespace == nil)
{
timestampVerticalWhitespace = [[NSAttributedString alloc] initWithString:@"\n" attributes:@{NSForegroundColorAttributeName : [UIColor blackColor],
NSFontAttributeName: [UIFont systemFontOfSize:12]}];
}
}
return timestampVerticalWhitespace;
}
+ (NSAttributedString *)verticalWhitespaceForHeight:(CGFloat)height
{
UIFont *sizingFont = [UIFont systemFontOfSize:2];
CGFloat returnHeight = sizingFont.lineHeight;
NSUInteger returns = (NSUInteger)round(height/returnHeight);
NSMutableString *returnString = [NSMutableString string];
for (NSUInteger i = 0; i < returns; i++)
{
[returnString appendString:@"\n"];
}
return [[NSAttributedString alloc] initWithString:returnString attributes:@{NSForegroundColorAttributeName : [UIColor blackColor],
NSFontAttributeName: sizingFont}];
}
- (BOOL)hasSameSenderAsBubbleCellData:(id<MXKRoomBubbleCellDataStoring>)bubbleCellData
{
if (self.tag == RoomBubbleCellDataTagMembership || bubbleCellData.tag == RoomBubbleCellDataTagMembership)
{
// We do not want to merge membership event cells with other cell types
return NO;
}
if (self.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor || bubbleCellData.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor)
{
// We do not want to merge room create event cells with other cell types
return NO;
}
if (self.tag == RoomBubbleCellDataTagPoll) {
MXEvent* event = self.events.firstObject;
if (event) {
// m.poll.ended events should always show the sender information
return event.eventType != MXEventTypePollEnd;
}
}
if (self.hasThreadRoot || bubbleCellData.hasThreadRoot)
{
// We do not want to merge events containing thread roots
return NO;
}
return [super hasSameSenderAsBubbleCellData:bubbleCellData];
}
- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState
{
if (self.hasThreadRoot)
{
// We don't want to add any events into this bubble data if it's a thread root
return NO;
}
RoomTimelineConfiguration *timelineConfiguration = [RoomTimelineConfiguration shared];
if (NO == [timelineConfiguration.currentStyle canAddEvent:event and:roomState to:self]) {
return NO;
}
BOOL shouldAddEvent = YES;
switch (self.tag)
{
case RoomBubbleCellDataTagKeyVerificationNoDisplay:
case RoomBubbleCellDataTagKeyVerificationRequest:
case RoomBubbleCellDataTagKeyVerificationRequestIncomingApproval:
case RoomBubbleCellDataTagKeyVerificationConclusion:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagRoomCreateWithPredecessor:
// We do not want to merge room create event cells with other cell types
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagMembership:
// One single bubble per membership event
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagCall:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagGroupCall:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagRTCCallNotify:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagRoomCreateConfiguration:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagRoomCreationIntro:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagPoll:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagLocation:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagLiveLocation:
shouldAddEvent = NO;
break;
case RoomBubbleCellDataTagVoiceBroadcastRecord:
case RoomBubbleCellDataTagVoiceBroadcastPlayback:
case RoomBubbleCellDataTagVoiceBroadcastNoDisplay:
shouldAddEvent = NO;
break;
default:
break;
}
// If the current bubbleData supports adding events then check
// if the incoming event can be added in
if (shouldAddEvent)
{
switch (event.eventType)
{
case MXEventTypeRoomMessage:
{
if (event.location) {
shouldAddEvent = NO;
break;
}
NSString *messageType = event.content[kMXMessageTypeKey];
if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest])
{
shouldAddEvent = NO;
}
break;
}
case MXEventTypeKeyVerificationStart:
case MXEventTypeKeyVerificationAccept:
case MXEventTypeKeyVerificationKey:
case MXEventTypeKeyVerificationMac:
case MXEventTypeKeyVerificationDone:
case MXEventTypeKeyVerificationCancel:
shouldAddEvent = NO;
break;
case MXEventTypeRoomMember:
shouldAddEvent = NO;
break;
case MXEventTypeRoomCreate:
shouldAddEvent = NO;
break;
case MXEventTypeRoomTopic:
case MXEventTypeRoomName:
case MXEventTypeRoomEncryption:
case MXEventTypeRoomHistoryVisibility:
case MXEventTypeRoomGuestAccess:
case MXEventTypeRoomAvatar:
case MXEventTypeRoomJoinRules:
shouldAddEvent = NO;
break;
case MXEventTypeCallInvite:
case MXEventTypeCallAnswer:
case MXEventTypeCallHangup:
case MXEventTypeCallReject:
shouldAddEvent = NO;
break;
case MXEventTypeCallNotify:
shouldAddEvent = NO;
break;
case MXEventTypePollStart:
case MXEventTypePollEnd:
shouldAddEvent = NO;
break;
case MXEventTypeBeaconInfo:
shouldAddEvent = NO;
break;
case MXEventTypeCustom:
{
if ([event.type isEqualToString:kWidgetMatrixEventTypeString]
|| [event.type isEqualToString:kWidgetModularEventTypeString])
{
Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:roomDataSource.mxSession];
if ([widget.type isEqualToString:kWidgetTypeJitsiV1] ||
[widget.type isEqualToString:kWidgetTypeJitsiV2])
{
shouldAddEvent = NO;
}
} else if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) {
shouldAddEvent = NO;
}
break;
}
default:
break;
}
}
if (shouldAddEvent)
{
shouldAddEvent = [super addEvent:event andRoomState:roomState];
// If the event was added, load any url preview data if necessary.
if (shouldAddEvent)
{
[self refreshURLPreviewForEventId:event.eventId];
}
}
return shouldAddEvent;
}
- (void)setKeyVerification:(MXKeyVerification *)keyVerification
{
_keyVerification = keyVerification;
[self keyVerificationDidUpdate];
}
- (void)keyVerificationDidUpdate
{
MXEvent *event = self.getFirstBubbleComponentWithDisplay.event;
MXKeyVerification *keyVerification = _keyVerification;
if (!event)
{
return;
}
switch (event.eventType)
{
case MXEventTypeKeyVerificationCancel:
{
RoomBubbleCellDataTag cellDataTag;
MXTransactionCancelCode *transactionCancelCode = keyVerification.transaction.reasonCancelCode;
if (transactionCancelCode
&& ([transactionCancelCode isEqual:[MXTransactionCancelCode mismatchedSas]]
|| [transactionCancelCode isEqual:[MXTransactionCancelCode mismatchedKeys]]
|| [transactionCancelCode isEqual:[MXTransactionCancelCode mismatchedCommitment]]
)
)
{
cellDataTag = RoomBubbleCellDataTagKeyVerificationConclusion;
}
else
{
cellDataTag = RoomBubbleCellDataTagKeyVerificationNoDisplay;
}
self.tag = cellDataTag;
}
break;
case MXEventTypeKeyVerificationDone:
{
RoomBubbleCellDataTag cellDataTag;
// Avoid to display incoming and outgoing done, only display the incoming one.
if (self.isIncoming && keyVerification && (keyVerification.state == MXKeyVerificationStateVerified))
{
cellDataTag = RoomBubbleCellDataTagKeyVerificationConclusion;
}
else
{
cellDataTag = RoomBubbleCellDataTagKeyVerificationNoDisplay;
}
self.tag = cellDataTag;
}
break;
case MXEventTypeRoomMessage:
{
NSString *msgType = event.content[kMXMessageTypeKey];
if ([msgType isEqualToString:kMXMessageTypeKeyVerificationRequest])
{
RoomBubbleCellDataTag cellDataTag;
if (self.isIncoming && !self.isKeyVerificationOperationPending && keyVerification && keyVerification.state == MXKeyVerificationRequestStatePending)
{
cellDataTag = RoomBubbleCellDataTagKeyVerificationRequestIncomingApproval;
}
else
{
cellDataTag = RoomBubbleCellDataTagKeyVerificationRequest;
}
self.tag = cellDataTag;
}
}
break;
default:
break;
}
}
#pragma mark - Show all reactions
- (BOOL)showAllReactionsForEvent:(NSString*)eventId
{
return [self.eventsToShowAllReactions containsObject:eventId];
}
- (void)setShowAllReactions:(BOOL)showAllReactions forEvent:(NSString*)eventId
{
if (showAllReactions)
{
[self.eventsToShowAllReactions addObject:eventId];
}
else
{
[self.eventsToShowAllReactions removeObject:eventId];
}
}
- (NSString *)accessibilityLabel
{
NSString *accessibilityLabel;
// Only media require manual handling for accessibility
if (self.attachment)
{
NSString *mediaName = [self accessibilityLabelForAttachmentType:self.attachment.type];
MXJSONModelSetString(accessibilityLabel, self.events.firstObject.content[kMXMessageBodyKey]);
if (accessibilityLabel)
{
accessibilityLabel = [NSString stringWithFormat:@"%@ %@", mediaName, accessibilityLabel];
}
else
{
accessibilityLabel = mediaName;
}
}
return accessibilityLabel;
}
- (NSString*)accessibilityLabelForAttachmentType:(MXKAttachmentType)attachmentType
{
NSString *accessibilityLabel;
switch (attachmentType)
{
case MXKAttachmentTypeImage:
accessibilityLabel = [VectorL10n mediaTypeAccessibilityImage];
break;
case MXKAttachmentTypeAudio:
accessibilityLabel = [VectorL10n mediaTypeAccessibilityAudio];
break;
case MXKAttachmentTypeVoiceMessage:
accessibilityLabel = [VectorL10n mediaTypeAccessibilityAudio];
break;
case MXKAttachmentTypeVideo:
accessibilityLabel = [VectorL10n mediaTypeAccessibilityVideo];
break;
case MXKAttachmentTypeFile:
accessibilityLabel = [VectorL10n mediaTypeAccessibilityFile];
break;
case MXKAttachmentTypeSticker:
accessibilityLabel = [VectorL10n mediaTypeAccessibilitySticker];
break;
default:
accessibilityLabel = @"";
break;
}
return accessibilityLabel;
}
#pragma mark - URL Previews
- (void)refreshURLPreviewForEventId:(NSString *)eventId
{
// Get the event's component, but only if it has a link.
MXKRoomBubbleComponent *component = [self bubbleComponentWithLinkForEventId:eventId];
if (!component)
{
return;
}
// Don't show the preview if they're disabled globally or this one has been dismissed previously.
component.showURLPreview = RiotSettings.shared.roomScreenShowsURLPreviews && [URLPreviewService.shared shouldShowPreviewFor:component.event];
if (!component.showURLPreview)
{
return;
}
// If there is existing preview data, the message has been edited.
// Clear the data to show the loading state when the preview isn't cached.
if (component.urlPreviewData)
{
component.urlPreviewData = nil;
}
// Set the preview data.
MXWeakify(self);
NSDictionary<NSString *, NSString*> *userInfo = @{
@"eventId": eventId,
@"roomId": self.roomId
};
[URLPreviewService.shared previewFor:component.link
and:component.event
with:self.mxSession
success:^(URLPreviewData * _Nonnull urlPreviewData) {
MXStrongifyAndReturnIfNil(self);
// Update the preview data, indicate that the message layout needs refreshing and send a notification for refresh
component.urlPreviewData = urlPreviewData;
[self invalidateLayout];
dispatch_async(dispatch_get_main_queue(), ^{
[NSNotificationCenter.defaultCenter postNotificationName:URLPreviewDidUpdateNotification object:nil userInfo:userInfo];
});
} failure:^(NSError * _Nullable error) {
MXStrongifyAndReturnIfNil(self);
MXLogDebug(@"[RoomBubbleCellData] Failed to get url preview")
// Remove the loading URLPreviewView, indicate that the layout needs refreshing and send a notification for refresh
component.showURLPreview = NO;
[self invalidateLayout];
dispatch_async(dispatch_get_main_queue(), ^{
[NSNotificationCenter.defaultCenter postNotificationName:URLPreviewDidUpdateNotification object:nil userInfo:userInfo];
});
}];
}
- (void)updateBeaconInfoSummaryWithId:(NSString *)eventId andEvent:(MXEvent*)event
{
if (event.eventType != MXEventTypeBeaconInfo)
{
MXLogErrorDetails(@"[RoomBubbleCellData] Try to update beacon info summary with wrong event type", @{
@"event_id": eventId ?: @"unknown"
});
return;
}
id<MXBeaconInfoSummaryProtocol> beaconInfoSummary = [self.mxSession.aggregations.beaconAggregations beaconInfoSummaryFor:eventId inRoomWithId:self.roomId];
if (!beaconInfoSummary)
{
MXBeaconInfo *beaconInfo = [[MXBeaconInfo alloc] initWithMXEvent:event];
// A start beacon info event (isLive == true) should have an associated BeaconInfoSummary
if (beaconInfo && beaconInfo.isLive)
{
MXLogErrorDetails(@"[RoomBubbleCellData] No beacon info summary found for beacon info start event", @{
@"event_id": eventId ?: @"unknown"
});
}
}
self.beaconInfoSummary = beaconInfoSummary;
}
@end