element-ios/Riot/Modules/Room/MXKRoomViewController.m

4174 lines
180 KiB
Objective-C

/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C
Copyright 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2015 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
#define MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC 10
#define MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT 50
#import "MXKRoomViewController.h"
#import <MediaPlayer/MediaPlayer.h>
#import "MXKRoomBubbleTableViewCell.h"
#import "MXKSearchTableViewCell.h"
#import "MXKImageView.h"
#import "MXKRoomDataSourceManager.h"
#import "MXKRoomInputToolbarViewWithSimpleTextView.h"
#import "MXKConstants.h"
#import "MXKRoomBubbleCellData.h"
#import "MXKEncryptionKeysImportView.h"
#import "NSBundle+MatrixKit.h"
#import "MXKSwiftHeader.h"
#import "MXKPreviewViewController.h"
// Constant used to determine whether an event is visible at the bottom of the tableview, based on its visible height
static const CGFloat kCellVisibilityMinimumHeight = 8.0;
@interface MXKRoomViewController () <MXKPreviewViewControllerDelegate>
{
/**
YES once the view has appeared
*/
BOOL hasAppearedOnce;
/**
YES if scrolling to bottom is in progress
*/
BOOL isScrollingToBottom;
/**
Date of the last observed typing
*/
NSDate *lastTypingDate;
/**
Local typing timout
*/
NSTimer *typingTimer;
/**
YES when pagination is in progress.
*/
BOOL isPaginationInProgress;
/**
The back pagination spinner view.
*/
UIView* backPaginationActivityView;
/**
Store the height of the first bubble before back pagination.
*/
CGFloat backPaginationSavedFirstBubbleHeight;
/**
Potential request in progress to join the selected room
*/
MXHTTPOperation *joinRoomRequest;
/**
Text selection
*/
NSString *selectedText;
/**
The class used to instantiate attachments viewer for image and video..
*/
Class attachmentsViewerClass;
/**
The class used to display event details.
*/
Class customEventDetailsViewClass;
/**
The reconnection animated view.
*/
UIView* reconnectingView;
/**
The view to import e2e keys.
*/
MXKEncryptionKeysImportView *importView;
/**
The latest server sync date
*/
NSDate* latestServerSync;
/**
The restart the event connnection
*/
BOOL restartConnection;
}
/**
The eventId of the Attachment that was used to open the Attachments ViewController
*/
@property (nonatomic) NSString *openedAttachmentEventId;
/**
The eventId of the Attachment from which the Attachments ViewController was closed
*/
@property (nonatomic) NSString *closedAttachmentEventId;
@property (nonatomic) UIImageView *openedAttachmentImageView;
/**
Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room.
*/
@property (nonatomic, weak) id mxSessionWillLeaveRoomNotificationObserver;
/**
Observe UIApplicationDidBecomeActiveNotification to refresh bubbles when app leaves the background state.
*/
@property (nonatomic, weak) id uiApplicationDidBecomeActiveNotificationObserver;
/**
Observe UIMenuControllerDidHideMenuNotification to cancel text selection
*/
@property (nonatomic, weak) id uiMenuControllerDidHideMenuNotificationObserver;
/**
The attachments viewer for image and video.
*/
@property (nonatomic, weak) MXKAttachmentsViewController *attachmentsViewer;
@end
@implementation MXKRoomViewController
@synthesize roomDataSource, titleView, inputToolbarView, activitiesView;
#pragma mark - Class methods
+ (UINib *)nib
{
return [UINib nibWithNibName:NSStringFromClass([MXKRoomViewController class])
bundle:[NSBundle bundleForClass:[MXKRoomViewController class]]];
}
+ (instancetype)roomViewController
{
return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomViewController class])
bundle:[NSBundle bundleForClass:[MXKRoomViewController class]]];
}
#pragma mark -
- (void)finalizeInit
{
[super finalizeInit];
// Scroll to bottom the bubble history at first display
shouldScrollToBottomOnTableRefresh = YES;
// Default pagination settings
_paginationThreshold = 300;
_paginationLimit = 30;
// Save progress text input by default
_saveProgressTextInput = YES;
// Enable auto join option by default
_autoJoinInvitedRoom = YES;
// Do not take ownership of room data source by default
_hasRoomDataSourceOwnership = NO;
// Turn on the automatic events acknowledgement.
_eventsAcknowledgementEnabled = YES;
// Do not update the read marker by default.
_updateRoomReadMarker = NO;
// Center the table content on the initial event top by default.
_centerBubblesTableViewContentOnTheInitialEventBottom = NO;
// Scroll to the bottom when a keyboard is presented
_scrollHistoryToTheBottomOnKeyboardPresentation = YES;
// Keep visible the status bar by default.
isStatusBarHidden = NO;
// By default actions button is shown in document preview
_allowActionsInDocumentPreview = YES;
// By default the duration of the composer resizing is 0.3s
_resizeComposerAnimationDuration = 0.3;
}
- (void)viewDidLoad
{
[super viewDidLoad];
if (BuildSettings.newAppLayoutEnabled)
{
[self vc_setLargeTitleDisplayMode: UINavigationItemLargeTitleDisplayModeNever];
}
// Check whether the view controller has been pushed via storyboard
if (!_bubblesTableView)
{
// Instantiate view controller objects
[[[self class] nib] instantiateWithOwner:self options:nil];
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated"
// Adjust bottom constraint of the input toolbar container in order to take into account potential tabBar
_roomInputToolbarContainerBottomConstraint.active = NO;
_roomInputToolbarContainerBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:self.roomInputToolbarContainer
attribute:NSLayoutAttributeBottom
multiplier:1.0f
constant:0.0f];
#pragma clang diagnostic pop
_roomInputToolbarContainerBottomConstraint.active = YES;
[self.view setNeedsUpdateConstraints];
// Hide bubbles table by default in order to hide initial scrolling to the bottom
_bubblesTableView.hidden = YES;
// Ensure that the titleView will be scaled when it will be required
// during a screen rotation for example.
_roomTitleViewContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth;
// Set default input toolbar view
[self setRoomInputToolbarViewClass:MXKRoomInputToolbarViewWithSimpleTextView.class];
// set the default extra
[self setRoomActivitiesViewClass:MXKRoomActivitiesView.class];
// Finalize table view configuration
[self configureBubblesTableView];
// Observe UIApplicationDidBecomeActiveNotification to refresh bubbles when app leaves the background state.
MXWeakify(self);
_uiApplicationDidBecomeActiveNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
MXStrongifyAndReturnIfNil(self);
if (self->roomDataSource.state == MXKDataSourceStateReady && [self->roomDataSource tableView:self->_bubblesTableView numberOfRowsInSection:0])
{
// Reload the full table
self.bubbleTableViewDisplayInTransition = YES;
[self reloadBubblesTable:YES];
self.bubbleTableViewDisplayInTransition = NO;
}
}];
if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenEnteringRoom)
{
[self shareEncryptionKeys];
}
}
- (BOOL)prefersStatusBarHidden
{
// Return the current status bar visibility.
// Caution: Enable [UIViewController prefersStatusBarHidden] use at application level
// by turning on UIViewControllerBasedStatusBarAppearance in Info.plist.
return isStatusBarHidden;
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self.navigationController setToolbarHidden:YES animated:NO];
// Observe server sync process at room data source level too
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil];
// Observe timeline failure
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onTimelineError:) name:kMXKRoomDataSourceTimelineError object:nil];
// Observe the server sync
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil];
// Be sure to display the activity indicator during back pagination
if (isPaginationInProgress)
{
[self startActivityIndicator];
}
// Finalize view controller appearance
dispatch_async(dispatch_get_main_queue(), ^{
[self updateViewControllerAppearanceOnRoomDataSourceState];
});
// no need to reload the tableview at this stage
// IOS is going to load it after calling this method
// so give a breath to scroll to the bottom if required
if (shouldScrollToBottomOnTableRefresh)
{
self.bubbleTableViewDisplayInTransition = YES;
dispatch_async(dispatch_get_main_queue(), ^{
[self scrollBubblesTableViewToBottomAnimated:NO];
// Show bubbles table after initial scrolling to the bottom
// Patch: We need to delay this operation to wait for the end of scrolling.
dispatch_after(dispatch_walltime(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
self->_bubblesTableView.hidden = NO;
self.bubbleTableViewDisplayInTransition = NO;
});
});
}
else
{
_bubblesTableView.hidden = NO;
}
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
// Remove the rounded bottom unsafe area of the iPhone X
_bubblesTableViewBottomConstraint.constant += self.view.safeAreaInsets.bottom;
if (_saveProgressTextInput && roomDataSource)
{
// Retrieve the potential message partially typed during last room display.
// Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early)
[inputToolbarView setPartialContent:roomDataSource.partialAttributedTextMessage];
}
if (!hasAppearedOnce)
{
hasAppearedOnce = YES;
}
// Mark all messages as read when the room is displayed
[self.roomDataSource.room.summary markAllAsReadLocally];
[self updateCurrentEventIdAtTableBottom:YES];
if (!self.isContextPreview)
{
[self.roomDataSource.room resetUnread];
}
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceTimelineError object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil];
[self removeReconnectingView];
}
- (void)dealloc
{
if (_mxSessionWillLeaveRoomNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:_mxSessionWillLeaveRoomNotificationObserver];
}
if (_uiApplicationDidBecomeActiveNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:_uiApplicationDidBecomeActiveNotificationObserver];
}
if (_uiMenuControllerDidHideMenuNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:_uiMenuControllerDidHideMenuNotificationObserver];
}
[self destroy];
}
- (void)didReceiveMemoryWarning
{
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator
{
isSizeTransitionInProgress = YES;
shouldScrollToBottomOnTableRefresh = [self isBubblesTableScrollViewAtTheBottom];
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(coordinator.transitionDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (!self.keyboardView)
{
[self updateMessageTextViewFrame];
}
// Force full table refresh to take into account cell width change.
self.bubbleTableViewDisplayInTransition = YES;
[self reloadBubblesTable:YES invalidateBubblesCellDataCache:YES];
self.bubbleTableViewDisplayInTransition = NO;
self->shouldScrollToBottomOnTableRefresh = NO;
self->isSizeTransitionInProgress = NO;
});
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
// The 2 following methods are deprecated since iOS 8
- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
{
isSizeTransitionInProgress = YES;
shouldScrollToBottomOnTableRefresh = [self isBubblesTableScrollViewAtTheBottom];
[super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];
}
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation
{
[super didRotateFromInterfaceOrientation:fromInterfaceOrientation];
if (!self.keyboardView)
{
[self updateMessageTextViewFrame];
}
dispatch_async(dispatch_get_main_queue(), ^{
// Force full table refresh to take into account cell width change.
self.bubbleTableViewDisplayInTransition = YES;
[self reloadBubblesTable:YES];
self.bubbleTableViewDisplayInTransition = NO;
self->shouldScrollToBottomOnTableRefresh = NO;
self->isSizeTransitionInProgress = NO;
});
}
#pragma clang diagnostic pop
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
CGFloat bubblesTableViewBottomConst = self.roomInputToolbarContainerBottomConstraint.constant + self.roomInputToolbarContainerHeightConstraint.constant + self.roomActivitiesContainerHeightConstraint.constant;
if (self.bubblesTableViewBottomConstraint.constant != bubblesTableViewBottomConst)
{
self.bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst;
}
}
#pragma mark - Override MXKViewController
- (void)onMatrixSessionChange
{
[super onMatrixSessionChange];
// Check dataSource state
if (self.roomDataSource && (self.roomDataSource.state == MXKDataSourceStatePreparing || self.roomDataSource.serverSyncEventCount))
{
// dataSource is not ready, keep running the loading wheel
[self startActivityIndicator];
}
}
- (void)onKeyboardShowAnimationComplete
{
// Check first if the first responder belongs to title view
UIView *keyboardView = titleView.inputAccessoryView.superview;
if (!keyboardView)
{
// Check whether the first responder is the input tool bar text composer
keyboardView = inputToolbarView.inputAccessoryViewForKeyboard.superview;
}
// Report the keyboard view in order to track keyboard frame changes
self.keyboardView = keyboardView;
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated"
- (void)setKeyboardHeight:(CGFloat)keyboardHeight
{
// Deduce the bottom constraint for the input toolbar view (Don't forget the potential tabBar)
CGFloat inputToolbarViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length;
// Check whether the keyboard is over the tabBar
if (inputToolbarViewBottomConst < 0)
{
inputToolbarViewBottomConst = 0;
}
// Update constraints
_roomInputToolbarContainerBottomConstraint.constant = inputToolbarViewBottomConst;
_bubblesTableViewBottomConstraint.constant = inputToolbarViewBottomConst + _roomInputToolbarContainerHeightConstraint.constant + _roomActivitiesContainerHeightConstraint.constant;
// Remove the rounded bottom unsafe area of the iPhone X
_bubblesTableViewBottomConstraint.constant += self.view.safeAreaInsets.bottom;
// Invalidate the current layout to take into account the new constraints in the next update cycle.
[self.view setNeedsLayout];
// Compute the visible area (tableview + toolbar) at the end of animation
CGFloat visibleArea = self.view.frame.size.height - _bubblesTableView.adjustedContentInset.top - keyboardHeight;
// Deduce max height of the message text input by considering the minimum height of the table view.
inputToolbarView.maxHeight = visibleArea - MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT;
// Check conditions before scrolling the tableview content when a new keyboard is presented.
if ((_scrollHistoryToTheBottomOnKeyboardPresentation || [self isBubblesTableScrollViewAtTheBottom]) && !super.keyboardHeight && keyboardHeight && !currentAlert)
{
self.bubbleTableViewDisplayInTransition = YES;
// Force here the layout update to scroll correctly the table content.
[self.view layoutIfNeeded];
[self scrollBubblesTableViewToBottomAnimated:NO];
self.bubbleTableViewDisplayInTransition = NO;
}
else
{
[self updateCurrentEventIdAtTableBottom:NO];
}
super.keyboardHeight = keyboardHeight;
}
#pragma clang diagnostic pop
- (void)destroy
{
if (documentInteractionController)
{
[documentInteractionController dismissPreviewAnimated:NO];
[documentInteractionController dismissMenuAnimated:NO];
documentInteractionController = nil;
}
if (currentSharedAttachment)
{
[currentSharedAttachment onShareEnded];
currentSharedAttachment = nil;
}
[self dismissTemporarySubViews];
_bubblesTableView.dataSource = nil;
_bubblesTableView.delegate = nil;
_bubblesTableView = nil;
if (roomDataSource.delegate == self)
{
roomDataSource.delegate = nil;
}
if (_hasRoomDataSourceOwnership)
{
// Release the room data source
[roomDataSource destroy];
}
roomDataSource = nil;
if (titleView)
{
[titleView removeFromSuperview];
[titleView destroy];
titleView = nil;
}
if (inputToolbarView)
{
[inputToolbarView removeFromSuperview];
[inputToolbarView destroy];
inputToolbarView = nil;
}
if (activitiesView)
{
[activitiesView removeFromSuperview];
[activitiesView destroy];
activitiesView = nil;
}
[typingTimer invalidate];
typingTimer = nil;
if (joinRoomRequest)
{
[joinRoomRequest cancel];
joinRoomRequest = nil;
}
[super destroy];
}
#pragma mark -
- (void)configureBubblesTableView
{
// Set up table delegates
_bubblesTableView.delegate = self;
_bubblesTableView.dataSource = roomDataSource; // Note: data source may be nil here, it will be set during [displayRoom:] call.
// Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room.
MXWeakify(self);
_mxSessionWillLeaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
MXStrongifyAndReturnIfNil(self);
// Check whether the user will leave the current room
if (notif.object == self.mainSession)
{
NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey];
if (roomId && [roomId isEqualToString:self->roomDataSource.roomId])
{
// Update view controller appearance
[self leaveRoomOnEvent:notif.userInfo[kMXSessionNotificationEventKey]];
}
}
}];
}
- (void)updateMessageTextViewFrame
{
if (!self.keyboardView)
{
// Compute the visible area (tableview + toolbar)
CGFloat visibleArea = self.view.frame.size.height - _bubblesTableView.adjustedContentInset.top;
// Deduce max height of the message text input by considering the minimum height of the table view.
inputToolbarView.maxHeight = visibleArea - MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT;
}
}
- (CGFloat)tableViewSafeAreaWidth
{
CGFloat safeAreaInsetsWidth;
// Take safe area into account
safeAreaInsetsWidth = self.bubblesTableView.safeAreaInsets.left + self.bubblesTableView.safeAreaInsets.right;
return self.bubblesTableView.frame.size.width - safeAreaInsetsWidth;
}
#pragma mark - Public API
- (void)displayRoom:(MXKRoomDataSource *)dataSource
{
if (roomDataSource)
{
if (self.hasRoomDataSourceOwnership)
{
// Release the room data source
[roomDataSource destroy];
}
else if (roomDataSource.delegate == self)
{
roomDataSource.delegate = nil;
}
roomDataSource = nil;
[self removeMatrixSession:self.mainSession];
}
// Reset the current event id
currentEventIdAtTableBottom = nil;
if (dataSource)
{
if (dataSource.isPeeking)
{
// Remove the input toolbar in case of peeking.
// We do not let the user type message in this case.
[self setRoomInputToolbarViewClass:nil];
}
roomDataSource = dataSource;
roomDataSource.delegate = self;
roomDataSource.paginationLimitAroundInitialEvent = _paginationLimit;
// Report the matrix session at view controller level to update UI according to session state
[self addMatrixSession:roomDataSource.mxSession];
if (_bubblesTableView)
{
[self dismissTemporarySubViews];
// Set up table data source
_bubblesTableView.dataSource = roomDataSource;
}
// When ready, do the initial back pagination
if (roomDataSource.state == MXKDataSourceStateReady)
{
[self onRoomDataSourceReady];
}
}
[self updateViewControllerAppearanceOnRoomDataSourceState];
}
- (void)onRoomDataSourceReady
{
// If the user is only invited, auto-join the room if this option is enabled
if (roomDataSource.room.summary.membership == MXMembershipInvite)
{
if (_autoJoinInvitedRoom)
{
[self joinRoom:nil];
}
}
else
{
[self triggerInitialBackPagination];
}
}
- (void)updateViewControllerAppearanceOnRoomDataSourceState
{
// Update UI by considering dataSource state
if (roomDataSource && roomDataSource.state == MXKDataSourceStateReady)
{
[self stopActivityIndicator];
if (titleView)
{
titleView.mxRoom = roomDataSource.room;
titleView.editable = YES;
titleView.hidden = NO;
}
else
{
// set default title
self.navigationItem.title = roomDataSource.room.summary.displayName;
}
// Show input tool bar
inputToolbarView.hidden = NO;
}
else
{
// Update the title except if the room has just been left
if (!_leftRoomReasonLabel)
{
if (roomDataSource && roomDataSource.state == MXKDataSourceStatePreparing)
{
if (titleView)
{
titleView.mxRoom = roomDataSource.room;
titleView.hidden = (!titleView.mxRoom);
}
else
{
self.navigationItem.title = roomDataSource.room.summary.displayName;
}
}
else
{
if (titleView)
{
titleView.mxRoom = nil;
titleView.hidden = NO;
}
else
{
self.navigationItem.title = nil;
}
}
}
titleView.editable = NO;
// Hide input tool bar
inputToolbarView.hidden = YES;
}
// Finalize room title refresh
[titleView refreshDisplay];
if (activitiesView)
{
// Hide by default the activity view when no room is displayed
activitiesView.hidden = (roomDataSource == nil);
}
}
- (void)onTimelineError:(NSNotification *)notif
{
if (notif.object == roomDataSource)
{
[self stopActivityIndicator];
// Compute the message to display to the end user
NSString *errorTitle;
NSString *errorMessage;
NSError *error = notif.userInfo[kMXKRoomDataSourceTimelineErrorErrorKey];
if ([MXError isMXError:error])
{
MXError *mxError = [[MXError alloc] initWithNSError:error];
if ([mxError.errcode isEqualToString:kMXErrCodeStringNotFound])
{
errorTitle = [VectorL10n roomErrorTimelineEventNotFoundTitle];
errorMessage = [VectorL10n roomErrorTimelineEventNotFound];
}
else
{
errorTitle = [VectorL10n roomErrorCannotLoadTimeline];
errorMessage = mxError.error;
}
}
else
{
errorTitle = [VectorL10n roomErrorCannotLoadTimeline];
}
// And show it
[currentAlert dismissViewControllerAnimated:NO completion:nil];
__weak typeof(self) weakSelf = self;
UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:errorTitle
message:errorMessage
preferredStyle:UIAlertControllerStyleAlert];
[errorAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
}]];
[self presentViewController:errorAlert animated:YES completion:nil];
currentAlert = errorAlert;
}
}
- (void)joinRoom:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion
{
if (joinRoomRequest != nil)
{
if (completion)
{
completion(MXKRoomViewControllerJoinRoomResultFailureJoinInProgress);
}
return;
}
UserIndicatorCancel cancelIndicator = [self.userIndicatorStore presentLoadingWithLabel:[VectorL10n joining] isInteractionBlocking:YES];
joinRoomRequest = [roomDataSource.room join:^{
self->joinRoomRequest = nil;
cancelIndicator();
[self triggerInitialBackPagination];
if (completion)
{
completion(MXKRoomViewControllerJoinRoomResultSuccess);
}
} failure:^(NSError *error) {
cancelIndicator();
MXLogDebug(@"[MXKRoomVC] Failed to join room (%@)", self->roomDataSource.room.summary.displayName);
[self processRoomJoinFailureWithError:error completion:completion];
}];
}
- (void)joinRoomWithRoomIdOrAlias:(NSString*)roomIdOrAlias
viaServers:(NSArray<NSString*>*)viaServers
andSignUrl:(NSString*)signUrl
completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion
{
if (joinRoomRequest != nil)
{
if (completion)
{
completion(MXKRoomViewControllerJoinRoomResultFailureJoinInProgress);
}
return;
}
UserIndicatorCancel cancelIndicator = [self.userIndicatorStore presentLoadingWithLabel:[VectorL10n joining] isInteractionBlocking:YES];
void (^success)(MXRoom *room) = ^(MXRoom *room) {
self->joinRoomRequest = nil;
cancelIndicator();
MXWeakify(self);
// The room is now part of the user's room
MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession];
[roomDataSourceManager roomDataSourceForRoom:room.roomId create:YES onComplete:^(MXKRoomDataSource *newRoomDataSource) {
MXStrongifyAndReturnIfNil(self);
// And can be displayed
[self displayRoom:newRoomDataSource];
if (completion)
{
completion(MXKRoomViewControllerJoinRoomResultSuccess);
}
}];
};
void (^failure)(NSError *error) = ^(NSError *error) {
cancelIndicator();
MXLogDebug(@"[MXKRoomVC] Failed to join room (%@)", roomIdOrAlias);
[self processRoomJoinFailureWithError:error completion:completion];
};
// Does the join need to be validated before?
if (signUrl)
{
joinRoomRequest = [self.mainSession joinRoom:roomIdOrAlias viaServers:viaServers withSignUrl:signUrl success:success failure:failure];
}
else
{
joinRoomRequest = [self.mainSession joinRoom:roomIdOrAlias viaServers:viaServers success:success failure:failure];
}
}
- (void)processRoomJoinFailureWithError:(NSError *)error completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion
{
self->joinRoomRequest = nil;
[self stopActivityIndicator];
// Show the error to the end user
NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey];
// FIXME: We should hide this inside the SDK and expose it as a domain specific error
BOOL isRoomEmpty = [msg isEqualToString:@"No known servers"];
if (isRoomEmpty)
{
// minging kludge until https://matrix.org/jira/browse/SYN-678 is fixed
// 'Error when trying to join an empty room should be more explicit'
msg = [VectorL10n roomErrorJoinFailedEmptyRoom];
}
MXWeakify(self);
[self->currentAlert dismissViewControllerAnimated:NO completion:nil];
UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomErrorJoinFailedTitle]
message:msg
preferredStyle:UIAlertControllerStyleAlert];
[errorAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
if (completion)
{
completion((isRoomEmpty ? MXKRoomViewControllerJoinRoomResultFailureRoomEmpty : MXKRoomViewControllerJoinRoomResultFailureGeneric));
}
}]];
[self presentViewController:errorAlert animated:YES completion:nil];
currentAlert = errorAlert;
}
- (void)leaveRoomOnEvent:(MXEvent*)event
{
[self dismissTemporarySubViews];
NSString *reason = nil;
if (event)
{
MXKEventFormatterError error;
reason = [roomDataSource.eventFormatter
stringFromEvent:event
withRoomState:roomDataSource.roomState
andLatestRoomState:nil
error:&error];
if (error != MXKEventFormatterErrorNone)
{
reason = nil;
}
}
if (!reason.length)
{
if (self.roomDataSource.room.isDirect)
{
reason = [VectorL10n roomLeftForDm];
}
else
{
reason = [VectorL10n roomLeft];
}
}
_bubblesTableView.dataSource = nil;
_bubblesTableView.delegate = nil;
if (self.hasRoomDataSourceOwnership)
{
// Release the room data source
[roomDataSource destroy];
}
else if (roomDataSource.delegate == self)
{
roomDataSource.delegate = nil;
}
roomDataSource = nil;
// Add reason label
UILabel *leftRoomReasonLabel = [[UILabel alloc] initWithFrame:CGRectMake(10, 5, self.view.frame.size.width - 20, 70)];
leftRoomReasonLabel.numberOfLines = 0;
leftRoomReasonLabel.text = reason;
leftRoomReasonLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth;
_bubblesTableView.tableHeaderView = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 80)];
[_bubblesTableView.tableHeaderView addSubview:leftRoomReasonLabel];
[_bubblesTableView reloadData];
_leftRoomReasonLabel = leftRoomReasonLabel;
[self updateViewControllerAppearanceOnRoomDataSourceState];
}
- (void)setPaginationLimit:(NSUInteger)paginationLimit
{
_paginationLimit = paginationLimit;
// Use the same value when loading messages around the initial event
roomDataSource.paginationLimitAroundInitialEvent = _paginationLimit;
}
- (void)setRoomTitleViewClass:(Class)roomTitleViewClass
{
if ([self.titleView.class isEqual:roomTitleViewClass]) {
return;
}
// Sanity check: accept only MXKRoomTitleView classes or sub-classes
NSParameterAssert([roomTitleViewClass isSubclassOfClass:MXKRoomTitleView.class]);
// Remove potential title view
if (titleView)
{
[NSLayoutConstraint deactivateConstraints:titleView.constraints];
[titleView dismissKeyboard];
[titleView removeFromSuperview];
[titleView destroy];
}
self.navigationItem.titleView = titleView = [roomTitleViewClass roomTitleView];
titleView.delegate = self;
// Define directly the navigation titleView with the custom title view instance. Do not use anymore a container.
self.navigationItem.titleView = titleView;
[self updateViewControllerAppearanceOnRoomDataSourceState];
}
- (void)setRoomInputToolbarViewClass:(Class)roomInputToolbarViewClass
{
if (!_roomInputToolbarContainer)
{
MXLogDebug(@"[MXKRoomVC] Set roomInputToolbarViewClass failed: container is missing");
return;
}
// Remove potential toolbar
if (inputToolbarView)
{
MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView with class %@ to nil", [self.inputToolbarView class]);
[NSLayoutConstraint deactivateConstraints:inputToolbarView.constraints];
[inputToolbarView dismissKeyboard];
[inputToolbarView removeFromSuperview];
[inputToolbarView destroy];
inputToolbarView = nil;
}
if (roomDataSource && roomDataSource.isPeeking)
{
// Do not show the input toolbar if the displayed timeline in case of peeking.
// We do not let the user type message in this case.
roomInputToolbarViewClass = nil;
}
if (roomInputToolbarViewClass)
{
// Sanity check: accept only MXKRoomInputToolbarView classes or sub-classes
NSParameterAssert([roomInputToolbarViewClass isSubclassOfClass:MXKRoomInputToolbarView.class]);
MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView to class %@", roomInputToolbarViewClass);
id inputToolbarView = [roomInputToolbarViewClass instantiateRoomInputToolbarView];
self->inputToolbarView = inputToolbarView;
self->inputToolbarView.delegate = self;
// Add the input toolbar view and define edge constraints
[_roomInputToolbarContainer addSubview:inputToolbarView];
[_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer
attribute:NSLayoutAttributeBottom
relatedBy:NSLayoutRelationEqual
toItem:inputToolbarView
attribute:NSLayoutAttributeBottom
multiplier:1.0f
constant:0.0f]];
[_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:inputToolbarView
attribute:NSLayoutAttributeTop
multiplier:1.0f
constant:0.0f]];
[_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:inputToolbarView
attribute:NSLayoutAttributeLeading
multiplier:1.0f
constant:0.0f]];
[_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer
attribute:NSLayoutAttributeTrailing
relatedBy:NSLayoutRelationEqual
toItem:inputToolbarView
attribute:NSLayoutAttributeTrailing
multiplier:1.0f
constant:0.0f]];
}
[_roomInputToolbarContainer setNeedsUpdateConstraints];
}
- (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass
{
if (!_roomActivitiesContainer)
{
MXLogDebug(@"[MXKRoomVC] Set RoomActivitiesViewClass failed: container is missing");
return;
}
// Remove potential toolbar
if (activitiesView)
{
[NSLayoutConstraint deactivateConstraints:activitiesView.constraints];
[activitiesView removeFromSuperview];
[activitiesView destroy];
activitiesView = nil;
}
if (roomActivitiesViewClass)
{
// Sanity check: accept only MXKRoomExtraInfoView classes or sub-classes
NSParameterAssert([roomActivitiesViewClass isSubclassOfClass:MXKRoomActivitiesView.class]);
activitiesView = [roomActivitiesViewClass roomActivitiesView];
// Add the view and define edge constraints
activitiesView.translatesAutoresizingMaskIntoConstraints = NO;
[_roomActivitiesContainer addSubview:activitiesView];
NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:activitiesView
attribute:NSLayoutAttributeTop
multiplier:1.0f
constant:0.0f];
NSLayoutConstraint* leadingConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:activitiesView
attribute:NSLayoutAttributeLeading
multiplier:1.0f
constant:0.0f];
NSLayoutConstraint* widthConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer
attribute:NSLayoutAttributeWidth
relatedBy:NSLayoutRelationEqual
toItem:activitiesView
attribute:NSLayoutAttributeWidth
multiplier:1.0f
constant:0.0f];
NSLayoutConstraint* heightConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer
attribute:NSLayoutAttributeHeight
relatedBy:NSLayoutRelationEqual
toItem:activitiesView
attribute:NSLayoutAttributeHeight
multiplier:1.0f
constant:0.0f];
[NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, widthConstraint, heightConstraint]];
// let the provide view to define a height.
// it could have no constrainst if there is no defined xib
_roomActivitiesContainerHeightConstraint.constant = activitiesView.height;
// Listen to activities view change
activitiesView.delegate = self;
}
else
{
_roomActivitiesContainerHeightConstraint.constant = 0;
}
_bubblesTableViewBottomConstraint.constant = _roomInputToolbarContainerBottomConstraint.constant + _roomInputToolbarContainerHeightConstraint.constant +_roomActivitiesContainerHeightConstraint.constant;
[_roomActivitiesContainer setNeedsUpdateConstraints];
}
- (void)setAttachmentsViewerClass:(Class)theAttachmentsViewerClass
{
if (theAttachmentsViewerClass)
{
// Sanity check: accept only MXKAttachmentsViewController classes or sub-classes
NSParameterAssert([theAttachmentsViewerClass isSubclassOfClass:MXKAttachmentsViewController.class]);
}
attachmentsViewerClass = theAttachmentsViewerClass;
}
- (void)setEventDetailsViewClass:(Class)eventDetailsViewClass
{
if (eventDetailsViewClass)
{
// Sanity check: accept only MXKEventDetailsView classes or sub-classes
NSParameterAssert([eventDetailsViewClass isSubclassOfClass:MXKEventDetailsView.class]);
}
customEventDetailsViewClass = eventDetailsViewClass;
}
- (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string
{
// Check whether the provided text may be an IRC-style command
if ([string hasPrefix:@"/"] == NO || [string hasPrefix:@"//"] == YES)
{
return NO;
}
// Parse command line
NSArray *components = [string componentsSeparatedByString:@" "];
NSString *cmd = [components objectAtIndex:0];
NSUInteger index = 1;
// TODO: display an alert with the cmd usage in case of error or unrecognized cmd.
NSString *cmdUsage;
NSString* kMXKSlashCmdChangeDisplayName = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeDisplayName];
NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom];
NSString* kMXKSlashCmdPartRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandPartRoom];
NSString* kMXKSlashCmdChangeRoomTopic = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandChangeRoomTopic];
if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]])
{
// send message as an emote
[self sendTextMessage:string];
}
else if ([string hasPrefix:kMXKSlashCmdChangeDisplayName])
{
// Change display name
NSString *displayName;
// Sanity check
if (string.length > kMXKSlashCmdChangeDisplayName.length)
{
displayName = [string substringFromIndex:kMXKSlashCmdChangeDisplayName.length + 1];
// Remove white space from both ends
displayName = [displayName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
if (displayName.length)
{
[roomDataSource.mxSession.matrixRestClient setDisplayName:displayName success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Set displayName failed");
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeDisplayName];
}
}
else if ([string hasPrefix:kMXKSlashCmdJoinRoom])
{
// Join a room
NSString *roomAlias;
// Sanity check
if (string.length > kMXKSlashCmdJoinRoom.length)
{
roomAlias = [string substringFromIndex:kMXKSlashCmdJoinRoom.length + 1];
// Remove white space from both ends
roomAlias = [roomAlias stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
// Check
if (roomAlias.length)
{
// TODO: /join command does not support via parameters yet
[roomDataSource.mxSession joinRoom:roomAlias viaServers:nil success:^(MXRoom *room) {
// Do nothing by default when we succeed to join the room
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Join roomAlias (%@) failed", roomAlias);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom];
}
}
else if ([string hasPrefix:kMXKSlashCmdPartRoom])
{
// Leave this room or another one
NSString *roomId;
NSString *roomIdOrAlias;
// Sanity check
if (string.length > kMXKSlashCmdPartRoom.length)
{
roomIdOrAlias = [string substringFromIndex:kMXKSlashCmdPartRoom.length + 1];
// Remove white space from both ends
roomIdOrAlias = [roomIdOrAlias stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
// Check
if (roomIdOrAlias.length)
{
// Leave another room
if ([MXTools isMatrixRoomAlias:roomIdOrAlias])
{
// Convert the alias to a room ID
MXRoom *room = [roomDataSource.mxSession roomWithAlias:roomIdOrAlias];
if (room)
{
roomId = room.roomId;
}
}
else if ([MXTools isMatrixRoomIdentifier:roomIdOrAlias])
{
roomId = roomIdOrAlias;
}
}
else
{
// Leave the current room
roomId = roomDataSource.roomId;
}
if (roomId.length)
{
[roomDataSource.mxSession leaveRoom:roomId success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Part room_alias (%@ / %@) failed", roomIdOrAlias, roomId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandPartRoom];
}
}
else if ([string hasPrefix:kMXKSlashCmdChangeRoomTopic])
{
// Change topic
NSString *topic;
// Sanity check
if (string.length > kMXKSlashCmdChangeRoomTopic.length)
{
topic = [string substringFromIndex:kMXKSlashCmdChangeRoomTopic.length + 1];
// Remove white space from both ends
topic = [topic stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
if (topic.length)
{
[roomDataSource.room setTopic:topic success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Set topic failed");
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandChangeRoomTopic];
}
}
else if ([string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandDiscardSession]])
{
[roomDataSource.mxSession.crypto discardOutboundGroupSessionForRoomWithRoomId:roomDataSource.roomId onComplete:^{
MXLogDebug(@"[MXKRoomVC] Manually discarded outbound group session");
}];
}
else
{
// Retrieve userId
NSString *userId = nil;
while (index < components.count)
{
userId = [components objectAtIndex:index++];
if (userId.length)
{
// done
break;
}
// reset
userId = nil;
}
if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandInviteUser]])
{
if (userId)
{
// Invite the user
[roomDataSource.room inviteUser:userId success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Invite user (%@) failed", userId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandInviteUser];
}
}
else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandKickUser]])
{
if (userId)
{
// Retrieve potential reason
NSString *reason = nil;
while (index < components.count)
{
if (reason)
{
reason = [NSString stringWithFormat:@"%@ %@", reason, [components objectAtIndex:index++]];
}
else
{
reason = [components objectAtIndex:index++];
}
}
// Kick the user
[roomDataSource.room kickUser:userId reason:reason success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Kick user (%@) failed", userId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandKickUser];
}
}
else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandBanUser]])
{
if (userId)
{
// Retrieve potential reason
NSString *reason = nil;
while (index < components.count)
{
if (reason)
{
reason = [NSString stringWithFormat:@"%@ %@", reason, [components objectAtIndex:index++]];
}
else
{
reason = [components objectAtIndex:index++];
}
}
// Ban the user
[roomDataSource.room banUser:userId reason:reason success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Ban user (%@) failed", userId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandBanUser];
}
}
else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandUnbanUser]])
{
if (userId)
{
// Unban the user
[roomDataSource.room unbanUser:userId success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Unban user (%@) failed", userId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandUnbanUser];
}
}
else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandSetUserPowerLevel]])
{
// Retrieve power level
NSString *powerLevel = nil;
while (index < components.count)
{
powerLevel = [components objectAtIndex:index++];
if (powerLevel.length)
{
// done
break;
}
// reset
powerLevel = nil;
}
// Set power level
if (userId && powerLevel)
{
// Set user power level
[roomDataSource.room setPowerLevelOfUserWithUserID:userId powerLevel:[powerLevel integerValue] success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Set user power (%@) failed", userId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandSetUserPowerLevel];
}
}
else if ([cmd isEqualToString:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandResetUserPowerLevel]])
{
if (userId)
{
// Reset user power level
[roomDataSource.room setPowerLevelOfUserWithUserID:userId powerLevel:0 success:^{
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomVC] Reset user power (%@) failed", userId);
// Notify MatrixKit user
NSString *myUserId = self->roomDataSource.mxSession.myUser.userId;
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil];
}];
}
else
{
// Display cmd usage in text input as placeholder
cmdUsage = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandResetUserPowerLevel];
}
}
else
{
MXLogDebug(@"[MXKRoomVC] Unrecognised IRC-style command: %@", string);
// cmdUsage = [NSString stringWithFormat:@"Unrecognised IRC-style command: %@", cmd];
return NO;
}
}
return YES;
}
- (void)mention:(MXRoomMember*)roomMember
{
NSString *memberName = roomMember.displayname.length ? roomMember.displayname : roomMember.userId;
if (inputToolbarView.textMessage.length)
{
[inputToolbarView pasteText:memberName];
}
else if ([roomMember.userId isEqualToString:self.mainSession.myUser.userId])
{
// Prepare emote
inputToolbarView.textMessage = @"/me ";
}
else
{
// Bing the member
inputToolbarView.textMessage = [NSString stringWithFormat:@"%@: ", memberName];
}
[inputToolbarView becomeFirstResponder];
}
- (void)dismissKeyboard
{
[titleView dismissKeyboard];
[inputToolbarView dismissKeyboard];
}
- (BOOL)isBubblesTableScrollViewAtTheBottom
{
if (_bubblesTableView.contentSize.height)
{
// Check whether the most recent message is visible.
// Compute the max vertical position visible according to contentOffset
CGFloat maxPositionY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom);
// Be a bit less retrictive, consider the table view at the bottom even if the most recent message is partially hidden
maxPositionY += 44;
BOOL isScrolledToBottom = (maxPositionY >= _bubblesTableView.contentSize.height);
// Consider the table view at the bottom if a scrolling to bottom is in progress too
return (isScrolledToBottom || isScrollingToBottom);
}
// Consider empty table view as at the bottom. Only do this after it has appeared.
// Returning YES here before the view has appeared allows calls to scrollBubblesTableViewToBottomAnimated
// before the view knows its final size, resulting in a position offset the second time a room is shown (#4524).
return hasAppearedOnce;
}
- (void)scrollBubblesTableViewToBottomAnimated:(BOOL)animated
{
if (_bubblesTableView.contentSize.height)
{
CGFloat visibleHeight = _bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.top - _bubblesTableView.adjustedContentInset.bottom;
if (visibleHeight < _bubblesTableView.contentSize.height)
{
CGFloat wantedOffsetY = _bubblesTableView.contentSize.height - visibleHeight - _bubblesTableView.adjustedContentInset.top;
CGFloat currentOffsetY = _bubblesTableView.contentOffset.y;
if (wantedOffsetY != currentOffsetY)
{
isScrollingToBottom = YES;
BOOL savedBubbleTableViewDisplayInTransition = self.isBubbleTableViewDisplayInTransition;
self.bubbleTableViewDisplayInTransition = YES;
[self setBubbleTableViewContentOffset:CGPointMake(0, wantedOffsetY) animated:animated];
self.bubbleTableViewDisplayInTransition = savedBubbleTableViewDisplayInTransition;
}
else
{
// upateCurrentEventIdAtTableBottom must be called here (it is usually called by the scrollview delegate at the end of scrolling).
[self updateCurrentEventIdAtTableBottom:YES];
}
}
else
{
[self setBubbleTableViewContentOffset:CGPointMake(0, -_bubblesTableView.adjustedContentInset.top) animated:animated];
}
shouldScrollToBottomOnTableRefresh = NO;
}
}
- (void)dismissTemporarySubViews
{
[self dismissKeyboard];
if (currentAlert)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
currentAlert = nil;
}
if (eventDetailsView)
{
[eventDetailsView removeFromSuperview];
eventDetailsView = nil;
}
if (_leftRoomReasonLabel)
{
[_leftRoomReasonLabel removeFromSuperview];
_leftRoomReasonLabel = nil;
_bubblesTableView.tableHeaderView = nil;
}
// Dispose potential keyboard view
self.keyboardView = nil;
}
- (void)setBubbleTableViewContentOffset:(CGPoint)contentOffset animated:(BOOL)animated
{
if (preventBubblesTableViewScroll)
{
return;
}
[self.bubblesTableView setContentOffset:contentOffset animated:animated];
}
#pragma mark - properties
- (void)setBubbleTableViewDisplayInTransition:(BOOL)bubbleTableViewDisplayInTransition
{
if (_bubbleTableViewDisplayInTransition != bubbleTableViewDisplayInTransition)
{
_bubbleTableViewDisplayInTransition = bubbleTableViewDisplayInTransition;
[self updateCurrentEventIdAtTableBottom:YES];
}
}
- (void)setUpdateRoomReadMarker:(BOOL)updateRoomReadMarker
{
if (_updateRoomReadMarker != updateRoomReadMarker)
{
_updateRoomReadMarker = updateRoomReadMarker;
if (updateRoomReadMarker == YES)
{
if (currentEventIdAtTableBottom)
{
[self.roomDataSource.room moveReadMarkerToEventId:currentEventIdAtTableBottom];
}
else
{
// Look for the last displayed event.
[self updateCurrentEventIdAtTableBottom:YES];
}
}
}
}
#pragma mark - activity indicator
- (BOOL)canStopActivityIndicator {
// Keep the loading wheel displayed while we are joining the room
if (joinRoomRequest)
{
return NO;
}
// Check internal processes before stopping the loading wheel
if (isPaginationInProgress || isInputToolbarProcessing)
{
// Keep activity indicator running
return NO;
}
return [super canStopActivityIndicator];
}
#pragma mark - Pagination
- (void)triggerInitialBackPagination
{
// Trigger back pagination to fill all the screen
CGRect frame = [[UIScreen mainScreen] bounds];
MXWeakify(self);
isPaginationInProgress = YES;
[self startActivityIndicator];
[roomDataSource paginateToFillRect:frame
direction:MXTimelineDirectionBackwards
withMinRequestMessagesCount:_paginationLimit
success:^{
MXStrongifyAndReturnIfNil(self);
// Stop spinner
self->isPaginationInProgress = NO;
[self stopActivityIndicator];
self.bubbleTableViewDisplayInTransition = YES;
// Reload table
[self reloadBubblesTable:YES];
if (self->roomDataSource.timeline.initialEventId)
{
// Center the table view to the cell that contains this event
NSInteger index = [self->roomDataSource indexOfCellDataWithEventId:self->roomDataSource.timeline.initialEventId];
if (index != NSNotFound)
{
// Let iOS put the cell at the top of the table view
[self.bubblesTableView scrollToRowAtIndexPath: [NSIndexPath indexPathForRow:index inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO];
// Apply an offset to move the targeted component at the center of the screen.
UITableViewCell *cell = [self->_bubblesTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:index inSection:0]];
CGPoint contentOffset = self->_bubblesTableView.contentOffset;
CGFloat firstVisibleContentRowOffset = self->_bubblesTableView.contentOffset.y + self->_bubblesTableView.adjustedContentInset.top;
CGFloat lastVisibleContentRowOffset = self->_bubblesTableView.frame.size.height - self->_bubblesTableView.adjustedContentInset.bottom;
CGFloat localPositionOfEvent = 0.0;
if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
if (self->_centerBubblesTableViewContentOnTheInitialEventBottom)
{
localPositionOfEvent = [roomBubbleTableViewCell bottomPositionOfEvent:self->roomDataSource.timeline.initialEventId];
}
else
{
localPositionOfEvent = [roomBubbleTableViewCell topPositionOfEvent:self->roomDataSource.timeline.initialEventId];
}
}
contentOffset.y += localPositionOfEvent - (lastVisibleContentRowOffset / 2 - (cell.frame.origin.y - firstVisibleContentRowOffset));
// Sanity check
if (contentOffset.y + lastVisibleContentRowOffset > self->_bubblesTableView.contentSize.height)
{
contentOffset.y = self->_bubblesTableView.contentSize.height - lastVisibleContentRowOffset;
}
[self setBubbleTableViewContentOffset:contentOffset animated:NO];
// Update the read receipt and potentially the read marker.
[self updateCurrentEventIdAtTableBottom:YES];
}
}
self.bubbleTableViewDisplayInTransition = NO;
}
failure:^(NSError *error) {
MXStrongifyAndReturnIfNil(self);
// Stop spinner
self->isPaginationInProgress = NO;
[self stopActivityIndicator];
self.bubbleTableViewDisplayInTransition = YES;
// Reload table
[self reloadBubblesTable:YES];
self.bubbleTableViewDisplayInTransition = NO;
}];
}
/**
Trigger an inconspicuous pagination.
The retrieved history is added discretely to the top or the bottom of bubbles table without change the current display.
@param limit the maximum number of messages to retrieve.
@param direction backwards or forwards.
*/
- (void)triggerPagination:(NSUInteger)limit direction:(MXTimelineDirection)direction
{
// Paginate only if possible
if (isPaginationInProgress || roomDataSource.state != MXKDataSourceStateReady || NO == [roomDataSource.timeline canPaginate:direction])
{
return;
}
UserIndicatorCancel cancelIndicator = [self.userIndicatorStore presentLoadingWithLabel:[VectorL10n loading] isInteractionBlocking:NO];
// Store the current height of the first bubble (if any)
backPaginationSavedFirstBubbleHeight = 0;
if (direction == MXTimelineDirectionBackwards && [roomDataSource tableView:_bubblesTableView numberOfRowsInSection:0])
{
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
backPaginationSavedFirstBubbleHeight = [self tableView:_bubblesTableView heightForRowAtIndexPath:indexPath];
}
isPaginationInProgress = YES;
MXWeakify(self);
// Trigger pagination
[roomDataSource paginate:limit direction:direction onlyFromStore:NO success:^(NSUInteger addedCellNumber) {
MXStrongifyAndReturnIfNil(self);
// We will adjust the vertical offset in order to unchange the current display (pagination should be inconspicuous)
CGFloat verticalOffset = 0;
if (direction == MXTimelineDirectionBackwards)
{
// Compute the cumulative height of the added messages
for (NSUInteger index = 0; index < addedCellNumber; index++)
{
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0];
verticalOffset += [self tableView:self->_bubblesTableView heightForRowAtIndexPath:indexPath];
}
// Add delta of the height of the previous first cell (if any)
if (addedCellNumber < [self->roomDataSource tableView:self->_bubblesTableView numberOfRowsInSection:0])
{
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:addedCellNumber inSection:0];
verticalOffset += ([self tableView:self->_bubblesTableView heightForRowAtIndexPath:indexPath] - self->backPaginationSavedFirstBubbleHeight);
}
self->_bubblesTableView.tableHeaderView = self->backPaginationActivityView = nil;
}
else
{
self->_bubblesTableView.tableFooterView = self->reconnectingView = nil;
}
// Trigger a full table reload. We could not only insert new cells related to pagination,
// because some other changes may have been ignored during pagination (see[dataSource:didCellChange:]).
self.bubbleTableViewDisplayInTransition = YES;
// Disable temporarily scrolling and hide the scroll indicator during refresh to prevent flickering
[self.bubblesTableView setShowsVerticalScrollIndicator:NO];
[self.bubblesTableView setScrollEnabled:NO];
CGPoint contentOffset = self.bubblesTableView.contentOffset;
BOOL hasBeenScrolledToBottom = [self reloadBubblesTable:NO];
if (direction == MXTimelineDirectionBackwards)
{
// Backwards pagination adds cells at the top of the tableview content.
// Vertical content offset needs to be updated (except if the table has been scrolled to bottom)
if ((!hasBeenScrolledToBottom && verticalOffset > 0) || direction == MXTimelineDirectionForwards)
{
// Adjust vertical offset in order to compensate scrolling
contentOffset.y += verticalOffset;
[self setBubbleTableViewContentOffset:contentOffset animated:NO];
}
}
else
{
[self setBubbleTableViewContentOffset:contentOffset animated:NO];
}
// Restore scrolling and the scroll indicator
[self.bubblesTableView setShowsVerticalScrollIndicator:YES];
[self.bubblesTableView setScrollEnabled:YES];
self.bubbleTableViewDisplayInTransition = NO;
self->isPaginationInProgress = NO;
// Force the update of the current visual position
// Else there is a scroll jump on incoming message (see https://github.com/vector-im/vector-ios/issues/79)
if (direction == MXTimelineDirectionBackwards)
{
[self updateCurrentEventIdAtTableBottom:NO];
}
if (cancelIndicator) {
cancelIndicator();
}
} failure:^(NSError *error) {
MXStrongifyAndReturnIfNil(self);
self.bubbleTableViewDisplayInTransition = YES;
// Reload table on failure because some changes may have been ignored during pagination (see[dataSource:didCellChange:])
self->isPaginationInProgress = NO;
self->_bubblesTableView.tableHeaderView = self->backPaginationActivityView = nil;
[self reloadBubblesTable:NO];
self.bubbleTableViewDisplayInTransition = NO;
if (cancelIndicator) {
cancelIndicator();
}
}];
}
- (void)triggerAttachmentBackPagination:(NSString*)eventId
{
// Paginate only if possible
if (NO == [roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] && self.attachmentsViewer)
{
return;
}
isPaginationInProgress = YES;
MXWeakify(self);
// Trigger back pagination to find previous attachments
[roomDataSource paginate:_paginationLimit direction:MXTimelineDirectionBackwards onlyFromStore:NO success:^(NSUInteger addedCellNumber) {
MXStrongifyAndReturnIfNil(self);
// Check whether attachments viewer is still visible
if (self.attachmentsViewer)
{
// Check whether some older attachments have been added.
// Note: the stickers are excluded from the attachments list returned by the room datasource.
BOOL isDone = NO;
NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail;
if (attachmentsWithThumbnail.count)
{
MXKAttachment *attachment = attachmentsWithThumbnail.firstObject;
isDone = ![attachment.eventId isEqualToString:eventId];
}
// Check whether pagination is still available
self.attachmentsViewer.complete = ([self->roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] == NO);
if (isDone || self.attachmentsViewer.complete)
{
// Refresh the current attachments list.
[self.attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:nil];
// Trigger a full table reload without scrolling. We could not only insert new cells related to back pagination,
// because some other changes may have been ignored during back pagination (see[dataSource:didCellChange:]).
self.bubbleTableViewDisplayInTransition = YES;
self->isPaginationInProgress = NO;
[self reloadBubblesTable:YES];
self.bubbleTableViewDisplayInTransition = NO;
// Done
return;
}
// Here a new back pagination is required
[self triggerAttachmentBackPagination:eventId];
}
else
{
// Trigger a full table reload without scrolling. We could not only insert new cells related to back pagination,
// because some other changes may have been ignored during back pagination (see[dataSource:didCellChange:]).
self.bubbleTableViewDisplayInTransition = YES;
self->isPaginationInProgress = NO;
[self reloadBubblesTable:YES];
self.bubbleTableViewDisplayInTransition = NO;
}
} failure:^(NSError *error) {
MXStrongifyAndReturnIfNil(self);
// Reload table on failure because some changes may have been ignored during back pagination (see[dataSource:didCellChange:])
self.bubbleTableViewDisplayInTransition = YES;
self->isPaginationInProgress = NO;
[self reloadBubblesTable:YES];
self.bubbleTableViewDisplayInTransition = NO;
if (self.attachmentsViewer)
{
// Force attachments update to cancel potential loading wheel
[self.attachmentsViewer displayAttachments:self.attachmentsViewer.attachments focusOn:nil];
}
}];
}
#pragma mark - Post messages
- (void)sendTextMessage:(NSString*)msgTxt
{
// Let the datasource send it and manage the local echo
[roomDataSource sendTextMessage:msgTxt success:nil failure:^(NSError *error)
{
// Just log the error. The message will be displayed in red in the room history
MXLogDebug(@"[MXKRoomViewController] sendTextMessage failed.");
}];
}
# pragma mark - Event handling
- (void)showEventDetails:(MXEvent *)event
{
[self dismissKeyboard];
// Remove potential existing subviews
[self dismissTemporarySubViews];
MXKEventDetailsView *eventDetailsView;
if (customEventDetailsViewClass)
{
eventDetailsView = [[customEventDetailsViewClass alloc] initWithEvent:event andMatrixSession:roomDataSource.mxSession];
}
else
{
eventDetailsView = [[MXKEventDetailsView alloc] initWithEvent:event andMatrixSession:roomDataSource.mxSession];
}
// Add shadow on event details view
eventDetailsView.layer.cornerRadius = 5;
eventDetailsView.layer.shadowOffset = CGSizeMake(0, 1);
eventDetailsView.layer.shadowOpacity = 0.5f;
// Add the view and define edge constraints
[self.view addSubview:eventDetailsView];
self->eventDetailsView = eventDetailsView;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated"
[self.view addConstraint:[NSLayoutConstraint constraintWithItem:eventDetailsView
attribute:NSLayoutAttributeTop
relatedBy:NSLayoutRelationEqual
toItem:self.topLayoutGuide
attribute:NSLayoutAttributeBottom
multiplier:1.0f
constant:10.0f]];
[self.view addConstraint:[NSLayoutConstraint constraintWithItem:eventDetailsView
attribute:NSLayoutAttributeBottom
relatedBy:NSLayoutRelationEqual
toItem:self.bottomLayoutGuide
attribute:NSLayoutAttributeTop
multiplier:1.0f
constant:-10.0f]];
#pragma clang diagnostic pop
[self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view
attribute:NSLayoutAttributeLeading
relatedBy:NSLayoutRelationEqual
toItem:eventDetailsView
attribute:NSLayoutAttributeLeading
multiplier:1.0f
constant:-10.0f]];
[self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view
attribute:NSLayoutAttributeTrailing
relatedBy:NSLayoutRelationEqual
toItem:eventDetailsView
attribute:NSLayoutAttributeTrailing
multiplier:1.0f
constant:10.0f]];
[self.view setNeedsUpdateConstraints];
}
- (void)promptUserToResendEvent:(NSString *)eventId
{
MXEvent *event = [roomDataSource eventWithEventId:eventId];
MXLogDebug(@"[MXKRoomViewController] promptUserToResendEvent: %@", event);
if (event && event.eventType == MXEventTypeRoomMessage)
{
NSString *msgtype = event.content[kMXMessageTypeKey];
NSString* textMessage;
if ([msgtype isEqualToString:kMXMessageTypeText])
{
textMessage = event.content[kMXMessageBodyKey];
}
// Show a confirmation popup to the end user
if (currentAlert)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
}
__weak typeof(self) weakSelf = self;
UIAlertController *resendAlert = [UIAlertController alertControllerWithTitle:[VectorL10n resendMessage]
message:textMessage
preferredStyle:UIAlertControllerStyleAlert];
[resendAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
}]];
[resendAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Let the datasource resend. It will manage local echo, etc.
[self->roomDataSource resendEventWithEventId:eventId success:nil failure:nil];
}]];
[self presentViewController:resendAlert animated:YES completion:nil];
currentAlert = resendAlert;
}
}
#pragma mark - bubbles table
- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor
{
return [self reloadBubblesTable:useBottomAnchor invalidateBubblesCellDataCache:NO];
}
- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor invalidateBubblesCellDataCache:(BOOL)invalidateBubblesCellDataCache
{
BOOL shouldScrollToBottom = shouldScrollToBottomOnTableRefresh;
// When no size transition is in progress, check if the bottom of the content is currently visible.
// If this is the case, we will scroll automatically to the bottom after table refresh.
if (!isSizeTransitionInProgress && !shouldScrollToBottom)
{
shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom];
}
// Force bubblesCellData message recalculation if requested
if (invalidateBubblesCellDataCache)
{
[self.roomDataSource invalidateBubblesCellDataCache];
}
// When scroll to bottom is not active, check whether we should keep the current event displayed at the bottom of the table
if (!shouldScrollToBottom && useBottomAnchor && currentEventIdAtTableBottom)
{
// Update content offset after refresh in order to keep visible the current event displayed at the bottom
[_bubblesTableView reloadData];
// Retrieve the new cell index of the event displayed previously at the bottom of table
NSInteger rowIndex = [roomDataSource indexOfCellDataWithEventId:currentEventIdAtTableBottom];
if (rowIndex != NSNotFound)
{
// Retrieve the corresponding cell
UITableViewCell *cell = [_bubblesTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:rowIndex inSection:0]];
UITableViewCell *cellTmp;
if (!cell)
{
NSString *reuseIdentifier = [self cellReuseIdentifierForCellData:[roomDataSource cellDataAtIndex:rowIndex]];
// Create temporarily the cell (this cell will released at the end, to be reusable)
// Do not pass in the indexPath when creating this cell, as there is a possible crash by dequeuing
// multiple cells for the same index path if rotating the device coincides with reloading the data.
cellTmp = [_bubblesTableView dequeueReusableCellWithIdentifier:reuseIdentifier];
cell = cellTmp;
}
if (cell)
{
CGFloat eventTopPosition = cell.frame.origin.y;
CGFloat eventBottomPosition = eventTopPosition + cell.frame.size.height;
// Compute accurate event positions in case of bubble with multiple components
if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
NSArray *bubbleComponents = roomBubbleTableViewCell.bubbleData.bubbleComponents;
if (bubbleComponents.count > 1)
{
// Check and update each component position
[roomBubbleTableViewCell.bubbleData prepareBubbleComponentsPosition];
NSInteger index = bubbleComponents.count - 1;
MXKRoomBubbleComponent *component = bubbleComponents[index];
if ([component.event.eventId isEqualToString:currentEventIdAtTableBottom])
{
eventTopPosition += roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y;
}
else
{
while (index--)
{
MXKRoomBubbleComponent *previousComponent = bubbleComponents[index];
if ([previousComponent.event.eventId isEqualToString:currentEventIdAtTableBottom])
{
// Update top position if this is not the first component
if (index)
{
eventTopPosition += roomBubbleTableViewCell.msgTextViewTopConstraint.constant + previousComponent.position.y;
}
eventBottomPosition = cell.frame.origin.y + roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y;
break;
}
component = previousComponent;
}
}
}
}
// Compute the offset of the content displayed at the bottom.
CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom);
if (contentBottomOffsetY > _bubblesTableView.contentSize.height)
{
contentBottomOffsetY = _bubblesTableView.contentSize.height;
}
// Check whether this event is no more displayed at the bottom
if ((contentBottomOffsetY <= eventTopPosition ) || (eventBottomPosition < contentBottomOffsetY))
{
// Compute the top content offset to display again this event at the table bottom
CGFloat contentOffsetY = eventBottomPosition - (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom);
// Check if there are enought data to fill the top
if (contentOffsetY < -_bubblesTableView.adjustedContentInset.top)
{
// Scroll to the top
contentOffsetY = -_bubblesTableView.adjustedContentInset.top;
}
CGPoint contentOffset = _bubblesTableView.contentOffset;
contentOffset.y = contentOffsetY;
[self setBubbleTableViewContentOffset:contentOffset animated:NO];
}
if (cellTmp && [cellTmp conformsToProtocol:@protocol(MXKCellRendering)] && [cellTmp respondsToSelector:@selector(didEndDisplay)])
{
// Release here resources, and restore reusable cells
[(id<MXKCellRendering>)cellTmp didEndDisplay];
}
}
}
}
else
{
// Do a full reload
[_bubblesTableView reloadData];
if (shouldScrollToBottom) {
// If we need to scroll to the bottom after the reload, layout refresh needs to be triggered,
// otherwise contentSize of the table view will not be up-to-date
// e.g. https://stackoverflow.com/a/31324129
[_bubblesTableView layoutIfNeeded];
}
}
if (shouldScrollToBottom)
{
[self scrollBubblesTableViewToBottomAnimated:NO];
}
return shouldScrollToBottom;
}
- (void)updateCurrentEventIdAtTableBottom:(BOOL)acknowledge
{
// Do not update events if the controller is used as context menu preview.
if (self.isContextPreview)
{
return;
}
// Update the identifier of the event displayed at the bottom of the table, except if a rotation or other size transition is in progress.
if (!isSizeTransitionInProgress && !self.isBubbleTableViewDisplayInTransition)
{
// Compute the content offset corresponding to the line displayed at the table bottom (just above the toolbar).
CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom);
if (contentBottomOffsetY > _bubblesTableView.contentSize.height)
{
contentBottomOffsetY = _bubblesTableView.contentSize.height;
}
// Be a bit less retrictive, consider visible an event at the bottom even if is partially hidden.
contentBottomOffsetY += kCellVisibilityMinimumHeight;
// Reset the current event id
currentEventIdAtTableBottom = nil;
// Consider the visible cells (starting by those displayed at the bottom)
NSArray *visibleCells = [_bubblesTableView visibleCells];
NSInteger index = visibleCells.count;
UITableViewCell *cell;
while (index--)
{
cell = visibleCells[index];
// Check whether the cell is actually visible
if (cell && (cell.frame.origin.y < contentBottomOffsetY))
{
if (![cell isKindOfClass:MXKTableViewCell.class])
{
continue;
}
MXKCellData *cellData = ((MXKTableViewCell *)cell).mxkCellData;
// Only 'MXKRoomBubbleCellData' is supported here for the moment.
if (![cellData isKindOfClass:MXKRoomBubbleCellData.class])
{
continue;
}
MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData;
// Check which bubble component is displayed at the bottom.
// For that update each component position.
[bubbleData prepareBubbleComponentsPosition];
NSArray *bubbleComponents = bubbleData.bubbleComponents;
NSInteger componentIndex = bubbleComponents.count;
CGFloat bottomPositionY = cell.frame.size.height;
MXKRoomBubbleComponent *component;
while (componentIndex --)
{
component = bubbleComponents[componentIndex];
if (![cell isKindOfClass:MXKRoomBubbleTableViewCell.class])
{
continue;
}
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
// Check whether the bottom part of the component is visible.
CGFloat pos = cell.frame.origin.y + bottomPositionY;
if (pos <= contentBottomOffsetY)
{
// We found the component
currentEventIdAtTableBottom = component.event.eventId;
break;
}
// Prepare the bottom position for the next component
bottomPositionY = roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y;
}
if (currentEventIdAtTableBottom)
{
if (acknowledge && self.isEventsAcknowledgementEnabled)
{
// Indicate to the homeserver that the user has read this event.
if (self.navigationController.viewControllers.lastObject == self)
{
// Check if the selected event is eligible to be the new read marker position too
if (!bubbleData.collapsed && [self eligibleForReadMarkerUpdate:component.event])
{
BOOL updateRoomReadMarker = _updateRoomReadMarker && [self isEventPosteriorToCurrentReadMarker:component.event];
// Acknowledge this event and update the read marker if needed
[roomDataSource.room acknowledgeEvent:component.event andUpdateReadMarker:updateRoomReadMarker];
}
else
{
// Acknowledge only this event. The read marker is handled separately
[roomDataSource.room acknowledgeEvent:component.event andUpdateReadMarker:NO];
if (_updateRoomReadMarker)
{
// Try to find the best event for the new read marker position
[self updateReadMarkerEvent];
}
}
}
}
break;
}
// else we consider the previous cell.
}
}
}
}
- (BOOL)eligibleForReadMarkerUpdate:(MXEvent *)event {
// Prevent the readmarker to be placed on a relatesTo or a redaction event
if (event.relatesTo || event.redacts)
{
return NO;
}
return YES;
}
- (BOOL)isEventPosteriorToCurrentReadMarker:(MXEvent *)event {
if (roomDataSource.room.accountData.readMarkerEventId)
{
MXEvent *currentReadMarkerEvent = [roomDataSource.mxSession.store eventWithEventId:roomDataSource.room.accountData.readMarkerEventId inRoom:roomDataSource.roomId];
if (!currentReadMarkerEvent)
{
currentReadMarkerEvent = [roomDataSource eventWithEventId:roomDataSource.room.accountData.readMarkerEventId];
}
// Update the read marker only if the current event is available, and the new event is posterior to it.
return currentReadMarkerEvent && (currentReadMarkerEvent.originServerTs <= event.originServerTs);
}
return YES;
}
/// Try to update the read marker by looking for an eligible event displayed at the bottom of the tableview
- (void)updateReadMarkerEvent
{
// Compute the content offset corresponding to the line displayed at the table bottom (just above the toolbar).
CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom);
if (contentBottomOffsetY > _bubblesTableView.contentSize.height)
{
contentBottomOffsetY = _bubblesTableView.contentSize.height;
}
// Be a bit less retrictive, consider visible an event at the bottom even if is partially hidden.
contentBottomOffsetY += kCellVisibilityMinimumHeight;
// Consider the visible cells (starting by those displayed at the bottom)
NSArray *visibleCells = [_bubblesTableView visibleCells];
NSInteger index = visibleCells.count;
UITableViewCell *cell;
while (index--)
{
cell = visibleCells[index];
// Check whether the cell is actually visible
if (!cell || cell.frame.origin.y > contentBottomOffsetY)
{
continue;
}
if (![cell isKindOfClass:MXKRoomBubbleTableViewCell.class])
{
continue;
}
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
MXKRoomBubbleCellData *bubbleData = roomBubbleTableViewCell.bubbleData;
if (!bubbleData)
{
continue;
}
// Prevent to place the read marker on a collapsed cell
if (bubbleData.collapsed)
{
continue;
}
// Check which bubble component is displayed at the bottom.
// For that update each component position.
[bubbleData prepareBubbleComponentsPosition];
NSArray *bubbleComponents = bubbleData.bubbleComponents;
NSInteger componentIndex = bubbleComponents.count;
CGFloat bottomPositionY = cell.frame.size.height;
MXKRoomBubbleComponent *component;
while (componentIndex --)
{
component = bubbleComponents[componentIndex];
// Prevent the read marker to be placed on an unsupported event (e.g. redactions, reactions, ...)
if (![self eligibleForReadMarkerUpdate:component.event])
{
continue;
}
// Check whether the bottom part of the component is visible.
CGFloat pos = cell.frame.origin.y + bottomPositionY;
if (pos <= contentBottomOffsetY)
{
// We found the component
// Check whether the read marker must be updated.
if ([self isEventPosteriorToCurrentReadMarker:component.event])
{
// Move the read marker to this event
[roomDataSource.room moveReadMarkerToEventId:component.event.eventId];
}
return;
}
// Prepare the bottom position for the next component
bottomPositionY = roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y;
}
// else we consider the previous cell.
}
}
#pragma mark - MXKDataSourceDelegate
- (Class<MXKCellRendering>)cellViewClassForCellData:(MXKCellData*)cellData
{
return nil;
}
- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData
{
Class class = [self cellViewClassForCellData:cellData];
if ([class respondsToSelector:@selector(defaultReuseIdentifier)])
{
return [class defaultReuseIdentifier];
}
return nil;
}
- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes
{
UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)];
if (sharedApplication && sharedApplication.applicationState != UIApplicationStateActive)
{
// Do nothing at the UI level if the application do a sync in background
return;
}
if (isPaginationInProgress)
{
// Ignore these changes, the table will be full updated at the end of pagination.
return;
}
if (self.attachmentsViewer)
{
// Refresh the current attachments list without changing the current displayed attachment (see focus = nil).
NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail;
[self.attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:nil];
}
self.bubbleTableViewDisplayInTransition = YES;
CGPoint contentOffset = self.bubblesTableView.contentOffset;
BOOL hasScrolledToTheBottom = [self reloadBubblesTable:YES];
// If the user is scrolling while we reload the data for a new incoming message for example,
// there will be a jump in the table view display.
// Resetting the contentOffset after the reload fixes the issue.
if (hasScrolledToTheBottom == NO)
{
[self setBubbleTableViewContentOffset:contentOffset animated:NO];
}
self.bubbleTableViewDisplayInTransition = NO;
}
- (void)dataSource:(MXKDataSource *)dataSource didStateChange:(MXKDataSourceState)state
{
[self updateViewControllerAppearanceOnRoomDataSourceState];
if (state == MXKDataSourceStateReady)
{
[self onRoomDataSourceReady];
}
}
- (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)actionIdentifier inCell:(id<MXKCellRendering>)cell userInfo:(NSDictionary *)userInfo
{
MXLogDebug(@"Gesture %@ has been recognized in %@. UserInfo: %@", actionIdentifier, cell, userInfo);
if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnMessageTextView])
{
MXLogDebug(@" -> A message has been tapped");
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnSenderNameLabel] || [actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAvatarView])
{
// MXLogDebug(@" -> Name or avatar of %@ has been tapped", userInfo[kMXKRoomBubbleCellUserIdKey]);
// Add the member display name in text input
MXRoomMember *selectedRoomMember = [roomDataSource.roomState.members memberWithUserId:userInfo[kMXKRoomBubbleCellUserIdKey]];
if (selectedRoomMember)
{
[self mention:selectedRoomMember];
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnDateTimeContainer])
{
roomDataSource.showBubblesDateTime = !roomDataSource.showBubblesDateTime;
MXLogDebug(@" -> Turn %@ cells date", roomDataSource.showBubblesDateTime ? @"ON" : @"OFF");
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAttachmentView] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
[self showAttachmentInCell:(MXKRoomBubbleTableViewCell *)cell];
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnProgressView] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
// Check if there is a download in progress, then offer to cancel it
NSString *downloadId = roomBubbleTableViewCell.bubbleData.attachment.downloadId;
if ([MXMediaManager existingDownloaderWithIdentifier:downloadId])
{
if (currentAlert)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
}
__weak __typeof(self) weakSelf = self;
UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:nil
message:[VectorL10n attachmentCancelDownload]
preferredStyle:UIAlertControllerStyleAlert];
[cancelAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n no]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
}]];
[cancelAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n yes]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Get again the loader
MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId];
if (loader)
{
[loader cancel];
}
// Hide the progress animation
roomBubbleTableViewCell.progressView.hidden = YES;
}]];
[self presentViewController:cancelAlert animated:YES completion:nil];
currentAlert = cancelAlert;
}
else if (roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStatePreparing ||
roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStateEncrypting ||
roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStateUploading)
{
// Offer to cancel the upload in progress
// Upload id is stored in attachment url (nasty trick)
NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL;
if ([MXMediaManager existingUploaderWithId:uploadId])
{
if (currentAlert)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
}
__weak __typeof(self) weakSelf = self;
UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:nil
message:[VectorL10n attachmentCancelUpload]
preferredStyle:UIAlertControllerStyleAlert];
[cancelAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n no]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
}]];
[cancelAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n yes]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
// TODO cancel the attachment encryption if it is in progress.
// Get again the loader
MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId];
if (loader)
{
[loader cancel];
}
// Hide the progress animation
roomBubbleTableViewCell.progressView.hidden = YES;
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Remove the outgoing message and its related cached file.
[[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.cacheFilePath error:nil];
[[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.thumbnailCachePath error:nil];
[self.roomDataSource removeEventWithEventId:roomBubbleTableViewCell.bubbleData.attachment.eventId];
}
}]];
[self presentViewController:cancelAlert animated:YES completion:nil];
currentAlert = cancelAlert;
}
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnEvent] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
[self dismissKeyboard];
MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey];
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment;
if (selectedEvent)
{
if (currentAlert)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
currentAlert = nil;
// Cancel potential text selection in other bubbles
for (MXKRoomBubbleTableViewCell *bubble in self.bubblesTableView.visibleCells)
{
[bubble highlightTextMessageForEvent:nil];
}
}
__weak __typeof(self) weakSelf = self;
UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
// Add actions for a failed event
if (selectedEvent.sentState == MXEventSentStateFailed)
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n resend]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Let the datasource resend. It will manage local echo, etc.
[self.roomDataSource resendEventWithEventId:selectedEvent.eventId success:nil failure:nil];
}]];
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n delete]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
[self.roomDataSource removeEventWithEventId:selectedEvent.eventId];
}]];
}
// Add actions for text message
if (!attachment)
{
// Highlight the select event
[roomBubbleTableViewCell highlightTextMessageForEvent:selectedEvent.eventId];
// Retrieved data related to the selected event
NSArray *components = roomBubbleTableViewCell.bubbleData.bubbleComponents;
MXKRoomBubbleComponent *selectedComponent;
for (selectedComponent in components)
{
if ([selectedComponent.event.eventId isEqualToString:selectedEvent.eventId])
{
break;
}
selectedComponent = nil;
}
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n copy]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Cancel event highlighting
[roomBubbleTableViewCell highlightTextMessageForEvent:nil];
NSString *textMessage = selectedComponent.textMessage;
if (textMessage)
{
MXKPasteboardManager.shared.pasteboard.string = textMessage;
}
else
{
MXLogDebug(@"[MXKRoomViewController] Copy text failed. Text is nil for room id/event id: %@/%@", selectedComponent.event.roomId, selectedComponent.event.eventId);
}
}]];
if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing)
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n share]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Cancel event highlighting
[roomBubbleTableViewCell highlightTextMessageForEvent:nil];
NSArray *activityItems = [NSArray arrayWithObjects:selectedComponent.textMessage, nil];
UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:nil];
if (activityViewController)
{
activityViewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical;
activityViewController.popoverPresentationController.sourceView = roomBubbleTableViewCell;
activityViewController.popoverPresentationController.sourceRect = roomBubbleTableViewCell.bounds;
[self presentViewController:activityViewController animated:YES completion:nil];
}
}]];
}
if (components.count > 1)
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n selectAll]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
[self selectAllTextMessageInCell:cell];
}]];
}
}
else // Add action for attachment
{
if (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo)
{
if ([MXKAppSettings standardAppSettings].messageDetailsAllowSaving)
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n save]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
[self startActivityIndicator];
[attachment save:^{
typeof(self) self = weakSelf;
[self stopActivityIndicator];
} failure:^(NSError *error) {
typeof(self) self = weakSelf;
[self stopActivityIndicator];
// Notify MatrixKit user
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error];
}];
// Start animation in case of download during attachment preparing
[roomBubbleTableViewCell startProgressUI];
}]];
}
}
if (attachment.type != MXKAttachmentTypeSticker)
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n copyButtonName]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
[self startActivityIndicator];
[attachment copy:^{
typeof(self) self = weakSelf;
[self stopActivityIndicator];
} failure:^(NSError *error) {
typeof(self) self = weakSelf;
[self stopActivityIndicator];
// Notify MatrixKit user
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error];
}];
// Start animation in case of download during attachment preparing
[roomBubbleTableViewCell startProgressUI];
}]];
if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing)
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n share]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
[attachment prepareShare:^(NSURL *fileURL) {
typeof(self) self = weakSelf;
self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL];
[self->documentInteractionController setDelegate:self];
self->currentSharedAttachment = attachment;
if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES])
{
self->documentInteractionController = nil;
[attachment onShareEnded];
self->currentSharedAttachment = nil;
}
} failure:^(NSError *error) {
// Notify MatrixKit user
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error];
}];
// Start animation in case of download during attachment preparing
[roomBubbleTableViewCell startProgressUI];
}]];
}
}
// Check status of the selected event
if (selectedEvent.sentState == MXEventSentStatePreparing ||
selectedEvent.sentState == MXEventSentStateEncrypting ||
selectedEvent.sentState == MXEventSentStateUploading)
{
// Upload id is stored in attachment url (nasty trick)
NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL;
if ([MXMediaManager existingUploaderWithId:uploadId])
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n cancelUpload]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
// TODO cancel the attachment encryption if it is in progress.
// Cancel the loader
MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId];
if (loader)
{
[loader cancel];
}
// Hide the progress animation
roomBubbleTableViewCell.progressView.hidden = YES;
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Remove the outgoing message and its related cached file.
[[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.cacheFilePath error:nil];
[[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.thumbnailCachePath error:nil];
[self.roomDataSource removeEventWithEventId:selectedEvent.eventId];
}
}]];
}
}
}
// Check status of the selected event
if (selectedEvent.sentState == MXEventSentStateSent)
{
// Check whether download is in progress
if (selectedEvent.isMediaAttachment)
{
NSString *downloadId = roomBubbleTableViewCell.bubbleData.attachment.downloadId;
if ([MXMediaManager existingDownloaderWithIdentifier:downloadId])
{
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n cancelDownload]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Get again the loader
MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId];
if (loader)
{
[loader cancel];
}
// Hide the progress animation
roomBubbleTableViewCell.progressView.hidden = YES;
}]];
}
}
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n showDetails]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Cancel event highlighting (if any)
[roomBubbleTableViewCell highlightTextMessageForEvent:nil];
// Display event details
[self showEventDetails:selectedEvent];
}]];
}
[actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel]
style:UIAlertActionStyleCancel
handler:^(UIAlertAction * action) {
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Cancel event highlighting (if any)
[roomBubbleTableViewCell highlightTextMessageForEvent:nil];
}]];
// Do not display empty action sheet
if (actionSheet.actions.count > 1)
{
[actionSheet popoverPresentationController].sourceView = roomBubbleTableViewCell;
[actionSheet popoverPresentationController].sourceRect = roomBubbleTableViewCell.bounds;
[self presentViewController:actionSheet animated:YES completion:nil];
currentAlert = actionSheet;
}
}
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnAvatarView])
{
MXLogDebug(@" -> Avatar of %@ has been long pressed", userInfo[kMXKRoomBubbleCellUserIdKey]);
}
else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellUnsentButtonPressed])
{
MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey];
if (selectedEvent)
{
// The user may want to resend it
[self promptUserToResendEvent:selectedEvent.eventId];
}
}
}
#pragma mark - Clipboard
- (void)selectAllTextMessageInCell:(id<MXKCellRendering>)cell
{
if (![MXKAppSettings standardAppSettings].messageDetailsAllowSharing)
{
return;
}
if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
selectedText = roomBubbleTableViewCell.bubbleData.textMessage;
roomBubbleTableViewCell.allTextHighlighted = YES;
// Display Menu (dispatch is required here, else the attributed text change hides the menu)
dispatch_async(dispatch_get_main_queue(), ^{
MXWeakify(self);
self.uiMenuControllerDidHideMenuNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIMenuControllerDidHideMenuNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
MXStrongifyAndReturnIfNil(self);
// Deselect text
roomBubbleTableViewCell.allTextHighlighted = NO;
self->selectedText = nil;
[UIMenuController sharedMenuController].menuItems = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self.uiMenuControllerDidHideMenuNotificationObserver];
}];
[self becomeFirstResponder];
UIMenuController *menu = [UIMenuController sharedMenuController];
menu.menuItems = @[[[UIMenuItem alloc] initWithTitle:[VectorL10n share] action:@selector(share:)]];
[menu setTargetRect:roomBubbleTableViewCell.messageTextView.frame inView:roomBubbleTableViewCell];
[menu setMenuVisible:YES animated:YES];
});
}
}
- (void)copy:(id)sender
{
if (selectedText)
{
MXKPasteboardManager.shared.pasteboard.string = selectedText;
}
else
{
MXLogDebug(@"[MXKRoomViewController] Selected text copy failed. Selected text is nil");
}
}
- (void)share:(id)sender
{
if (selectedText)
{
NSArray *activityItems = [NSArray arrayWithObjects:selectedText, nil];
UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:nil];
if (activityViewController)
{
activityViewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical;
activityViewController.popoverPresentationController.sourceView = self.view;
activityViewController.popoverPresentationController.sourceRect = self.view.bounds;
[self presentViewController:activityViewController animated:YES completion:nil];
}
}
}
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
if (selectedText.length && (action == @selector(copy:) || action == @selector(share:)))
{
return YES;
}
return NO;
}
- (BOOL)canBecomeFirstResponder
{
return (selectedText.length != 0);
}
#pragma mark - UITableView delegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
if (tableView == _bubblesTableView)
{
return [roomDataSource cellHeightAtIndex:indexPath.row withMaximumWidth:self.tableViewSafeAreaWidth];
}
return 0;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
if (tableView == _bubblesTableView)
{
// Dismiss keyboard when user taps on messages table view content
[self dismissKeyboard];
}
}
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath
{
// Release here resources, and restore reusable cells
if ([cell respondsToSelector:@selector(didEndDisplay)])
{
[(id<MXKCellRendering>)cell didEndDisplay];
}
}
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
// Detect vertical bounce at the top of the tableview to trigger pagination
if (scrollView == _bubblesTableView)
{
// Detect top bounce
if (scrollView.contentOffset.y < -scrollView.adjustedContentInset.top)
{
// Shall we add back pagination spinner?
if (isPaginationInProgress && !backPaginationActivityView)
{
UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
spinner.hidesWhenStopped = NO;
spinner.backgroundColor = [UIColor clearColor];
[spinner startAnimating];
// no need to manage constraints here
// IOS defines them.
// since IOS7 the spinner is centered so need to create a background and add it.
_bubblesTableView.tableHeaderView = backPaginationActivityView = spinner;
}
}
else
{
// Shall we add forward pagination spinner?
if (!roomDataSource.isLive && isPaginationInProgress && scrollView.contentOffset.y + scrollView.frame.size.height > scrollView.contentSize.height + 64 && !reconnectingView)
{
[self addReconnectingView];
}
else
{
[self detectPullToKick:scrollView];
}
}
}
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
if (scrollView == _bubblesTableView)
{
// if the user scrolls the history content without animation
// upateCurrentEventIdAtTableBottom must be called here (without dispatch).
// else it will be done in scrollViewDidEndDecelerating
if (!decelerate)
{
[self updateCurrentEventIdAtTableBottom:YES];
}
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
if (scrollView == _bubblesTableView)
{
// do not dispatch the upateCurrentEventIdAtTableBottom call
// else it might triggers weird UI lags.
[self updateCurrentEventIdAtTableBottom:YES];
[self managePullToKick:scrollView];
}
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
{
if (scrollView == _bubblesTableView)
{
// do not dispatch the upateCurrentEventIdAtTableBottom call
// else it might triggers weird UI lags.
[self updateCurrentEventIdAtTableBottom:YES];
}
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
if (scrollView == _bubblesTableView)
{
BOOL wasScrollingToBottom = isScrollingToBottom;
// Consider this callback to reset scrolling to bottom flag
isScrollingToBottom = NO;
// shouldScrollToBottomOnTableRefresh is used to inhibit false detection of
// scrolling action from the user when the viewVC appears or rotates
if (scrollView == _bubblesTableView && scrollView.contentSize.height && !shouldScrollToBottomOnTableRefresh)
{
// when the content size if smaller that the frame
// scrollViewDidEndDecelerating is not called
// so test it when the content offset goes back to the screen top.
if ((scrollView.contentSize.height < scrollView.frame.size.height) && (-scrollView.contentOffset.y == scrollView.adjustedContentInset.top))
{
[self managePullToKick:scrollView];
}
// Trigger inconspicuous pagination when user scrolls toward the top
if (scrollView.contentOffset.y < _paginationThreshold)
{
[self triggerPagination:_paginationLimit direction:MXTimelineDirectionBackwards];
}
// Enable forwards pagination when displaying non live timeline
else if (!roomDataSource.isLive && !wasScrollingToBottom && ((scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.frame.size.height) < _paginationThreshold))
{
[self triggerPagination:_paginationLimit direction:MXTimelineDirectionForwards];
}
}
if (wasScrollingToBottom)
{
// When scrolling to the bottom is performed without animation, 'scrollViewDidEndScrollingAnimation' is not called.
// upateCurrentEventIdAtTableBottom must be called here (without dispatch).
[self updateCurrentEventIdAtTableBottom:YES];
}
}
}
#pragma mark - MXKRoomTitleViewDelegate
- (void)roomTitleView:(MXKRoomTitleView*)titleView presentAlertController:(UIAlertController *)alertController
{
[self dismissKeyboard];
[self presentViewController:alertController animated:YES completion:nil];
}
- (BOOL)roomTitleViewShouldBeginEditing:(MXKRoomTitleView*)titleView
{
return YES;
}
- (void)roomTitleView:(MXKRoomTitleView*)titleView isSaving:(BOOL)saving
{
if (saving)
{
[self startActivityIndicator];
}
else
{
[self stopActivityIndicator];
}
}
#pragma mark - MXKRoomInputToolbarViewDelegate
- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView hideStatusBar:(BOOL)isHidden
{
isStatusBarHidden = isHidden;
// Trigger status bar update
[self setNeedsStatusBarAppearanceUpdate];
// Handle status bar with the historical method.
// TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9).
// Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system.
UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)];
if (sharedApplication)
{
sharedApplication.statusBarHidden = isHidden;
}
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing
{
if (_saveProgressTextInput && roomDataSource)
{
// Store the potential message partially typed in text input
roomDataSource.partialAttributedTextMessage = inputToolbarView.attributedTextMessage;
}
[self handleTypingState:typing];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion
{
// This dispatch fixes a simultaneous accesses crash if this gets called twice quickly in succession
dispatch_async(dispatch_get_main_queue(), ^{
// Update layout with animation
[UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn
animations:^{
// We will scroll to bottom if the bottom of the table is currently visible
BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom];
self->_roomInputToolbarContainerHeightConstraint.constant = height;
CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant;
self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst;
// Force to render the view
[self.view layoutIfNeeded];
if (shouldScrollToBottom)
{
[self scrollBubblesTableViewToBottomAnimated:NO];
}
}
completion:^(BOOL finished){
if (completion)
{
completion(finished);
}
}];
});
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage
{
// Handle potential IRC commands in typed string
if ([self sendAsIRCStyleCommandIfPossible:textMessage] == NO)
{
// Send text message in the current room
[self sendTextMessage:textMessage];
}
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(UIImage*)image
{
// Let the datasource send it and manage the local echo
[roomDataSource sendImage:image success:nil failure:^(NSError *error)
{
// Nothing to do. The image is marked as unsent in the room history by the datasource
MXLogDebug(@"[MXKRoomViewController] sendImage failed.");
}];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(NSData*)imageData withMimeType:(NSString*)mimetype
{
// Let the datasource send it and manage the local echo
[roomDataSource sendImage:imageData mimeType:mimetype success:nil failure:^(NSError *error)
{
// Nothing to do. The image is marked as unsent in the room history by the datasource
MXLogDebug(@"[MXKRoomViewController] sendImage with mimetype failed.");
}];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideo:(NSURL*)videoLocalURL withThumbnail:(UIImage*)videoThumbnail
{
AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL];
[self roomInputToolbarView:toolbarView sendVideoAsset:videoAsset withThumbnail:videoThumbnail];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideoAsset:(AVAsset*)videoAsset withThumbnail:(UIImage*)videoThumbnail
{
// Let the datasource send it and manage the local echo
[roomDataSource sendVideoAsset:videoAsset withThumbnail:videoThumbnail success:nil failure:^(NSError *error)
{
// Nothing to do. The video is marked as unsent in the room history by the datasource
MXLogDebug(@"[MXKRoomViewController] sendVideo failed.");
}];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendFile:(NSURL *)fileLocalURL withMimeType:(NSString*)mimetype
{
// Let the datasource send it and manage the local echo
[roomDataSource sendFile:fileLocalURL mimeType:mimetype success:nil failure:^(NSError *error)
{
// Nothing to do. The file is marked as unsent in the room history by the datasource
MXLogDebug(@"[MXKRoomViewController] sendFile failed.");
}];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentAlertController:(UIAlertController *)alertController
{
[self dismissKeyboard];
[self presentViewController:alertController animated:YES completion:nil];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentViewController:(UIViewController*)viewControllerToPresent
{
[self dismissKeyboard];
[self presentViewController:viewControllerToPresent animated:YES completion:nil];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion
{
[self dismissViewControllerAnimated:flag completion:completion];
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating
{
isInputToolbarProcessing = isAnimating;
if (isAnimating)
{
[self startActivityIndicator];
}
else
{
[self stopActivityIndicator];
}
}
# pragma mark - Typing notification
- (void)handleTypingState:(BOOL)typing
{
NSUInteger notificationTimeoutMS = -1;
if (typing)
{
// Check whether a typing event has been already reported to server (We wait for the end of the local timout before considering this new event)
if (typingTimer)
{
// Refresh date of the last observed typing
lastTypingDate = [[NSDate alloc] init];
return;
}
// No typing event has been yet reported -> share encryption keys if requested
if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenTyping)
{
[self shareEncryptionKeys];
}
// Launch a timer to prevent sending multiple typing notifications
NSTimeInterval timerTimeout = MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC;
if (lastTypingDate)
{
NSTimeInterval lastTypingAge = -[lastTypingDate timeIntervalSinceNow];
if (lastTypingAge < timerTimeout)
{
// Subtract the time interval since last typing from the timer timeout
timerTimeout -= lastTypingAge;
}
else
{
timerTimeout = 0;
}
}
else
{
// Keep date of this typing event
lastTypingDate = [[NSDate alloc] init];
}
if (timerTimeout)
{
typingTimer = [NSTimer scheduledTimerWithTimeInterval:timerTimeout target:self selector:@selector(typingTimeout:) userInfo:self repeats:NO];
// Compute the notification timeout in ms (consider the double of the local typing timeout)
notificationTimeoutMS = 2000 * MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC;
}
else
{
// This typing event is too old, we will ignore it
typing = NO;
MXLogDebug(@"[MXKRoomVC] Ignore typing event (too old)");
}
}
else
{
// Cancel any typing timer
[typingTimer invalidate];
typingTimer = nil;
// Reset last typing date
lastTypingDate = nil;
}
[self sendTypingNotification:typing timeout:notificationTimeoutMS];
}
- (void)sendTypingNotification:(BOOL)typing timeout:(NSUInteger)notificationTimeoutMS
{
MXWeakify(self);
// Send typing notification to server
[roomDataSource.room sendTypingNotification:typing
timeout:notificationTimeoutMS
success:^{
MXStrongifyAndReturnIfNil(self);
// Reset last typing date
self->lastTypingDate = nil;
} failure:^(NSError *error)
{
MXStrongifyAndReturnIfNil(self);
MXLogDebug(@"[MXKRoomVC] Failed to send typing notification (%d)", typing);
// Cancel timer (if any)
[self->typingTimer invalidate];
self->typingTimer = nil;
}];
}
- (IBAction)typingTimeout:(id)sender
{
[typingTimer invalidate];
typingTimer = nil;
// Check whether a new typing event has been observed
BOOL typing = (lastTypingDate != nil);
// Post a new typing notification
[self handleTypingState:typing];
}
# pragma mark - Attachment handling
- (void)showAttachmentInCell:(UITableViewCell*)cell
{
[self dismissKeyboard];
// Retrieve the attachment information from the associated cell data
if ([cell isKindOfClass:MXKTableViewCell.class])
{
MXKCellData *cellData = ((MXKTableViewCell*)cell).mxkCellData;
// Only 'MXKRoomBubbleCellData' is supported here for the moment.
if ([cellData isKindOfClass:MXKRoomBubbleCellData.class])
{
MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData;
MXKAttachment *selectedAttachment = bubbleData.attachment;
if (bubbleData.isAttachmentWithThumbnail)
{
// The attachments viewer is opened only on a valid attachment. It does not display the stickers.
if (selectedAttachment.eventSentState == MXEventSentStateSent && selectedAttachment.type != MXKAttachmentTypeSticker)
{
// Note: the stickers are presently excluded from the attachments list returned by the room dataSource.
NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail;
MXKAttachmentsViewController *attachmentsViewer;
// Present an attachment viewer
if (attachmentsViewerClass)
{
attachmentsViewer = [attachmentsViewerClass animatedAttachmentsViewControllerWithSourceViewController:self];
}
else
{
attachmentsViewer = [MXKAttachmentsViewController animatedAttachmentsViewControllerWithSourceViewController:self];
}
attachmentsViewer.delegate = self;
attachmentsViewer.complete = ([roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] == NO);
attachmentsViewer.hidesBottomBarWhenPushed = YES;
[attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:selectedAttachment.eventId];
// Keep here the image view used to display the attachment in the selected cell.
// Note: Only `MXKRoomBubbleTableViewCell` and `MXKSearchTableViewCell` are supported for the moment.
if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
self.openedAttachmentImageView = ((MXKRoomBubbleTableViewCell *)cell).attachmentView.imageView;
}
else if ([cell isKindOfClass:MXKSearchTableViewCell.class])
{
self.openedAttachmentImageView = ((MXKSearchTableViewCell *)cell).attachmentImageView.imageView;
}
self.openedAttachmentEventId = selectedAttachment.eventId;
// "Initializing" closedAttachmentEventId so it is equal to openedAttachmentEventId at the beginning
self.closedAttachmentEventId = self.openedAttachmentEventId;
if (@available(iOS 13.0, *))
{
attachmentsViewer.modalPresentationStyle = UIModalPresentationFullScreen;
}
[self presentViewController:attachmentsViewer animated:YES completion:nil];
self.attachmentsViewer = attachmentsViewer;
}
else
{
// Let's the application do something
MXLogDebug(@"[MXKRoomVC] showAttachmentInCell on an unsent media");
}
}
else if (selectedAttachment.type == MXKAttachmentTypeFile || selectedAttachment.type == MXKAttachmentTypeAudio)
{
// Start activity indicator as feedback on file selection.
[self startActivityIndicator];
[selectedAttachment prepareShare:^(NSURL *fileURL) {
[self stopActivityIndicator];
MXWeakify(self);
void(^viewAttachment)(void) = ^() {
MXStrongifyAndReturnIfNil(self);
if (![self canPreviewFileAttachment:selectedAttachment withLocalFileURL:fileURL])
{
// When we don't support showing a preview for a file, show a share
// sheet if allowed, otherwise display an error to inform the user.
if (self.allowActionsInDocumentPreview)
{
UIActivityViewController *shareSheet = [[UIActivityViewController alloc] initWithActivityItems:@[fileURL]
applicationActivities:nil];
MXWeakify(self);
shareSheet.completionWithItemsHandler = ^(UIActivityType activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) {
MXStrongifyAndReturnIfNil(self);
[selectedAttachment onShareEnded];
self->currentSharedAttachment = nil;
};
self->currentSharedAttachment = selectedAttachment;
[self presentViewController:shareSheet animated:YES completion:nil];
}
else
{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:VectorL10n.attachmentUnsupportedPreviewTitle
message:VectorL10n.attachmentUnsupportedPreviewMessage
preferredStyle:UIAlertControllerStyleAlert];
MXWeakify(self);
[alert addAction:[UIAlertAction actionWithTitle:VectorL10n.ok style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
MXStrongifyAndReturnIfNil(self);
[selectedAttachment onShareEnded];
self->currentAlert = nil;
}]];
[self presentViewController:alert animated:YES completion:nil];
self->currentAlert = alert;
}
return;
}
if (self.allowActionsInDocumentPreview)
{
// We could get rid of this part of code and use only a MXKPreviewViewController
// Nevertheless, MXKRoomViewController is compliant to UIDocumentInteractionControllerDelegate
// and remove all this code could have effect on some custom implementations.
self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL];
[self->documentInteractionController setDelegate:self];
self->currentSharedAttachment = selectedAttachment;
if (![self->documentInteractionController presentPreviewAnimated:YES])
{
if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES])
{
self->documentInteractionController = nil;
[selectedAttachment onShareEnded];
self->currentSharedAttachment = nil;
}
}
}
else
{
self->currentSharedAttachment = selectedAttachment;
[MXKPreviewViewController presentFrom:self fileUrl:fileURL allowActions:self.allowActionsInDocumentPreview delegate:self];
}
};
if (self->roomDataSource.mxSession.crypto
&& [selectedAttachment.contentInfo[@"mimetype"] isEqualToString:@"text/plain"]
&& [MXMegolmExportEncryption isMegolmKeyFile:fileURL])
{
// The file is a megolm key file
// Ask the user if they wants to view the file as a classic file attachment
// or open an import process
[self->currentAlert dismissViewControllerAnimated:NO completion:nil];
__weak typeof(self) weakSelf = self;
UIAlertController *keysPrompt = [UIAlertController alertControllerWithTitle:@""
message:[VectorL10n attachmentE2eKeysFilePrompt]
preferredStyle:UIAlertControllerStyleAlert];
[keysPrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n view]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
// View file content
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
viewAttachment();
}
}]];
[keysPrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n attachmentE2eKeysImport]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Show the keys import dialog
self->importView = [[MXKEncryptionKeysImportView alloc] initWithMatrixSession:self->roomDataSource.mxSession];
self->currentAlert = self->importView.alertController;
[self->importView showInViewController:self toImportKeys:fileURL onComplete:^{
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
self->importView = nil;
}
}];
}
}]];
[self presentViewController:keysPrompt animated:YES completion:nil];
self->currentAlert = keysPrompt;
}
else
{
viewAttachment();
}
} failure:^(NSError *error) {
[self stopActivityIndicator];
// Notify MatrixKit user
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error];
}];
if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class])
{
// Start animation in case of download
MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell;
[roomBubbleTableViewCell startProgressUI];
}
}
}
}
}
- (BOOL)canPreviewFileAttachment:(MXKAttachment *)attachment withLocalFileURL:(NSURL *)localFileURL
{
// Sanity check.
if (![NSFileManager.defaultManager isReadableFileAtPath:localFileURL.path])
{
return NO;
}
if (UIDevice.currentDevice.systemVersion.floatValue >= 13)
{
return YES;
}
MXKUTI *attachmentUTI = attachment.uti;
MXKUTI *fileUTI = [[MXKUTI alloc] initWithLocalFileURL:localFileURL];
if (!attachmentUTI || !fileUTI)
{
return NO;
}
NSArray<MXKUTI *> *unsupportedUTIs = @[MXKUTI.html, MXKUTI.xml, MXKUTI.svg];
if ([attachmentUTI conformsToAnyOf:unsupportedUTIs] || [fileUTI conformsToAnyOf:unsupportedUTIs])
{
return NO;
}
return YES;
}
#pragma mark - MXKAttachmentsViewControllerDelegate
- (BOOL)attachmentsViewController:(MXKAttachmentsViewController*)attachmentsViewController paginateAttachmentBefore:(NSString*)eventId
{
[self triggerAttachmentBackPagination:eventId];
return [self.roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards];
}
- (void)displayedNewAttachmentWithEventId:(NSString *)eventId {
self.closedAttachmentEventId = eventId;
}
#pragma mark - MXKRoomActivitiesViewDelegate
- (void)didChangeHeight:(MXKRoomActivitiesView *)roomActivitiesView oldHeight:(CGFloat)oldHeight newHeight:(CGFloat)newHeight
{
// We will scroll to bottom if the bottom of the table is currently visible
BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom];
// Apply height change to constraints
_roomActivitiesContainerHeightConstraint.constant = newHeight;
_bubblesTableViewBottomConstraint.constant += newHeight - oldHeight;
// Force to render the view
[self.view layoutIfNeeded];
if (shouldScrollToBottom)
{
[self scrollBubblesTableViewToBottomAnimated:YES];
}
}
#pragma mark - MXKPreviewViewControllerDelegate
- (void)previewViewControllerDidEndPreview:(MXKPreviewViewController *)controller
{
if (currentSharedAttachment)
{
[currentSharedAttachment onShareEnded];
currentSharedAttachment = nil;
}
}
#pragma mark - UIDocumentInteractionControllerDelegate
- (UIViewController *)documentInteractionControllerViewControllerForPreview: (UIDocumentInteractionController *) controller
{
return self;
}
// Preview presented/dismissed on document. Use to set up any HI underneath.
- (void)documentInteractionControllerWillBeginPreview:(UIDocumentInteractionController *)controller
{
documentInteractionController = controller;
}
- (void)documentInteractionControllerDidEndPreview:(UIDocumentInteractionController *)controller
{
documentInteractionController = nil;
if (currentSharedAttachment)
{
[currentSharedAttachment onShareEnded];
currentSharedAttachment = nil;
}
}
- (void)documentInteractionControllerDidDismissOptionsMenu:(UIDocumentInteractionController *)controller
{
documentInteractionController = nil;
if (currentSharedAttachment)
{
[currentSharedAttachment onShareEnded];
currentSharedAttachment = nil;
}
}
- (void)documentInteractionControllerDidDismissOpenInMenu:(UIDocumentInteractionController *)controller
{
documentInteractionController = nil;
if (currentSharedAttachment)
{
[currentSharedAttachment onShareEnded];
currentSharedAttachment = nil;
}
}
#pragma mark - resync management
- (void)onSyncNotification
{
latestServerSync = [NSDate date];
[self removeReconnectingView];
}
- (BOOL)canReconnect
{
// avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null)
NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000;
return (interval > 1) && [self.mainSession reconnect];
}
- (void)addReconnectingView
{
if (!reconnectingView)
{
UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
[spinner sizeToFit];
spinner.hidesWhenStopped = NO;
spinner.backgroundColor = [UIColor clearColor];
[spinner startAnimating];
// no need to manage constraints here
// IOS defines them.
// since IOS7 the spinner is centered so need to create a background and add it.
_bubblesTableView.tableFooterView = reconnectingView = spinner;
}
}
- (void)removeReconnectingView
{
if (reconnectingView && !restartConnection)
{
_bubblesTableView.tableFooterView = reconnectingView = nil;
}
}
/**
Detect if the current connection must be restarted.
The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called).
*/
- (void)detectPullToKick:(UIScrollView *)scrollView
{
if (roomDataSource.isLive && !reconnectingView)
{
// detect if the user scrolls over the tableview bottom
restartConnection = (
((scrollView.contentSize.height < scrollView.frame.size.height) && (scrollView.contentOffset.y > 128))
||
((scrollView.contentSize.height > scrollView.frame.size.height) && (scrollView.contentOffset.y + scrollView.frame.size.height) > (scrollView.contentSize.height + 128)));
if (restartConnection)
{
// wait that list decelerate to display / hide it
[self addReconnectingView];
}
}
}
/**
Restarts the current connection if it is required.
The 0.3s delay is added to avoid flickering if the connection does not require to be restarted.
*/
- (void)managePullToKick:(UIScrollView *)scrollView
{
// the current connection must be restarted
if (roomDataSource.isLive && restartConnection)
{
// display at least 0.3s the spinner to show to the user that something is pending
// else the UI is flickering
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
self->restartConnection = NO;
if (![self canReconnect])
{
// if the event stream has not been restarted
// hide the spinner
[self removeReconnectingView];
}
// else wait that onSyncNotification is called.
});
}
}
#pragma mark - MXKSourceAttachmentAnimatorDelegate
- (UIImageView *)originalImageView {
if ([self.openedAttachmentEventId isEqualToString:self.closedAttachmentEventId]) {
return self.openedAttachmentImageView;
}
return nil;
}
- (CGRect)convertedFrameForOriginalImageView {
if ([self.openedAttachmentEventId isEqualToString:self.closedAttachmentEventId]) {
return [self.openedAttachmentImageView convertRect:self.openedAttachmentImageView.frame toView:nil];
}
//default frame which will be used if the user scrolls to other attachments in MXKAttachmentsViewController
return CGRectMake(CGRectGetWidth(self.view.frame)/2, 0.0, 0.0, 0.0);
}
#pragma mark - Encryption key sharing
- (void)shareEncryptionKeys
{
__block NSString *roomId = roomDataSource.roomId;
[roomDataSource.mxSession.crypto ensureEncryptionInRoom:roomId success:^{
MXLogDebug(@"[MXKRoomViewController] Key shared for room: %@", roomId);
} failure:^(NSError *error) {
MXLogDebug(@"[MXKRoomViewController] Failed to share key for room %@: %@", roomId, error);
}];
}
@end