element-ios/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m

4143 lines
166 KiB
Objective-C

/*
Copyright 2015 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
#import "MXKRoomDataSource.h"
@import MatrixSDK;
#import "MXKQueuedEvent.h"
#import "MXKRoomBubbleTableViewCell.h"
#import "MXKRoomBubbleCellData.h"
#import "MXKTools.h"
#import "MXAggregatedReactions+MatrixKit.h"
#import "MXKAppSettings.h"
#import "MXKSendReplyEventStringLocalizer.h"
#import "MXKSlashCommands.h"
#pragma mark - Constant definitions
NSString *const kMXKRoomBubbleCellDataIdentifier = @"kMXKRoomBubbleCellDataIdentifier";
NSString *const kMXKRoomDataSourceSyncStatusChanged = @"kMXKRoomDataSourceSyncStatusChanged";
NSString *const kMXKRoomDataSourceFailToLoadTimelinePosition = @"kMXKRoomDataSourceFailToLoadTimelinePosition";
NSString *const kMXKRoomDataSourceTimelineError = @"kMXKRoomDataSourceTimelineError";
NSString *const kMXKRoomDataSourceTimelineErrorErrorKey = @"kMXKRoomDataSourceTimelineErrorErrorKey";
NSString * const MXKRoomDataSourceErrorDomain = @"kMXKRoomDataSourceErrorDomain";
typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) {
MXKRoomDataSourceErrorResendGeneric = 10001,
MXKRoomDataSourceErrorResendInvalidMessageType = 10002,
MXKRoomDataSourceErrorResendInvalidLocalFilePath = 10003,
};
@interface MXKRoomDataSource ()
{
/**
If the data is not from a live timeline, `initialEventId` is the event in the past
where the timeline starts.
*/
NSString *initialEventId;
/**
Current pagination request (if any)
*/
MXHTTPOperation *paginationRequest;
/**
The actual listener related to the current pagination in the timeline.
*/
id paginationListener;
/**
The listener to incoming events in the room.
*/
id liveEventsListener;
/**
The listener to redaction events in the room.
*/
id redactionListener;
/**
The listener to receipts events in the room.
*/
id receiptsListener;
/**
The listener to the related groups state events in the room.
*/
id relatedGroupsListener;
/**
The listener to reactions changed in the room.
*/
id reactionsChangeListener;
/**
The listener to edits in the room.
*/
id eventEditsListener;
/**
Current secondary pagination request (if any)
*/
MXHTTPOperation *secondaryPaginationRequest;
/**
The listener to incoming events in the secondary room.
*/
id secondaryLiveEventsListener;
/**
The listener to redaction events in the secondary room.
*/
id secondaryRedactionListener;
/**
The actual listener related to the current pagination in the secondary timeline.
*/
id secondaryPaginationListener;
/**
Mapping between events ids and bubbles.
*/
NSMutableDictionary *eventIdToBubbleMap;
/**
Typing notifications listener.
*/
id typingNotifListener;
/**
List of members who are typing in the room.
*/
NSArray *currentTypingUsers;
/**
Snapshot of the queued events.
*/
NSMutableArray *eventsToProcessSnapshot;
/**
Snapshot of the bubbles used during events processing.
*/
NSMutableArray<id<MXKRoomBubbleCellDataStoring>> *bubblesSnapshot;
/**
The room being peeked, if any.
*/
MXPeekingRoom *peekingRoom;
/**
If any, the non terminated series of collapsable events at the start of self.bubbles.
(Such series is determined by the cell data of its oldest event).
*/
id<MXKRoomBubbleCellDataStoring> collapsableSeriesAtStart;
/**
If any, the non terminated series of collapsable events at the end of self.bubbles.
(Such series is determined by the cell data of its oldest event).
*/
id<MXKRoomBubbleCellDataStoring> collapsableSeriesAtEnd;
/**
Observe UIApplicationSignificantTimeChangeNotification to trigger cell change on time formatting change.
*/
id UIApplicationSignificantTimeChangeNotificationObserver;
/**
Observe NSCurrentLocaleDidChangeNotification to trigger cell change on time formatting change.
*/
id NSCurrentLocaleDidChangeNotificationObserver;
/**
Observe kMXRoomDidFlushDataNotification to trigger cell change when existing room history has been flushed during server sync.
*/
id roomDidFlushDataNotificationObserver;
/**
Observe kMXRoomDidUpdateUnreadNotification to refresh unread counters.
*/
id roomDidUpdateUnreadNotificationObserver;
/**
Emote slash command prefix @"/me "
*/
NSString *emoteMessageSlashCommandPrefix;
}
/**
Indicate to stop back-paginating when finding an un-decryptable event as previous event.
It is used to hide pre join UTD events before joining the room.
*/
@property (nonatomic, assign) BOOL shouldPreventBackPaginationOnPreviousUTDEvent;
/**
Indicate to stop back-paginating.
*/
@property (nonatomic, assign) BOOL shouldStopBackPagination;
@property (nonatomic, readwrite) MXRoom *room;
@property (nonatomic, readwrite) MXRoom *secondaryRoom;
@property (nonatomic, strong) MXEventTimeline *secondaryTimeline;
@end
@implementation MXKRoomDataSource
+ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete
{
MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId andMatrixSession:mxSession];
[self ensureSessionStateForDataSource:roomDataSource initialEventId:nil andMatrixSession:mxSession onComplete:onComplete];
}
+ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete
{
MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId initialEventId:initialEventId andMatrixSession:mxSession];
[self ensureSessionStateForDataSource:roomDataSource initialEventId:initialEventId andMatrixSession:mxSession onComplete:onComplete];
}
+ (void)loadRoomDataSourceWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId onComplete:(void (^)(id roomDataSource))onComplete
{
MXKRoomDataSource *roomDataSource = [[self alloc] initWithPeekingRoom:peekingRoom andInitialEventId:initialEventId];
[self finalizeRoomDataSource:roomDataSource onComplete:onComplete];
}
/// Ensure session state to be store data ready for the roomDataSource.
+ (void)ensureSessionStateForDataSource:(MXKRoomDataSource*)roomDataSource initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete
{
// if store is not ready, roomDataSource.room will be nil. So onComplete block will never be called.
// In order to successfully fetch the room, we should wait for store to be ready.
if (mxSession.state >= MXSessionStateStoreDataReady)
{
[self finalizeRoomDataSource:roomDataSource onComplete:onComplete];
}
else
{
// wait for session state to be store data ready
__block id sessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:mxSession queue:nil usingBlock:^(NSNotification * _Nonnull note) {
if (mxSession.state >= MXSessionStateStoreDataReady)
{
[[NSNotificationCenter defaultCenter] removeObserver:sessionStateObserver];
[self finalizeRoomDataSource:roomDataSource onComplete:onComplete];
}
}];
}
}
+ (void)finalizeRoomDataSource:(MXKRoomDataSource*)roomDataSource onComplete:(void (^)(id roomDataSource))onComplete
{
if (roomDataSource)
{
[roomDataSource finalizeInitialization];
// Asynchronously preload data here so that the data will be ready later
// to synchronously respond to that request
[roomDataSource.room liveTimeline:^(MXEventTimeline *liveTimeline) {
onComplete(roomDataSource);
}];
}
}
- (instancetype)initWithRoomId:(NSString *)roomId andMatrixSession:(MXSession *)matrixSession
{
self = [super initWithMatrixSession:matrixSession];
if (self)
{
MXLogVerbose(@"[MXKRoomDataSource][%p] initWithRoomId: %@", self, roomId);
_roomId = roomId;
_secondaryRoomEventTypes = @[
kMXEventTypeStringCallInvite,
kMXEventTypeStringCallCandidates,
kMXEventTypeStringCallAnswer,
kMXEventTypeStringCallSelectAnswer,
kMXEventTypeStringCallHangup,
kMXEventTypeStringCallReject,
kMXEventTypeStringCallNegotiate,
kMXEventTypeStringCallReplaces,
kMXEventTypeStringCallRejectReplacement
];
NSString *virtualRoomId = [matrixSession virtualRoomOf:_roomId];
if (virtualRoomId)
{
_secondaryRoomId = virtualRoomId;
}
_isLive = YES;
bubbles = [NSMutableArray array];
eventsToProcess = [NSMutableArray array];
eventIdToBubbleMap = [NSMutableDictionary dictionary];
externalRelatedGroups = [NSMutableDictionary dictionary];
_filterMessagesWithURL = NO;
emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote];
// Set default data and view classes
// Cell data
[self registerCellDataClass:MXKRoomBubbleCellData.class forCellIdentifier:kMXKRoomBubbleCellDataIdentifier];
// Set default MXEvent -> NSString formatter
self.eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:self.mxSession];
// Apply here the event types filter to display only the wanted event types.
self.eventFormatter.eventTypesFilterForMessages = [MXKAppSettings standardAppSettings].eventsFilterForMessages;
// display the read receips by default
self.showBubbleReceipts = YES;
// show the read marker by default
self.showReadMarker = YES;
// Disable typing notification in cells by default.
self.showTypingNotifications = NO;
self.useCustomDateTimeLabel = NO;
self.useCustomReceipts = NO;
self.useCustomUnsentButton = NO;
_maxBackgroundCachedBubblesCount = MXKROOMDATASOURCE_CACHED_BUBBLES_COUNT_THRESHOLD;
_paginationLimitAroundInitialEvent = MXKROOMDATASOURCE_PAGINATION_LIMIT_AROUND_INITIAL_EVENT;
// Observe UIApplicationSignificantTimeChangeNotification to refresh bubbles if date/time are shown.
// UIApplicationSignificantTimeChangeNotification is posted if DST is updated, carrier time is updated
UIApplicationSignificantTimeChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationSignificantTimeChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
[self onDateTimeFormatUpdate];
}];
// Observe NSCurrentLocaleDidChangeNotification to refresh bubbles if date/time are shown.
// NSCurrentLocaleDidChangeNotification is triggered when the time swicthes to AM/PM to 24h time format
NSCurrentLocaleDidChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSCurrentLocaleDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
[self onDateTimeFormatUpdate];
}];
// Listen to the event sent state changes
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidChangeSentState:) name:kMXEventDidChangeSentStateNotification object:nil];
// Listen to events decrypted
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidDecrypt:) name:kMXEventDidDecryptNotification object:nil];
// Listen to virtual rooms change
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(virtualRoomsDidChange:) name:kMXSessionVirtualRoomsDidChangeNotification object:matrixSession];
}
return self;
}
- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId2 andMatrixSession:(MXSession*)mxSession
{
self = [self initWithRoomId:roomId andMatrixSession:mxSession];
if (self)
{
if (initialEventId2)
{
initialEventId = initialEventId2;
_isLive = NO;
}
}
return self;
}
- (instancetype)initWithPeekingRoom:(MXPeekingRoom*)peekingRoom2 andInitialEventId:(NSString*)theInitialEventId
{
self = [self initWithRoomId:peekingRoom2.roomId initialEventId:theInitialEventId andMatrixSession:peekingRoom2.mxSession];
if (self)
{
peekingRoom = peekingRoom2;
_isPeeking = YES;
}
return self;
}
- (void)dealloc
{
[self unregisterEventEditsListener];
[self unregisterScanManagerNotifications];
[self unregisterReactionsChangeListener];
}
- (MXRoomState *)roomState
{
// @TODO(async-state): Just here for dev
NSAssert(_timeline.state, @"[MXKRoomDataSource] Room state must be preloaded before accessing to MXKRoomDataSource.roomState");
return _timeline.state;
}
- (void)onDateTimeFormatUpdate
{
// update the date and the time formatters
[self.eventFormatter initDateTimeFormatters];
// refresh the UI if it is required
if (self.showBubblesDateTime && self.delegate)
{
// Reload all the table
[self.delegate dataSource:self didCellChange:nil];
}
}
- (void)markAllAsRead
{
[_room.summary markAllAsRead];
}
- (void)limitMemoryUsage:(NSInteger)maxBubbleNb
{
NSInteger bubbleCount;
@synchronized(bubbles)
{
bubbleCount = bubbles.count;
}
if (bubbleCount > maxBubbleNb)
{
// Do nothing if some local echoes are in progress.
NSArray<MXEvent*>* outgoingMessages = _room.outgoingMessages;
for (NSInteger index = 0; index < outgoingMessages.count; index++)
{
MXEvent *outgoingMessage = [outgoingMessages objectAtIndex:index];
if (outgoingMessage.sentState == MXEventSentStateSending ||
outgoingMessage.sentState == MXEventSentStatePreparing ||
outgoingMessage.sentState == MXEventSentStateEncrypting ||
outgoingMessage.sentState == MXEventSentStateUploading)
{
MXLogDebug(@"[MXKRoomDataSource][%p] cancel limitMemoryUsage because some messages are being sent", self);
return;
}
}
// Reset the room data source (return in initial state: minimum memory usage).
[self reload];
}
}
- (void)reset
{
[self resetNotifying:YES];
}
- (void)resetNotifying:(BOOL)notify
{
[externalRelatedGroups removeAllObjects];
if (roomDidFlushDataNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver];
roomDidFlushDataNotificationObserver = nil;
}
if (roomDidUpdateUnreadNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:roomDidUpdateUnreadNotificationObserver];
roomDidUpdateUnreadNotificationObserver = nil;
}
if (paginationRequest)
{
// We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress
[_timeline removeListener:paginationListener];
paginationListener = nil;
[paginationRequest cancel];
paginationRequest = nil;
}
if (secondaryPaginationRequest)
{
// We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress
[_secondaryTimeline removeListener:secondaryPaginationListener];
secondaryPaginationListener = nil;
[secondaryPaginationRequest cancel];
secondaryPaginationRequest = nil;
}
if (_room && liveEventsListener)
{
[_timeline removeListener:liveEventsListener];
liveEventsListener = nil;
[_timeline removeListener:redactionListener];
redactionListener = nil;
[_timeline removeListener:receiptsListener];
receiptsListener = nil;
[_timeline removeListener:relatedGroupsListener];
relatedGroupsListener = nil;
}
if (_secondaryRoom && secondaryLiveEventsListener)
{
[_secondaryTimeline removeListener:secondaryLiveEventsListener];
secondaryLiveEventsListener = nil;
[_secondaryTimeline removeListener:secondaryRedactionListener];
secondaryRedactionListener = nil;
}
if (_room && typingNotifListener)
{
[_timeline removeListener:typingNotifListener];
typingNotifListener = nil;
}
currentTypingUsers = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomInitialSyncNotification object:nil];
@synchronized(eventsToProcess)
{
MXLogVerbose(@"[MXKRoomDataSource][%p] Reset eventsToProcess", self);
[eventsToProcess removeAllObjects];
}
// Suspend the reset operation if some events is under processing
@synchronized(eventsToProcessSnapshot)
{
eventsToProcessSnapshot = nil;
bubblesSnapshot = nil;
@synchronized(bubbles)
{
for (id<MXKRoomBubbleCellDataStoring> bubble in bubbles) {
bubble.prevCollapsableCellData = nil;
bubble.nextCollapsableCellData = nil;
}
[bubbles removeAllObjects];
}
@synchronized(eventIdToBubbleMap)
{
[eventIdToBubbleMap removeAllObjects];
}
self.room = nil;
self.secondaryRoom = nil;
}
_serverSyncEventCount = 0;
// Notify the delegate to reload its tableview
if (notify && self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
}
- (void)reload
{
[self reloadNotifying:YES];
}
- (void)reloadNotifying:(BOOL)notify
{
MXLogVerbose(@"[MXKRoomDataSource][%p] Reload - room id: %@", self, _roomId);
[self setState:MXKDataSourceStatePreparing];
[self resetNotifying:notify];
// Reload
[self didMXSessionStateChange];
}
- (void)destroy
{
MXLogDebug(@"[MXKRoomDataSource][%p] Destroy - room id: %@", self, _roomId);
[self unregisterScanManagerNotifications];
[self unregisterReactionsChangeListener];
[self unregisterEventEditsListener];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeSentStateNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidDecryptNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionVirtualRoomsDidChangeNotification object:nil];
if (NSCurrentLocaleDidChangeNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:NSCurrentLocaleDidChangeNotificationObserver];
NSCurrentLocaleDidChangeNotificationObserver = nil;
}
if (UIApplicationSignificantTimeChangeNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:UIApplicationSignificantTimeChangeNotificationObserver];
UIApplicationSignificantTimeChangeNotificationObserver = nil;
}
// If the room data source was used to peek into a room, stop the events stream on this room
if (peekingRoom)
{
[_room.mxSession stopPeeking:peekingRoom];
}
[self reset];
self.eventFormatter = nil;
eventsToProcess = nil;
bubbles = nil;
eventIdToBubbleMap = nil;
[_timeline destroy];
[_secondaryTimeline destroy];
externalRelatedGroups = nil;
[super destroy];
}
- (void)didMXSessionStateChange
{
if (MXSessionStateStoreDataReady <= self.mxSession.state)
{
// Check whether the room is not already set
if (!_room)
{
// Are we peeking into a random room or displaying a room the user is part of?
if (peekingRoom)
{
self.room = peekingRoom;
}
else
{
self.room = [self.mxSession roomWithRoomId:_roomId];
}
if (_room)
{
// This is the time to set up the timeline according to the called init method
if (_isLive)
{
// LIVE
MXWeakify(self);
[_room liveTimeline:^(MXEventTimeline *liveTimeline) {
MXStrongifyAndReturnIfNil(self);
self->_timeline = liveTimeline;
// Only one pagination process can be done at a time by an MXRoom object.
// This assumption is satisfied by MatrixKit. Only MXRoomDataSource does it.
[self.timeline resetPagination];
// Observe room history flush (sync with limited timeline, or state event redaction)
self->roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
MXRoom *room = notif.object;
if (self.mxSession == room.mxSession && ([self.roomId isEqualToString:room.roomId] ||
([self.secondaryRoomId isEqualToString:room.roomId])))
{
// The existing room history has been flushed during server sync because a gap has been observed between local and server storage.
[self reload];
}
}];
// Add the event listeners, by considering all the event types (the event filtering is applying by the event formatter),
// except if only the events with a url key in their content must be handled.
[self refreshEventListeners:(self.filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages)];
// display typing notifications is optional
// the inherited class can manage them by its own.
if (self.showTypingNotifications)
{
// Register on typing notif
[self listenTypingNotifications];
}
// Manage unsent messages
[self handleUnsentMessages];
// Update here data source state if it is not already ready
if (!self->_secondaryRoomId)
{
[self setState:MXKDataSourceStateReady];
}
// Check user membership in this room
MXMembership membership = self.room.summary.membership;
if (membership == MXMembershipUnknown || membership == MXMembershipInvite)
{
// Here the initial sync is not ended or the room is a pending invitation.
// Note: In case of invitation, a full sync will be triggered if the user joins this room.
// We have to observe here 'kMXRoomInitialSyncNotification' to reload room data when room sync is done.
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXRoomInitialSynced:) name:kMXRoomInitialSyncNotification object:self.room];
}
}];
if (!_secondaryRoom && _secondaryRoomId)
{
_secondaryRoom = [self.mxSession roomWithRoomId:_secondaryRoomId];
if (_secondaryRoom)
{
MXWeakify(self);
[_secondaryRoom liveTimeline:^(MXEventTimeline *liveTimeline) {
MXStrongifyAndReturnIfNil(self);
self->_secondaryTimeline = liveTimeline;
// Only one pagination process can be done at a time by an MXRoom object.
// This assumption is satisfied by MatrixKit. Only MXRoomDataSource does it.
[self.secondaryTimeline resetPagination];
// Add the secondary event listeners, by considering the event types in self.secondaryRoomEventTypes
[self refreshSecondaryEventListeners:self.secondaryRoomEventTypes];
// Update here data source state if it is not already ready
[self setState:MXKDataSourceStateReady];
// Check user membership in the secondary room
MXMembership membership = self.secondaryRoom.summary.membership;
if (membership == MXMembershipUnknown || membership == MXMembershipInvite)
{
// Here the initial sync is not ended or the room is a pending invitation.
// Note: In case of invitation, a full sync will be triggered if the user joins this room.
// We have to observe here 'kMXRoomInitialSyncNotification' to reload room data when room sync is done.
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXRoomInitialSynced:) name:kMXRoomInitialSyncNotification object:self.secondaryRoom];
}
}];
}
}
}
else
{
// Past timeline
// Less things need to configured
_timeline = [_room timelineOnEvent:initialEventId];
// Refresh the event listeners. Note: events for past timelines come only from pagination request
[self refreshEventListeners:nil];
MXWeakify(self);
// Preload the state and some messages around the initial event
[_timeline resetPaginationAroundInitialEventWithLimit:_paginationLimitAroundInitialEvent success:^{
MXStrongifyAndReturnIfNil(self);
// Do a "classic" reset. The room view controller will paginate
// from the events stored in the timeline store
[self.timeline resetPagination];
// Update here data source state if it is not already ready
[self setState:MXKDataSourceStateReady];
} failure:^(NSError *error) {
MXStrongifyAndReturnIfNil(self);
MXLogDebug(@"[MXKRoomDataSource][%p] Failed to resetPaginationAroundInitialEventWithLimit", self);
// Notify the error
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceTimelineError
object:self
userInfo:@{
kMXKRoomDataSourceTimelineErrorErrorKey: error
}];
}];
}
}
else
{
MXLogDebug(@"[MXKRoomDataSource][%p] Warning: The user does not know the room %@", self, _roomId);
// Update here data source state if it is not already ready
[self setState:MXKDataSourceStateFailed];
}
}
if (_room && MXSessionStateRunning == self.mxSession.state)
{
// Flair handling: observe the update in the publicised groups by users when the flair is enabled in the room.
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession];
[self.room state:^(MXRoomState *roomState) {
if (roomState.relatedGroups.count)
{
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionUpdatePublicisedGroupsForUsers:) name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession];
// Get a fresh profile for all the related groups. Trigger a table refresh when all requests are done.
__block NSUInteger count = roomState.relatedGroups.count;
for (NSString *groupId in roomState.relatedGroups)
{
MXGroup *group = [self.mxSession groupWithGroupId:groupId];
if (!group)
{
// Create a group instance for the groups that the current user did not join.
group = [[MXGroup alloc] initWithGroupId:groupId];
[self->externalRelatedGroups setObject:group forKey:groupId];
}
// Refresh the group profile from server.
[self.mxSession updateGroupProfile:group success:^{
if (self.delegate && !(--count))
{
// All the requests have been done.
[self.delegate dataSource:self didCellChange:nil];
}
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomDataSource][%p] group profile update failed %@", self, groupId);
if (self.delegate && !(--count))
{
// All the requests have been done.
[self.delegate dataSource:self didCellChange:nil];
}
}];
}
}
}];
}
}
}
- (NSArray *)attachmentsWithThumbnail
{
NSMutableArray *attachments = [NSMutableArray array];
@synchronized(bubbles)
{
for (id<MXKRoomBubbleCellDataStoring> bubbleData in bubbles)
{
if (bubbleData.isAttachmentWithThumbnail && bubbleData.attachment.type != MXKAttachmentTypeSticker && !bubbleData.showAntivirusScanStatus)
{
[attachments addObject:bubbleData.attachment];
}
}
}
return attachments;
}
- (NSString *)partialTextMessage
{
return _room.partialTextMessage;
}
- (void)setPartialTextMessage:(NSString *)partialTextMessage
{
_room.partialTextMessage = partialTextMessage;
}
- (void)refreshEventListeners:(NSArray *)liveEventTypesFilterForMessages
{
// Remove the existing listeners
if (liveEventsListener)
{
[_timeline removeListener:liveEventsListener];
[_timeline removeListener:redactionListener];
[_timeline removeListener:receiptsListener];
[_timeline removeListener:relatedGroupsListener];
}
// Listen to live events only for live timeline
// Events for past timelines come only from pagination request
if (_isLive)
{
// Register a new one with the requested filter
MXWeakify(self);
liveEventsListener = [_timeline listenToEventsOfTypes:liveEventTypesFilterForMessages onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) {
MXStrongifyAndReturnIfNil(self);
if (MXTimelineDirectionForwards == direction)
{
// Check for local echo suppression
MXEvent *localEcho;
if (self.room.outgoingMessages.count && [event.sender isEqualToString:self.mxSession.myUser.userId])
{
localEcho = [self.room pendingLocalEchoRelatedToEvent:event];
if (localEcho)
{
// Check whether the local echo has a timestamp (in this case, it is replaced with the actual event).
if (localEcho.originServerTs != kMXUndefinedTimestamp)
{
// Replace the local echo by the true event sent by the homeserver
[self replaceEvent:localEcho withEvent:event];
}
else
{
// Remove the local echo, and process independently the true event.
[self replaceEvent:localEcho withEvent:nil];
localEcho = nil;
}
}
}
if (self.secondaryRoom)
{
[self reloadNotifying:NO];
}
else if (nil == localEcho)
{
// Process here incoming events, and outgoing events sent from another device.
[self queueEventForProcessing:event withRoomState:roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
}];
receiptsListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringReceipt] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) {
if (MXTimelineDirectionForwards == direction)
{
// Handle this read receipt
[self didReceiveReceiptEvent:event roomState:roomState];
}
}];
// Flair handling: register a listener for the related groups state event in this room.
relatedGroupsListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRelatedGroups] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) {
if (MXTimelineDirectionForwards == direction)
{
// The flair settings have been updated: flush the current bubble data and rebuild them.
[self reload];
}
}];
}
// Register a listener to handle redaction which can affect live and past timelines
redactionListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRedaction] onEvent:^(MXEvent *redactionEvent, MXTimelineDirection direction, MXRoomState *roomState) {
// Consider only live redaction events
if (direction == MXTimelineDirectionForwards)
{
// Do the processing on the processing queue
dispatch_async(MXKRoomDataSource.processingQueue, ^{
// Check whether a message contains the redacted event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:redactionEvent.redacts];
if (bubbleData)
{
BOOL shouldRemoveBubbleData = NO;
BOOL hasChanged = NO;
MXEvent *redactedEvent = nil;
@synchronized (bubbleData)
{
// Retrieve the original event to redact it
NSArray *events = bubbleData.events;
for (MXEvent *event in events)
{
if ([event.eventId isEqualToString:redactionEvent.redacts])
{
// Check whether the event was not already redacted (Redaction may be handled by event timeline too).
if (!event.isRedactedEvent)
{
redactedEvent = [event prune];
redactedEvent.redactedBecause = redactionEvent.JSONDictionary;
}
break;
}
}
if (redactedEvent)
{
// Update bubble data
NSUInteger remainingEvents = [bubbleData updateEvent:redactionEvent.redacts withEvent:redactedEvent];
hasChanged = YES;
// Remove the bubble if there is no more events
shouldRemoveBubbleData = (remainingEvents == 0);
}
}
// Check whether the bubble should be removed
if (shouldRemoveBubbleData)
{
[self removeCellData:bubbleData];
}
if (hasChanged)
{
// Update the delegate on main thread
dispatch_async(dispatch_get_main_queue(), ^{
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
});
}
}
});
}
}];
}
- (void)refreshSecondaryEventListeners:(NSArray *)liveEventTypesFilterForMessages
{
// Remove the existing listeners
if (secondaryLiveEventsListener)
{
[_secondaryTimeline removeListener:secondaryLiveEventsListener];
[_secondaryTimeline removeListener:secondaryRedactionListener];
}
// Listen to live events only for live timeline
// Events for past timelines come only from pagination request
if (_isLive)
{
// Register a new one with the requested filter
MXWeakify(self);
secondaryLiveEventsListener = [_secondaryTimeline listenToEventsOfTypes:liveEventTypesFilterForMessages onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) {
MXStrongifyAndReturnIfNil(self);
if (MXTimelineDirectionForwards == direction)
{
// Check for local echo suppression
MXEvent *localEcho;
if (self.secondaryRoom.outgoingMessages.count && [event.sender isEqualToString:self.mxSession.myUserId])
{
localEcho = [self.secondaryRoom pendingLocalEchoRelatedToEvent:event];
if (localEcho)
{
// Check whether the local echo has a timestamp (in this case, it is replaced with the actual event).
if (localEcho.originServerTs != kMXUndefinedTimestamp)
{
// Replace the local echo by the true event sent by the homeserver
[self replaceEvent:localEcho withEvent:event];
}
else
{
// Remove the local echo, and process independently the true event.
[self replaceEvent:localEcho withEvent:nil];
localEcho = nil;
}
}
}
if (nil == localEcho)
{
// Process here incoming events, and outgoing events sent from another device.
[self queueEventForProcessing:event withRoomState:roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
}];
}
// Register a listener to handle redaction which can affect live and past timelines
secondaryRedactionListener = [_secondaryTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRedaction] onEvent:^(MXEvent *redactionEvent, MXTimelineDirection direction, MXRoomState *roomState) {
// Consider only live redaction events
if (direction == MXTimelineDirectionForwards)
{
// Do the processing on the processing queue
dispatch_async(MXKRoomDataSource.processingQueue, ^{
// Check whether a message contains the redacted event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:redactionEvent.redacts];
if (bubbleData)
{
BOOL shouldRemoveBubbleData = NO;
BOOL hasChanged = NO;
MXEvent *redactedEvent = nil;
@synchronized (bubbleData)
{
// Retrieve the original event to redact it
NSArray *events = bubbleData.events;
for (MXEvent *event in events)
{
if ([event.eventId isEqualToString:redactionEvent.redacts])
{
// Check whether the event was not already redacted (Redaction may be handled by event timeline too).
if (!event.isRedactedEvent)
{
redactedEvent = [event prune];
redactedEvent.redactedBecause = redactionEvent.JSONDictionary;
}
break;
}
}
if (redactedEvent)
{
// Update bubble data
NSUInteger remainingEvents = [bubbleData updateEvent:redactionEvent.redacts withEvent:redactedEvent];
hasChanged = YES;
// Remove the bubble if there is no more events
shouldRemoveBubbleData = (remainingEvents == 0);
}
}
// Check whether the bubble should be removed
if (shouldRemoveBubbleData)
{
[self removeCellData:bubbleData];
}
if (hasChanged)
{
// Update the delegate on main thread
dispatch_async(dispatch_get_main_queue(), ^{
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
});
}
}
});
}
}];
}
- (void)setFilterMessagesWithURL:(BOOL)filterMessagesWithURL
{
_filterMessagesWithURL = filterMessagesWithURL;
if (_isLive && _room)
{
// Update the event listeners by considering the right types for the live events.
[self refreshEventListeners:(_filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages)];
}
}
- (void)setEventFormatter:(MXKEventFormatter *)eventFormatter
{
if (_eventFormatter)
{
// Remove observers on previous event formatter settings
[_eventFormatter.settings removeObserver:self forKeyPath:@"showRedactionsInRoomHistory"];
[_eventFormatter.settings removeObserver:self forKeyPath:@"showUnsupportedEventsInRoomHistory"];
}
_eventFormatter = eventFormatter;
if (_eventFormatter)
{
// Add observer to flush stored data on settings changes
[_eventFormatter.settings addObserver:self forKeyPath:@"showRedactionsInRoomHistory" options:0 context:nil];
[_eventFormatter.settings addObserver:self forKeyPath:@"showUnsupportedEventsInRoomHistory" options:0 context:nil];
}
}
- (void)setShowBubblesDateTime:(BOOL)showBubblesDateTime
{
_showBubblesDateTime = showBubblesDateTime;
if (self.delegate)
{
// Reload all the table
[self.delegate dataSource:self didCellChange:nil];
}
}
- (void)setShowTypingNotifications:(BOOL)shouldShowTypingNotifications
{
_showTypingNotifications = shouldShowTypingNotifications;
if (shouldShowTypingNotifications)
{
// Register on typing notif
[self listenTypingNotifications];
}
else
{
// Remove the live listener
if (typingNotifListener)
{
[_timeline removeListener:typingNotifListener];
currentTypingUsers = nil;
typingNotifListener = nil;
}
}
}
- (void)listenTypingNotifications
{
// Remove the previous live listener
if (typingNotifListener)
{
[_timeline removeListener:typingNotifListener];
currentTypingUsers = nil;
}
// Add typing notification listener
MXWeakify(self);
typingNotifListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringTypingNotification] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState)
{
MXStrongifyAndReturnIfNil(self);
// Handle only live events
if (direction == MXTimelineDirectionForwards)
{
// Retrieve typing users list
NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self.room.typingUsers];
// Remove typing info for the current user
NSUInteger index = [typingUsers indexOfObject:self.mxSession.myUser.userId];
if (index != NSNotFound)
{
[typingUsers removeObjectAtIndex:index];
}
// Ignore this notification if both arrays are empty
if (self->currentTypingUsers.count || typingUsers.count)
{
self->currentTypingUsers = typingUsers;
if (self.delegate)
{
// refresh all the table
[self.delegate dataSource:self didCellChange:nil];
}
}
}
}];
currentTypingUsers = _room.typingUsers;
}
- (void)cancelAllRequests
{
if (paginationRequest)
{
// We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress
[_timeline removeListener:paginationListener];
paginationListener = nil;
[paginationRequest cancel];
paginationRequest = nil;
}
[super cancelAllRequests];
}
- (void)setDelegate:(id<MXKDataSourceDelegate>)delegate
{
super.delegate = delegate;
// Register to MXScanManager notification only when a delegate is set
if (delegate && self.mxSession.scanManager)
{
[self registerScanManagerNotifications];
}
// Register to reaction notification only when a delegate is set
if (delegate)
{
[self registerReactionsChangeListener];
[self registerEventEditsListener];
}
}
- (void)setRoom:(MXRoom *)room
{
if (![_room isEqual:room])
{
_room = room;
[self roomDidSet];
}
}
- (void)roomDidSet
{
}
#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if ([@"showRedactionsInRoomHistory" isEqualToString:keyPath] || [@"showUnsupportedEventsInRoomHistory" isEqualToString:keyPath])
{
// Flush the current bubble data and rebuild them
[self reload];
}
}
#pragma mark - Public methods
- (id<MXKRoomBubbleCellDataStoring>)cellDataAtIndex:(NSInteger)index
{
id<MXKRoomBubbleCellDataStoring> bubbleData;
@synchronized(bubbles)
{
if (index < bubbles.count)
{
bubbleData = bubbles[index];
}
}
return bubbleData;
}
- (id<MXKRoomBubbleCellDataStoring>)cellDataOfEventWithEventId:(NSString *)eventId
{
id<MXKRoomBubbleCellDataStoring> bubbleData;
@synchronized(eventIdToBubbleMap)
{
bubbleData = eventIdToBubbleMap[eventId];
}
return bubbleData;
}
- (NSInteger)indexOfCellDataWithEventId:(NSString *)eventId
{
NSInteger index = NSNotFound;
id<MXKRoomBubbleCellDataStoring> bubbleData;
@synchronized(eventIdToBubbleMap)
{
bubbleData = eventIdToBubbleMap[eventId];
}
if (bubbleData)
{
@synchronized(bubbles)
{
index = [bubbles indexOfObject:bubbleData];
}
}
return index;
}
- (CGFloat)cellHeightAtIndex:(NSInteger)index withMaximumWidth:(CGFloat)maxWidth
{
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataAtIndex:index];
// Sanity check
if (bubbleData && self.delegate)
{
// Compute here height of bubble cell
Class<MXKCellRendering> cellViewClass = [self.delegate cellViewClassForCellData:bubbleData];
return [cellViewClass heightForCellData:bubbleData withMaximumWidth:maxWidth];
}
return 0;
}
- (void)invalidateBubblesCellDataCache
{
@synchronized(bubbles)
{
for (id<MXKRoomBubbleCellDataStoring> bubble in bubbles)
{
[bubble invalidateTextLayout];
}
}
}
#pragma mark - Pagination
- (void)paginate:(NSUInteger)numItems direction:(MXTimelineDirection)direction onlyFromStore:(BOOL)onlyFromStore success:(void (^)(NSUInteger addedCellNumber))success failure:(void (^)(NSError *error))failure
{
// Check the current data source state, and the actual user membership for this room.
if (state != MXKDataSourceStateReady || ((self.room.summary.membership == MXMembershipUnknown || self.room.summary.membership == MXMembershipInvite) && ![self.roomState.historyVisibility isEqualToString:kMXRoomHistoryVisibilityWorldReadable]))
{
// Back pagination is not available here.
if (failure)
{
failure(nil);
}
return;
}
if (paginationRequest || secondaryPaginationRequest)
{
MXLogDebug(@"[MXKRoomDataSource][%p] paginate: a pagination is already in progress", self);
if (failure)
{
failure(nil);
}
return;
}
if (NO == [self canPaginate:direction])
{
MXLogDebug(@"[MXKRoomDataSource][%p] paginate: No more events to paginate", self);
if (success)
{
success(0);
}
}
__block NSUInteger addedCellNb = 0;
__block NSMutableArray<NSError*> *operationErrors = [NSMutableArray arrayWithCapacity:2];
dispatch_group_t dispatchGroup = dispatch_group_create();
// Define a new listener for this pagination
paginationListener = [_timeline listenToEventsOfTypes:(_filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages) onEvent:^(MXEvent *event, MXTimelineDirection direction2, MXRoomState *roomState) {
if (direction2 == direction)
{
[self queueEventForProcessing:event withRoomState:roomState direction:direction];
}
}];
// Keep a local reference to this listener.
id localPaginationListenerRef = paginationListener;
dispatch_group_enter(dispatchGroup);
// Launch the pagination
MXWeakify(self);
paginationRequest = [_timeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{
MXStrongifyAndReturnIfNil(self);
// Everything went well, remove the listener
self->paginationRequest = nil;
[self.timeline removeListener:self->paginationListener];
self->paginationListener = nil;
// Once done, process retrieved events
[self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) {
addedCellNb += (direction == MXTimelineDirectionBackwards) ? addedHistoryCellNb : addedLiveCellNb;
dispatch_group_leave(dispatchGroup);
}];
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomDataSource][%p] paginateBackMessages fails", self);
MXStrongifyAndReturnIfNil(self);
// Something wrong happened or the request was cancelled.
// Check whether the request is the actual one before removing listener and handling the retrieved events.
if (localPaginationListenerRef == self->paginationListener)
{
self->paginationRequest = nil;
[self.timeline removeListener:self->paginationListener];
self->paginationListener = nil;
// Process at least events retrieved from store
[self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) {
[operationErrors addObject:error];
if (addedHistoryCellNb)
{
addedCellNb += addedHistoryCellNb;
}
dispatch_group_leave(dispatchGroup);
}];
}
}];
if (_secondaryTimeline)
{
// Define a new listener for this pagination
secondaryPaginationListener = [_secondaryTimeline listenToEventsOfTypes:_secondaryRoomEventTypes onEvent:^(MXEvent *event, MXTimelineDirection direction2, MXRoomState *roomState) {
if (direction2 == direction)
{
[self queueEventForProcessing:event withRoomState:roomState direction:direction];
}
}];
// Keep a local reference to this listener.
id localPaginationListenerRef = secondaryPaginationListener;
dispatch_group_enter(dispatchGroup);
// Launch the pagination
MXWeakify(self);
secondaryPaginationRequest = [_secondaryTimeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{
MXStrongifyAndReturnIfNil(self);
// Everything went well, remove the listener
self->secondaryPaginationRequest = nil;
[self.secondaryTimeline removeListener:self->secondaryPaginationListener];
self->secondaryPaginationListener = nil;
// Once done, process retrieved events
[self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) {
addedCellNb += (direction == MXTimelineDirectionBackwards) ? addedHistoryCellNb : addedLiveCellNb;
dispatch_group_leave(dispatchGroup);
}];
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomDataSource][%p] paginateBackMessages fails", self);
MXStrongifyAndReturnIfNil(self);
// Something wrong happened or the request was cancelled.
// Check whether the request is the actual one before removing listener and handling the retrieved events.
if (localPaginationListenerRef == self->secondaryPaginationListener)
{
self->secondaryPaginationRequest = nil;
[self.secondaryTimeline removeListener:self->secondaryPaginationListener];
self->secondaryPaginationListener = nil;
// Process at least events retrieved from store
[self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) {
[operationErrors addObject:error];
if (addedHistoryCellNb)
{
addedCellNb += addedHistoryCellNb;
}
dispatch_group_leave(dispatchGroup);
}];
}
}];
}
dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{
if (operationErrors.count)
{
if (failure)
{
failure(operationErrors.firstObject);
}
}
else
{
if (success)
{
success(addedCellNb);
}
}
});
}
- (void)paginateToFillRect:(CGRect)rect direction:(MXTimelineDirection)direction withMinRequestMessagesCount:(NSUInteger)minRequestMessagesCount success:(void (^)(void))success failure:(void (^)(NSError *error))failure
{
MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: %@", self, NSStringFromCGRect(rect));
// During the first call of this method, the delegate is supposed defined.
// This delegate may be removed whereas this method is called by itself after a pagination request.
// The delegate is required here to be able to compute cell height (and prevent infinite loop in case of reentrancy).
if (!self.delegate)
{
MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect ignored (delegate is undefined)", self);
if (failure)
{
failure(nil);
}
return;
}
// Get the total height of cells already loaded in memory
CGFloat minMessageHeight = CGFLOAT_MAX;
CGFloat bubblesTotalHeight = 0;
@synchronized(bubbles)
{
// Check whether data has been aldready loaded
if (bubbles.count)
{
NSUInteger eventsCount = 0;
for (NSInteger i = bubbles.count - 1; i >= 0; i--)
{
id<MXKRoomBubbleCellDataStoring> bubbleData = bubbles[i];
eventsCount += bubbleData.events.count;
CGFloat bubbleHeight = [self cellHeightAtIndex:i withMaximumWidth:rect.size.width];
// Sanity check
if (bubbleHeight)
{
bubblesTotalHeight += bubbleHeight;
if (bubblesTotalHeight > rect.size.height)
{
// No need to compute more cells heights, there are enough to fill the rect
MXLogDebug(@"[MXKRoomDataSource][%p] -> %tu already loaded bubbles (%tu events) are enough to fill the screen", self, bubbles.count - i, eventsCount);
break;
}
// Compute the minimal height an event takes
minMessageHeight = MIN(minMessageHeight, bubbleHeight / bubbleData.events.count);
}
}
}
else if (minRequestMessagesCount && [self canPaginate:direction])
{
MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: Prefill with data from the store", self);
// Give a chance to load data from the store before doing homeserver requests
// Reuse minRequestMessagesCount because we need to provide a number.
[self paginate:minRequestMessagesCount direction:direction onlyFromStore:YES success:^(NSUInteger addedCellNumber) {
// Then retry
[self paginateToFillRect:rect direction:direction withMinRequestMessagesCount:minRequestMessagesCount success:success failure:failure];
} failure:failure];
return;
}
}
// Is there enough cells to cover all the requested height?
if (bubblesTotalHeight < rect.size.height)
{
// No. Paginate to get more messages
if ([self canPaginate:direction])
{
// Bound the minimal height to 44
minMessageHeight = MIN(minMessageHeight, 44);
// Load messages to cover the remaining height
// Use an extra of 50% to manage unsupported/unexpected/redated events
NSUInteger messagesToLoad = ceil((rect.size.height - bubblesTotalHeight) / minMessageHeight * 1.5);
// It does not worth to make a pagination request for only 1 message.
// So, use minRequestMessagesCount
messagesToLoad = MAX(messagesToLoad, minRequestMessagesCount);
MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: need to paginate %tu events to cover %fpx", self, messagesToLoad, rect.size.height - bubblesTotalHeight);
[self paginate:messagesToLoad direction:direction onlyFromStore:NO success:^(NSUInteger addedCellNumber) {
[self paginateToFillRect:rect direction:direction withMinRequestMessagesCount:minRequestMessagesCount success:success failure:failure];
} failure:failure];
}
else
{
MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: No more events to paginate", self);
if (success)
{
success();
}
}
}
else
{
// Yes. Nothing to do
if (success)
{
success();
}
}
}
#pragma mark - Sending
- (void)sendTextMessage:(NSString *)text success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
__block MXEvent *localEchoEvent = nil;
BOOL isEmote = [self isMessageAnEmote:text];
NSString *sanitizedText = [self sanitizedMessageText:text];
NSString *html = [self htmlMessageFromSanitizedText:sanitizedText];
// Make the request to the homeserver
if (isEmote)
{
[_room sendEmote:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure];
}
else
{
[_room sendTextMessage:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure];
}
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendReplyToEventWithId:(NSString*)eventIdToReply
withTextMessage:(NSString *)text
success:(void (^)(NSString *))success
failure:(void (^)(NSError *))failure
{
MXEvent *eventToReply = [self eventWithEventId:eventIdToReply];
__block MXEvent *localEchoEvent = nil;
NSString *sanitizedText = [self sanitizedMessageText:text];
NSString *html = [self htmlMessageFromSanitizedText:sanitizedText];
id<MXSendReplyEventStringLocalizerProtocol> stringLocalizer = [MXKSendReplyEventStringLocalizer new];
[_room sendReplyToEvent:eventToReply withTextMessage:sanitizedText formattedTextMessage:html stringLocalizer:stringLocalizer localEcho:&localEchoEvent success:success failure:failure];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (BOOL)isMessageAnEmote:(NSString*)text
{
return [text hasPrefix:emoteMessageSlashCommandPrefix];
}
- (NSString*)sanitizedMessageText:(NSString*)rawText
{
NSString *text;
//Remove NULL bytes from the string, as they are likely to trip up many things later,
//including our own C-based Markdown-to-HTML convertor.
//
//Normally, we don't expect people to be entering NULL bytes in messages,
//but because of a bug in iOS 11, it's easy to have it happen.
//
//iOS 11's Smart Punctuation feature "conveniently" converts double hyphens (`--`) to longer en-dashes (`—`).
//However, when adding any kind of dash/hyphen after such an en-dash,
//iOS would also insert a NULL byte inbetween the dashes (`<en-dash>NULL<some other dash>`).
//
//Even if a future iOS update fixes this,
//we'd better be defensive and always remove occurrences of NULL bytes from text messages.
text = [rawText stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"%C", 0x00000000] withString:@""];
// Check whether the message is an emote
if ([self isMessageAnEmote:text])
{
// Remove "/me " string
text = [text substringFromIndex:emoteMessageSlashCommandPrefix.length];
}
return text;
}
- (NSString*)htmlMessageFromSanitizedText:(NSString*)sanitizedText
{
NSString *html;
// Did user use Markdown text?
NSString *htmlStringFromMarkdown = [_eventFormatter htmlStringFromMarkdownString:sanitizedText];
if ([htmlStringFromMarkdown isEqualToString:sanitizedText])
{
// No formatted string
html = nil;
}
else
{
html = htmlStringFromMarkdown;
}
return html;
}
- (void)sendImage:(UIImage *)image success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
// Make sure the uploaded image orientation is up
image = [MXKTools forceImageOrientationUp:image];
// Only jpeg image is supported here
NSString *mimetype = @"image/jpeg";
NSData *imageData = UIImageJPEGRepresentation(image, 0.9);
// Shall we need to consider a thumbnail?
UIImage *thumbnail = nil;
if (_room.summary.isEncrypted)
{
// Thumbnail is useful only in case of encrypted room
thumbnail = [MXKTools reduceImage:image toFitInSize:CGSizeMake(800, 600)];
if (thumbnail == image)
{
thumbnail = nil;
}
}
[self sendImageData:imageData withImageSize:image.size mimeType:mimetype andThumbnail:thumbnail success:success failure:failure];
}
- (BOOL)canReplyToEventWithId:(NSString*)eventIdToReply
{
MXEvent *eventToReply = [self eventWithEventId:eventIdToReply];
return [self.room canReplyToEvent:eventToReply];
}
- (void)sendImage:(NSData *)imageData mimeType:(NSString *)mimetype success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
UIImage *image = [UIImage imageWithData:imageData];
// Shall we need to consider a thumbnail?
UIImage *thumbnail = nil;
if (_room.summary.isEncrypted)
{
// Thumbnail is useful only in case of encrypted room
thumbnail = [MXKTools reduceImage:image toFitInSize:CGSizeMake(800, 600)];
if (thumbnail == image)
{
thumbnail = nil;
}
}
[self sendImageData:imageData withImageSize:image.size mimeType:mimetype andThumbnail:thumbnail success:success failure:failure];
}
- (void)sendImageData:(NSData*)imageData withImageSize:(CGSize)imageSize mimeType:(NSString*)mimetype andThumbnail:(UIImage*)thumbnail success:(void (^)(NSString *eventId))success failure:(void (^)(NSError *error))failure
{
__block MXEvent *localEchoEvent = nil;
[_room sendImage:imageData withImageSize:imageSize mimeType:mimetype andThumbnail:thumbnail localEcho:&localEchoEvent success:success failure:failure];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendVideo:(NSURL *)videoLocalURL withThumbnail:(UIImage *)videoThumbnail success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL];
[self sendVideoAsset:videoAsset withThumbnail:videoThumbnail success:success failure:failure];
}
- (void)sendVideoAsset:(AVAsset *)videoAsset withThumbnail:(UIImage *)videoThumbnail success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
__block MXEvent *localEchoEvent = nil;
[_room sendVideoAsset:videoAsset withThumbnail:videoThumbnail localEcho:&localEchoEvent success:success failure:failure];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendAudioFile:(NSURL *)audioFileLocalURL mimeType:mimeType success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
__block MXEvent *localEchoEvent = nil;
[_room sendAudioFile:audioFileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendVoiceMessage:(NSURL *)audioFileLocalURL
mimeType:mimeType
duration:(NSUInteger)duration
samples:(NSArray<NSNumber *> *)samples
success:(void (^)(NSString *))success
failure:(void (^)(NSError *))failure
{
__block MXEvent *localEchoEvent = nil;
[_room sendVoiceMessage:audioFileLocalURL mimeType:mimeType duration:duration samples:samples localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendFile:(NSURL *)fileLocalURL mimeType:(NSString*)mimeType success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
__block MXEvent *localEchoEvent = nil;
[_room sendFile:fileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendMessageWithContent:(NSDictionary *)msgContent success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
__block MXEvent *localEchoEvent = nil;
// Make the request to the homeserver
[_room sendMessageWithContent:msgContent localEcho:&localEchoEvent success:success failure:failure];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)sendEventOfType:(MXEventTypeString)eventTypeString content:(NSDictionary<NSString*, id>*)msgContent success:(void (^)(NSString *eventId))success failure:(void (^)(NSError *error))failure
{
__block MXEvent *localEchoEvent = nil;
// Make the request to the homeserver
[_room sendEventOfType:eventTypeString content:msgContent localEcho:&localEchoEvent success:success failure:failure];
if (localEchoEvent)
{
// Make the data source digest this fake local echo message
[self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
}
}
- (void)resendEventWithEventId:(NSString *)eventId success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure
{
MXEvent *event = [self eventWithEventId:eventId];
// Sanity check
if (!event)
{
return;
}
MXLogInfo(@"[MXKRoomDataSource][%p] resendEventWithEventId. EventId: %@", self, event.eventId);
// Check first whether the event is encrypted
if ([event.wireType isEqualToString:kMXEventTypeStringRoomEncrypted])
{
// We try here to resent an encrypted event
// Note: we keep the existing local echo.
[_room sendEventOfType:kMXEventTypeStringRoomEncrypted content:event.wireContent localEcho:&event success:success failure:failure];
}
else if ([event.type isEqualToString:kMXEventTypeStringRoomMessage])
{
// And retry the send the message according to its type
NSString *msgType = event.content[kMXMessageTypeKey];
if ([msgType isEqualToString:kMXMessageTypeText] || [msgType isEqualToString:kMXMessageTypeEmote])
{
// Resend the Matrix event by reusing the existing echo
[_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure];
}
else if ([msgType isEqualToString:kMXMessageTypeImage])
{
// Check whether the sending failed while uploading the data.
// If the content url corresponds to a upload id, the upload was not complete.
NSString *contentURL = event.content[@"url"];
if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix])
{
NSString *mimetype = nil;
if (event.content[@"info"])
{
mimetype = event.content[@"info"][@"mimetype"];
}
NSString *localImagePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId];
UIImage* image = [MXMediaManager loadPictureFromFilePath:localImagePath];
if (image)
{
// Restart sending the image from the beginning.
// Remove the local echo.
[self removeEventWithEventId:eventId];
if (mimetype)
{
NSData *imageData = [NSData dataWithContentsOfFile:localImagePath];
[self sendImage:imageData mimeType:mimetype success:success failure:failure];
}
else
{
[self sendImage:image success:success failure:failure];
}
}
else
{
failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendGeneric userInfo:nil]);
MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType);
}
}
else
{
// Resend the Matrix event by reusing the existing echo
[_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure];
}
}
else if ([msgType isEqualToString:kMXMessageTypeAudio])
{
// Check whether the sending failed while uploading the data.
// If the content url corresponds to a upload id, the upload was not complete.
NSString *contentURL = event.content[@"url"];
if (!contentURL || ![contentURL hasPrefix:kMXMediaUploadIdPrefix])
{
// Resend the Matrix event by reusing the existing echo
[_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure];
return;
}
NSString *mimetype = event.content[@"info"][@"mimetype"];
NSString *localFilePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId];
NSURL *localFileURL = [NSURL URLWithString:localFilePath];
if (![NSFileManager.defaultManager fileExistsAtPath:localFilePath]) {
failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidLocalFilePath userInfo:nil]);
MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend voice message, invalid file path.", self);
return;
}
// Remove the local echo.
[self removeEventWithEventId:eventId];
if (event.isVoiceMessage) {
NSNumber *duration = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioDuration];
NSArray<NSNumber *> *samples = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioWaveform];
[self sendVoiceMessage:localFileURL mimeType:mimetype duration:duration.doubleValue samples:samples success:success failure:failure];
} else {
[self sendAudioFile:localFileURL mimeType:mimetype success:success failure:failure];
}
}
else if ([msgType isEqualToString:kMXMessageTypeVideo])
{
// Check whether the sending failed while uploading the data.
// If the content url corresponds to a upload id, the upload was not complete.
NSString *contentURL = event.content[@"url"];
if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix])
{
// TODO: Support resend on attached video when upload has been failed.
MXLogDebug(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend attached video (upload was not complete)", self);
failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]);
}
else
{
// Resend the Matrix event by reusing the existing echo
[_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure];
}
}
else if ([msgType isEqualToString:kMXMessageTypeFile])
{
// Check whether the sending failed while uploading the data.
// If the content url corresponds to a upload id, the upload was not complete.
NSString *contentURL = event.content[@"url"];
if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix])
{
NSString *mimetype = nil;
if (event.content[@"info"])
{
mimetype = event.content[@"info"][@"mimetype"];
}
if (mimetype)
{
// Restart sending the image from the beginning.
// Remove the local echo
[self removeEventWithEventId:eventId];
NSString *localFilePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId];
[self sendFile:[NSURL fileURLWithPath:localFilePath isDirectory:NO] mimeType:mimetype success:success failure:failure];
}
else
{
failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendGeneric userInfo:nil]);
MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType);
}
}
else
{
// Resend the Matrix event by reusing the existing echo
[_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure];
}
}
else
{
failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]);
MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType);
}
}
else
{
failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]);
MXLogWarning(@"[MXKRoomDataSource][%p] MXKRoomDataSource: Warning - Only resend of MXEventTypeRoomMessage is allowed. Event.type: %@", self, event.type);
}
}
#pragma mark - Events management
- (MXEvent *)eventWithEventId:(NSString *)eventId
{
MXEvent *theEvent;
// First, retrieve the cell data hosting the event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:eventId];
if (bubbleData)
{
// Then look into the events in this cell
for (MXEvent *event in bubbleData.events)
{
if ([event.eventId isEqualToString:eventId])
{
theEvent = event;
break;
}
}
}
return theEvent;
}
- (void)removeEventWithEventId:(NSString *)eventId
{
MXLogVerbose(@"[MXKRoomDataSource][%p] removeEventWithEventId: %@", self, eventId);
// First, retrieve the cell data hosting the event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:eventId];
if (bubbleData)
{
NSUInteger remainingEvents;
@synchronized (bubbleData)
{
remainingEvents = [bubbleData removeEvent:eventId];
}
// If there is no more events in the bubble, remove it
if (0 == remainingEvents)
{
[self removeCellData:bubbleData];
}
// Remove the event from the outgoing messages storage
[_room removeOutgoingMessage:eventId];
// Update the delegate
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
}
}
- (void)didReceiveReceiptEvent:(MXEvent *)receiptEvent roomState:(MXRoomState *)roomState
{
// Do the processing on the same processing queue
MXWeakify(self);
dispatch_async(MXKRoomDataSource.processingQueue, ^{
MXStrongifyAndReturnIfNil(self);
// Remove the previous displayed read receipt for each user who sent a
// new read receipt.
// To implement it, we need to find the sender id of each new read receipt
// among the read receipts array of all events in all bubbles.
NSArray *readReceiptSenders = receiptEvent.readReceiptSenders;
@synchronized(self->bubbles)
{
for (MXKRoomBubbleCellData *cellData in self->bubbles)
{
NSMutableDictionary<NSString* /* eventId */, NSArray<MXReceiptData*> *> *updatedCellDataReadReceipts = [NSMutableDictionary dictionary];
for (NSString *eventId in cellData.readReceipts)
{
for (MXReceiptData *receiptData in cellData.readReceipts[eventId])
{
for (NSString *senderId in readReceiptSenders)
{
if ([receiptData.userId isEqualToString:senderId])
{
if (!updatedCellDataReadReceipts[eventId])
{
updatedCellDataReadReceipts[eventId] = cellData.readReceipts[eventId];
}
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"userId!=%@", receiptData.userId];
updatedCellDataReadReceipts[eventId] = [updatedCellDataReadReceipts[eventId] filteredArrayUsingPredicate:predicate];
break;
}
}
}
}
// Flush found changed to the cell data
for (NSString *eventId in updatedCellDataReadReceipts)
{
if (updatedCellDataReadReceipts[eventId].count)
{
[self updateCellData:cellData withReadReceipts:updatedCellDataReadReceipts[eventId] forEventId:eventId];
}
else
{
[self updateCellData:cellData withReadReceipts:nil forEventId:eventId];
}
}
}
}
dispatch_group_t dispatchGroup = dispatch_group_create();
// Update cell data we have received a read receipt for
NSArray *readEventIds = receiptEvent.readReceiptEventIds;
for (NSString* eventId in readEventIds)
{
MXKRoomBubbleCellData *cellData = [self cellDataOfEventWithEventId:eventId];
if (cellData)
{
@synchronized(self->bubbles)
{
dispatch_group_enter(dispatchGroup);
[self addReadReceiptsForEvent:eventId inCellDatas:self->bubbles startingAtCellData:cellData completion:^{
dispatch_group_leave(dispatchGroup);
}];
}
}
}
dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
});
});
}
- (void)updateCellData:(MXKRoomBubbleCellData*)cellData withReadReceipts:(NSArray<MXReceiptData*>*)readReceipts forEventId:(NSString*)eventId
{
cellData.readReceipts[eventId] = readReceipts;
// Indicate that the text message layout should be recomputed.
[cellData invalidateTextLayout];
}
- (void)handleUnsentMessages
{
// Add the unsent messages at the end of the conversation
NSArray<MXEvent*>* outgoingMessages = _room.outgoingMessages;
[self.mxSession decryptEvents:outgoingMessages inTimeline:nil onComplete:^(NSArray<MXEvent *> *failedEvents) {
for (MXEvent *outgoingMessage in outgoingMessages)
{
[self queueEventForProcessing:outgoingMessage withRoomState:self.roomState direction:MXTimelineDirectionForwards];
}
MXLogVerbose(@"[MXKRoomDataSource][%p] handleUnsentMessages: queued %tu events", self, outgoingMessages.count);
[self processQueuedEvents:nil];
}];
}
#pragma mark - Bubble collapsing
- (void)collapseRoomBubble:(id<MXKRoomBubbleCellDataStoring>)bubbleData collapsed:(BOOL)collapsed
{
if (bubbleData.collapsed != collapsed)
{
id<MXKRoomBubbleCellDataStoring> nextBubbleData = bubbleData;
do
{
nextBubbleData.collapsed = collapsed;
}
while ((nextBubbleData = nextBubbleData.nextCollapsableCellData));
if (self.delegate)
{
// Reload all the table
[self.delegate dataSource:self didCellChange:nil];
}
}
}
#pragma mark - Private methods
- (void)replaceEvent:(MXEvent*)eventToReplace withEvent:(MXEvent*)event
{
MXLogVerbose(@"[MXKRoomDataSource][%p] replaceEvent: %@ with: %@", self, eventToReplace.eventId, event.eventId);
if (eventToReplace.isLocalEvent)
{
// Stop listening to the identifier change for the replaced event.
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:eventToReplace];
}
// Retrieve the cell data hosting the replaced event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:eventToReplace.eventId];
if (!bubbleData)
{
return;
}
NSUInteger remainingEvents;
@synchronized (bubbleData)
{
// Check whether the local echo is replaced or removed
if (event)
{
remainingEvents = [bubbleData updateEvent:eventToReplace.eventId withEvent:event];
}
else
{
remainingEvents = [bubbleData removeEvent:eventToReplace.eventId];
}
}
// Update bubbles mapping
@synchronized (eventIdToBubbleMap)
{
// Remove the broken link from the map
[eventIdToBubbleMap removeObjectForKey:eventToReplace.eventId];
if (event && remainingEvents)
{
eventIdToBubbleMap[event.eventId] = bubbleData;
if (event.isLocalEvent)
{
// Listen to the identifier change for the local events.
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localEventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:event];
}
}
}
// If there is no more events in the bubble, remove it
if (0 == remainingEvents)
{
[self removeCellData:bubbleData];
}
// Update the delegate
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
}
- (NSArray<NSIndexPath *> *)removeCellData:(id<MXKRoomBubbleCellDataStoring>)cellData
{
NSMutableArray *deletedRows = [NSMutableArray array];
MXLogVerbose(@"[MXKRoomDataSource][%p] removeCellData: %@", self, [cellData.events valueForKey:@"eventId"]);
// Remove potential occurrences in bubble map
@synchronized (eventIdToBubbleMap)
{
for (MXEvent *event in cellData.events)
{
[eventIdToBubbleMap removeObjectForKey:event.eventId];
if (event.isLocalEvent)
{
// Stop listening to the identifier change for this event.
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:event];
}
}
}
// Check whether the adjacent bubbles can merge together
@synchronized(bubbles)
{
NSUInteger index = [bubbles indexOfObject:cellData];
if (index != NSNotFound)
{
[bubbles removeObjectAtIndex:index];
[deletedRows addObject:[NSIndexPath indexPathForRow:index inSection:0]];
if (bubbles.count)
{
// Update flag in remaining data
if (index == 0)
{
// We removed here the first bubble.
// We have to update the 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags of the new first bubble.
id<MXKRoomBubbleCellDataStoring> firstCellData = bubbles.firstObject;
firstCellData.isPaginationFirstBubble = ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && firstCellData.date);
// Keep visible the sender information by default,
// except if the bubble has no display (composed only by ignored events).
firstCellData.shouldHideSenderInformation = firstCellData.hasNoDisplay;
}
else if (index < bubbles.count)
{
// We removed here a bubble which is not the before last.
id<MXKRoomBubbleCellDataStoring> cellData1 = bubbles[index-1];
id<MXKRoomBubbleCellDataStoring> cellData2 = bubbles[index];
// Check first whether the neighbor bubbles can merge
Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier];
if ([class instancesRespondToSelector:@selector(mergeWithBubbleCellData:)])
{
if ([cellData1 mergeWithBubbleCellData:cellData2])
{
[bubbles removeObjectAtIndex:index];
[deletedRows addObject:[NSIndexPath indexPathForRow:(index + 1) inSection:0]];
cellData2 = nil;
}
}
if (cellData2)
{
// Update its 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags
// Pagination handling
if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay && !cellData2.isPaginationFirstBubble)
{
// Check whether a new pagination starts on the second cellData
NSString *cellData1DateString = [self.eventFormatter dateStringFromDate:cellData1.date withTime:NO];
NSString *cellData2DateString = [self.eventFormatter dateStringFromDate:cellData2.date withTime:NO];
if (!cellData1DateString)
{
cellData2.isPaginationFirstBubble = (cellData2DateString && cellData.isPaginationFirstBubble);
}
else
{
cellData2.isPaginationFirstBubble = (cellData2DateString && ![cellData2DateString isEqualToString:cellData1DateString]);
}
}
// Check whether the sender information is relevant for this bubble.
// Check first if the bubble is not composed only by ignored events.
cellData2.shouldHideSenderInformation = cellData2.hasNoDisplay;
if (!cellData2.shouldHideSenderInformation && cellData2.isPaginationFirstBubble == NO)
{
// Check whether the neighbor bubbles have been sent by the same user.
cellData2.shouldHideSenderInformation = [cellData2 hasSameSenderAsBubbleCellData:cellData1];
}
}
}
}
}
}
return deletedRows;
}
- (void)didMXRoomInitialSynced:(NSNotification *)notif
{
// Refresh the room data source when the room has been initialSync'ed
MXRoom *room = notif.object;
if (self.mxSession == room.mxSession &&
([self.roomId isEqualToString:room.roomId] || [self.secondaryRoomId isEqualToString:room.roomId]))
{
MXLogDebug(@"[MXKRoomDataSource][%p] didMXRoomInitialSynced for room: %@", self, room.roomId);
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomInitialSyncNotification object:room];
[self reload];
}
}
- (void)didMXSessionUpdatePublicisedGroupsForUsers:(NSNotification *)notif
{
// Retrieved the list of the concerned users
NSArray<NSString*> *userIds = notif.userInfo[kMXSessionNotificationUserIdsArrayKey];
if (userIds.count)
{
// Check whether at least one listed user is a room member.
for (NSString* userId in userIds)
{
MXRoomMember * roomMember = [self.roomState.members memberWithUserId:userId];
if (roomMember)
{
// Inform the delegate to refresh the bubble display
// We dispatch here this action in order to let each bubble data update their sender flair.
if (self.delegate)
{
dispatch_async(dispatch_get_main_queue(), ^{
[self.delegate dataSource:self didCellChange:nil];
});
}
break;
}
}
}
}
- (void)eventDidChangeSentState:(NSNotification *)notif
{
MXEvent *event = notif.object;
if ([event.roomId isEqualToString:_roomId])
{
MXLogVerbose(@"[MXKRoomDataSource][%p] eventDidChangeSentState: %@, to: %tu", self, event.eventId, event.sentState);
// Retrieve the cell data hosting the local echo
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:event.eventId];
if (!bubbleData)
{
// Initial state for local echos
BOOL isInitial = event.isLocalEvent &&
(event.sentState == MXEventSentStateSending || event.sentState == MXEventSentStateEncrypting);
if (!isInitial)
{
MXLogWarning(@"[MXKRoomDataSource][%p] eventDidChangeSentState: Cannot find bubble data for event: %@", self, event.eventId);
}
return;
}
@synchronized (bubbleData)
{
[bubbleData updateEvent:event.eventId withEvent:event];
}
// Inform the delegate
if (self.delegate && (self.secondaryRoom ? bubbles.count > 0 : YES))
{
[self.delegate dataSource:self didCellChange:nil];
}
}
}
- (void)localEventDidChangeIdentifier:(NSNotification *)notif
{
MXEvent *event = notif.object;
NSString *previousId = notif.userInfo[kMXEventIdentifierKey];
MXLogVerbose(@"[MXKRoomDataSource][%p] localEventDidChangeIdentifier from: %@ to: %@", self, previousId, event.eventId);
if (event && previousId)
{
// Update bubbles mapping
@synchronized (eventIdToBubbleMap)
{
id<MXKRoomBubbleCellDataStoring> bubbleData = eventIdToBubbleMap[previousId];
if (bubbleData && event.eventId)
{
eventIdToBubbleMap[event.eventId] = bubbleData;
[eventIdToBubbleMap removeObjectForKey:previousId];
// The bubble data must use the final event id too
[bubbleData updateEvent:previousId withEvent:event];
}
}
if (!event.isLocalEvent)
{
// Stop listening to the identifier change when the event becomes an actual event.
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:event];
}
}
}
- (void)eventDidDecrypt:(NSNotification *)notif
{
MXEvent *event = notif.object;
if ([event.roomId isEqualToString:_roomId] ||
([event.roomId isEqualToString:_secondaryRoomId] && [_secondaryRoomEventTypes containsObject:event.type]))
{
// Retrieve the cell data hosting the event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:event.eventId];
if (!bubbleData)
{
return;
}
// We need to update the data of the cell that displays the event.
// The trickiest update is when the cell contains several events and the event
// to update turns out to be an attachment.
// In this case, we need to split the cell into several cells so that the attachment
// has its own cell.
if (bubbleData.events.count == 1 || ![_eventFormatter isSupportedAttachment:event])
{
// If the event is still a text, a simple update is enough
// If the event is an attachment, it has already its own cell. Let the bubble
// data handle the type change.
@synchronized (bubbleData)
{
[bubbleData updateEvent:event.eventId withEvent:event];
}
}
else
{
@synchronized (bubbleData)
{
BOOL eventIsFirstInBubble = NO;
NSInteger bubbleDataIndex = [bubbles indexOfObject:bubbleData];
if (NSNotFound == bubbleDataIndex)
{
// If bubbleData is not in bubbles there is nothing to update for this event, its not displayed.
return;
}
// We need to create a dedicated cell for the event attachment.
// From the current bubble, remove the updated event and all events after.
NSMutableArray<MXEvent*> *removedEvents;
NSUInteger remainingEvents = [bubbleData removeEventsFromEvent:event.eventId removedEvents:&removedEvents];
// If there is no more events in this bubble, remove it
if (0 == remainingEvents)
{
eventIsFirstInBubble = YES;
@synchronized (eventsToProcessSnapshot)
{
[bubbles removeObjectAtIndex:bubbleDataIndex];
bubbleDataIndex--;
}
}
// Create a dedicated bubble for the attachment
if (removedEvents.count)
{
Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier];
id<MXKRoomBubbleCellDataStoring> newBubbleData = [[class alloc] initWithEvent:removedEvents[0] andRoomState:self.roomState andRoomDataSource:self];
if (eventIsFirstInBubble)
{
// Apply same config as before
newBubbleData.isPaginationFirstBubble = bubbleData.isPaginationFirstBubble;
newBubbleData.shouldHideSenderInformation = bubbleData.shouldHideSenderInformation;
}
else
{
// This new bubble is not the first. Show nothing
newBubbleData.isPaginationFirstBubble = NO;
newBubbleData.shouldHideSenderInformation = YES;
}
// Update bubbles mapping
@synchronized (eventIdToBubbleMap)
{
eventIdToBubbleMap[event.eventId] = newBubbleData;
}
@synchronized (eventsToProcessSnapshot)
{
[bubbles insertObject:newBubbleData atIndex:bubbleDataIndex + 1];
}
}
// And put other cutted events in another bubble
if (removedEvents.count > 1)
{
Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier];
id<MXKRoomBubbleCellDataStoring> newBubbleData;
for (NSUInteger i = 1; i < removedEvents.count; i++)
{
MXEvent *removedEvent = removedEvents[i];
if (i == 1)
{
newBubbleData = [[class alloc] initWithEvent:removedEvent andRoomState:self.roomState andRoomDataSource:self];
}
else
{
[newBubbleData addEvent:removedEvent andRoomState:self.roomState];
}
// Update bubbles mapping
@synchronized (eventIdToBubbleMap)
{
eventIdToBubbleMap[removedEvent.eventId] = newBubbleData;
}
}
// Do not show the
newBubbleData.isPaginationFirstBubble = NO;
newBubbleData.shouldHideSenderInformation = YES;
@synchronized (eventsToProcessSnapshot)
{
[bubbles insertObject:newBubbleData atIndex:bubbleDataIndex + 2];
}
}
}
}
// Update the delegate
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
}
}
// Indicates whether an event has base requirements to allow actions (like reply, reactions, edit, etc.)
- (BOOL)canPerformActionOnEvent:(MXEvent*)event
{
BOOL isSent = event.sentState == MXEventSentStateSent;
if (!isSent) {
return NO;
}
if (event.eventType == MXEventTypePollStart) {
return YES;
}
BOOL isRoomMessage = (event.eventType == MXEventTypeRoomMessage);
if (!isRoomMessage) {
return NO;
}
NSString *messageType = event.content[kMXMessageTypeKey];
if (messageType == nil || [messageType isEqualToString:@"m.bad.encrypted"]) {
return NO;
}
return YES;
}
- (void)setState:(MXKDataSourceState)newState
{
self->state = newState;
if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)])
{
[self.delegate dataSource:self didStateChange:self->state];
}
}
- (void)setSecondaryRoomId:(NSString *)secondaryRoomId
{
if (_secondaryRoomId != secondaryRoomId)
{
_secondaryRoomId = secondaryRoomId;
if (self.state == MXKDataSourceStateReady)
{
[self reload];
}
}
}
- (void)setSecondaryRoomEventTypes:(NSArray<MXEventTypeString> *)secondaryRoomEventTypes
{
if (_secondaryRoomEventTypes != secondaryRoomEventTypes)
{
_secondaryRoomEventTypes = secondaryRoomEventTypes;
if (self.state == MXKDataSourceStateReady)
{
[self reload];
}
}
}
#pragma mark - Asynchronous events processing
+ (dispatch_queue_t)processingQueue
{
static dispatch_queue_t processingQueue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
processingQueue = dispatch_queue_create("MXKRoomDataSource", DISPATCH_QUEUE_SERIAL);
});
return processingQueue;
}
/**
Queue an event in order to process its display later.
@param event the event to process.
@param roomState the state of the room when the event fired.
@param direction the order of the events in the arrays
*/
- (void)queueEventForProcessing:(MXEvent*)event withRoomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction
{
if (event.isLocalEvent)
{
MXLogVerbose(@"[MXKRoomDataSource][%p] queueEventForProcessing: %@", self, event.eventId);
}
if (self.filterMessagesWithURL)
{
// Check whether the event has a value for the 'url' key in its content.
if (!event.getMediaURLs.count)
{
// Ignore the event
return;
}
}
// Check for undecryptable messages that were sent while the user was not in the room and hide them
if ([MXKAppSettings standardAppSettings].hidePreJoinedUndecryptableEvents
&& direction == MXTimelineDirectionBackwards)
{
[self checkForPreJoinUTDWithEvent:event roomState:roomState];
// Hide pre joint UTD events
if (self.shouldStopBackPagination)
{
return;
}
}
MXKQueuedEvent *queuedEvent = [[MXKQueuedEvent alloc] initWithEvent:event andRoomState:roomState direction:direction];
// Count queued events when the server sync is in progress
if (self.mxSession.state == MXSessionStateSyncInProgress)
{
queuedEvent.serverSyncEvent = YES;
_serverSyncEventCount++;
if (_serverSyncEventCount == 1)
{
// Notify that sync process starts
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceSyncStatusChanged object:self userInfo:nil];
}
}
@synchronized(eventsToProcess)
{
[eventsToProcess addObject:queuedEvent];
if (self.secondaryRoom)
{
// use a stable sorting here, which means it won't change the order of events unless it has to.
[eventsToProcess sortWithOptions:NSSortStable
usingComparator:^NSComparisonResult(MXKQueuedEvent * _Nonnull event1, MXKQueuedEvent * _Nonnull event2) {
return [event2.eventDate compare:event1.eventDate];
}];
}
}
}
- (BOOL)canPaginate:(MXTimelineDirection)direction
{
if (_secondaryTimeline)
{
if (![_timeline canPaginate:direction] && ![_secondaryTimeline canPaginate:direction])
{
return NO;
}
}
else
{
if (![_timeline canPaginate:direction])
{
return NO;
}
}
if (direction == MXTimelineDirectionBackwards && self.shouldStopBackPagination)
{
return NO;
}
return YES;
}
// Check for undecryptable messages that were sent while the user was not in the room.
- (void)checkForPreJoinUTDWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState
{
// Only check for encrypted rooms
if (!self.room.summary.isEncrypted)
{
return;
}
// Back pagination is stopped do not check for other pre join events
if (self.shouldStopBackPagination)
{
return;
}
// if we reach a UTD and flag is set, hide previous encrypted messages and stop back-paginating
if (event.eventType == MXEventTypeRoomEncrypted
&& [event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain]
&& self.shouldPreventBackPaginationOnPreviousUTDEvent)
{
self.shouldStopBackPagination = YES;
return;
}
self.shouldStopBackPagination = NO;
if (event.eventType != MXEventTypeRoomMember)
{
return;
}
NSString *userId = event.stateKey;
// Only check "m.room.member" event for current user
if (![userId isEqualToString:self.mxSession.myUserId])
{
return;
}
BOOL shouldPreventBackPaginationOnPreviousUTDEvent = NO;
MXRoomMember *member = [roomState.members memberWithUserId:userId];
if (member)
{
switch (member.membership) {
case MXMembershipJoin:
{
// if we reach a join event for the user:
// - if prev-content is invite, continue back-paginating
// - if prev-content is join (was just an avatar or displayname change), continue back-paginating
// - otherwise, set a flag and continue back-paginating
NSString *previousMemberhsip = event.prevContent[@"membership"];
BOOL isPrevContentAnInvite = [previousMemberhsip isEqualToString:@"invite"];
BOOL isPrevContentAJoin = [previousMemberhsip isEqualToString:@"join"];
if (!(isPrevContentAnInvite || isPrevContentAJoin))
{
shouldPreventBackPaginationOnPreviousUTDEvent = YES;
}
}
break;
case MXMembershipInvite:
// if we reach an invite event for the user, set flag and continue back-paginating
shouldPreventBackPaginationOnPreviousUTDEvent = YES;
break;
default:
break;
}
}
self.shouldPreventBackPaginationOnPreviousUTDEvent = shouldPreventBackPaginationOnPreviousUTDEvent;
}
- (BOOL)checkBing:(MXEvent*)event
{
BOOL isHighlighted = NO;
// read receipts have no rule
if (![event.type isEqualToString:kMXEventTypeStringReceipt]) {
// Check if we should bing this event
MXPushRule *rule = [self.mxSession.notificationCenter ruleMatchingEvent:event roomState:self.roomState];
if (rule)
{
// Check whether is there an highlight tweak on it
for (MXPushRuleAction *ruleAction in rule.actions)
{
if (ruleAction.actionType == MXPushRuleActionTypeSetTweak)
{
if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"highlight"])
{
// Check the highlight tweak "value"
// If not present, highlight. Else check its value before highlighting
if (nil == ruleAction.parameters[@"value"] || YES == [ruleAction.parameters[@"value"] boolValue])
{
isHighlighted = YES;
break;
}
}
}
}
}
}
event.mxkIsHighlighted = isHighlighted;
return isHighlighted;
}
/**
Start processing pending events.
@param onComplete a block called (on the main thread) when the processing has been done. Can be nil.
Note this block returns the number of added cells in first and last positions.
*/
- (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete
{
MXWeakify(self);
// Do the processing on the processing queue
dispatch_async(MXKRoomDataSource.processingQueue, ^{
MXStrongifyAndReturnIfNil(self);
// Note: As this block is always called from the same processing queue,
// only one batch process is done at a time. Thus, an event cannot be
// processed twice
// Snapshot queued events to avoid too long lock.
@synchronized(self->eventsToProcess)
{
if (self->eventsToProcess.count)
{
self->eventsToProcessSnapshot = self->eventsToProcess;
if (self.secondaryRoom)
{
@synchronized(self->bubbles)
{
[self->bubblesSnapshot removeAllObjects];
}
}
else
{
self->eventsToProcess = [NSMutableArray array];
}
}
}
NSUInteger serverSyncEventCount = 0;
NSUInteger addedHistoryCellCount = 0;
NSUInteger addedLiveCellCount = 0;
dispatch_group_t dispatchGroup = dispatch_group_create();
// Lock on `eventsToProcessSnapshot` to suspend reload or destroy during the process.
@synchronized(self->eventsToProcessSnapshot)
{
// Is there events to process?
// The list can be empty because several calls of processQueuedEvents may be processed
// in one pass in the processingQueue
if (self->eventsToProcessSnapshot.count)
{
// Make a quick copy of changing data to avoid to lock it too long time
@synchronized(self->bubbles)
{
self->bubblesSnapshot = [self->bubbles mutableCopy];
}
NSMutableSet<id<MXKRoomBubbleCellDataStoring>> *collapsingCellDataSeriess = [NSMutableSet set];
for (MXKQueuedEvent *queuedEvent in self->eventsToProcessSnapshot)
{
@synchronized (self->eventIdToBubbleMap)
{
// Check whether the event processed before
if (self->eventIdToBubbleMap[queuedEvent.event.eventId])
{
MXLogVerbose(@"[MXKRoomDataSource][%p] processQueuedEvents: Skip event: %@, state: %tu", self, queuedEvent.event.eventId, queuedEvent.event.sentState);
continue;
}
}
@autoreleasepool
{
// Count events received while the server sync was in progress
if (queuedEvent.serverSyncEvent)
{
serverSyncEventCount ++;
}
// Check whether the event must be highlighted
[self checkBing:queuedEvent.event];
// Retrieve the MXKCellData class to manage the data
Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier];
NSAssert([class conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)], @"MXKRoomDataSource only manages MXKCellData that conforms to MXKRoomBubbleCellDataStoring protocol");
BOOL eventManaged = NO;
BOOL updatedBubbleDataHadNoDisplay = NO;
id<MXKRoomBubbleCellDataStoring> bubbleData;
if ([class instancesRespondToSelector:@selector(addEvent:andRoomState:)] && 0 < self->bubblesSnapshot.count)
{
// Try to concatenate the event to the last or the oldest bubble?
if (queuedEvent.direction == MXTimelineDirectionBackwards)
{
bubbleData = self->bubblesSnapshot.firstObject;
}
else
{
bubbleData = self->bubblesSnapshot.lastObject;
}
@synchronized (bubbleData)
{
updatedBubbleDataHadNoDisplay = bubbleData.hasNoDisplay;
eventManaged = [bubbleData addEvent:queuedEvent.event andRoomState:queuedEvent.state];
}
}
if (NO == eventManaged)
{
// The event has not been concatenated to an existing cell, create a new bubble for this event
bubbleData = [[class alloc] initWithEvent:queuedEvent.event andRoomState:queuedEvent.state andRoomDataSource:self];
if (!bubbleData)
{
// The event is ignored
continue;
}
// Check cells collapsing
if (bubbleData.hasAttributedTextMessage)
{
if (bubbleData.collapsable)
{
if (queuedEvent.direction == MXTimelineDirectionBackwards)
{
// Try to collapse it with the series at the start of self.bubbles
if (self->collapsableSeriesAtStart && [self->collapsableSeriesAtStart collapseWith:bubbleData])
{
// bubbleData becomes the oldest cell data of the current series
self->collapsableSeriesAtStart.prevCollapsableCellData = bubbleData;
bubbleData.nextCollapsableCellData = self->collapsableSeriesAtStart;
// The new cell must have the collapsed state as the series
bubbleData.collapsed = self->collapsableSeriesAtStart.collapsed;
// Release data of the previous header
self->collapsableSeriesAtStart.collapseState = nil;
self->collapsableSeriesAtStart.collapsedAttributedTextMessage = nil;
[collapsingCellDataSeriess removeObject:self->collapsableSeriesAtStart];
// And keep a ref of data for the new start of the series
self->collapsableSeriesAtStart = bubbleData;
self->collapsableSeriesAtStart.collapseState = queuedEvent.state;
[collapsingCellDataSeriess addObject:self->collapsableSeriesAtStart];
}
else
{
// This is a ending point for a new collapsable series of cells
self->collapsableSeriesAtStart = bubbleData;
self->collapsableSeriesAtStart.collapseState = queuedEvent.state;
[collapsingCellDataSeriess addObject:self->collapsableSeriesAtStart];
}
}
else
{
// Try to collapse it with the series at the end of self.bubbles
if (self->collapsableSeriesAtEnd && [self->collapsableSeriesAtEnd collapseWith:bubbleData])
{
// Put bubbleData at the series tail
// Find the tail
id<MXKRoomBubbleCellDataStoring> tailBubbleData = self->collapsableSeriesAtEnd;
while (tailBubbleData.nextCollapsableCellData)
{
tailBubbleData = tailBubbleData.nextCollapsableCellData;
}
tailBubbleData.nextCollapsableCellData = bubbleData;
bubbleData.prevCollapsableCellData = tailBubbleData;
// The new cell must have the collapsed state as the series
bubbleData.collapsed = tailBubbleData.collapsed;
// If the start of the collapsible series stems from an event in a different processing
// batch, we need to track it here so that we can update the summary string later
if (![collapsingCellDataSeriess containsObject:self->collapsableSeriesAtEnd]) {
[collapsingCellDataSeriess addObject:self->collapsableSeriesAtEnd];
}
}
else
{
// This is a starting point for a new collapsable series of cells
self->collapsableSeriesAtEnd = bubbleData;
self->collapsableSeriesAtEnd.collapseState = queuedEvent.state;
[collapsingCellDataSeriess addObject:self->collapsableSeriesAtEnd];
}
}
}
else
{
// The new bubble is not collapsable.
// We can close one border of the current series being built (if any)
if (queuedEvent.direction == MXTimelineDirectionBackwards && self->collapsableSeriesAtStart)
{
// This is the begin border of the series
self->collapsableSeriesAtStart = nil;
}
else if (queuedEvent.direction == MXTimelineDirectionForwards && self->collapsableSeriesAtEnd)
{
// This is the end border of the series
self->collapsableSeriesAtEnd = nil;
}
}
}
if (queuedEvent.direction == MXTimelineDirectionBackwards)
{
// The new bubble data will be inserted at first position.
// We have to update the 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags of the current first bubble.
// Pagination handling
if ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && bubbleData.date)
{
// A new pagination starts with this new bubble data
bubbleData.isPaginationFirstBubble = YES;
// Check whether the current first displayed pagination title is still relevant.
if (self->bubblesSnapshot.count)
{
NSInteger index = 0;
id<MXKRoomBubbleCellDataStoring> previousFirstBubbleDataWithDate;
NSString *firstBubbleDateString;
while (index < self->bubblesSnapshot.count)
{
previousFirstBubbleDataWithDate = self->bubblesSnapshot[index++];
firstBubbleDateString = [self.eventFormatter dateStringFromDate:previousFirstBubbleDataWithDate.date withTime:NO];
if (firstBubbleDateString)
{
break;
}
}
if (firstBubbleDateString)
{
NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO];
previousFirstBubbleDataWithDate.isPaginationFirstBubble = (bubbleDateString && ![firstBubbleDateString isEqualToString:bubbleDateString]);
}
}
}
else
{
bubbleData.isPaginationFirstBubble = NO;
}
// Sender information are required for this new first bubble data,
// except if the bubble has no display (composed only by ignored events).
bubbleData.shouldHideSenderInformation = bubbleData.hasNoDisplay;
// Check whether this information is relevant for the current first bubble.
if (!bubbleData.shouldHideSenderInformation && self->bubblesSnapshot.count)
{
id<MXKRoomBubbleCellDataStoring> previousFirstBubbleData = self->bubblesSnapshot.firstObject;
if (previousFirstBubbleData.isPaginationFirstBubble == NO)
{
// Check whether the current first bubble has been sent by the same user.
previousFirstBubbleData.shouldHideSenderInformation |= [previousFirstBubbleData hasSameSenderAsBubbleCellData:bubbleData];
}
}
// Insert the new bubble data in first position
[self->bubblesSnapshot insertObject:bubbleData atIndex:0];
addedHistoryCellCount++;
}
else
{
// The new bubble data will be added at the last position
// We have to update its 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags according to the previous last bubble.
// Pagination handling
if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay)
{
// Check whether a new pagination starts at this bubble
NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO];
// Look for the current last bubble with date
NSInteger index = self->bubblesSnapshot.count;
NSString *lastBubbleDateString;
while (index--)
{
id<MXKRoomBubbleCellDataStoring> previousLastBubbleData = self->bubblesSnapshot[index];
lastBubbleDateString = [self.eventFormatter dateStringFromDate:previousLastBubbleData.date withTime:NO];
if (lastBubbleDateString)
{
break;
}
}
if (lastBubbleDateString)
{
bubbleData.isPaginationFirstBubble = (bubbleDateString && ![bubbleDateString isEqualToString:lastBubbleDateString]);
}
else
{
bubbleData.isPaginationFirstBubble = (bubbleDateString != nil);
}
}
else
{
bubbleData.isPaginationFirstBubble = NO;
}
// Check whether the sender information is relevant for this new bubble.
bubbleData.shouldHideSenderInformation = bubbleData.hasNoDisplay;
if (!bubbleData.shouldHideSenderInformation && self->bubblesSnapshot.count && (bubbleData.isPaginationFirstBubble == NO))
{
// Check whether the previous bubble has been sent by the same user.
id<MXKRoomBubbleCellDataStoring> previousLastBubbleData = self->bubblesSnapshot.lastObject;
bubbleData.shouldHideSenderInformation = [bubbleData hasSameSenderAsBubbleCellData:previousLastBubbleData];
}
// Insert the new bubble in last position
[self->bubblesSnapshot addObject:bubbleData];
addedLiveCellCount++;
}
}
else if (updatedBubbleDataHadNoDisplay && !bubbleData.hasNoDisplay)
{
// Here the event has been added in an existing bubble data which had no display,
// and the added event provides a display to this bubble data.
if (queuedEvent.direction == MXTimelineDirectionBackwards)
{
// The bubble is the first one.
// Pagination handling
if ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && bubbleData.date)
{
// A new pagination starts with this bubble data
bubbleData.isPaginationFirstBubble = YES;
// Look for the first next bubble with date to check whether its pagination title is still relevant.
if (self->bubblesSnapshot.count)
{
NSInteger index = 1;
id<MXKRoomBubbleCellDataStoring> nextBubbleDataWithDate;
NSString *firstNextBubbleDateString;
while (index < self->bubblesSnapshot.count)
{
nextBubbleDataWithDate = self->bubblesSnapshot[index++];
firstNextBubbleDateString = [self.eventFormatter dateStringFromDate:nextBubbleDataWithDate.date withTime:NO];
if (firstNextBubbleDateString)
{
break;
}
}
if (firstNextBubbleDateString)
{
NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO];
nextBubbleDataWithDate.isPaginationFirstBubble = (bubbleDateString && ![firstNextBubbleDateString isEqualToString:bubbleDateString]);
}
}
}
else
{
bubbleData.isPaginationFirstBubble = NO;
}
// Sender information are required for this new first bubble data
bubbleData.shouldHideSenderInformation = NO;
// Check whether this information is still relevant for the next bubble.
if (self->bubblesSnapshot.count > 1)
{
id<MXKRoomBubbleCellDataStoring> nextBubbleData = self->bubblesSnapshot[1];
if (nextBubbleData.isPaginationFirstBubble == NO)
{
// Check whether the current first bubble has been sent by the same user.
nextBubbleData.shouldHideSenderInformation |= [nextBubbleData hasSameSenderAsBubbleCellData:bubbleData];
}
}
}
else
{
// The bubble data is the last one
// Pagination handling
if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay)
{
// Check whether a new pagination starts at this bubble
NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO];
// Look for the first previous bubble with date
NSInteger index = self->bubblesSnapshot.count - 1;
NSString *firstPreviousBubbleDateString;
while (index--)
{
id<MXKRoomBubbleCellDataStoring> previousBubbleData = self->bubblesSnapshot[index];
firstPreviousBubbleDateString = [self.eventFormatter dateStringFromDate:previousBubbleData.date withTime:NO];
if (firstPreviousBubbleDateString)
{
break;
}
}
if (firstPreviousBubbleDateString)
{
bubbleData.isPaginationFirstBubble = (bubbleDateString && ![bubbleDateString isEqualToString:firstPreviousBubbleDateString]);
}
else
{
bubbleData.isPaginationFirstBubble = (bubbleDateString != nil);
}
}
else
{
bubbleData.isPaginationFirstBubble = NO;
}
// Check whether the sender information is relevant for this new bubble.
bubbleData.shouldHideSenderInformation = NO;
if (self->bubblesSnapshot.count && (bubbleData.isPaginationFirstBubble == NO))
{
// Check whether the previous bubble has been sent by the same user.
NSInteger index = self->bubblesSnapshot.count - 1;
if (index--)
{
id<MXKRoomBubbleCellDataStoring> previousBubbleData = self->bubblesSnapshot[index];
bubbleData.shouldHideSenderInformation = [bubbleData hasSameSenderAsBubbleCellData:previousBubbleData];
}
}
}
}
[self updateCellDataReactions:bubbleData forEventId:queuedEvent.event.eventId];
// Store event-bubble link to the map
@synchronized (self->eventIdToBubbleMap)
{
self->eventIdToBubbleMap[queuedEvent.event.eventId] = bubbleData;
}
if (queuedEvent.event.isLocalEvent)
{
// Listen to the identifier change for the local events.
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localEventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:queuedEvent.event];
}
}
}
for (MXKQueuedEvent *queuedEvent in self->eventsToProcessSnapshot)
{
@autoreleasepool
{
dispatch_group_enter(dispatchGroup);
[self addReadReceiptsForEvent:queuedEvent.event.eventId inCellDatas:self->bubblesSnapshot startingAtCellData:self->eventIdToBubbleMap[queuedEvent.event.eventId] completion:^{
dispatch_group_leave(dispatchGroup);
}];
}
}
// Check if all cells of self.bubbles belongs to a single collapse series.
// In this case, collapsableSeriesAtStart and collapsableSeriesAtEnd must be equal
// in order to handle next forward or backward pagination.
if (self->collapsableSeriesAtStart && self->collapsableSeriesAtStart == self->bubbles.firstObject)
{
// Find the tail
id<MXKRoomBubbleCellDataStoring> tailBubbleData = self->collapsableSeriesAtStart;
while (tailBubbleData.nextCollapsableCellData)
{
tailBubbleData = tailBubbleData.nextCollapsableCellData;
}
if (tailBubbleData == self->bubbles.lastObject)
{
self->collapsableSeriesAtEnd = self->collapsableSeriesAtStart;
}
}
else if (self->collapsableSeriesAtEnd)
{
// Find the start
id<MXKRoomBubbleCellDataStoring> startBubbleData = self->collapsableSeriesAtEnd;
while (startBubbleData.prevCollapsableCellData)
{
startBubbleData = startBubbleData.prevCollapsableCellData;
}
if (startBubbleData == self->bubbles.firstObject)
{
self->collapsableSeriesAtStart = self->collapsableSeriesAtEnd;
}
}
// Compose (= compute collapsedAttributedTextMessage) of collapsable seriess
for (id<MXKRoomBubbleCellDataStoring> bubbleData in collapsingCellDataSeriess)
{
// Get all events of the series
NSMutableArray<MXEvent*> *events = [NSMutableArray array];
id<MXKRoomBubbleCellDataStoring> nextBubbleData = bubbleData;
do
{
[events addObjectsFromArray:nextBubbleData.events];
}
while ((nextBubbleData = nextBubbleData.nextCollapsableCellData));
// Build the summary string for the series
bubbleData.collapsedAttributedTextMessage = [self.eventFormatter attributedStringFromEvents:events withRoomState:bubbleData.collapseState error:nil];
// Release collapseState objects, even the one of collapsableSeriesAtStart.
// We do not need to keep its state because if an collapsable event comes before collapsableSeriesAtStart,
// we will take the room state of this event.
if (bubbleData != self->collapsableSeriesAtEnd)
{
bubbleData.collapseState = nil;
}
}
}
self->eventsToProcessSnapshot = nil;
}
// Check whether some events have been processed
if (self->bubblesSnapshot)
{
// Updated data can be displayed now
// Block MXKRoomDataSource.processingQueue while the processing is finalised on the main thread
dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER);
dispatch_sync(dispatch_get_main_queue(), ^{
// Check whether self has not been reloaded or destroyed
if (self.state == MXKDataSourceStateReady && self->bubblesSnapshot)
{
if (self.serverSyncEventCount)
{
self->_serverSyncEventCount -= serverSyncEventCount;
if (!self.serverSyncEventCount)
{
// Notify that sync process ends
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceSyncStatusChanged object:self userInfo:nil];
}
}
if (self.secondaryRoom) {
[self->bubblesSnapshot sortWithOptions:NSSortStable
usingComparator:^NSComparisonResult(MXKRoomBubbleCellData * _Nonnull bubbleData1, MXKRoomBubbleCellData * _Nonnull bubbleData2) {
if (bubbleData1.date)
{
if (bubbleData2.date)
{
return [bubbleData1.date compare:bubbleData2.date];
}
else
{
return NSOrderedDescending;
}
}
else
{
if (bubbleData2.date)
{
return NSOrderedAscending;
}
else
{
return NSOrderedSame;
}
}
}];
}
self->bubbles = self->bubblesSnapshot;
self->bubblesSnapshot = nil;
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
else
{
// Check the memory usage of the data source. Reload it if the cache is too huge.
[self limitMemoryUsage:self.maxBackgroundCachedBubblesCount];
}
}
// Inform about the end if requested
if (onComplete)
{
onComplete(addedHistoryCellCount, addedLiveCellCount);
}
});
}
else
{
// No new event has been added, we just inform about the end if requested.
if (onComplete)
{
dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{
onComplete(0, 0);
});
}
}
});
}
/**
Add the read receipts of an event into the timeline (which is in array of cell datas)
If the event is not displayed, read receipts will be added to a previous displayed message.
@param eventId the id of the event.
@param cellDatas the working array of cell datas.
@param cellData the original cell data the event belongs to.
*/
- (void)addReadReceiptsForEvent:(NSString*)eventId inCellDatas:(NSArray<id<MXKRoomBubbleCellDataStoring>>*)cellDatas startingAtCellData:(id<MXKRoomBubbleCellDataStoring>)cellData completion:(void (^)(void))completion
{
if (self.showBubbleReceipts)
{
if (self.room)
{
[self.room getEventReceipts:eventId sorted:YES completion:^(NSArray<MXReceiptData *> * _Nonnull readReceipts) {
if (readReceipts.count)
{
NSInteger cellDataIndex = [cellDatas indexOfObject:cellData];
if (cellDataIndex != NSNotFound)
{
[self addReadReceipts:readReceipts forEvent:eventId inCellDatas:cellDatas atCellDataIndex:cellDataIndex];
}
}
if (completion)
{
completion();
}
}];
}
else if (completion)
{
dispatch_async(dispatch_get_main_queue(), ^{
completion();
});
}
}
else if (completion)
{
dispatch_async(dispatch_get_main_queue(), ^{
completion();
});
}
}
- (void)addReadReceipts:(NSArray<MXReceiptData*> *)readReceipts forEvent:(NSString*)eventId inCellDatas:(NSArray<id<MXKRoomBubbleCellDataStoring>>*)cellDatas atCellDataIndex:(NSInteger)cellDataIndex
{
id<MXKRoomBubbleCellDataStoring> cellData = cellDatas[cellDataIndex];
if ([cellData isKindOfClass:MXKRoomBubbleCellData.class])
{
MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)cellData;
BOOL areReadReceiptsAssigned = NO;
for (MXKRoomBubbleComponent *component in roomBubbleCellData.bubbleComponents.reverseObjectEnumerator)
{
if (component.attributedTextMessage)
{
if (roomBubbleCellData.readReceipts[component.event.eventId])
{
NSArray<MXReceiptData*> *currentReadReceipts = roomBubbleCellData.readReceipts[component.event.eventId];
NSMutableArray<MXReceiptData*> *newReadReceipts = [NSMutableArray arrayWithArray:currentReadReceipts];
for (MXReceiptData *readReceipt in readReceipts)
{
BOOL alreadyHere = NO;
for (MXReceiptData *currentReadReceipt in currentReadReceipts)
{
if ([readReceipt.userId isEqualToString:currentReadReceipt.userId])
{
alreadyHere = YES;
break;
}
}
if (!alreadyHere)
{
[newReadReceipts addObject:readReceipt];
}
}
[self updateCellData:roomBubbleCellData withReadReceipts:newReadReceipts forEventId:component.event.eventId];
}
else
{
[self updateCellData:roomBubbleCellData withReadReceipts:readReceipts forEventId:component.event.eventId];
}
areReadReceiptsAssigned = YES;
break;
}
MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Read receipts for an event(%@) that is not displayed", self, eventId);
}
if (!areReadReceiptsAssigned)
{
MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Try to attach read receipts to an older message: %@", self, eventId);
// Try to assign RRs to a previous cell data
if (cellDataIndex >= 1)
{
[self addReadReceipts:readReceipts forEvent:eventId inCellDatas:cellDatas atCellDataIndex:cellDataIndex - 1];
}
else
{
MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Fail to attach read receipts for an event(%@)", self, eventId);
}
}
}
}
#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
// PATCH: Presently no bubble must be displayed until the user joins the room.
// FIXME: Handle room data source in case of room preview
if (self.room.summary.membership == MXMembershipInvite)
{
return 0;
}
NSInteger count;
@synchronized(bubbles)
{
count = bubbles.count;
}
return count;
}
- (void)scanBubbleDataIfNeeded:(id<MXKRoomBubbleCellDataStoring>)bubbleData
{
MXScanManager *scanManager = self.mxSession.scanManager;
if (!scanManager && ![bubbleData isKindOfClass:MXKRoomBubbleCellData.class])
{
return;
}
MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)bubbleData;
NSString *contentURL = roomBubbleCellData.attachment.contentURL;
// If the content url corresponds to an upload id, the upload is in progress or not complete.
// Create a fake event scan with in progress status when uploading media.
// Since there is no event scan in database it will be overriden by MXScanManager on media upload complete.
if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix])
{
MXKRoomBubbleComponent *firstBubbleComponent = roomBubbleCellData.bubbleComponents.firstObject;
MXEvent *firstBubbleComponentEvent = firstBubbleComponent.event;
if (firstBubbleComponent && firstBubbleComponent.eventScan.antivirusScanStatus != MXAntivirusScanStatusInProgress && firstBubbleComponentEvent)
{
MXEventScan *uploadEventScan = [MXEventScan new];
uploadEventScan.eventId = firstBubbleComponentEvent.eventId;
uploadEventScan.antivirusScanStatus = MXAntivirusScanStatusInProgress;
uploadEventScan.antivirusScanDate = nil;
uploadEventScan.mediaScans = @[];
firstBubbleComponent.eventScan = uploadEventScan;
}
}
else
{
for (MXKRoomBubbleComponent *bubbleComponent in roomBubbleCellData.bubbleComponents)
{
MXEvent *event = bubbleComponent.event;
if ([event isContentScannable])
{
[scanManager scanEventIfNeeded:event];
// NOTE: - [MXScanManager scanEventIfNeeded:] perform modification in background, so - [MXScanManager eventScanWithId:] do not retrieve the last state of event scan.
// It is noticeable when eventScan should be created for the first time. It would be better to return an eventScan with an in progress scan status instead of nil.
MXEventScan *eventScan = [scanManager eventScanWithId:event.eventId];
bubbleComponent.eventScan = eventScan;
}
}
}
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell<MXKCellRendering> *cell;
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataAtIndex:indexPath.row];
// Launch an antivirus scan on events contained in bubble data if needed
[self scanBubbleDataIfNeeded:bubbleData];
if (bubbleData && self.delegate)
{
// Retrieve the cell identifier according to cell data.
NSString *identifier = [self.delegate cellReuseIdentifierForCellData:bubbleData];
if (identifier)
{
cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath];
// Make sure we listen to user actions on the cell
cell.delegate = self;
// Update typing flag before rendering
bubbleData.isTyping = _showTypingNotifications && currentTypingUsers && ([currentTypingUsers indexOfObject:bubbleData.senderId] != NSNotFound);
// Report the current timestamp display option
bubbleData.showBubbleDateTime = self.showBubblesDateTime;
// display the read receipts
bubbleData.showBubbleReceipts = self.showBubbleReceipts;
// let the caller application manages the time label?
bubbleData.useCustomDateTimeLabel = self.useCustomDateTimeLabel;
// let the caller application manages the receipt?
bubbleData.useCustomReceipts = self.useCustomReceipts;
// let the caller application manages the unsent button?
bubbleData.useCustomUnsentButton = self.useCustomUnsentButton;
// Make the bubble display the data
[cell render:bubbleData];
}
}
// Sanity check: this method may be called during a layout refresh while room data have been modified.
if (!cell)
{
// Return an empty cell
return [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"fakeCell"];
}
return cell;
}
#pragma mark - Groups
- (MXGroup *)groupWithGroupId:(NSString*)groupId
{
MXGroup *group = [self.mxSession groupWithGroupId:groupId];
if (!group)
{
// Check whether an instance has been already created.
group = [externalRelatedGroups objectForKey:groupId];
}
if (!group)
{
// Create a new group instance.
group = [[MXGroup alloc] initWithGroupId:groupId];
[externalRelatedGroups setObject:group forKey:groupId];
// Retrieve at least the group profile
[self.mxSession updateGroupProfile:group success:nil failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomDataSource][%p] groupWithGroupId: group profile update failed %@", self, groupId);
}];
}
return group;
}
#pragma mark - MXScanManager notifications
- (void)registerScanManagerNotifications
{
[[NSNotificationCenter defaultCenter] removeObserver:self name:MXScanManagerEventScanDidChangeNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventScansDidChange:) name:MXScanManagerEventScanDidChangeNotification object:nil];
}
- (void)unregisterScanManagerNotifications
{
[[NSNotificationCenter defaultCenter] removeObserver:self name:MXScanManagerEventScanDidChangeNotification object:nil];
}
- (void)eventScansDidChange:(NSNotification*)notification
{
// TODO: Avoid to call the delegate to often. Set a minimum time interval to avoid table view flickering.
[self.delegate dataSource:self didCellChange:nil];
}
#pragma mark - Reactions
- (void)registerReactionsChangeListener
{
if (!self.showReactions || reactionsChangeListener)
{
return;
}
MXWeakify(self);
reactionsChangeListener = [self.mxSession.aggregations listenToReactionCountUpdateInRoom:self.roomId block:^(NSDictionary<NSString *,MXReactionCountChange *> * _Nonnull changes) {
MXStrongifyAndReturnIfNil(self);
BOOL updated = NO;
for (NSString *eventId in changes)
{
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:eventId];
if (bubbleData)
{
// TODO: Be smarted and use changes[eventId]
[self updateCellDataReactions:bubbleData forEventId:eventId];
updated = YES;
}
}
if (updated)
{
[self.delegate dataSource:self didCellChange:nil];
}
}];
}
- (void)unregisterReactionsChangeListener
{
if (reactionsChangeListener)
{
[self.mxSession.aggregations removeListener:reactionsChangeListener];
reactionsChangeListener = nil;
}
}
- (void)updateCellDataReactions:(id<MXKRoomBubbleCellDataStoring>)cellData forEventId:(NSString*)eventId
{
if (!self.showReactions || ![cellData isKindOfClass:MXKRoomBubbleCellData.class])
{
return;
}
MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)cellData;
MXAggregatedReactions *aggregatedReactions = [self.mxSession.aggregations aggregatedReactionsOnEvent:eventId inRoom:self.roomId].aggregatedReactionsWithNonZeroCount;
if (self.showOnlySingleEmojiReactions)
{
aggregatedReactions = aggregatedReactions.aggregatedReactionsWithSingleEmoji;
}
if (aggregatedReactions)
{
if (!roomBubbleCellData.reactions)
{
roomBubbleCellData.reactions = [NSMutableDictionary dictionary];
}
roomBubbleCellData.reactions[eventId] = aggregatedReactions;
}
else
{
// unreaction
roomBubbleCellData.reactions[eventId] = nil;
}
// Indicate that the text message layout should be recomputed.
[roomBubbleCellData invalidateTextLayout];
}
- (BOOL)canReactToEventWithId:(NSString*)eventId
{
BOOL canReact = NO;
MXEvent *event = [self eventWithEventId:eventId];
if ([self canPerformActionOnEvent:event])
{
NSString *messageType = event.content[kMXMessageTypeKey];
if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest])
{
canReact = NO;
}
else
{
canReact = YES;
}
}
return canReact;
}
- (void)addReaction:(NSString *)reaction forEventId:(NSString *)eventId success:(void (^)(void))success failure:(void (^)(NSError *))failure
{
[self.mxSession.aggregations addReaction:reaction forEvent:eventId inRoom:self.roomId success:success failure:^(NSError * _Nonnull error) {
MXLogDebug(@"[MXKRoomDataSource][%p] Fail to send reaction on eventId: %@", self, eventId);
if (failure)
{
failure(error);
}
}];
}
- (void)removeReaction:(NSString *)reaction forEventId:(NSString *)eventId success:(void (^)(void))success failure:(void (^)(NSError *))failure
{
[self.mxSession.aggregations removeReaction:reaction forEvent:eventId inRoom:self.roomId success:success failure:^(NSError * _Nonnull error) {
MXLogDebug(@"[MXKRoomDataSource][%p] Fail to unreact on eventId: %@", self, eventId);
if (failure)
{
failure(error);
}
}];
}
#pragma mark - Editions
- (BOOL)canEditEventWithId:(NSString*)eventId
{
MXEvent *event = [self eventWithEventId:eventId];
BOOL isRoomMessage = event.eventType == MXEventTypeRoomMessage;
NSString *messageType = event.content[kMXMessageTypeKey];
return isRoomMessage
&& ([messageType isEqualToString:kMXMessageTypeText] || [messageType isEqualToString:kMXMessageTypeEmote])
&& [event.sender isEqualToString:self.mxSession.myUserId]
&& [event.roomId isEqualToString:self.roomId];
}
- (NSString*)editableTextMessageForEvent:(MXEvent*)event
{
NSString *editableTextMessage;
if (event.isReplyEvent)
{
MXReplyEventParser *replyEventParser = [MXReplyEventParser new];
MXReplyEventParts *replyEventParts = [replyEventParser parse:event];
editableTextMessage = replyEventParts.bodyParts.replyText;
}
else
{
editableTextMessage = event.content[kMXMessageBodyKey];
}
return editableTextMessage;
}
- (void)registerEventEditsListener
{
if (eventEditsListener)
{
return;
}
MXWeakify(self);
eventEditsListener = [self.mxSession.aggregations listenToEditsUpdateInRoom:self.roomId block:^(MXEvent * _Nonnull replaceEvent) {
MXStrongifyAndReturnIfNil(self);
[self updateEventWithReplaceEvent:replaceEvent];
}];
}
- (void)updateEventWithReplaceEvent:(MXEvent*)replaceEvent
{
NSString *editedEventId = replaceEvent.relatesTo.eventId;
dispatch_async(MXKRoomDataSource.processingQueue, ^{
// Check whether a message contains the edited event
id<MXKRoomBubbleCellDataStoring> bubbleData = [self cellDataOfEventWithEventId:editedEventId];
if (bubbleData)
{
BOOL hasChanged = [self updateCellData:bubbleData forEditionWithReplaceEvent:replaceEvent andEventId:editedEventId];
if (hasChanged)
{
// Update the delegate on main thread
dispatch_async(dispatch_get_main_queue(), ^{
if (self.delegate)
{
[self.delegate dataSource:self didCellChange:nil];
}
});
}
}
});
}
- (void)unregisterEventEditsListener
{
if (eventEditsListener)
{
[self.mxSession.aggregations removeListener:eventEditsListener];
eventEditsListener = nil;
}
}
- (BOOL)updateCellData:(id<MXKRoomBubbleCellDataStoring>)bubbleCellData forEditionWithReplaceEvent:(MXEvent*)replaceEvent andEventId:(NSString*)eventId
{
BOOL hasChanged = NO;
@synchronized (bubbleCellData)
{
// Retrieve the original event to edit it
NSArray *events = bubbleCellData.events;
MXEvent *editedEvent = nil;
// If not already done, update edited event content in-place
// This is required for:
// - local echo
// - non live timeline in memory store (permalink)
for (MXEvent *event in events)
{
if ([event.eventId isEqualToString:eventId])
{
// Check whether the event was not already edited
if (![event.unsignedData.relations.replace.eventId isEqualToString:replaceEvent.eventId])
{
editedEvent = [event editedEventFromReplacementEvent:replaceEvent];
}
break;
}
}
if (editedEvent)
{
if (editedEvent.sentState != replaceEvent.sentState)
{
// Relay the replace event state to the edited event so that the display
// of the edited will rerun the classic sending color flow.
// Note: this must be done on the main thread (this operation triggers
// the call of [self eventDidChangeSentState])
dispatch_async(dispatch_get_main_queue(), ^{
editedEvent.sentState = replaceEvent.sentState;
});
}
[bubbleCellData updateEvent:eventId withEvent:editedEvent];
[bubbleCellData invalidateTextLayout];
hasChanged = YES;
}
}
return hasChanged;
}
- (void)replaceTextMessageForEventWithId:(NSString*)eventId
withTextMessage:(NSString *)text
success:(void (^)(NSString *))success
failure:(void (^)(NSError *))failure
{
MXEvent *event = [self eventWithEventId:eventId];
NSString *sanitizedText = [self sanitizedMessageText:text];
NSString *formattedText = [self htmlMessageFromSanitizedText:sanitizedText];
NSString *eventBody = event.content[kMXMessageBodyKey];
NSString *eventFormattedBody = event.content[@"formatted_body"];
if (![sanitizedText isEqualToString:eventBody] && (!eventFormattedBody || ![formattedText isEqualToString:eventFormattedBody]))
{
[self.mxSession.aggregations replaceTextMessageEvent:event withTextMessage:sanitizedText formattedText:formattedText localEchoBlock:^(MXEvent * _Nonnull replaceEventLocalEcho) {
// Apply the local echo to the timeline
[self updateEventWithReplaceEvent:replaceEventLocalEcho];
// Integrate the replace local event into the timeline like when sending a message
// This also allows to manage read receipt on this replace event
[self queueEventForProcessing:replaceEventLocalEcho withRoomState:self.roomState direction:MXTimelineDirectionForwards];
[self processQueuedEvents:nil];
} success:success failure:failure];
}
else
{
failure(nil);
}
}
#pragma mark - Virtual Rooms
- (void)virtualRoomsDidChange:(NSNotification *)notification
{
// update secondary room id
self.secondaryRoomId = [self.mxSession virtualRoomOf:self.roomId];
}
@end