/* 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 #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 () { /** 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 )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]; } // bwi: #6019 show error alert with custom german translation for M_FORBIDDEN error else if ([mxError.errcode isEqualToString:kMXErrCodeStringForbidden]) { errorTitle = [VectorL10n roomErrorCannotLoadTimeline]; if ([mxError.error isEqualToString:@"You don't have permission to access that event."]) { errorMessage = [BWIL10n bwiErrorRoomHistoryNotAvailable]; } else { errorMessage = mxError.error; } } 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]; // bwi #5393 track federated joins if (BWIBuildSettings.shared.isFederationEnabled) { if ([self.roomDataSource.room amiFederated]) { NSString *name = self.roomDataSource.roomState.isJoinRulePublic ? @"federated_invite_public" : @"federated_invite"; [BWIAnalytics.sharedTracker trackEventWithCategory:@"Federation" action:@"JoinRoom" name: name number:nil]; } } 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*)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]; } else if ([msg isEqualToString:@"Server is banned from room"]) // bwi: #5716 show custom error message when the federation was disabled after the user has been invited to a federated room { msg = BWIL10n.roomErrorJoinFailedFederationDisabledMessage; } 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)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)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)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)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)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:^(NSString *eventId) { // bwi: 5365 NSURL *url = [(AVURLAsset *)videoAsset URL]; if(url) { [self deleteFileAtURL: url]; } } failure:^(NSError *error) { // Nothing to do. The video is marked as unsent in the room history by the datasource MXLogDebug(@"[MXKRoomViewController] sendVideo failed."); // bwi: 5365 NSURL *url = [(AVURLAsset *)videoAsset URL]; if(url) { [self deleteFileAtURL: url]; } }]; } - (void)deleteFileAtURL:(NSURL *)url { if (url == nil) { return; } NSFileManager *fileManager = [NSFileManager defaultManager]; NSError *error = nil; if (![fileManager removeItemAtURL:url error:&error]) { MXLogDebug(@"Cannot delete file at %@", [url absoluteString]) MXLogDebug(@"%@", [error localizedDescription]) } } - (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 *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