/* Copyright 2018-2024 New Vector Ltd. Copyright 2017 Vector Creations Ltd Copyright 2014 OpenMarket Ltd Copyright (c) 2021 BWI GmbH SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ @import MobileCoreServices; #import "RoomViewController.h" #import "RoomDataSource.h" #import "RoomBubbleCellData.h" #import "RoomInputToolbarView.h" #import "DisabledRoomInputToolbarView.h" #import "RoomActivitiesView.h" #import "AttachmentsViewController.h" #import "EventDetailsView.h" #import "RoomAvatarTitleView.h" #import "ExpandedRoomTitleView.h" #import "SimpleRoomTitleView.h" #import "PreviewRoomTitleView.h" #import "RoomMemberDetailsViewController.h" #import "ContactDetailsViewController.h" #import "SegmentedViewController.h" #import "RoomSettingsViewController.h" #import "RoomFilesViewController.h" #import "RoomSearchViewController.h" #import "UsersDevicesViewController.h" #import "ReadReceiptsViewController.h" #ifdef CALL_STACK_JINGLE #import "JitsiViewController.h" #endif #import "RoomEmptyBubbleCell.h" #import "RoomMembershipExpandedBubbleCell.h" #import "MXKRoomBubbleTableViewCell+Riot.h" #import "AvatarGenerator.h" #import "Tools.h" #import "WidgetManager.h" #import "ShareManager.h" #import "GBDeviceInfo_iOS.h" #import "RoomEncryptedDataBubbleCell.h" #import "EncryptionInfoView.h" #import "MXRoom+Riot.h" #import "IntegrationManagerViewController.h" #import "WidgetPickerViewController.h" #import "StickerPickerViewController.h" #import "EventFormatter.h" #import "RoomViewController+ContentScanner.h" #import "SettingsViewController.h" #import "SecurityViewController.h" #import "TypingUserInfo.h" #import "MXSDKOptions.h" #import "RoomTimelineCellProvider.h" #import "GeneratedInterface-Swift.h" #import #import NSNotificationName const RoomCallTileTappedNotification = @"RoomCallTileTappedNotification"; NSNotificationName const RoomGroupCallTileTappedNotification = @"RoomGroupCallTileTappedNotification"; const NSTimeInterval kResizeComposerAnimationDuration = .05; static const int kThreadListBarButtonItemTag = 99; static UIEdgeInsets kThreadListBarButtonItemContentInsetsNoDot; static UIEdgeInsets kThreadListBarButtonItemContentInsetsDot; static CGSize kThreadListBarButtonItemImageSize; @interface RoomViewController () { // The preview header __weak PreviewRoomTitleView *previewHeader; // The user taps on a user id contained in a message MXKContact *selectedContact; // List of members who are typing in the room. NSArray *currentTypingUsers; // Typing notifications listener. __weak id typingNotifListener; // The position of the first touch down event stored in case of scrolling when the expanded header is visible. CGPoint startScrollingPoint; // Missed discussions badge NSUInteger missedDiscussionsCount; NSUInteger missedHighlightCount; UILabel *missedDiscussionsBadgeLabel; UIView *missedDiscussionsDotView; // Potential encryption details view. __weak EncryptionInfoView *encryptionInfoView; // The list of unknown devices that prevent outgoing messages from being sent MXUsersDevicesMap *unknownDevices; // Observe kAppDelegateDidTapStatusBarNotification to handle tap on clock status bar. __weak id kAppDelegateDidTapStatusBarNotificationObserver; // Observe kAppDelegateNetworkStatusDidChangeNotification to handle network status change. __weak id kAppDelegateNetworkStatusDidChangeNotificationObserver; // Observers to manage MXSession state (and sync errors) __weak id kMXSessionStateDidChangeObserver; // Observers to manage ongoing conference call banner __weak id kMXCallStateDidChangeObserver; __weak id kMXCallManagerConferenceStartedObserver; __weak id kMXCallManagerConferenceFinishedObserver; // Observers to manage widgets __weak id kMXKWidgetManagerDidUpdateWidgetObserver; // Observer kMXRoomSummaryDidChangeNotification to keep updated the missed discussion count __weak id mxRoomSummaryDidChangeObserver; // Observer for removing the re-request explanation/waiting dialog __weak id mxEventDidDecryptNotificationObserver; // The table view cell in which the read marker is displayed (nil by default). MXKRoomBubbleTableViewCell *readMarkerTableViewCell; // Tell whether the view controller is appeared or not. BOOL isAppeared; // A flag indicating whether a room has been left BOOL isRoomLeft; // The last known frame of the view used to detect whether size-related layout change is needed CGRect lastViewBounds; // Tell whether the room has a Jitsi call or not. BOOL hasJitsiCall; // The right bar button items back up. NSArray *rightBarButtonItems; // Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change. __weak id kThemeServiceDidChangeThemeNotificationObserver; // Observe URL preview updates to refresh cells. __weak id URLPreviewDidUpdateNotificationObserver; // Listener for `m.room.tombstone` event type __weak id tombstoneEventNotificationsListener; // Homeserver notices MXServerNotices *serverNotices; // Formatted body parser for events FormattedBodyParser *formattedBodyParser; // Time to display notification content in the timeline MXTaskProfile *notificationTaskProfile; // Observe kMXEventTypeStringRoomMember events __weak id roomMemberEventListener; } @property (nonatomic, strong) RemoveJitsiWidgetView *removeJitsiWidgetView; @property (nonatomic, strong) RoomContextualMenuViewController *roomContextualMenuViewController; @property (nonatomic, strong) RoomContextualMenuPresenter *roomContextualMenuPresenter; @property (nonatomic, strong) MXKErrorAlertPresentation *errorPresenter; @property (nonatomic, strong) NSAttributedString *textMessageBeforeEditing; @property (nonatomic, strong) NSString *htmlTextBeforeEditing; @property (nonatomic, strong) EditHistoryCoordinatorBridgePresenter *editHistoryPresenter; @property (nonatomic, strong) MXKDocumentPickerPresenter *documentPickerPresenter; @property (nonatomic, strong) EmojiPickerCoordinatorBridgePresenter *emojiPickerCoordinatorBridgePresenter; @property (nonatomic, strong) ReactionHistoryCoordinatorBridgePresenter *reactionHistoryCoordinatorBridgePresenter; @property (nonatomic, strong) CameraPresenter *cameraPresenter; @property (nonatomic, strong) MediaPickerCoordinatorBridgePresenter *mediaPickerPresenter; @property (nonatomic, strong) RoomMessageURLParser *roomMessageURLParser; @property (nonatomic, strong) RoomCreationModalCoordinatorBridgePresenter *roomCreationModalCoordinatorBridgePresenter; @property (nonatomic, strong) RoomInfoCoordinatorBridgePresenter *roomInfoCoordinatorBridgePresenter; @property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController; @property (nonatomic, strong) RoomParticipantsInviteCoordinatorBridgePresenter *participantsInvitePresenter; @property (nonatomic, strong) ThreadsCoordinatorBridgePresenter *threadsBridgePresenter; @property (nonatomic, strong) ThreadsBetaCoordinatorBridgePresenter *threadsBetaBridgePresenter; @property (nonatomic, strong) SlidingModalPresenter *threadsNoticeModalPresenter; @property (nonatomic, strong) ComposerCreateActionListBridgePresenter *composerCreateActionListBridgePresenter; @property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded; @property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden; @property (nonatomic, getter=isMissedDiscussionsBadgeHidden) BOOL missedDiscussionsBadgeHidden; @property (nonatomic, strong) VoiceMessageController *voiceMessageController; @property (nonatomic, strong) SpaceDetailPresenter *spaceDetailPresenter; @property (nonatomic, strong) ShareManager *shareManager; @property (nonatomic, strong) EventMenuBuilder *eventMenuBuilder; @property (nonatomic, strong) CompletionSuggestionCoordinatorBridge *completionSuggestionCoordinator; @property (nonatomic, weak) IBOutlet UIView *completionSuggestionContainerView; @property (nonatomic, readwrite) RoomDisplayConfiguration *displayConfiguration; // The direct chat target user. The room timeline is presented without an actual room until the direct chat is created @property (nonatomic, nullable, strong) MXUser *directChatTargetUser; // When layout of the screen changes (e.g. height), we no longer know whether // to autoscroll to the bottom again or not. Instead we need to capture the // scroll state just before the layout change, and restore it after the layout. @property (nonatomic) BOOL wasScrollAtBottomBeforeLayout; // Check if we should wait for other participants @property (nonatomic, readonly) BOOL shouldWaitForOtherParticipants; // bwi: #5304 show federation decision sheet @property (nonatomic) BOOL wasFederationDecisionSheetShownBefore; @end @implementation RoomViewController @synthesize roomPreviewData; #pragma mark - Class methods + (void)initialize { kThreadListBarButtonItemContentInsetsNoDot = UIEdgeInsetsMake(0, 8, 0, 8); kThreadListBarButtonItemContentInsetsDot = UIEdgeInsetsMake(0, 8, 6, 8); kThreadListBarButtonItemImageSize = CGSizeMake(21, 21); } + (UINib *)nib { return [UINib nibWithNibName:NSStringFromClass(self.class) bundle:[NSBundle bundleForClass:self.class]]; } + (instancetype)roomViewController { RoomViewController *controller = [[[self class] alloc] initWithNibName:NSStringFromClass(self.class) bundle:[NSBundle bundleForClass:self.class]]; controller.displayConfiguration = [RoomDisplayConfiguration default]; return controller; } + (instancetype)instantiateWithConfiguration:(RoomDisplayConfiguration *)configuration { UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle mainBundle]]; NSString *storyboardId = [NSString stringWithFormat:@"%@StoryboardId", self.className]; RoomViewController *controller = [storyboard instantiateViewControllerWithIdentifier:storyboardId]; controller.displayConfiguration = configuration; return controller; } + (NSString *)className { NSString *result = NSStringFromClass(self.class); if ([result containsString:@"."]) { result = [result componentsSeparatedByString:@"."].lastObject; } return result; } #pragma mark - - (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; if (self) { // Disable auto join self.autoJoinInvitedRoom = NO; // Disable auto scroll to bottom on keyboard presentation self.scrollHistoryToTheBottomOnKeyboardPresentation = NO; } return self; } - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (self) { // Disable auto join self.autoJoinInvitedRoom = NO; // Disable auto scroll to bottom on keyboard presentation self.scrollHistoryToTheBottomOnKeyboardPresentation = NO; } return self; } #pragma mark - - (void)finalizeInit { [super finalizeInit]; [self registerPillAttachmentViewProviderIfNeeded]; self.resizeComposerAnimationDuration = kResizeComposerAnimationDuration; // Setup `MXKViewControllerHandling` properties self.enableBarTintColorStatusChange = NO; self.rageShakeManager = [RageShakeManager sharedManager]; formattedBodyParser = [FormattedBodyParser new]; self.eventMenuBuilder = [EventMenuBuilder new]; _showMissedDiscussionsBadge = YES; _scrollToBottomHidden = YES; _isWaitingForOtherParticipants = NO; // Listen to the event sent state changes [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidChangeSentState:) name:kMXEventDidChangeSentStateNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:nil]; // Show / hide actions button in document preview according BuildSettings self.allowActionsInDocumentPreview = BWIBuildSettings.shared.messageDetailsAllowShare; _voiceMessageController = [[VoiceMessageController alloc] initWithThemeService:ThemeService.shared mediaServiceProvider:VoiceMessageMediaServiceProvider.sharedProvider]; self.voiceMessageController.delegate = self; } - (void)viewDidLoad { [super viewDidLoad]; // Register first customized cell view classes used to render bubbles [[RoomTimelineConfiguration shared].currentStyle.cellProvider registerCellsForTableView:self.bubblesTableView]; [self vc_removeBackTitle]; if (BWIBuildSettings.shared.enableAntivirusScan) { // Register antivirus scan cells [self registerContentScannerCells]; } // Display leftBarButtonItems or leftBarButtonItem to the right of the Back button self.navigationItem.leftItemsSupplementBackButton = YES; [self setupRemoveJitsiWidgetRemoveView]; dispatch_async(dispatch_get_main_queue(), ^{ // Replace the default input toolbar view. // Note: this operation will force the layout of subviews. That is why cell view classes must be registered before. [self updateRoomInputToolbarViewClassIfNeeded]; }); // set extra area [self setRoomActivitiesViewClass:RoomActivitiesView.class]; // Custom the attachmnet viewer [self setAttachmentsViewerClass:AttachmentsViewController.class]; // Custom the event details view [self setEventDetailsViewClass:EventDetailsView.class]; // Prepare missed dicussion badge (if any) self.showMissedDiscussionsBadge = _showMissedDiscussionsBadge; // Refresh the waiting for other participants state [self refreshWaitForOtherParticipantsState]; // Set up the room title view according to the data source (if any) [self refreshRoomTitle]; // Refresh tool bar if the room data source is set. if (self.roomDataSource) { [self refreshRoomInputToolbar]; } self.roomContextualMenuPresenter = [RoomContextualMenuPresenter new]; self.errorPresenter = [MXKErrorAlertPresentation new]; self.roomMessageURLParser = [RoomMessageURLParser new]; self.jumpToLastUnreadLabel.text = [VectorL10n roomJumpToFirstUnread]; if(BWIBuildSettings.shared.bwiEnableBuMUI) { self.jumpToLastUnreadImageView.image = [UIImage imageNamed:@"room_scroll_up_bum"]; } else { self.jumpToLastUnreadImageView.image = [UIImage imageNamed:@"room_scroll_up"]; } MXWeakify(self); // Observe user interface theme change. kThemeServiceDidChangeThemeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kThemeServiceDidChangeThemeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); [self userInterfaceThemeDidChange]; }]; [self userInterfaceThemeDidChange]; // Observe URL preview updates. [self registerURLPreviewNotifications]; [self setupActions]; [self setupCompletionSuggestionViewIfNeeded]; [self.topBannersStackView vc_removeAllSubviews]; // bwi: #5304 show federation decision sheet self.wasFederationDecisionSheetShownBefore = false; } - (void)userInterfaceThemeDidChange { // Consider the main navigation controller if the current view controller is embedded inside a split view controller. UINavigationController *mainNavigationController = self.navigationController; if (self.splitViewController.isCollapsed && self.splitViewController.viewControllers.count) { mainNavigationController = self.splitViewController.viewControllers.firstObject; } [ThemeService.shared.theme applyStyleOnNavigationBar:self.navigationController.navigationBar]; if (mainNavigationController) { [ThemeService.shared.theme applyStyleOnNavigationBar:mainNavigationController.navigationBar]; } // Keep navigation bar transparent in some cases if (!self.previewHeaderContainer.hidden) { self.navigationController.navigationBar.translucent = YES; mainNavigationController.navigationBar.translucent = YES; } [self.inputToolbarView customizeViewRendering]; self.activityIndicator.backgroundColor = ThemeService.shared.theme.overlayBackgroundColor; [self.removeJitsiWidgetView updateWithTheme:ThemeService.shared.theme]; // Prepare jump to last unread banner self.jumpToLastUnreadImageView.tintColor = ThemeService.shared.theme.tintColor; self.jumpToLastUnreadLabel.textColor = ThemeService.shared.theme.textPrimaryColor; self.previewHeaderContainer.backgroundColor = ThemeService.shared.theme.headerBackgroundColor; // Check the table view style to select its bg color. self.bubblesTableView.backgroundColor = ((self.bubblesTableView.style == UITableViewStylePlain) ? ThemeService.shared.theme.backgroundColor : ThemeService.shared.theme.headerBackgroundColor); self.bubblesTableView.separatorColor = ThemeService.shared.theme.lineBreakColor; self.view.backgroundColor = self.bubblesTableView.backgroundColor; if (self.bubblesTableView.dataSource) { [self.bubblesTableView reloadData]; } [self.scrollToBottomButton vc_addShadowWithColor:ThemeService.shared.theme.shadowColor offset:CGSizeMake(0, 4) radius:6 opacity:0.2]; self.inputBackgroundView.backgroundColor = [ThemeService.shared.theme.backgroundColor colorWithAlphaComponent:0.98]; if (ThemeService.shared.isCurrentThemeDark) { [self.scrollToBottomButton setImage:AssetImages.scrolldownDark.image forState:UIControlStateNormal]; self.jumpToLastUnreadBanner.backgroundColor = ThemeService.shared.theme.colors.navigation; [self.jumpToLastUnreadBanner vc_removeShadow]; self.resetReadMarkerButton.tintColor = ThemeService.shared.theme.colors.quarterlyContent; if (self.maximisedToolbarDimmingView) { self.maximisedToolbarDimmingView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.29]; } } else { [self.scrollToBottomButton setImage:AssetImages.scrolldown.image forState:UIControlStateNormal]; self.jumpToLastUnreadBanner.backgroundColor = ThemeService.shared.theme.colors.background; [self.jumpToLastUnreadBanner vc_addShadowWithColor:ThemeService.shared.theme.shadowColor offset:CGSizeMake(0, 4) radius:8 opacity:0.1]; self.resetReadMarkerButton.tintColor = ThemeService.shared.theme.colors.tertiaryContent; if (self.maximisedToolbarDimmingView) { self.maximisedToolbarDimmingView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.12]; } } self.scrollToBottomBadgeLabel.badgeColor = ThemeService.shared.theme.tintColor; [self updateThreadListBarButtonBadgeWith:self.mainSession.threadingService]; [self.liveLocationSharingBannerView updateWithTheme:ThemeService.shared.theme]; [self setNeedsStatusBarAppearanceUpdate]; } - (UIStatusBarStyle)preferredStatusBarStyle { return ThemeService.shared.theme.statusBarStyle; } - (void)didReceiveMemoryWarning { [super didReceiveMemoryWarning]; // Dispose of any resources that can be recreated. } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // Refresh the room title view [self refreshRoomTitle]; // refresh remove Jitsi widget view [self refreshRemoveJitsiWidgetView]; // Refresh tool bar if the room data source is set. if (self.roomDataSource) { [self refreshRoomInputToolbar]; } // Reset typing notification in order to remove the allocated space if ([self.roomDataSource isKindOfClass:RoomDataSource.class]) { [((RoomDataSource*)self.roomDataSource) resetTypingNotification]; } [self listenTypingNotifications]; [self listenCallNotifications]; [self listenWidgetNotifications]; [self listenTombstoneEventNotifications]; [self listenMXSessionStateChangeNotifications]; MXWeakify(self); // Observe kAppDelegateDidTapStatusBarNotification. kAppDelegateDidTapStatusBarNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kAppDelegateDidTapStatusBarNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); [self setBubbleTableViewContentOffset:CGPointMake(-self.bubblesTableView.adjustedContentInset.left, -self.bubblesTableView.adjustedContentInset.top) animated:YES]; }]; if ([self.roomDataSource.roomId isEqualToString:[LegacyAppDelegate theDelegate].lastNavigatedRoomIdFromPush]) { [self startActivityIndicator]; [self.roomDataSource reload]; [LegacyAppDelegate theDelegate].lastNavigatedRoomIdFromPush = nil; notificationTaskProfile = [MXSDKOptions.sharedInstance.profiler startMeasuringTaskWithName:MXTaskProfileNameNotificationsOpenEvent]; } [self updateTopBanners]; self.bubblesTableView.clipsToBounds = NO; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; // hide action if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } [self removeTypingNotificationsListener]; if (self.customizedRoomDataSource) { // Cancel potential selected event (to leave edition mode) if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } } [self cancelEventHighlight]; // Hide preview header to restore navigation bar settings [self showPreviewHeader:NO]; if (kAppDelegateDidTapStatusBarNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kAppDelegateDidTapStatusBarNotificationObserver]; kAppDelegateDidTapStatusBarNotificationObserver = nil; } [self removeCallNotificationsListeners]; [self removeWidgetNotificationsListeners]; [self removeTombstoneEventNotificationsListener]; [self removeMXSessionStateChangeNotificationsListener]; // Re-enable the read marker display, and disable its update. self.roomDataSource.showReadMarker = YES; self.updateRoomReadMarker = NO; isAppeared = NO; [VoiceMessageMediaServiceProvider.sharedProvider pauseAllServices]; [VoiceBroadcastRecorderProvider.shared pauseRecording]; [VoiceBroadcastPlaybackProvider.shared pausePlaying]; if([BWIBuildSettings.shared showMentionsInRoom]) { [Mentions setCountMentionsToZeroWithRoomID:self.roomDataSource.roomId]; } // Stop the loading indicator even if the session is still in progress [self stopLoadingUserIndicator]; [self setMaximisedToolbarIsHiddenIfNeeded: YES]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // Screen tracking MXRoomSummary *summary = [self.mainSession roomWithRoomId:self.roomDataSource.roomId].summary; if (!summary || !summary.isJoined) { [AnalyticsScreenTracker trackScreen: AnalyticsScreenRoomPreview]; } else { [AnalyticsScreenTracker trackScreen: AnalyticsScreenRoom]; } isAppeared = YES; [self checkReadMarkerVisibility]; if (self.roomDataSource) { // Set visible room id [AppDelegate theDelegate].visibleRoomId = self.roomDataSource.roomId; } MXWeakify(self); // Observe network reachability kAppDelegateNetworkStatusDidChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kAppDelegateNetworkStatusDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); [self refreshActivitiesViewDisplay]; }]; [self refreshActivitiesViewDisplay]; [self refreshJumpToLastUnreadBannerDisplay]; // Observe missed notifications mxRoomSummaryDidChangeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomSummaryDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); MXRoomSummary *roomSummary = notif.object; if ([roomSummary.roomId isEqualToString:self.roomDataSource.roomId]) { [self refreshMissedDiscussionsCount:NO]; } }]; [self refreshMissedDiscussionsCount:YES]; self.keyboardHeight = MAX(self.keyboardHeight, 0); if (hasJitsiCall && !self.isRoomHavingAJitsiCall) { // the room had a Jitsi call before, but not now hasJitsiCall = NO; [self reloadBubblesTable:YES]; } self.showSettingsInitially = NO; // bwi: check if threads are enabled and show thread notice once if enabled if (!RiotSettings.shared.threadsNoticeDisplayed && self.showThreadOption) { [self showThreadsNotice]; } if (self.saveProgressTextInput && self.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) [self.inputToolbarView setPartialContent:self.roomDataSource.partialAttributedTextMessage]; } [self setMaximisedToolbarIsHiddenIfNeeded: NO]; // bwi: #5304 show federation decision sheet only for admins and only in rooms / not in DMs [self showFederationDecisionSheet]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; // Hide contextual menu if needed [self hideContextualMenuAnimated:NO]; // Reset visible room id [AppDelegate theDelegate].visibleRoomId = nil; if (kAppDelegateNetworkStatusDidChangeNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kAppDelegateNetworkStatusDidChangeNotificationObserver]; kAppDelegateNetworkStatusDidChangeNotificationObserver = nil; } if (mxRoomSummaryDidChangeObserver) { [[NSNotificationCenter defaultCenter] removeObserver:mxRoomSummaryDidChangeObserver]; mxRoomSummaryDidChangeObserver = nil; } if (mxEventDidDecryptNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:mxEventDidDecryptNotificationObserver]; mxEventDidDecryptNotificationObserver = nil; } if (self.isRoomHavingAJitsiCall) { hasJitsiCall = YES; [self reloadBubblesTable:YES]; } } - (void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; self.wasScrollAtBottomBeforeLayout = self.isBubblesTableScrollViewAtTheBottom; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; BOOL didViewChangeBounds = !CGRectEqualToRect(lastViewBounds, self.view.bounds); lastViewBounds = self.view.bounds; UIEdgeInsets contentInset = self.bubblesTableView.contentInset; contentInset.bottom = self.view.safeAreaInsets.bottom; self.bubblesTableView.contentInset = contentInset; // Check here whether a subview has been added or removed if (encryptionInfoView) { if (!encryptionInfoView.superview) { // Reset encryptionInfoView = nil; // Reload the full table to take into account a potential change on a device status. [self.bubblesTableView reloadData]; } } if (eventDetailsView) { if (!eventDetailsView.superview) { // Reset eventDetailsView = nil; } } // Check whether the preview header is visible if (previewHeader) { if (previewHeader.mainHeaderContainer.isHidden) { // Check here the main background height to display a correct navigation bar background. CGRect frame = self.navigationController.navigationBar.frame; CGFloat mainHeaderBackgroundHeight = frame.size.height + (frame.origin.y > 0 ? frame.origin.y : 0); if (previewHeader.mainHeaderBackgroundHeightConstraint.constant != mainHeaderBackgroundHeight) { previewHeader.mainHeaderBackgroundHeightConstraint.constant = mainHeaderBackgroundHeight; // Force the layout of previewHeader to update the position of 'bottomBorderView' which // is used to define the actual height of the preview container. [previewHeader layoutIfNeeded]; } } self.edgesForExtendedLayout = UIRectEdgeAll; // Adjust the top constraint of the bubbles table CGRect frame = previewHeader.bottomBorderView.frame; self.previewHeaderContainerHeightConstraint.constant = frame.origin.y + frame.size.height; self.bubblesTableViewTopConstraint.constant = self.previewHeaderContainerHeightConstraint.constant - self.bubblesTableView.adjustedContentInset.top; } else { // In non expanded header mode, the navigation bar is opaque // The table view must not display behind it self.edgesForExtendedLayout = UIRectEdgeLeft | UIRectEdgeBottom | UIRectEdgeRight; } // re-scroll to the bottom, if at bottom before the most recent layout if (self.wasScrollAtBottomBeforeLayout && didViewChangeBounds) { self.wasScrollAtBottomBeforeLayout = NO; [self scrollBubblesTableViewToBottomAnimated:NO]; } [self refreshMissedDiscussionsCount:YES]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id )coordinator { if ([self.titleView isKindOfClass:RoomTitleView.class]) { RoomTitleView *roomTitleView = (RoomTitleView*)self.titleView; if (UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation)) { [roomTitleView updateLayoutForOrientation:UIInterfaceOrientationPortrait]; } else { [roomTitleView updateLayoutForOrientation:UIInterfaceOrientationLandscapeLeft]; } } // Hide the expanded header or the preview in case of iPad and iPhone 6 plus. // On these devices, the display mode of the splitviewcontroller may change during screen rotation. // It may correspond to an overlay mode in portrait and a side-by-side mode in landscape. // This display mode change involves a change at the navigation bar level. // If we don't hide the header, the navigation bar is in a wrong state after rotation. FIXME: Find a way to keep visible the header on rotation. if ([GBDeviceInfo deviceInfo].family == GBDeviceFamilyiPad || [GBDeviceInfo deviceInfo].displayInfo.display >= GBDeviceDisplay5p5Inch) { // Hide the preview header (if any) before rotating (It will be restored by `refreshRoomTitle` call if this is still a room preview). [self showPreviewHeader:NO]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((coordinator.transitionDuration + 0.5) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ // Let [self refreshRoomTitle] refresh this title view correctly [self refreshRoomTitle]; }); } else if (previewHeader) { // Refresh here the preview header according to the coming screen orientation. // Retrieve the affine transform indicating the amount of rotation being applied to the interface. // This transform is the identity transform when no rotation is applied. // Otherwise, it is a transform that applies a 90 degree, -90 degree, or 180 degree rotation. CGAffineTransform transform = coordinator.targetTransform; // Consider here only the transform that applies a +/- 90 degree. if (transform.b * transform.c == -1) { UIInterfaceOrientation currentScreenOrientation = [[UIApplication sharedApplication] statusBarOrientation]; BOOL isLandscapeOriented = YES; switch (currentScreenOrientation) { case UIInterfaceOrientationLandscapeRight: case UIInterfaceOrientationLandscapeLeft: { // We leave here landscape orientation isLandscapeOriented = NO; break; } default: break; } [self refreshPreviewHeader:isLandscapeOriented]; } } else { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((coordinator.transitionDuration + 0.5) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ // Refresh the room title at the end of the transition to take into account the potential changes during the transition. // For example the display of a preview header is ignored during transition. [self refreshRoomTitle]; }); } [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; } #pragma mark - Accessibility // Handle scrolling when VoiceOver is on because it does not work well if we let the system do: // VoiceOver loses the focus on the tableview - (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction { BOOL canScroll = YES; // Scroll by one page CGFloat tableViewHeight = self.bubblesTableView.frame.size.height; CGPoint offset = self.bubblesTableView.contentOffset; switch (direction) { case UIAccessibilityScrollDirectionUp: offset.y -= tableViewHeight; break; case UIAccessibilityScrollDirectionDown: offset.y += tableViewHeight; break; default: break; } if (offset.y < 0 && ![self.roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards]) { // Can't paginate more. Let's stick on the first item UIView *focusedView = [self firstCellWithAccessibilityDataInCells:self.bubblesTableView.visibleCells.objectEnumerator]; UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, focusedView); canScroll = NO; } else if (offset.y > self.bubblesTableView.contentSize.height - tableViewHeight && ![self.roomDataSource.timeline canPaginate:MXTimelineDirectionForwards]) { // Can't paginate more. Let's stick on the last item with accessibility UIView *focusedView = [self firstCellWithAccessibilityDataInCells:self.bubblesTableView.visibleCells.reverseObjectEnumerator]; UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, focusedView); canScroll = NO; } else { // Disable VoiceOver while scrolling self.bubblesTableView.accessibilityElementsHidden = YES; [self setBubbleTableViewContentOffset:offset animated:NO]; NSEnumerator *cells; if (direction == UIAccessibilityScrollDirectionUp) { cells = self.bubblesTableView.visibleCells.objectEnumerator; } else { cells = self.bubblesTableView.visibleCells.reverseObjectEnumerator; } UIView *cell = [self firstCellWithAccessibilityDataInCells:cells]; self.bubblesTableView.accessibilityElementsHidden = NO; // Force VoiceOver to focus on a visible item UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, cell); } // If we cannot scroll, let VoiceOver indicates the border return canScroll; } - (UIView*)firstCellWithAccessibilityDataInCells:(NSEnumerator*)cells { UIView *view; for (UITableViewCell *cell in cells) { if (![cell isKindOfClass:[RoomEmptyBubbleCell class]]) { view = cell; break; } } return view; } #pragma mark - Override MXKRoomViewController - (void)addMatrixSession:(MXSession *)mxSession { [super addMatrixSession:mxSession]; [mxSession.threadingService addDelegate:self]; [self updateThreadListBarButtonBadgeWith:mxSession.threadingService]; } - (void)removeMatrixSession:(MXSession *)mxSession { [mxSession.threadingService removeDelegate:self]; [super removeMatrixSession:mxSession]; } - (void)onMatrixSessionChange { [super onMatrixSessionChange]; // Re-enable the read marker display, and disable its update. self.roomDataSource.showReadMarker = YES; self.updateRoomReadMarker = NO; } #pragma mark - Loading indicators - (BOOL)providesCustomActivityIndicator { return YES; } // Override of a legacy method to determine whether to use a newer implementation instead. // Will be removed in the future https://github.com/vector-im/element-ios/issues/5608 - (void)startActivityIndicator { [self.delegate roomViewControllerDidStartLoading:self]; } // Override of a legacy method to determine whether to use a newer implementation instead. // Will be removed in the future https://github.com/vector-im/element-ios/issues/5608 - (void)stopActivityIndicator { if (notificationTaskProfile) { // Consider here we have displayed the message corresponding to the notification [MXSDKOptions.sharedInstance.profiler stopMeasuringTaskWithProfile:notificationTaskProfile]; notificationTaskProfile = nil; } // The legacy super implementation of `stopActivityIndicator` contains a number of checks grouped under `canStopActivityIndicator` // to determine whether the indicator can be stopped or not (and the method should thus rather be called `stopActivityIndicatorIfPossible`). // Since the newer indicators are not calling super implementation, the check for `canStopActivityIndicator` has to be performed manually. if ([self canStopActivityIndicator]) { [self stopLoadingUserIndicator]; } } - (void)stopLoadingUserIndicator { [self.delegate roomViewControllerDidStopLoading:self]; } - (void)displayRoom:(MXKRoomDataSource *)dataSource { // Remove potential preview Data if (roomPreviewData) { roomPreviewData = nil; [self removeMatrixSession:self.mainSession]; } // Set potential discussion target user to nil, now use the dataSource to populate the view self.directChatTargetUser = nil; // Enable the read marker display, and disable its update. dataSource.showReadMarker = YES; self.updateRoomReadMarker = NO; [super displayRoom:dataSource]; self.customizedRoomDataSource = nil; if (self.roomDataSource) { [self listenToServerNotices]; self.eventsAcknowledgementEnabled = YES; // Store ref on customized room data source if ([dataSource isKindOfClass:RoomDataSource.class]) { self.customizedRoomDataSource = (RoomDataSource*)dataSource; } // Set room title view [self refreshRoomTitle]; // Stop any pending voice broadcast if needed [self stopUncompletedVoiceBroadcastIfNeeded]; } else { self.navigationItem.rightBarButtonItem.enabled = NO; } [self refreshRoomInputToolbar]; [VoiceMessageMediaServiceProvider.sharedProvider setCurrentRoomSummary:dataSource.room.summary]; _voiceMessageController.roomId = dataSource.roomId; _completionSuggestionCoordinator = [[CompletionSuggestionCoordinatorBridge alloc] initWithMediaManager:self.roomDataSource.mxSession.mediaManager room:dataSource.room userID:self.roomDataSource.mxSession.myUserId]; _completionSuggestionCoordinator.delegate = self; [self setupCompletionSuggestionViewIfNeeded]; [self updateRoomInputToolbarViewClassIfNeeded]; [self updateTopBanners]; } - (void)onRoomDataSourceReady { // Handle here invitation if (self.roomDataSource.room.summary.membership == MXMembershipInvite) { self.navigationItem.rightBarButtonItem.enabled = NO; // Show preview header [self showPreviewHeader:YES]; } [super onRoomDataSourceReady]; } - (void)updateViewControllerAppearanceOnRoomDataSourceState { [super updateViewControllerAppearanceOnRoomDataSourceState]; if (self.isRoomPreview) { self.navigationItem.rightBarButtonItem.enabled = NO; // Remove input tool bar if any if (self.inputToolbarView) { [super setRoomInputToolbarViewClass:nil]; } if (previewHeader) { previewHeader.mxRoom = self.roomDataSource.room; // Force the layout of subviews (some constraints may have been updated) [self forceLayoutRefresh]; } } else if (self.isNewDirectChat) { [self refreshRoomInputToolbar]; } else { [self showPreviewHeader:NO]; self.navigationItem.rightBarButtonItem.enabled = (self.roomDataSource != nil); self.titleView.editable = NO; if (self.roomDataSource) { // Update the input toolbar class and update the layout [self updateRoomInputToolbarViewClassIfNeeded]; self.inputToolbarView.hidden = (self.roomDataSource.state != MXKDataSourceStateReady); // Restore room activities view if none if (!self.activitiesView) { // And the extra area [self setRoomActivitiesViewClass:RoomActivitiesView.class]; } } } } - (void)leaveRoomOnEvent:(MXEvent*)event { // Force a simple title view initialised with the current room before leaving actually the room. [self setRoomTitleViewClass:SimpleRoomTitleView.class]; self.titleView.editable = NO; self.titleView.mxRoom = self.roomDataSource.room; // Hide the potential read marker banner. self.jumpToLastUnreadBannerContainer.hidden = YES; [super leaveRoomOnEvent:event]; [self notifyDelegateOnLeaveRoomIfNecessary]; } + (Class) mainToolbarClass { if (RiotSettings.shared.enableWysiwygComposer) { return WysiwygInputToolbarView.class; } else { return RoomInputToolbarView.class; } } // Set the input toolbar according to the current display - (void)updateRoomInputToolbarViewClassIfNeeded { Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; // If RTE is enabled, delay the toolbar setup until `completionSuggestionCoordinator` is ready. if (roomInputToolbarViewClass == WysiwygInputToolbarView.class && _completionSuggestionCoordinator == nil) { return; } BOOL shouldDismissContextualMenu = NO; // Check the user has enough power to post message if (self.roomDataSource.roomState) { MXRoomPowerLevels *powerLevels = self.roomDataSource.roomState.powerLevels; NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]; BOOL canSend = (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsMessage:kMXEventTypeStringRoomMessage]); BOOL isRoomObsolete = self.roomDataSource.roomState.isObsolete; BOOL isResourceLimitExceeded = [self.roomDataSource.mxSession.syncError.errcode isEqualToString:kMXErrCodeStringResourceLimitExceeded]; if (isRoomObsolete || isResourceLimitExceeded || _isWaitingForOtherParticipants) { roomInputToolbarViewClass = nil; shouldDismissContextualMenu = YES; } else if (!canSend) { roomInputToolbarViewClass = DisabledRoomInputToolbarView.class; shouldDismissContextualMenu = YES; } } // Do not show toolbar in case of preview if (self.isRoomPreview) { roomInputToolbarViewClass = nil; shouldDismissContextualMenu = YES; } if (shouldDismissContextualMenu) { [self hideContextualMenuAnimated:NO]; } // Change inputToolbarView class only if given class is different from current one if (!self.inputToolbarView || ![self.inputToolbarView isMemberOfClass:roomInputToolbarViewClass]) { [super setRoomInputToolbarViewClass:roomInputToolbarViewClass]; if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) { id inputToolbar = (id)self.inputToolbarView; [inputToolbar setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView]; } [self updateInputToolBarViewHeight]; [self refreshRoomInputToolbar]; } } // Get the height of the current room input toolbar - (CGFloat)inputToolbarHeight { CGFloat height = 0; if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) { id inputToolbar = (id)self.inputToolbarView; height = inputToolbar.toolbarHeight; } else if ([self.inputToolbarView isKindOfClass:DisabledRoomInputToolbarView.class]) { height = ((DisabledRoomInputToolbarView*)self.inputToolbarView).mainToolbarMinHeightConstraint.constant; } return height; } - (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass { // Do not show room activities in case of preview (FIXME: show it when live events will be supported during peeking) if (self.isRoomPreview) { roomActivitiesViewClass = nil; } [super setRoomActivitiesViewClass:roomActivitiesViewClass]; if (!self.isActivitiesViewExpanded) { self.roomActivitiesContainerHeightConstraint.constant = 0; } } - (BOOL)sendAsIRCStyleCommandIfPossible:(NSString*)string { // Override the default behavior for `/join` command in order to open automatically the joined room NSString* kMXKSlashCmdJoinRoom = [MXKSlashCommandsHelper commandNameFor:MXKSlashCommandJoinRoom]; 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) { Analytics.shared.joinedRoomTrigger = AnalyticsJoinedRoomTriggerSlashCommand; // TODO: /join command does not support via parameters yet [self.mainSession joinRoom:roomAlias viaServers:nil success:^(MXRoom *room) { [self showRoomWithId:room.roomId]; } failure:^(NSError *error) { MXLogDebug(@"[RoomVC] Join roomAlias (%@) failed", roomAlias); //Alert user [self showError:error]; }]; } else { // Display cmd usage in text input as placeholder self.inputToolbarView.placeholder = [MXKSlashCommandsHelper commandUsageFor:MXKSlashCommandJoinRoom]; } return YES; } return [super sendAsIRCStyleCommandIfPossible:string]; } - (void)setKeyboardHeight:(CGFloat)keyboardHeight { [super setKeyboardHeight:keyboardHeight]; self.inputToolbarView.maxHeight = round(([UIScreen mainScreen].bounds.size.height - keyboardHeight) * 0.7); // Make the activity indicator follow the keyboard // At runtime, this creates a smooth animation CGPoint activityIndicatorCenter = self.activityIndicator.center; activityIndicatorCenter.y = self.view.center.y - keyboardHeight / 2; self.activityIndicator.center = activityIndicatorCenter; } - (void)dismissTemporarySubViews { [super dismissTemporarySubViews]; if (encryptionInfoView) { [encryptionInfoView removeFromSuperview]; encryptionInfoView = nil; } } - (void)setBubbleTableViewDisplayInTransition:(BOOL)bubbleTableViewDisplayInTransition { if (self.isBubbleTableViewDisplayInTransition != bubbleTableViewDisplayInTransition) { [super setBubbleTableViewDisplayInTransition:bubbleTableViewDisplayInTransition]; // Refresh additional displays when the table is ready. if (!bubbleTableViewDisplayInTransition && !self.bubblesTableView.isHidden) { [self refreshActivitiesViewDisplay]; [self refreshRoomTitle]; [self checkReadMarkerVisibility]; [self refreshJumpToLastUnreadBannerDisplay]; } } } - (void)sendTextMessage:(NSString*)msgTxt { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { // bwi: evaluate send message performance PerformanceProfile *sendTextMessageProfile = [[PerformanceProfile alloc] initWithThreshold:BWIBuildSettings.shared.sendMessageThreshold]; [sendTextMessageProfile startMeasurement]; // The event modified is always fetch from the actual data source MXEvent *eventModified = [self.roomDataSource eventWithEventId:self.customizedRoomDataSource.selectedEventId]; // In the case the event is a reply or and edit, and it's done on a non-live timeline // we have to fetch live timeline in order to display the event properly [self setupRoomDataSourceToResolveEvent:^(MXKRoomDataSource *roomDataSource) { if (self.inputToolBarSendMode == RoomInputToolbarViewSendModeReply && eventModified) { [roomDataSource sendReplyToEvent:eventModified withTextMessage:msgTxt success:^(NSString *string) { [self finishTextMessageProfil:sendTextMessageProfile]; } failure:^(NSError *error) { // Just log the error. The message will be displayed in red in the room history MXLogDebug(@"[MXKRoomViewController] sendTextMessage failed."); [sendTextMessageProfile abortMeasurement]; }]; } else if (self.inputToolBarSendMode == RoomInputToolbarViewSendModeEdit && eventModified) { [roomDataSource replaceTextMessageForEvent:eventModified withTextMessage:msgTxt success:^(NSString *string) { [self finishTextMessageProfil:sendTextMessageProfile]; } failure:^(NSError *error) { // Just log the error. The message will be displayed in red in the room history MXLogDebug(@"[MXKRoomViewController] sendTextMessage failed."); [sendTextMessageProfile abortMeasurement]; }]; } else { // Let the datasource send it and manage the local echo [roomDataSource sendTextMessage:msgTxt success:^(NSString *string) { [self finishTextMessageProfil:sendTextMessageProfile]; } failure:^(NSError *error) { // Just log the error. The message will be displayed in red in the room history MXLogDebug(@"[MXKRoomViewController] sendTextMessage failed."); [sendTextMessageProfile abortMeasurement]; }]; } if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } }]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)setupRoomDataSourceToResolveEvent: (void (^)(MXKRoomDataSource *roomDataSource))onComplete { // If the event occur on timeline not live, use the live data source to resolve event BOOL isLive = self.roomDataSource.isLive; if (!isLive) { if (self.roomDataSourceLive == nil) { MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession]; [roomDataSourceManager roomDataSourceForRoom:self.roomDataSource.roomId create:YES onComplete:^(MXKRoomDataSource *roomDataSource) { self.roomDataSourceLive = roomDataSource; [self.roomDataSourceLive finalizeInitialization]; onComplete(self.roomDataSourceLive); }]; } else { onComplete(self.roomDataSourceLive); } } else { onComplete(self.roomDataSource); } } - (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]); MXKRoomTitleView *titleView = [roomTitleViewClass roomTitleView]; [self setValue:titleView forKey:@"titleView"]; titleView.delegate = self; titleView.mxRoom = self.roomDataSource.room; titleView.mxUser = self.directChatTargetUser; self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:titleView]; if ([titleView isKindOfClass:RoomTitleView.class]) { RoomTitleView *roomTitleView = (RoomTitleView*)self.titleView; missedDiscussionsBadgeLabel = roomTitleView.missedDiscussionsBadgeLabel; missedDiscussionsDotView = roomTitleView.dotView; [roomTitleView updateLayoutForOrientation:[UIApplication sharedApplication].statusBarOrientation]; } [self updateViewControllerAppearanceOnRoomDataSourceState]; [self updateTitleViewEncryptionDecoration]; } - (void)destroy { if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } if (self.customizedRoomDataSource) { self.customizedRoomDataSource.selectedEventId = nil; self.customizedRoomDataSource = nil; } [self removeTypingNotificationsListener]; if (kThemeServiceDidChangeThemeNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kThemeServiceDidChangeThemeNotificationObserver]; kThemeServiceDidChangeThemeNotificationObserver = nil; } if (kAppDelegateDidTapStatusBarNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kAppDelegateDidTapStatusBarNotificationObserver]; kAppDelegateDidTapStatusBarNotificationObserver = nil; } if (kAppDelegateNetworkStatusDidChangeNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kAppDelegateNetworkStatusDidChangeNotificationObserver]; kAppDelegateNetworkStatusDidChangeNotificationObserver = nil; } if (mxRoomSummaryDidChangeObserver) { [[NSNotificationCenter defaultCenter] removeObserver:mxRoomSummaryDidChangeObserver]; mxRoomSummaryDidChangeObserver = nil; } if (mxEventDidDecryptNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:mxEventDidDecryptNotificationObserver]; mxEventDidDecryptNotificationObserver = nil; } if (URLPreviewDidUpdateNotificationObserver) { [NSNotificationCenter.defaultCenter removeObserver:URLPreviewDidUpdateNotificationObserver]; } [self removeCallNotificationsListeners]; [self removeWidgetNotificationsListeners]; [self removeTombstoneEventNotificationsListener]; [self removeMXSessionStateChangeNotificationsListener]; [self removeServerNoticesListener]; if (previewHeader) { // Here [destroy] is called before [viewWillDisappear:] MXLogDebug(@"[RoomVC] destroyed whereas it is still visible"); [previewHeader removeFromSuperview]; previewHeader = nil; // Hide preview header container to ignore [self showPreviewHeader:NO] call (if any). self.previewHeaderContainer.hidden = YES; } roomPreviewData = nil; missedDiscussionsBadgeLabel = nil; [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeSentStateNotification object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:nil]; [self waitForOtherParticipant:NO]; [super destroy]; } #pragma mark - Start DM /** Create a direct chat with given user. */ - (void)createDiscussionWithUser:(MXUser*)user completion:(void (^)(BOOL success))onComplete { [self startActivityIndicator]; [[AppDelegate theDelegate] createDirectChatWithUserId:user.userId completion:^(NSString *roomId) { if (roomId) { MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession]; [roomDataSourceManager roomDataSourceForRoom:roomId create:YES onComplete:^(MXKRoomDataSource *roomDataSource) { [self stopActivityIndicator]; [self setRoomInputToolbarViewClass:nil]; [self displayRoom:roomDataSource]; onComplete(YES); }]; } else { [self stopActivityIndicator]; onComplete(NO); } }]; } /** Create the discussion if needed */ - (void)createDiscussionIfNeeded:(void (^)(BOOL readyToSend))onComplete { void(^completion)(BOOL) = ^(BOOL readyToSend) { self.inputToolbarView.userInteractionEnabled = true; if (onComplete) { onComplete(readyToSend); } }; if (self.directChatTargetUser) { // Disable the input tool bar during this operation. This prevents us from creating several discussions, or // trying to send several invites. self.inputToolbarView.userInteractionEnabled = false; [self createDiscussionWithUser:self.directChatTargetUser completion:completion]; } else { completion(YES); } } #pragma mark - Properties -(void)setActivitiesViewExpanded:(BOOL)activitiesViewExpanded { if (_activitiesViewExpanded != activitiesViewExpanded) { _activitiesViewExpanded = activitiesViewExpanded; self.roomActivitiesContainerHeightConstraint.constant = activitiesViewExpanded ? 53 : 0; [super roomInputToolbarView:self.inputToolbarView heightDidChanged:[self inputToolbarHeight] completion:nil]; } } - (void)setScrollToBottomHidden:(BOOL)scrollToBottomHidden { if (_scrollToBottomHidden != scrollToBottomHidden) { _scrollToBottomHidden = scrollToBottomHidden; } if (!_scrollToBottomHidden && [self.roomDataSource isKindOfClass:RoomDataSource.class]) { RoomDataSource *roomDataSource = (RoomDataSource *) self.roomDataSource; if (roomDataSource.currentTypingUsers && !roomDataSource.currentTypingUsers.count) { [roomDataSource resetTypingNotification]; [self.bubblesTableView reloadData]; } } [UIView animateWithDuration:.2 animations:^{ self.scrollToBottomBadgeLabel.alpha = (scrollToBottomHidden || !self.scrollToBottomBadgeLabel.text) ? 0 : 1; self.scrollToBottomButton.alpha = scrollToBottomHidden ? 0 : 1; }]; } - (void)setMissedDiscussionsBadgeHidden:(BOOL)missedDiscussionsBadgeHidden{ _missedDiscussionsBadgeHidden = missedDiscussionsBadgeHidden; missedDiscussionsBadgeLabel.hidden = missedDiscussionsBadgeHidden; missedDiscussionsDotView.hidden = missedDiscussionsBadgeHidden; } - (BOOL)shouldShowLiveLocationSharingBannerView { return self.customizedRoomDataSource.isCurrentUserSharingActiveLocation; } #pragma mark - Wait for 3rd party invitee - (void)setIsWaitingForOtherParticipants:(BOOL)isWaitingForOtherParticipants { if (_isWaitingForOtherParticipants == isWaitingForOtherParticipants) { return; } _isWaitingForOtherParticipants = isWaitingForOtherParticipants; [self updateRoomInputToolbarViewClassIfNeeded]; if (_isWaitingForOtherParticipants) { if (self->roomMemberEventListener == nil) { MXWeakify(self); self->roomMemberEventListener = [self.roomDataSource.room listenToEventsOfTypes:@[kMXEventTypeStringRoomMember] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { MXStrongifyAndReturnIfNil(self); if (direction != MXTimelineDirectionForwards) { return; } [self refreshWaitForOtherParticipantsState]; }]; } } else { if (self->roomMemberEventListener != nil) { [self.roomDataSource.room removeListener:self->roomMemberEventListener]; self->roomMemberEventListener = nil; } } } - (BOOL)shouldWaitForOtherParticipants { MXRoomState *roomState = self.roomDataSource.roomState; BOOL isDirect = self.roomDataSource.room.isDirect; // Wait for the other participant only if it is a direct encrypted room with only one member waiting for a third party guest. return (isDirect && roomState.isEncrypted && roomState.membersCount.members == 1 && roomState.thirdPartyInvites.count > 0); } - (void)refreshWaitForOtherParticipantsState { [self waitForOtherParticipant:self.shouldWaitForOtherParticipants]; } #pragma mark - Internals - (UIBarButtonItem *)videoCallBarButtonItem { UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:AssetImages.videoCall.image style:UIBarButtonItemStylePlain target:self action:@selector(onVideoCallPressed:)]; item.accessibilityLabel = [VectorL10n roomAccessibilityVideoCall]; return item; } - (UIBarButtonItem *)joinJitsiBarButtonItem { CallTileActionButton *button = [CallTileActionButton new]; [button setImage:AssetImages.callVideoIcon.image forState:UIControlStateNormal]; [button setTitle:[VectorL10n roomJoinGroupCall] forState:UIControlStateNormal]; [button addTarget:self action:@selector(onVideoCallPressed:) forControlEvents:UIControlEventTouchUpInside]; button.contentEdgeInsets = UIEdgeInsetsMake(4, 12, 4, 12); UIBarButtonItem *item; // bwi: check if threads are enabled if (self.showThreadOption) { // Add some spacing when there is a threads button UIView *buttonContainer = [[UIView alloc] initWithFrame:CGRectZero]; [buttonContainer vc_addSubViewMatchingParent:button withInsets:UIEdgeInsetsMake(0, 0, 0, -12)]; item = [[UIBarButtonItem alloc] initWithCustomView:buttonContainer]; } else { item = [[UIBarButtonItem alloc] initWithCustomView:button]; } item.accessibilityLabel = [VectorL10n roomAccessibilityVideoCall]; return item; } - (UIBarButtonItem *)threadMoreBarButtonItem { UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:AssetImages.roomContextMenuMore.image style:UIBarButtonItemStylePlain target:self action:@selector(onButtonPressed:)]; item.accessibilityLabel = [VectorL10n roomAccessibilityThreadMore]; return item; } - (UIBarButtonItem *)threadListBarButtonItem { UIButton *button = [UIButton new]; button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsNoDot; button.imageView.contentMode = UIViewContentModeScaleAspectFit; [button setImage:[AssetImages.threadsIcon.image vc_resizedWith:kThreadListBarButtonItemImageSize] forState:UIControlStateNormal]; [button addTarget:self action:@selector(onThreadListTapped:) forControlEvents:UIControlEventTouchUpInside]; button.accessibilityLabel = [VectorL10n roomAccessibilityThreads]; UIBarButtonItem *result = [[UIBarButtonItem alloc] initWithCustomView:button]; result.tag = kThreadListBarButtonItemTag; return result; } - (void)setupRemoveJitsiWidgetRemoveView { if (!self.displayConfiguration.jitsiWidgetRemoverEnabled) { return; } self.removeJitsiWidgetView = [RemoveJitsiWidgetView instantiate]; self.removeJitsiWidgetView.delegate = self; [self.removeJitsiWidgetContainer vc_addSubViewMatchingParent:self.removeJitsiWidgetView]; self.removeJitsiWidgetContainer.hidden = YES; [self refreshRemoveJitsiWidgetView]; } - (void)forceLayoutRefresh { // Sanity check: check whether the table view data source is set. if (self.bubblesTableView.dataSource) { [self.view layoutIfNeeded]; } } - (BOOL)isRoomPreview { if (self.isContextPreview) { return YES; } // Check first whether some preview data are defined. if (roomPreviewData) { return YES; } if (self.roomDataSource && self.roomDataSource.state == MXKDataSourceStateReady && self.roomDataSource.room.summary.membership == MXMembershipInvite) { return YES; } return NO; } // Indicates if a new direct chat with a target user (without associated room) is occuring. - (BOOL)isNewDirectChat { return self.directChatTargetUser != nil; } - (BOOL)isEncryptionEnabled { return self.roomDataSource.room.summary.isEncrypted && self.mainSession.crypto != nil; } - (BOOL)supportCallOption { if (!self.displayConfiguration.callsEnabled) { return NO; } BOOL callOptionAllowed = (self.roomDataSource.room.isDirect && RiotSettings.shared.roomScreenAllowVoIPForDirectRoom) || (!self.roomDataSource.room.isDirect && RiotSettings.shared.roomScreenAllowVoIPForNonDirectRoom); return callOptionAllowed && BWIBuildSettings.shared.allowVoIPUsage && self.roomDataSource.mxSession.callManager && self.roomDataSource.room.summary.membersCount.joined >= 2; } - (BOOL)isCallActive { MXCall *callInRoom = [self.roomDataSource.mxSession.callManager callInRoom:self.roomDataSource.roomId]; return (callInRoom && callInRoom.state != MXCallStateEnded) || self.customizedRoomDataSource.jitsiWidget; } - (BOOL)canSendStateEventWithType:(MXEventTypeString)eventTypeString { MXRoomPowerLevels *powerLevels = [self.roomDataSource.roomState powerLevels]; NSInteger requiredPower = [powerLevels minimumPowerLevelForSendingEventAsStateEvent:eventTypeString]; NSInteger myPower = [powerLevels powerLevelOfUserWithUserID:self.roomDataSource.mxSession.myUserId]; return myPower >= requiredPower; } /** Returns a flag for the current user whether it's privileged to add/remove Jitsi widgets to this room. */ - (BOOL)canEditJitsiWidget { return [self canSendStateEventWithType:kWidgetModularEventTypeString]; } - (void)registerURLPreviewNotifications { MXWeakify(self); URLPreviewDidUpdateNotificationObserver = [NSNotificationCenter.defaultCenter addObserverForName:URLPreviewDidUpdateNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull notification) { MXStrongifyAndReturnIfNil(self); // Ensure this is the correct room if (![(NSString*)notification.userInfo[@"roomId"] isEqualToString:self.roomDataSource.roomId]) { return; } // Get the indexPath for the updated cell. NSString *updatedEventId = notification.userInfo[@"eventId"]; NSInteger updatedEventIndex = [self.roomDataSource indexOfCellDataWithEventId:updatedEventId]; NSIndexPath *updatedIndexPath = [NSIndexPath indexPathForRow:updatedEventIndex inSection:0]; // Store the content size and offset before reloading the cell CGFloat originalContentSize = self.bubblesTableView.contentSize.height; CGPoint contentOffset = self.bubblesTableView.contentOffset; // Only update the content offset if the cell is visible or above the current visible cells. BOOL shouldUpdateContentOffset = NO; NSIndexPath *lastVisibleIndexPath = [self.bubblesTableView indexPathsForVisibleRows].lastObject; if (lastVisibleIndexPath && updatedIndexPath.row < lastVisibleIndexPath.row) { shouldUpdateContentOffset = YES; } // Note: Despite passing in the index path, this reloads the whole table. [self dataSource:self.roomDataSource didCellChange:updatedIndexPath]; // Update the content offset to include any changes to the scroll view's height. if (shouldUpdateContentOffset) { CGFloat delta = self.bubblesTableView.contentSize.height - originalContentSize; contentOffset.y += delta; self.bubblesTableView.contentOffset = contentOffset; } }]; } - (void)refreshRoomTitle { NSMutableArray *rightBarButtonItems = nil; // Set the right room title view if (self.isRoomPreview) { [self showPreviewHeader:YES]; } else if (self.roomDataSource) { [self showPreviewHeader:NO]; if (self.roomDataSource.isLive) { rightBarButtonItems = [NSMutableArray new]; BOOL hasCustomJoinButton = NO; if (self.supportCallOption) { if (self.roomDataSource.room.summary.membersCount.joined == 2 && self.roomDataSource.room.isDirect && !self.mainSession.vc_homeserverConfiguration.jitsi.useFor1To1Calls) { // voice call button for Matrix call UIBarButtonItem *itemVoice = [[UIBarButtonItem alloc] initWithImage:AssetImages.voiceCallHangonIcon.image style:UIBarButtonItemStylePlain target:self action:@selector(onVoiceCallPressed:)]; itemVoice.accessibilityLabel = [VectorL10n roomAccessibilityCall]; itemVoice.enabled = !self.isCallActive; [rightBarButtonItems addObject:itemVoice]; // video call button for Matrix call UIBarButtonItem *itemVideo = [self videoCallBarButtonItem]; itemVideo.enabled = !self.isCallActive; [rightBarButtonItems addObject:itemVideo]; } else { // video call button for Jitsi call if (self.isCallActive) { if (self.isRoomHavingAJitsiCall) { // show a disabled call button UIBarButtonItem *item = [self videoCallBarButtonItem]; item.enabled = NO; [rightBarButtonItems addObject:item]; } else { UIBarButtonItem *item = [self joinJitsiBarButtonItem]; [rightBarButtonItems addObject:item]; hasCustomJoinButton = YES; } } else { // show a video call button // item will still be enabled, and when tapped an alert will be displayed to the user UIBarButtonItem *item = [self videoCallBarButtonItem]; if (!self.canEditJitsiWidget) { item.image = [AssetImages.videoCall.image vc_withAlpha:0.3]; } [rightBarButtonItems addObject:item]; } } } if ([self widgetsCount:NO]) { UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:AssetImages.integrationsIcon.image style:UIBarButtonItemStylePlain target:self action:@selector(onIntegrationsPressed:)]; item.accessibilityLabel = [VectorL10n roomAccessibilityIntegrations]; if (hasCustomJoinButton) { item.imageInsets = UIEdgeInsetsMake(0, -5, 0, -5); item.landscapeImagePhoneInsets = UIEdgeInsetsMake(0, -5, 0, -5); } [rightBarButtonItems addObject:item]; } } // Do not change title view class here if the expanded header is visible. [self setRoomTitleViewClass:RoomTitleView.class]; ((RoomTitleView*)self.titleView).tapGestureDelegate = self; MXKImageView *userPictureView = ((RoomTitleView*)self.titleView).pictureView; // Set user picture in input toolbar // bwi: if the room is a personal notes room a local image is used as room avatar if (userPictureView) { if (BWIBuildSettings.shared.bwiUseCustomPersonalNotesAvatar && [self.roomDataSource.room isPersonalNotesRoom]) { PersonalNotesDefaultService *service = [PersonalNotesDefaultService service:self.mainSession]; userPictureView.image = [UIImage imageNamed:[service avatarImageUrl]]; } else { [self.roomDataSource.room.summary setRoomAvatarImageIn:userPictureView]; } } [self refreshMissedDiscussionsCount:YES]; // bwi: check if threads are enabled if (self.showThreadOption && !_isWaitingForOtherParticipants) { if (self.roomDataSource.threadId) { // in a thread if (rightBarButtonItems == nil) { rightBarButtonItems = [NSMutableArray new]; } UIBarButtonItem *itemThreadMore = [self threadMoreBarButtonItem]; [rightBarButtonItems insertObject:itemThreadMore atIndex:0]; } else { // in a regular timeline UIBarButtonItem *itemThreadList = [self threadListBarButtonItem]; [self updateThreadListBarButtonItem:itemThreadList with:self.mainSession.threadingService]; [rightBarButtonItems insertObject:itemThreadList atIndex:0]; } } } else if (self.isNewDirectChat) { [self showPreviewHeader:NO]; [self setRoomTitleViewClass:RoomTitleView.class]; MXKImageView *userPictureView = ((RoomTitleView*)self.titleView).pictureView; // Set user picture in input toolbar if (userPictureView) { [userPictureView vc_setRoomAvatarImageWith:self.directChatTargetUser.avatarUrl roomId:self.directChatTargetUser.userId displayName:self.directChatTargetUser.displayname ?: self.directChatTargetUser.userId mediaManager:self.mainSession.mediaManager]; } } self.navigationItem.rightBarButtonItems = rightBarButtonItems; } - (void)updateInputToolBarVisibility { BOOL hideInputToolBar = NO; if (self.roomDataSource) { hideInputToolBar = (self.roomDataSource.state != MXKDataSourceStateReady); } self.inputToolbarView.hidden = hideInputToolBar; } - (void)refreshRoomInputToolbar { MXKImageView *userPictureView; // Show or hide input tool bar [self updateInputToolBarVisibility]; // Check whether the input toolbar is ready before updating it. if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol]) { id roomInputToolbarView = (id) self.inputToolbarView; // Update encryption decoration if needed [self updateEncryptionDecorationForRoomInputToolbar:roomInputToolbarView]; // Update actions when the input toolbar refreshed [self setupActions]; // Update placeholder and hide voice message view if (self.isNewDirectChat) { [self setInputToolBarSendMode:RoomInputToolbarViewSendModeCreateDM forEventWithId:nil]; [roomInputToolbarView setVoiceMessageToolbarView:nil]; } } else if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:DisabledRoomInputToolbarView.class]) { DisabledRoomInputToolbarView *roomInputToolbarView = (DisabledRoomInputToolbarView*)self.inputToolbarView; // Get user picture view in input toolbar userPictureView = roomInputToolbarView.pictureView; // For the moment, there is only one reason to use `DisabledRoomInputToolbarView` [roomInputToolbarView setDisabledReason:[VectorL10n roomDoNotHavePermissionToPost]]; } // Set user picture in input toolbar if (userPictureView) { UIImage *preview = [AvatarGenerator generateAvatarForMatrixItem:self.mainSession.myUser.userId withDisplayName:self.mainSession.myUser.displayname]; // Suppose the avatar is stored unencrypted on the Matrix media repository. userPictureView.enableInMemoryCache = YES; [userPictureView setImageURI:self.mainSession.myUser.avatarUrl withType:nil andImageOrientation:UIImageOrientationUp toFitViewSize:userPictureView.frame.size withMethod:MXThumbnailingMethodCrop previewImage:preview mediaManager:self.mainSession.mediaManager]; [userPictureView.layer setCornerRadius:userPictureView.frame.size.width / 2]; userPictureView.clipsToBounds = YES; } } - (void)setInputToolBarSendMode:(RoomInputToolbarViewSendMode)sendMode forEventWithId:(NSString *)eventId { if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol]) { MXKRoomInputToolbarView *roomInputToolbarView = (MXKRoomInputToolbarView *) self.inputToolbarView; if (eventId) { MXEvent *event = [self.roomDataSource eventWithEventId:eventId]; MXRoomMember * roomMember = [self.roomDataSource.roomState.members memberWithUserId:event.sender]; if (roomMember.displayname.length) { roomInputToolbarView.eventSenderDisplayName = roomMember.displayname; } else { roomInputToolbarView.eventSenderDisplayName = event.sender; } } else { roomInputToolbarView.eventSenderDisplayName = nil; } roomInputToolbarView.sendMode = sendMode; } } - (RoomInputToolbarViewSendMode)inputToolBarSendMode { RoomInputToolbarViewSendMode sendMode = RoomInputToolbarViewSendModeSend; if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:[RoomInputToolbarView class]]) { RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView; sendMode = roomInputToolbarView.sendMode; } return sendMode; } - (void)onSwipeGesture:(UISwipeGestureRecognizer*)swipeGestureRecognizer { UIView *view = swipeGestureRecognizer.view; if (view == self.activitiesView) { // Dismiss the keyboard when user swipes down on activities view. [self.inputToolbarView dismissKeyboard]; } } - (void)updateInputToolBarViewHeight { // Update the inputToolBar height. CGFloat height = [self inputToolbarHeight]; // Disable animation during the update [UIView setAnimationsEnabled:NO]; [self roomInputToolbarView:self.inputToolbarView heightDidChanged:height completion:nil]; [UIView setAnimationsEnabled:YES]; } - (UIImage*)roomEncryptionBadgeImage { UIImage *encryptionIcon; // bwi: #5236 remove encryption status shield if (self.isEncryptionEnabled && BWIBuildSettings.shared.showEncryptionStatusBadgeOnAvatar) { RoomEncryptionTrustLevel roomEncryptionTrustLevel = ((RoomDataSource*)self.roomDataSource).encryptionTrustLevel; encryptionIcon = [EncryptionTrustLevelBadgeImageHelper roomBadgeImageFor:roomEncryptionTrustLevel]; } return encryptionIcon; } - (void)updateInputToolbarEncryptionDecoration { if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol]) { id roomInputToolbarView = (id)self.inputToolbarView; [self updateEncryptionDecorationForRoomInputToolbar:roomInputToolbarView]; } } - (void)updateTitleViewEncryptionDecoration { if (![self.titleView isKindOfClass:[RoomTitleView class]]) { return; } RoomTitleView *roomTitleView = (RoomTitleView*)self.titleView; roomTitleView.badgeImageView.image = self.roomEncryptionBadgeImage; } - (void)updateEncryptionDecorationForRoomInputToolbar:(id)roomInputToolbarView { roomInputToolbarView.isEncryptionEnabled = self.isEncryptionEnabled; } - (void)handleLongPressFromCell:(id)cell withTappedEvent:(MXEvent*)event { // bwi: disable context menu for state events if (event && !self.customizedRoomDataSource.selectedEventId && !event.isState) { [self showContextualMenuForEvent:event fromSingleTapGesture:NO cell:cell animated:YES]; } } - (void)showReactionHistoryForEventId:(NSString*)eventId animated:(BOOL)animated { if (self.reactionHistoryCoordinatorBridgePresenter.isPresenting) { return; } ReactionHistoryCoordinatorBridgePresenter *presenter = [[ReactionHistoryCoordinatorBridgePresenter alloc] initWithSession:self.mainSession roomId:self.roomDataSource.roomId eventId:eventId]; presenter.delegate = self; [presenter presentFrom:self animated:animated]; self.reactionHistoryCoordinatorBridgePresenter = presenter; } - (void)showCameraControllerAnimated:(BOOL)animated { CameraPresenter *cameraPresenter = [CameraPresenter new]; cameraPresenter.delegate = self; [cameraPresenter presentCameraFrom:self with:@[MXKUTI.image, MXKUTI.movie] animated:YES]; self.cameraPresenter = cameraPresenter; } - (void)showMediaPickerAnimated:(BOOL)animated { MediaPickerCoordinatorBridgePresenter *mediaPickerPresenter = [[MediaPickerCoordinatorBridgePresenter alloc] initWithSession:self.mainSession mediaUTIs:@[MXKUTI.image, MXKUTI.movie] allowsMultipleSelection:YES]; mediaPickerPresenter.delegate = self; UIView *sourceView; if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { sourceView = ((RoomInputToolbarView*)self.inputToolbarView).attachMediaButton; } else { sourceView = self.inputToolbarView; } if(BWIBuildSettings.shared.useNewPhotosPickerAPI) { PHPhotoLibrary *sharedPhotoLibrary = [PHPhotoLibrary sharedPhotoLibrary]; PHPickerConfiguration *configuration = [[PHPickerConfiguration alloc] initWithPhotoLibrary:sharedPhotoLibrary]; configuration.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[PHPickerFilter.imagesFilter, PHPickerFilter.videosFilter]]; configuration.preferredAssetRepresentationMode = PHPickerConfigurationAssetRepresentationModeCompatible; configuration.selection = PHPickerConfigurationSelectionDefault; configuration.selectionLimit = 1; configuration.preselectedAssetIdentifiers = @[]; PHPickerViewController *pickerViewController = [[PHPickerViewController alloc] initWithConfiguration:configuration]; pickerViewController.delegate = self; [self presentViewController:pickerViewController animated:YES completion:nil]; } else { [mediaPickerPresenter presentFrom:self sourceView:sourceView sourceRect:sourceView.bounds animated:YES]; self.mediaPickerPresenter = mediaPickerPresenter; } } - (void)showRoomCreationModal { [self.roomCreationModalCoordinatorBridgePresenter dismissWithAnimated:NO completion:nil]; self.roomCreationModalCoordinatorBridgePresenter = [[RoomCreationModalCoordinatorBridgePresenter alloc] initWithSession:self.mainSession roomState:self.roomDataSource.roomState]; self.roomCreationModalCoordinatorBridgePresenter.delegate = self; [self.roomCreationModalCoordinatorBridgePresenter presentFrom:self animated:YES]; } - (void)showMemberDetails:(MXRoomMember *)member { if (!member) { return; } RoomMemberDetailsViewController *memberViewController = [RoomMemberDetailsViewController roomMemberDetailsViewController]; // Set delegate to handle action on member (start chat, mention) memberViewController.delegate = self; memberViewController.enableMention = (self.inputToolbarView != nil); memberViewController.enableVoipCall = NO; [memberViewController displayRoomMember:member withMatrixRoom:self.roomDataSource.room]; [self.navigationController pushViewController:memberViewController animated:YES]; } - (void)showRoomAvatarChange { [self showRoomInfoWithInitialSection:RoomInfoSectionChangeAvatar animated:YES]; } - (void)showAddParticipants { self.participantsInvitePresenter = [[RoomParticipantsInviteCoordinatorBridgePresenter alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room parentSpaceId:self.parentSpaceId]; self.participantsInvitePresenter.delegate = self; [self.participantsInvitePresenter presentFrom:self animated:YES]; } - (void)showRoomTopicChange { [self showRoomInfoWithInitialSection:RoomInfoSectionChangeTopic animated:YES]; } - (void)showRoomInfo { [self showRoomInfoWithInitialSection:RoomInfoSectionNone animated:YES]; } - (void)showRoomInfoWithInitialSection:(RoomInfoSection)roomInfoSection animated:(BOOL)animated { RoomInfoCoordinatorParameters *parameters = [[RoomInfoCoordinatorParameters alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room parentSpaceId:self.parentSpaceId initialSection:roomInfoSection canAddParticipants: !self.isWaitingForOtherParticipants]; self.roomInfoCoordinatorBridgePresenter = [[RoomInfoCoordinatorBridgePresenter alloc] initWithParameters:parameters]; self.roomInfoCoordinatorBridgePresenter.delegate = self; [self.roomInfoCoordinatorBridgePresenter pushFrom:self.navigationController animated:animated]; } - (void)setupActions { if (![self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { return; } RoomInputToolbarView *roomInputView = ((RoomInputToolbarView *) self.inputToolbarView); MXWeakify(self); NSMutableArray *actionItems = [NSMutableArray new]; if (RiotSettings.shared.roomScreenAllowMediaLibraryAction) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionMediaLibrary.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self showMediaPickerAnimated:YES]; }]]; } if (RiotSettings.shared.roomScreenAllowStickerAction && !self.isNewDirectChat) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionSticker.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self roomInputToolbarViewPresentStickerPicker]; }]]; } if (RiotSettings.shared.roomScreenAllowFilesAction) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionFile.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self roomInputToolbarViewDidTapFileUpload]; }]]; } if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionLive.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self roomInputToolbarViewDidTapVoiceBroadcast]; }]]; } if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionPoll.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self.delegate roomViewControllerDidRequestPollCreationFormPresentation:self]; }]]; } if ( [self bwiShowLocationSharingUI:self.roomDataSource.mxSession] && !self.isNewDirectChat) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionLocation.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self.delegate roomViewControllerDidRequestLocationSharingFormPresentation:self]; }]]; } if (RiotSettings.shared.roomScreenAllowCameraAction) { [actionItems addObject:[[RoomActionItem alloc] initWithImage:AssetImages.actionCamera.image andAction:^{ MXStrongifyAndReturnIfNil(self); if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { ((RoomInputToolbarView *) self.inputToolbarView).actionMenuOpened = NO; } [self showCameraControllerAnimated:YES]; }]]; } roomInputView.actionsBar.actionItems = actionItems; } - (NSString *)textInputContextIdentifier { return self.roomDataSource.roomId; } - (void)roomInputToolbarViewPresentStickerPicker { // Search for the sticker picker widget in the user account Widget *widget = [[WidgetManager sharedManager] userWidgets:self.roomDataSource.mxSession ofTypes:@[kWidgetTypeStickerPicker]].firstObject; if (widget) { // Display the widget [widget widgetUrl:^(NSString * _Nonnull widgetUrl) { StickerPickerViewController *stickerPickerVC = [[StickerPickerViewController alloc] initWithUrl:widgetUrl forWidget:widget]; stickerPickerVC.roomDataSource = self.roomDataSource; [self.navigationController pushViewController:stickerPickerVC animated:YES]; } failure:^(NSError * _Nonnull error) { MXLogDebug(@"[RoomVC] Cannot display widget %@", widget); [self showError:error]; }]; } else { // The Sticker picker widget is not installed yet. Propose the user to install it MXWeakify(self); [currentAlert dismissViewControllerAnimated:NO completion:nil]; NSString *alertMessage = [NSString stringWithFormat:@"%@\n%@", [VectorL10n widgetStickerPickerNoStickerpacksAlert], [VectorL10n widgetStickerPickerNoStickerpacksAlertAddNow]]; UIAlertController *installPrompt = [UIAlertController alertControllerWithTitle:nil message:alertMessage preferredStyle:UIAlertControllerStyleAlert]; [installPrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n no] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [installPrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n yes] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; // Show the sticker picker settings screen IntegrationManagerViewController *modularVC = [[IntegrationManagerViewController alloc] initForMXSession:self.roomDataSource.mxSession inRoom:self.roomDataSource.roomId screen:[IntegrationManagerViewController screenForWidget:kWidgetTypeStickerPicker] widgetId:nil]; [self presentViewController:modularVC animated:NO completion:nil]; }]]; [installPrompt mxk_setAccessibilityIdentifier:@"RoomVCStickerPickerAlert"]; [self presentViewController:installPrompt animated:YES completion:nil]; currentAlert = installPrompt; } } - (void)roomInputToolbarViewDidTapFileUpload { MXKDocumentPickerPresenter *documentPickerPresenter = [MXKDocumentPickerPresenter new]; documentPickerPresenter.delegate = self; NSArray *allowedUTIs = @[MXKUTI.data]; [documentPickerPresenter presentDocumentPickerWith:allowedUTIs from:self animated:YES completion:nil]; self.documentPickerPresenter = documentPickerPresenter; } - (void)roomInputToolbarViewDidTapVoiceBroadcast { // Check first the room permission if (![self canSendStateEventWithType:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) { [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastPermissionDeniedMessage]]; return; } MXSession* session = self.roomDataSource.mxSession; // Check whether the user is not already broadcasting here or in another room if (session.voiceBroadcastService) { [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastAlreadyInProgressMessage]]; return; } // Prevents listening a VB when recording a new one [VoiceBroadcastPlaybackProvider.shared pausePlaying]; // Check connectivity if ([AppDelegate theDelegate].isOffline) { [self showAlertWithTitle:[VectorL10n voiceBroadcastConnectionErrorTitle] message:[VectorL10n voiceBroadcastConnectionErrorMessage]]; return; } // Request the voice broadcast service to start recording - No service is returned if someone else is already broadcasting in the room [session getOrCreateVoiceBroadcastServiceFor:self.roomDataSource.room completion:^(VoiceBroadcastService *voiceBroadcastService) { if (voiceBroadcastService) { [voiceBroadcastService startVoiceBroadcastWithSuccess:^(NSString * _Nullable success) { } failure:^(NSError * _Nonnull error) { [self showAlertWithTitle:[VectorL10n voiceBroadcastConnectionErrorTitle] message:[VectorL10n voiceBroadcastConnectionErrorMessage]]; [session tearDownVoiceBroadcastService]; }]; } else { [self showAlertWithTitle:[VectorL10n voiceBroadcastUnauthorizedTitle] message:[VectorL10n voiceBroadcastBlockedBySomeoneElseMessage]]; } }]; } /** Send a video asset via the room input toolbar prompting the user for the conversion preset to use if the `showMediaCompressionPrompt` setting has been enabled. @param videoAsset The video asset to send @param isPhotoLibraryAsset Whether the asset was picked from the user's photo library. */ - (void)sendVideoAsset:(AVAsset *)videoAsset isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset { if (![self inputToolbarConformsToToolbarViewProtocol]) { return; } if (RiotSettings.shared.showMediaCompressionPrompt) { // Show the video conversion prompt for the user to select what size video they would like to send. UIAlertController *compressionPrompt = [MXKTools videoConversionPromptForVideoAsset:videoAsset withCompletion:^(NSString *presetName) { // When the preset name is missing, the user cancelled. if (!presetName) { return; } // Set the chosen preset and send the video (conversion takes place in the SDK). [MXSDKOptions sharedInstance].videoConversionPresetName = presetName; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { [self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; }]; UIView *sourceView; if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) { sourceView = ((RoomInputToolbarView*)self.inputToolbarView).attachMediaButton; } else { sourceView = self.inputToolbarView; } compressionPrompt.popoverPresentationController.sourceView = sourceView; compressionPrompt.popoverPresentationController.sourceRect = sourceView.bounds; [self presentViewController:compressionPrompt animated:YES completion:nil]; } else { // Otherwise default to 1080p and send the video. [MXSDKOptions sharedInstance].videoConversionPresetName = AVAssetExportPreset1920x1080; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { [self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } } // bwi: 5365 - (void)sendVideoAssetWithoutCompression:(AVAsset *)videoAsset isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset { // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { if(![self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]) { NSURL *url = [(AVURLAsset *)videoAsset URL]; if(url) { [self deleteFileAtURL:url]; } } } else { if([videoAsset isKindOfClass:[AVURLAsset class]]) { NSURL *url = [(AVURLAsset *)videoAsset URL]; [self deleteFileAtURL:url]; } } NSURL *url = [(AVURLAsset *)videoAsset URL]; if(url) { [self deleteFileAtURL:url]; } }]; } - (void)showRoomWithId:(NSString*)roomId { if (self.delegate) { [self.delegate roomViewController:self showRoomWithId:roomId eventId:nil]; } else { [[AppDelegate theDelegate] showRoom:roomId andEventId:nil withMatrixSession:self.roomDataSource.mxSession]; } } - (void)leaveRoom { [self startActivityIndicator]; [self.roomDataSource.room leave:^{ [self stopActivityIndicator]; [self notifyDelegateOnLeaveRoomIfNecessary]; } failure:^(NSError *error) { [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Failed to reject an invited room (%@) failed", self.roomDataSource.room.roomId); }]; } - (void)notifyDelegateOnLeaveRoomIfNecessary { if (isRoomLeft) { return; } isRoomLeft = YES; if (self.delegate) { [self.delegate roomViewControllerDidLeaveRoom:self]; } else { [[AppDelegate theDelegate] restoreInitialDisplay:^{}]; } } - (void)roomPreviewDidTapCancelAction { // Decline this invitation = leave this page if (self.delegate) { [self.delegate roomViewControllerPreviewDidTapCancel:self]; } else { [[AppDelegate theDelegate] restoreInitialDisplay:^{}]; } } - (void)startChatWithUserId:(NSString *)userId completion:(void (^)(void))completion { if (self.delegate) { [self.delegate roomViewController:self startChatWithUserId:userId completion:completion]; } else { [[AppDelegate theDelegate] showNewDirectChat:userId withMatrixSession:self.mainSession completion:completion]; } } - (void)showError:(NSError*)error { [[AppDelegate theDelegate] showErrorAsAlert:error]; } - (UIAlertController*)showAlertWithTitle:(NSString*)title message:(NSString*)message { return [[AppDelegate theDelegate] showAlertWithTitle:title message:message]; } - (ScreenPresentationParameters*)buildUniversalLinkPresentationParameters { return [[ScreenPresentationParameters alloc] initWithRestoreInitialDisplay:NO stackAboveVisibleViews:BWIBuildSettings.shared.allowSplitViewDetailsScreenStacking sender:self sourceView:nil]; } - (BOOL)handleUniversalLinkURL:(NSURL*)url { ScreenPresentationParameters *screenParameters = [self buildUniversalLinkPresentationParameters]; UniversalLinkParameters *parameters = [[UniversalLinkParameters alloc] initWithUrl:url presentationParameters:screenParameters]; return [self handleUniversalLinkWithParameters:parameters]; } - (BOOL)handleUniversalLinkFragment:(NSString*)fragment fromURL:(NSURL*)url { ScreenPresentationParameters *screenParameters = [self buildUniversalLinkPresentationParameters]; UniversalLink *universalLink = [[UniversalLink alloc] initWithUrl:url]; UniversalLinkParameters *parameters = [[UniversalLinkParameters alloc] initWithFragment:fragment universalLink:universalLink presentationParameters:screenParameters]; return [self handleUniversalLinkWithParameters:parameters]; } - (BOOL)handleUniversalLinkWithParameters:(UniversalLinkParameters*)parameters { Analytics.shared.joinedRoomTrigger = AnalyticsJoinedRoomTriggerTimeline; if (self.delegate) { return [self.delegate roomViewController:self handleUniversalLinkWithParameters:parameters]; } else { return [[AppDelegate theDelegate] handleUniversalLinkWithParameters:parameters]; } } - (void)setupCompletionSuggestionViewIfNeeded { if(!self.isViewLoaded) { return; } UIViewController *suggestionsViewController = self.completionSuggestionCoordinator.toPresentable; if (!suggestionsViewController) { return; } [suggestionsViewController.view setTranslatesAutoresizingMaskIntoConstraints:NO]; [self addChildViewController:suggestionsViewController]; [self.completionSuggestionContainerView addSubview:suggestionsViewController.view]; [NSLayoutConstraint activateConstraints:@[[suggestionsViewController.view.topAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.topAnchor], [suggestionsViewController.view.leadingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.leadingAnchor], [suggestionsViewController.view.trailingAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.trailingAnchor], [suggestionsViewController.view.bottomAnchor constraintEqualToAnchor:self.completionSuggestionContainerView.bottomAnchor],]]; [suggestionsViewController didMoveToParentViewController:self]; } - (void)updateTopBanners { [self.view bringSubviewToFront:self.topBannersStackView]; [self updateLiveLocationBannerViewVisibility]; } - (void)showEmojiPickerForEventId:(NSString *)eventId { EmojiPickerCoordinatorBridgePresenter *emojiPickerCoordinatorBridgePresenter = [[EmojiPickerCoordinatorBridgePresenter alloc] initWithSession:self.mainSession roomId:self.roomDataSource.roomId eventId:eventId]; emojiPickerCoordinatorBridgePresenter.delegate = self; NSInteger cellRow = [self.roomDataSource indexOfCellDataWithEventId:eventId]; UIView *sourceView; CGRect sourceRect = CGRectNull; if (cellRow >= 0) { NSIndexPath *cellIndexPath = [NSIndexPath indexPathForRow:cellRow inSection:0]; UITableViewCell *cell = [self.bubblesTableView cellForRowAtIndexPath:cellIndexPath]; sourceView = cell; if ([cell isKindOfClass:[MXKRoomBubbleTableViewCell class]]) { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell*)cell; NSInteger bubbleComponentIndex = [roomBubbleTableViewCell.bubbleData bubbleComponentIndexForEventId:eventId]; sourceRect = [roomBubbleTableViewCell componentFrameInContentViewForIndex:bubbleComponentIndex]; } } [emojiPickerCoordinatorBridgePresenter presentFrom:self sourceView:sourceView sourceRect:sourceRect animated:YES]; self.emojiPickerCoordinatorBridgePresenter = emojiPickerCoordinatorBridgePresenter; } #pragma mark - Jitsi - (void)showJitsiCallWithWidget:(Widget*)widget { [[AppDelegate theDelegate].callPresenter displayJitsiCallWithWidget:widget]; } - (void)endActiveJitsiCall { [[AppDelegate theDelegate].callPresenter endActiveJitsiCall]; } - (BOOL)isRoomHavingAJitsiCall { return [self isRoomHavingAJitsiCallForWidgetId:self.roomDataSource.roomId]; } - (BOOL)isRoomHavingAJitsiCallForWidgetId:(NSString*)widgetId { #ifdef CALL_STACK_JINGLE return [[AppDelegate theDelegate].callPresenter.jitsiVC.widget.roomId isEqualToString:widgetId]; #else return NO; #endif } #pragma mark - Dialpad - (void)openDialpad { DialpadViewController *controller = [DialpadViewController instantiateWithConfiguration:[DialpadConfiguration default]]; controller.delegate = self; self.customSizedPresentationController = [[CustomSizedPresentationController alloc] initWithPresentedViewController:controller presentingViewController:self]; self.customSizedPresentationController.dismissOnBackgroundTap = NO; self.customSizedPresentationController.cornerRadius = 16; controller.transitioningDelegate = self.customSizedPresentationController; [self presentViewController:controller animated:YES completion:nil]; } #pragma mark - DialpadViewControllerDelegate - (void)dialpadViewControllerDidTapCall:(DialpadViewController *)viewController withPhoneNumber:(NSString *)phoneNumber { if (self.mainSession.callManager && phoneNumber.length > 0) { [self startActivityIndicator]; [viewController dismissViewControllerAnimated:YES completion:^{ MXWeakify(self); [self.mainSession.callManager placeCallAgainst:phoneNumber withVideo:NO success:^(MXCall * _Nonnull call) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; self.customSizedPresentationController = nil; // do nothing extra here. UI will be handled automatically by the CallService. } failure:^(NSError * _Nullable error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; }]; }]; } } - (void)dialpadViewControllerDidTapClose:(DialpadViewController *)viewController { [viewController dismissViewControllerAnimated:YES completion:nil]; self.customSizedPresentationController = nil; } #pragma mark - Hide/Show preview header - (void)showPreviewHeader:(BOOL)isVisible { if (self.previewHeaderContainer && self.previewHeaderContainer.isHidden == isVisible) { // Check conditions before making the preview room header visible. // This operation is ignored if a screen rotation is in progress, // or if the view controller is not embedded inside a split view controller yet. if (isVisible && (isSizeTransitionInProgress == YES || !self.splitViewController)) { MXLogDebug(@"[RoomVC] Show preview header ignored"); return; } if (isVisible) { PreviewRoomTitleView *previewHeader = [PreviewRoomTitleView roomTitleView]; previewHeader.delegate = self; previewHeader.tapGestureDelegate = self; previewHeader.translatesAutoresizingMaskIntoConstraints = NO; [self.previewHeaderContainer addSubview:previewHeader]; self->previewHeader = previewHeader; // Force preview header in full width NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:previewHeader attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:self.previewHeaderContainer attribute:NSLayoutAttributeLeading multiplier:1.0 constant:0]; NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:previewHeader attribute:NSLayoutAttributeTrailing relatedBy:NSLayoutRelationEqual toItem:self.previewHeaderContainer attribute:NSLayoutAttributeTrailing multiplier:1.0 constant:0]; // Vertical constraints are required for iOS > 8 NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:previewHeader attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.previewHeaderContainer attribute:NSLayoutAttributeTop multiplier:1.0 constant:0]; NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:previewHeader attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.previewHeaderContainer attribute:NSLayoutAttributeBottom multiplier:1.0 constant:0]; [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]]; if (roomPreviewData) { previewHeader.roomPreviewData = roomPreviewData; } else if (self.roomDataSource) { previewHeader.mxRoom = self.roomDataSource.room; } self.previewHeaderContainer.hidden = NO; // Finalize preview header display according to the screen orientation [self refreshPreviewHeader:UIInterfaceOrientationIsLandscape([[UIApplication sharedApplication] statusBarOrientation])]; } else { [previewHeader removeFromSuperview]; previewHeader = nil; self.previewHeaderContainer.hidden = YES; // Consider the main navigation controller if the current view controller is embedded inside a split view controller. UINavigationController *mainNavigationController = self.navigationController; if (self.splitViewController.isCollapsed && self.splitViewController.viewControllers.count) { mainNavigationController = self.splitViewController.viewControllers.firstObject; } // Set a default title view class without handling tap gesture (Let [self refreshRoomTitle] refresh this view correctly). [self setRoomTitleViewClass:RoomTitleView.class]; // Remove the shadow image used to hide the bottom border of the navigation bar when the preview header is displayed [mainNavigationController.navigationBar setShadowImage:nil]; [mainNavigationController.navigationBar setBackgroundImage:nil forBarMetrics:UIBarMetricsDefault]; [UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn animations:^{ self.bubblesTableViewTopConstraint.constant = 0; // Force to render the view [self forceLayoutRefresh]; } completion:^(BOOL finished){ }]; } } // Consider the main navigation controller if the current view controller is embedded inside a split view controller. UINavigationController *mainNavigationController = self.navigationController; if (self.splitViewController.isCollapsed && self.splitViewController.viewControllers.count) { mainNavigationController = self.splitViewController.viewControllers.firstObject; } mainNavigationController.navigationBar.translucent = isVisible; self.navigationController.navigationBar.translucent = isVisible; } - (void)refreshPreviewHeader:(BOOL)isLandscapeOriented { if (previewHeader) { if (isLandscapeOriented && [GBDeviceInfo deviceInfo].family != GBDeviceFamilyiPad) { CGRect frame = self.navigationController.navigationBar.frame; previewHeader.mainHeaderContainer.hidden = YES; previewHeader.mainHeaderBackgroundHeightConstraint.constant = frame.size.height + (frame.origin.y > 0 ? frame.origin.y : 0); [self setRoomTitleViewClass:RoomTitleView.class]; // We don't want to handle tap gesture here // Remove details icon RoomTitleView *roomTitleView = (RoomTitleView*)self.titleView; // Set preview data to provide the room name roomTitleView.roomPreviewData = roomPreviewData; } else { previewHeader.mainHeaderContainer.hidden = NO; previewHeader.mainHeaderBackgroundHeightConstraint.constant = previewHeader.mainHeaderContainer.frame.size.height; if ([previewHeader isKindOfClass:PreviewRoomTitleView.class]) { // In case of preview, update the header height so that we can // display as much as possible the room topic in this header. // Note: the header height is handled by the previewHeader.mainHeaderBackgroundHeightConstraint. PreviewRoomTitleView *previewRoomTitleView = (PreviewRoomTitleView *)previewHeader; // Compute the height required to display all the room topic CGSize sizeThatFitsTextView = [previewRoomTitleView.roomTopic sizeThatFits:CGSizeMake(previewRoomTitleView.roomTopic.frame.size.width, MAXFLOAT)]; // Increase the preview header height according to the room topic height // but limit it in order to let room for room messages at the screen bottom. // This free space depends on the device. // On an iphone 5 screen, the room topic height cannot be more than 50px. // Then, on larger screen, we can allow it a bit more height but we // apply a factor to give more priority to the display of more messages. CGFloat screenHeight = [[UIScreen mainScreen] bounds].size.height; CGFloat maxRoomTopicHeight = 50 + (screenHeight - 568) / 3; CGFloat additionalHeight = MIN(maxRoomTopicHeight, sizeThatFitsTextView.height) - previewRoomTitleView.roomTopic.frame.size.height; previewHeader.mainHeaderBackgroundHeightConstraint.constant += additionalHeight; } [self setRoomTitleViewClass:RoomAvatarTitleView.class]; // Note the avatar title view does not define tap gesture. previewHeader.roomAvatar.alpha = 0.0; // Set the avatar provided in preview data if (roomPreviewData.roomAvatarUrl) { previewHeader.roomAvatarURL = roomPreviewData.roomAvatarUrl; } else if (roomPreviewData.roomId && roomPreviewData.roomName) { previewHeader.roomAvatarPlaceholder = [AvatarGenerator generateAvatarForMatrixItem:roomPreviewData.roomId withDisplayName:roomPreviewData.roomName]; } else { previewHeader.roomAvatarPlaceholder = [MXKTools paintImage:AssetImages.placeholder.image withColor:ThemeService.shared.theme.tintColor]; } } // Force the layout of previewHeader to update the position of 'bottomBorderView' which is used // to define the actual height of the preview container. [previewHeader layoutIfNeeded]; CGRect frame = previewHeader.bottomBorderView.frame; self.previewHeaderContainerHeightConstraint.constant = frame.origin.y + frame.size.height; // Consider the main navigation controller if the current view controller is embedded inside a split view controller. UINavigationController *mainNavigationController = self.navigationController; if (self.splitViewController.isCollapsed && self.splitViewController.viewControllers.count) { mainNavigationController = self.splitViewController.viewControllers.firstObject; } // When the preview header is displayed, we hide the bottom border of the navigation bar (the shadow image). // The default shadow image is nil. When non-nil, this property represents a custom shadow image to show instead // of the default. For a custom shadow image to be shown, a custom background image must also be set with the // setBackgroundImage:forBarMetrics: method. If the default background image is used, then the default shadow // image will be used regardless of the value of this property. UIImage *shadowImage = [[UIImage alloc] init]; [mainNavigationController.navigationBar setShadowImage:shadowImage]; [mainNavigationController.navigationBar setBackgroundImage:shadowImage forBarMetrics:UIBarMetricsDefault]; [UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn animations:^{ self.bubblesTableViewTopConstraint.constant = self.previewHeaderContainerHeightConstraint.constant - self.bubblesTableView.adjustedContentInset.top; self->previewHeader.roomAvatar.alpha = 1; // Force to render the view [self forceLayoutRefresh]; } completion:^(BOOL finished){ }]; } } #pragma mark - Preview - (void)displayRoomPreview:(RoomPreviewData *)previewData { // Release existing room data source or preview [self displayRoom:nil]; if (previewData) { self.eventsAcknowledgementEnabled = NO; [self addMatrixSession:previewData.mxSession]; roomPreviewData = previewData; [self refreshRoomTitle]; if (roomPreviewData.roomDataSource) { [super displayRoom:roomPreviewData.roomDataSource]; } } } #pragma mark - New discussion - (void)displayNewDirectChatWithTargetUser:(nonnull MXUser*)directChatTargetUser session:(nonnull MXSession*)session { // `[displayRoom:]` may require the session, setting it here before calling it [self addMatrixSession:session]; // Release existing room data source or preview [self displayRoom:nil]; self.directChatTargetUser = directChatTargetUser; self.eventsAcknowledgementEnabled = NO; [self refreshRoomTitle]; [self refreshRoomInputToolbar]; } #pragma mark - MXKDataSourceDelegate - (Class)cellViewClassForCellData:(MXKCellData*)cellData { RoomTimelineCellIdentifier cellIdentifier = [self cellIdentifierForCellData:cellData andRoomDataSource:self.customizedRoomDataSource]; RoomTimelineConfiguration *timelineConfiguration = [RoomTimelineConfiguration shared]; return [timelineConfiguration.currentStyle.cellProvider cellViewClassForCellIdentifier:cellIdentifier];; } - (RoomTimelineCellIdentifier)cellIdentifierForCellData:(MXKCellData*)cellData andRoomDataSource:(RoomDataSource *)customizedRoomDataSource; { // Sanity check if (![cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)]) { return RoomTimelineCellIdentifierUnknown; } BOOL showEncryptionBadge = NO; RoomTimelineCellIdentifier cellIdentifier; id bubbleData = (id)cellData; MXKRoomBubbleCellData *roomBubbleCellData; if ([bubbleData isKindOfClass:MXKRoomBubbleCellData.class]) { roomBubbleCellData = (MXKRoomBubbleCellData*)bubbleData; showEncryptionBadge = roomBubbleCellData.containsBubbleComponentWithEncryptionBadge; } // Select the suitable table view cell class, by considering first the empty bubble cell. if (BWIBuildSettings.shared.enableAntivirusScan && bubbleData.showAntivirusScanStatus) { // FRANK181 cellIdentifier = [self contentScannerClassForBubbleCellData:roomBubbleCellData]; } if (cellIdentifier == ContentScannerIdentiferStatus || cellIdentifier == ContentScannerIdentifierThumbnail) { return cellIdentifier; } if (bubbleData.hasNoDisplay) { cellIdentifier = RoomTimelineCellIdentifierEmpty; } else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreationIntro) { cellIdentifier = RoomTimelineCellIdentifierRoomCreationIntro; } else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor) { cellIdentifier = RoomTimelineCellIdentifierRoomPredecessor; } else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationRequestIncomingApproval) { cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierKeyVerificationIncomingRequestApprovalWithPaginationTitle : RoomTimelineCellIdentifierKeyVerificationIncomingRequestApproval; } else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationRequest) { cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierKeyVerificationRequestStatusWithPaginationTitle : RoomTimelineCellIdentifierKeyVerificationRequestStatus; } else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationConclusion) { cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierKeyVerificationConclusionWithPaginationTitle : RoomTimelineCellIdentifierKeyVerificationConclusion; } // bwi #5575 provide correct cell for ACL status messages -> Same as Membership cells else if (bubbleData.tag == RoomBubbleCellDataTagMembership || bubbleData.tag == RoomBubbleCellDataTagACL) { if (bubbleData.collapsed) { if (bubbleData.nextCollapsableCellData) { cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierMembershipCollapsedWithPaginationTitle : RoomTimelineCellIdentifierMembershipCollapsed; } else { // Use a normal membership cell for a single membership event cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierMembershipWithPaginationTitle : RoomTimelineCellIdentifierMembership; } } else if (bubbleData.collapsedAttributedTextMessage) { // The cell (and its series) is not collapsed but this cell is the first // of the series. So, use the cell with the "collapse" button. cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierMembershipExpandedWithPaginationTitle : RoomTimelineCellIdentifierMembershipExpanded; } else { cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierMembershipWithPaginationTitle : RoomTimelineCellIdentifierMembership; } } else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreateConfiguration || bubbleData.tag == RoomBubbleCellDataTagRoomConfiguration) { cellIdentifier = bubbleData.isPaginationFirstBubble ? RoomTimelineCellIdentifierRoomCreationCollapsedWithPaginationTitle : RoomTimelineCellIdentifierRoomCreationCollapsed; } else if (bubbleData.tag == RoomBubbleCellDataTagCall) { cellIdentifier = RoomTimelineCellIdentifierDirectCallStatus; } else if (bubbleData.tag == RoomBubbleCellDataTagGroupCall) { cellIdentifier = RoomTimelineCellIdentifierGroupCallStatus; } else if (bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage || bubbleData.attachment.type == MXKAttachmentTypeAudio) { if (bubbleData.isIncoming) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceMessageWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceMessageWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceMessage; } } else { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceMessageWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceMessageWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceMessage; } } } else if (bubbleData.tag == RoomBubbleCellDataTagPoll) { if (bubbleData.isIncoming) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierIncomingPollWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierIncomingPollWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierIncomingPoll; } } else { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierOutgoingPollWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierOutgoingPollWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierOutgoingPoll; } } } // bwi: #5806 bugfix: showing live location view when deleting location event else if ((bubbleData.tag == RoomBubbleCellDataTagLocation || bubbleData.tag == RoomBubbleCellDataTagLiveLocation) && bubbleData.isLocationType) { if (bubbleData.isIncoming) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierIncomingLocationWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierIncomingLocationWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierIncomingLocation; } } else { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierOutgoingLocationWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierOutgoingLocationWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierOutgoingLocation; } } } else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcastPlayback) { if (bubbleData.isIncoming) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceBroadcastPlaybackWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceBroadcastPlaybackWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierIncomingVoiceBroadcastPlayback; } } else { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastPlaybackWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastPlaybackWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastPlayback; } } } else if (bubbleData.tag == RoomBubbleCellDataTagVoiceBroadcastRecord) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorderWithoutSenderInfo; } else { cellIdentifier = RoomTimelineCellIdentifierOutgoingVoiceBroadcastRecorder; } } else if (roomBubbleCellData.getFirstBubbleComponentWithDisplay.event.isEmote) { if (bubbleData.isIncoming) { if (bubbleData.isPaginationFirstBubble) { if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingEmoteEncryptedWithPaginationTitleWithoutSenderName : RoomTimelineCellIdentifierIncomingEmoteWithPaginationTitleWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingEmoteEncryptedWithPaginationTitle : RoomTimelineCellIdentifierIncomingEmoteWithPaginationTitle; } } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingEmoteEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierIncomingEmoteWithoutSenderInfo; } else if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingEmoteEncryptedWithoutSenderName : RoomTimelineCellIdentifierIncomingEmoteWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingEmoteEncrypted : RoomTimelineCellIdentifierIncomingEmote; } } else { if (bubbleData.isPaginationFirstBubble) { if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingEmoteEncryptedWithPaginationTitleWithoutSenderName : RoomTimelineCellIdentifierOutgoingEmoteWithPaginationTitleWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingEmoteEncryptedWithPaginationTitle : RoomTimelineCellIdentifierOutgoingEmoteWithPaginationTitle; } } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingEmoteEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierOutgoingEmoteWithoutSenderInfo; } else if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingEmoteEncryptedWithoutSenderName : RoomTimelineCellIdentifierOutgoingEmoteWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingEmoteEncrypted : RoomTimelineCellIdentifierOutgoingEmote; } } } else if (bubbleData.isIncoming) { if (bubbleData.isAttachmentWithThumbnail) { // Check whether the provided celldata corresponds to a selected sticker if (customizedRoomDataSource.selectedEventId && (bubbleData.attachment.type == MXKAttachmentTypeSticker) && [bubbleData.attachment.eventId isEqualToString:customizedRoomDataSource.selectedEventId]) { cellIdentifier = RoomTimelineCellIdentifierSelectedSticker; } else if (bubbleData.isPaginationFirstBubble) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingAttachmentEncryptedWithPaginationTitle : RoomTimelineCellIdentifierIncomingAttachmentWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingAttachmentEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierIncomingAttachmentWithoutSenderInfo; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingAttachmentEncrypted : RoomTimelineCellIdentifierIncomingAttachment; } } else if (bubbleData.isAttachment) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingAttachmentWithoutThumbnailEncryptedWithPaginationTitle : RoomTimelineCellIdentifierIncomingAttachmentWithoutThumbnailWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingAttachmentWithoutThumbnailEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierIncomingAttachmentWithoutThumbnailWithoutSenderInfo; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingAttachmentWithoutThumbnailEncrypted : RoomTimelineCellIdentifierIncomingAttachmentWithoutThumbnail; } } else { if (bubbleData.isPaginationFirstBubble) { if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingTextMessageEncryptedWithPaginationTitleWithoutSenderName : RoomTimelineCellIdentifierIncomingTextMessageWithPaginationTitleWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingTextMessageEncryptedWithPaginationTitle : RoomTimelineCellIdentifierIncomingTextMessageWithPaginationTitle; } } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingTextMessageEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierIncomingTextMessageWithoutSenderInfo; } else if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingTextMessageEncryptedWithoutSenderName : RoomTimelineCellIdentifierIncomingTextMessageWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierIncomingTextMessageEncrypted : RoomTimelineCellIdentifierIncomingTextMessage; } } } else { // Handle here outgoing bubbles if (bubbleData.isAttachmentWithThumbnail) { // Check whether the provided celldata corresponds to a selected sticker if (customizedRoomDataSource.selectedEventId && (bubbleData.attachment.type == MXKAttachmentTypeSticker) && [bubbleData.attachment.eventId isEqualToString:customizedRoomDataSource.selectedEventId]) { cellIdentifier = RoomTimelineCellIdentifierSelectedSticker; } else if (bubbleData.isPaginationFirstBubble) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingAttachmentEncryptedWithPaginationTitle : RoomTimelineCellIdentifierOutgoingAttachmentWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingAttachmentEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierOutgoingAttachmentWithoutSenderInfo; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingAttachmentEncrypted : RoomTimelineCellIdentifierOutgoingAttachment; } } else if (bubbleData.isAttachment) { if (bubbleData.isPaginationFirstBubble) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingAttachmentWithoutThumbnailEncryptedWithPaginationTitle : RoomTimelineCellIdentifierOutgoingAttachmentWithoutThumbnailWithPaginationTitle; } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingAttachmentWithoutThumbnailEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierOutgoingAttachmentWithoutThumbnailWithoutSenderInfo; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingAttachmentWithoutThumbnailEncrypted : RoomTimelineCellIdentifierOutgoingAttachmentWithoutThumbnail; } } else { if (bubbleData.isPaginationFirstBubble) { if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingTextMessageEncryptedWithPaginationTitleWithoutSenderName : RoomTimelineCellIdentifierOutgoingTextMessageWithPaginationTitleWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingTextMessageEncryptedWithPaginationTitle : RoomTimelineCellIdentifierOutgoingTextMessageWithPaginationTitle; } } else if (bubbleData.shouldHideSenderInformation) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingTextMessageEncryptedWithoutSenderInfo : RoomTimelineCellIdentifierOutgoingTextMessageWithoutSenderInfo; } else if (bubbleData.shouldHideSenderName) { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingTextMessageEncryptedWithoutSenderName : RoomTimelineCellIdentifierOutgoingTextMessageWithoutSenderName; } else { cellIdentifier = showEncryptionBadge ? RoomTimelineCellIdentifierOutgoingTextMessageEncrypted : RoomTimelineCellIdentifierOutgoingTextMessage; } } } return cellIdentifier; } #pragma mark - MXKDataSource delegate - (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo { // Handle here user actions on bubbles for Vector app if (self.customizedRoomDataSource) { id bubbleData; if ([cell isKindOfClass:[MXKRoomBubbleTableViewCell class]]) { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell*)cell; bubbleData = roomBubbleTableViewCell.bubbleData; } if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAvatarView]) { MXRoomMember *member = [self.roomDataSource.roomState.members memberWithUserId:userInfo[kMXKRoomBubbleCellUserIdKey]]; [self showMemberDetails:member]; } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnAvatarView]) { // Add the member display name in text input MXRoomMember *roomMember = [self.roomDataSource.roomState.members memberWithUserId:userInfo[kMXKRoomBubbleCellUserIdKey]]; if (roomMember) { [self mention:roomMember]; } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellStopShareButtonPressed]) { NSString *beaconInfoEventId; if ([bubbleData isKindOfClass:[RoomBubbleCellData class]]) { RoomBubbleCellData *roomBubbleCellData = (RoomBubbleCellData*)bubbleData; beaconInfoEventId = roomBubbleCellData.beaconInfoSummary.id; } [self.delegate roomViewControllerDidStopLiveLocationSharing:self beaconInfoEventId:beaconInfoEventId]; } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellRetryShareButtonPressed]) { MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; if (selectedEvent) { // TODO: - Implement retry live location action } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnMessageTextView] || [actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnContentView]) { // Retrieve the tapped event MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; // Check whether a selection already exist or not if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } else if (bubbleData.tag == RoomBubbleCellDataTagLiveLocation) { [self.delegate roomViewController:self didRequestLiveLocationPresentationForBubbleData:bubbleData]; } else if (tappedEvent) { if (tappedEvent.eventType == MXEventTypeRoomCreate) { // Handle tap on RoomPredecessorBubbleCell MXRoomCreateContent *createContent = [MXRoomCreateContent modelFromJSON:tappedEvent.content]; NSString *predecessorRoomId = createContent.roomPredecessorInfo.roomId; if (predecessorRoomId) { // Show predecessor room Analytics.shared.viewRoomTrigger = AnalyticsViewRoomTriggerTombstone; [self showRoomWithId:predecessorRoomId]; } else { // Show contextual menu on single tap if bubble is not collapsed if (bubbleData.collapsed) { } else { [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:YES cell:cell animated:YES]; } } } else if (bubbleData.tag == RoomBubbleCellDataTagCall) { if ([bubbleData isKindOfClass:[RoomBubbleCellData class]]) { // post notification `RoomCallTileTapped` [[NSNotificationCenter defaultCenter] postNotificationName:RoomCallTileTappedNotification object:bubbleData]; preventBubblesTableViewScroll = YES; [self selectEventWithId:tappedEvent.eventId]; } } else if (bubbleData.tag == RoomBubbleCellDataTagGroupCall) { if ([bubbleData isKindOfClass:[RoomBubbleCellData class]]) { // post notification `RoomGroupCallTileTapped` [[NSNotificationCenter defaultCenter] postNotificationName:RoomGroupCallTileTappedNotification object:bubbleData]; preventBubblesTableViewScroll = YES; [self selectEventWithId:tappedEvent.eventId]; } } else { // bwi: Show contextual menu on single tap if bubble is not collapsed and if its not a state event if (bubbleData.collapsed) { [self selectEventWithId:tappedEvent.eventId]; } else { if (tappedEvent.location) { [_delegate roomViewController:self didRequestLocationPresentationForEvent:tappedEvent bubbleData:bubbleData]; } else { if (!tappedEvent.isState) { [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:YES cell:cell animated:YES]; } } } } } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnOverlayContainer]) { // Cancel the current event selection [self cancelEventSelection]; } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellRiotEditButtonPressed]) { [self dismissKeyboard]; MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; if (selectedEvent) { [self showContextualMenuForEvent:selectedEvent fromSingleTapGesture:YES cell:cell animated:YES]; } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellKeyVerificationIncomingRequestAcceptPressed]) { NSString *eventId = userInfo[kMXKRoomBubbleCellEventIdKey]; RoomDataSource *roomDataSource = (RoomDataSource*)self.roomDataSource; [roomDataSource acceptVerificationRequestForEventId:eventId success:^{ } failure:^(NSError *error) { [self showError:error]; }]; } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellKeyVerificationIncomingRequestDeclinePressed]) { NSString *eventId = userInfo[kMXKRoomBubbleCellEventIdKey]; RoomDataSource *roomDataSource = (RoomDataSource*)self.roomDataSource; [roomDataSource declineVerificationRequestForEventId:eventId success:^{ } failure:^(NSError *error) { [self showError:error]; }]; } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAttachmentView]) { if (((MXKRoomBubbleTableViewCell*)cell).bubbleData.attachment.eventSentState == MXEventSentStateFailed) { // Shortcut: when clicking on an unsent media, show the action sheet to resend it NSString *eventId = ((MXKRoomBubbleTableViewCell*)cell).bubbleData.attachment.eventId; MXEvent *selectedEvent = [self.roomDataSource eventWithEventId:eventId]; if (selectedEvent) { [self dataSource:dataSource didRecognizeAction:kMXKRoomBubbleCellRiotEditButtonPressed inCell:cell userInfo:@{kMXKRoomBubbleCellEventKey:selectedEvent}]; } else { MXLogDebug(@"[RoomViewController] didRecognizeAction:inCell:userInfo tap on attachment with event state MXEventSentStateFailed. Selected event is nil for event id %@", eventId); } } else if (((MXKRoomBubbleTableViewCell*)cell).bubbleData.attachment.type == MXKAttachmentTypeSticker) { // We don't open the attachments viewer when the user taps on a sticker. // We consider this tap like a selection. // Check whether a selection already exist or not if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } else { // Highlight this event in displayed message [self selectEventWithId:((MXKRoomBubbleTableViewCell*)cell).bubbleData.attachment.eventId]; } } else { // Keep default implementation [super dataSource:dataSource didRecognizeAction:actionIdentifier inCell:cell userInfo:userInfo]; } } else if ([actionIdentifier isEqualToString:kRoomEncryptedDataBubbleCellTapOnEncryptionIcon]) { // Retrieve the tapped event MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; if (tappedEvent) { [self showEncryptionInformation:tappedEvent]; } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnReceiptsContainer]) { MXKReceiptSendersContainer *container = userInfo[kMXKRoomBubbleCellReceiptsContainerKey]; [ReadReceiptsViewController openInViewController:self fromContainer:container withSession:self.mainSession]; } else if ([actionIdentifier isEqualToString:kRoomMembershipExpandedBubbleCellTapOnCollapseButton]) { // Reset the selection before collapsing self.customizedRoomDataSource.selectedEventId = nil; [self.roomDataSource collapseRoomBubble:((MXKRoomBubbleTableViewCell*)cell).bubbleData collapsed:YES]; } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnEvent]) { MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; if (!bubbleData.collapsed) { [self handleLongPressFromCell:cell withTappedEvent:tappedEvent]; } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnReactionView]) { NSString *tappedEventId = userInfo[kMXKRoomBubbleCellEventIdKey]; if (tappedEventId) { [self showReactionHistoryForEventId:tappedEventId animated:YES]; } } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAddReaction]) { NSString *tappedEventId = userInfo[kMXKRoomBubbleCellEventIdKey]; if (tappedEventId) { [self showEmojiPickerForEventId:tappedEventId]; } } else if ([actionIdentifier isEqualToString:RoomDirectCallStatusCell.callBackAction]) { MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; [self placeCallWithVideo2:eventContent.isVideoCall]; } else if ([actionIdentifier isEqualToString:RoomDirectCallStatusCell.declineAction]) { MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; MXCall *call = [self.mainSession.callManager callWithCallId:eventContent.callId]; [call hangup]; } else if ([actionIdentifier isEqualToString:RoomDirectCallStatusCell.answerAction]) { MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; MXCall *call = [self.mainSession.callManager callWithCallId:eventContent.callId]; [call answer]; } else if ([actionIdentifier isEqualToString:RoomDirectCallStatusCell.endCallAction]) { MXEvent *callInviteEvent = userInfo[kMXKRoomBubbleCellEventKey]; MXCallInviteEventContent *eventContent = [MXCallInviteEventContent modelFromJSON:callInviteEvent.content]; MXCall *call = [self.mainSession.callManager callWithCallId:eventContent.callId]; [call hangup]; } else if ([actionIdentifier isEqualToString:RoomGroupCallStatusCell.joinAction] || [actionIdentifier isEqualToString:RoomGroupCallStatusCell.answerAction]) { MXWeakify(self); // Check app permissions first [MXKTools checkAccessForCall:YES manualChangeMessageForAudio:[VectorL10n microphoneAccessNotGrantedForCall:AppInfo.current.displayName] manualChangeMessageForVideo:[VectorL10n cameraAccessNotGrantedForCall:AppInfo.current.displayName] showPopUpInViewController:self completionHandler:^(BOOL granted) { MXStrongifyAndReturnIfNil(self); if (granted) { // Present the Jitsi view controller Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; if (jitsiWidget) { [self showJitsiCallWithWidget:jitsiWidget]; } } else { MXLogDebug(@"[RoomVC] didRecognizeAction:inCell:userInfo Warning: The application does not have the permission to join/answer the group call"); } }]; #ifdef CALL_STACK_JINGLE MXEvent *widgetEvent = userInfo[kMXKRoomBubbleCellEventKey]; Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent inMatrixSession:self.customizedRoomDataSource.mxSession]; [[JitsiService shared] resetDeclineForWidgetWithId:widget.widgetId]; #endif } else if ([actionIdentifier isEqualToString:RoomGroupCallStatusCell.leaveAction]) { [self endActiveJitsiCall]; [self reloadBubblesTable:YES]; } else if ([actionIdentifier isEqualToString:RoomGroupCallStatusCell.declineAction]) { #ifdef CALL_STACK_JINGLE MXEvent *widgetEvent = userInfo[kMXKRoomBubbleCellEventKey]; Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent inMatrixSession:self.customizedRoomDataSource.mxSession]; [[JitsiService shared] declineWidgetWithId:widget.widgetId]; [self reloadBubblesTable:YES]; #endif } else if ([actionIdentifier isEqualToString:RoomCreationIntroCell.tapOnAvatarView]) { [self showRoomAvatarChange]; } else if ([actionIdentifier isEqualToString:RoomCreationIntroCell.tapOnAddParticipants]) { [self showAddParticipants]; } else if ([actionIdentifier isEqualToString:RoomCreationIntroCell.tapOnAddTopic]) { [self showRoomTopicChange]; } else if ([actionIdentifier isEqualToString:RoomCreationIntroCell.tapOnRoomName]) { [self showRoomCreationModal]; } else { // Keep default implementation for other actions [super dataSource:dataSource didRecognizeAction:actionIdentifier inCell:cell userInfo:userInfo]; } } else { // Keep default implementation for other actions [super dataSource:dataSource didRecognizeAction:actionIdentifier inCell:cell userInfo:userInfo]; } } // Display the additiontal event actions menu - (void)showAdditionalActionsMenuForEvent:(MXEvent*)selectedEvent inCell:(id)cell animated:(BOOL)animated { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; BOOL isJitsiCallEvent = NO; switch (selectedEvent.eventType) { case MXEventTypeCustom: if ([selectedEvent.type isEqualToString:kWidgetMatrixEventTypeString] || [selectedEvent.type isEqualToString:kWidgetModularEventTypeString]) { Widget *widget = [[Widget alloc] initWithWidgetEvent:selectedEvent inMatrixSession:self.roomDataSource.mxSession]; if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || [widget.type isEqualToString:kWidgetTypeJitsiV2]) { isJitsiCallEvent = YES; } } default: break; } if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } [self.eventMenuBuilder reset]; MXWeakify(self); BOOL showThreadOption = [self showThreadOptionForEvent:selectedEvent]; if (showThreadOption && [self canCopyEvent:selectedEvent andCell:cell]) { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKRoomBubbleCellData *cellData = roomBubbleTableViewCell.bubbleData; [self.eventMenuBuilder addItemWithType:EventMenuItemTypeCopy action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionCopy] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self copyEvent:selectedEvent inCell:cell withCellData:cellData]; }]]; } // Add actions for a failed event if (selectedEvent.sentState == MXEventSentStateFailed) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeRetrySending action:[UIAlertAction actionWithTitle:[VectorL10n retry] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Let the datasource resend. It will manage local echo, etc. [self.roomDataSource resendEventWithEventId:selectedEvent.eventId success:nil failure:nil]; }]]; [self.eventMenuBuilder addItemWithType:EventMenuItemTypeRemove action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionDelete] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; }]]; } // View in room action if (self.roomDataSource.threadId && [selectedEvent.eventId isEqualToString:self.roomDataSource.threadId]) { // if in the thread and selected event is the root event // add "View in room" action [self.eventMenuBuilder addItemWithType:EventMenuItemTypeViewInRoom action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewInRoom] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self.delegate roomViewController:self showRoomWithId:self.roomDataSource.roomId eventId:selectedEvent.eventId]; }]]; } // Add actions for text message if (!attachment) { // 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; } // Check status of the selected event if (selectedEvent.sentState == MXEventSentStatePreparing || selectedEvent.sentState == MXEventSentStateEncrypting || selectedEvent.sentState == MXEventSentStateSending) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeCancelSending action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionCancelSend] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; // Cancel and remove the outgoing message [self.roomDataSource.room cancelSendingOperation:selectedEvent.eventId]; [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; [self cancelEventSelection]; }]]; } if (selectedEvent.sentState == MXEventSentStateSent && !selectedEvent.isTimelinePollEvent && // Forwarding of live-location shares still to be implemented selectedEvent.eventType != MXEventTypeBeaconInfo) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeForward action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionForward] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self presentEventForwardingDialogForSelectedEvent:selectedEvent]; }]]; } if (!isJitsiCallEvent && BWIBuildSettings.shared.messageDetailsAllowShare && !selectedEvent.isTimelinePollEvent && selectedEvent.eventType != MXEventTypeBeaconInfo) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeShare action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionShare] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; UIActivityViewController *activityViewController = nil; if (selectedEvent.location) { activityViewController = [self.delegate roomViewController:self locationShareActivityViewControllerForEvent:selectedEvent]; } if (activityViewController == nil && selectedComponent.textMessage) { NSArray *activityItems = @[selectedComponent.textMessage]; 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]; } }]]; } } else // Add action for attachment { // Forwarding for already sent attachments if (selectedEvent.sentState == MXEventSentStateSent && (attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo || attachment.type == MXKAttachmentTypeVoiceMessage)) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeForward action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionForward] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self presentEventForwardingDialogForSelectedEvent:selectedEvent]; }]]; } if (BWIBuildSettings.shared.messageDetailsAllowSave) { if (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeSaveMedia action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionSave] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self startActivityIndicator]; MXWeakify(self); [attachment save:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; //Alert user [self showError: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 || selectedEvent.sentState == MXEventSentStateSending) { // Upload id is stored in attachment url (nasty trick) NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL; if ([MXMediaManager existingUploaderWithId:uploadId]) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeCancelSending action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionCancelSend] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); // Get again the loader MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId]; if (loader) { [loader cancel]; } // Hide the progress animation roomBubbleTableViewCell.progressView.hidden = YES; 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]; // Cancel and remove the outgoing message [self.roomDataSource.room cancelSendingOperation:selectedEvent.eventId]; [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; [self cancelEventSelection]; }]]; } } if (attachment.type != MXKAttachmentTypeSticker) { if (BWIBuildSettings.shared.messageDetailsAllowShare) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeShare action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionShare] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self startActivityIndicator]; MXWeakify(self); [attachment prepareShare:^(NSURL *fileURL) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; 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) { [self showError:error]; [self stopActivityIndicator]; }]; // Start animation in case of download during attachment preparing [roomBubbleTableViewCell startProgressUI]; }]]; } } } // 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]) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeCancelDownloading action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionCancelDownload] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Get again the loader MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; if (loader) { [loader cancel]; } // Hide the progress animation roomBubbleTableViewCell.progressView.hidden = YES; }]]; } } if (BWIBuildSettings.shared.messageDetailsAllowPermalink && !(self.roomDataSource.room.isDirect || self.roomDataSource.room.isPersonalNotesRoom || // bwi #4390: remove permalink action if room is private [self.roomDataSource.room.summary.joinRule isEqualToString: kMXRoomJoinRuleInvite])) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypePermalink action:[UIAlertAction actionWithTitle:[BWIL10n roomEventActionPermalink] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Create a matrix.to permalink that is common to all matrix clients NSString *permalink = [MXTools permalinkToEvent:selectedEvent.eventId inRoom:selectedEvent.roomId]; NSURL *url = [NSURL URLWithString:permalink]; if (url) { MXKPasteboardManager.shared.pasteboard.URL = url; [self.view vc_toastWithMessage:VectorL10n.roomEventCopyLinkInfo image:AssetImages.linkIcon.image duration:2.0 position:ToastPositionBottom additionalMargin:self.roomInputToolbarContainerHeightConstraint.constant]; } else { MXLogDebug(@"[RoomViewController] Contextual menu permalink action failed. Permalink is nil room id/event id: %@/%@", selectedEvent.roomId, selectedEvent.eventId); } }]]; } if (BWIBuildSettings.shared.messageDetailsAllowViewSource) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeViewSource action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewSource] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Display event details [self showEventDetails:selectedEvent]; }]]; // Add "View Decrypted Source" for e2ee event we can decrypt if (selectedEvent.isEncrypted && selectedEvent.clearEvent) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeViewDecryptedSource action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewDecryptedSource] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Display clear event details [self showEventDetails:selectedEvent.clearEvent]; }]]; } } // Do not allow to redact the event that enabled encryption (m.room.encryption) // because it breaks everything if (selectedEvent.eventType != MXEventTypeRoomEncryption) { NSString *title; EventMenuItemType itemType; if (selectedEvent.eventType == MXEventTypePollStart) { title = [BWIL10n roomEventActionRemovePoll]; itemType = EventMenuItemTypeRemovePoll; } else { title = [VectorL10n roomEventActionRedact]; itemType = EventMenuItemTypeRemove; } [self.eventMenuBuilder addItemWithType:itemType action:[UIAlertAction actionWithTitle:title style:UIAlertActionStyleDestructive handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self startActivityIndicator]; NSArray* relationTypes = nil; // If it's a voice broadcast, delete the selected event and all related events. if (selectedEvent.eventType == MXEventTypeCustom && [selectedEvent.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) { relationTypes = @[MXEventRelationTypeReference]; } MXWeakify(self); [self.roomDataSource.room redactEvent:selectedEvent.eventId withRelations:relationTypes reason:nil success:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Redact event (%@) failed", selectedEvent.eventId); //Alert user [self showError:error]; }]; }]]; } if (selectedEvent.eventType == MXEventTypePollStart && [selectedEvent.sender isEqualToString:self.mainSession.myUserId]) { if ([self.delegate roomViewController:self canEndPollWithEventIdentifier:selectedEvent.eventId]) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeEndPoll action:[UIAlertAction actionWithTitle:[BWIL10n roomEventActionEndPoll] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self.delegate roomViewController:self endPollWithEventIdentifier:selectedEvent.eventId]; [self hideContextualMenuAnimated:YES]; }]]; } } // Add reaction history if event contains reactions if (roomBubbleTableViewCell.bubbleData.reactions[selectedEvent.eventId].aggregatedReactionsWithNonZeroCount) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeReactionHistory action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionReactionHistory] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Show reaction history [self showReactionHistoryForEventId:selectedEvent.eventId animated:YES]; }]]; } if (![selectedEvent.sender isEqualToString:self.mainSession.myUserId] && RiotSettings.shared.roomContextualMenuShowReportContentOption) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeReport action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionReport] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Prompt user to enter a description of the problem content. UIAlertController *reportReasonAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomEventActionReportPromptReason] message:nil preferredStyle:UIAlertControllerStyleAlert]; [reportReasonAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) { textField.secureTextEntry = NO; textField.placeholder = nil; textField.keyboardType = UIKeyboardTypeDefault; }]; MXWeakify(self); [reportReasonAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); NSString *text = [self->currentAlert textFields].firstObject.text; self->currentAlert = nil; [self startActivityIndicator]; MXWeakify(self); [self.roomDataSource.room reportEvent:selectedEvent.eventId score:-100 reason:text success:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; // Prompt user to ignore content from this user UIAlertController *ignoreUserAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomEventActionReportPromptIgnoreUser] message:nil preferredStyle:UIAlertControllerStyleAlert]; MXWeakify(self); [ignoreUserAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n yes] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; [self startActivityIndicator]; MXWeakify(self); // Add the user to the blacklist: ignored users [self.mainSession ignoreUsers:@[selectedEvent.sender] success:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Ignore user (%@) failed", selectedEvent.sender); //Alert user [self showError:error]; }]; }]]; [ignoreUserAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n no] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [self presentViewController:ignoreUserAlert animated:YES completion:nil]; self->currentAlert = ignoreUserAlert; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Report event (%@) failed", selectedEvent.eventId); //Alert user [self showError:error]; }]; }]]; [reportReasonAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [self presentViewController:reportReasonAlert animated:YES completion:nil]; self->currentAlert = reportReasonAlert; }]]; } if (!isJitsiCallEvent && self.roomDataSource.room.summary.isEncrypted && BWIBuildSettings.shared.roomContextualMenuShowEncryptionOption) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeViewEncryption action:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewEncryption] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; // Display encryption details [self showEncryptionInformation:selectedEvent]; }]]; } } [self.eventMenuBuilder addItemWithType:EventMenuItemTypeCancel action:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES]; }]]; // Do not display empty action sheet if (!self.eventMenuBuilder.isEmpty) { UIAlertController *actionsMenu = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; // build actions and add them to the alert NSArray *actions = [self.eventMenuBuilder build]; for (UIAlertAction *action in actions) { [actionsMenu addAction:action]; } NSInteger bubbleComponentIndex = [roomBubbleTableViewCell.bubbleData bubbleComponentIndexForEventId:selectedEvent.eventId]; CGRect sourceRect = [roomBubbleTableViewCell componentFrameInContentViewForIndex:bubbleComponentIndex]; [actionsMenu mxk_setAccessibilityIdentifier:@"RoomVCEventMenuAlert"]; [actionsMenu popoverPresentationController].sourceView = roomBubbleTableViewCell; [actionsMenu popoverPresentationController].sourceRect = sourceRect; [self dismissKeyboard]; [self presentViewController:actionsMenu animated:animated completion:nil]; currentAlert = actionsMenu; } } - (void)presentEventForwardingDialogForSelectedEvent:(MXEvent *)selectedEvent { ForwardingShareItemSender *shareItemSender = [[ForwardingShareItemSender alloc] initWithEvent:selectedEvent]; self.shareManager = [[ShareManager alloc] initWithShareItemSender:shareItemSender type:ShareManagerTypeForward session:self.mainSession]; MXWeakify(self); [self.shareManager setCompletionCallback:^(ShareManagerResult result) { MXStrongifyAndReturnIfNil(self); if ([self.presentedViewController isEqual:self.shareManager.mainViewController]) { [self dismissViewControllerAnimated:YES completion:nil]; } self.shareManager = nil; }]; [self presentViewController:self.shareManager.mainViewController animated:YES completion:nil]; } - (BOOL)dataSource:(MXKDataSource *)dataSource shouldDoAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue { BOOL shouldDoAction = defaultValue; if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellShouldInteractWithURL]) { // Try to catch universal link supported by the app NSURL *url = userInfo[kMXKRoomBubbleCellUrl]; // Retrieve the type of interaction expected with the URL (See UITextItemInteraction) NSNumber *urlItemInteractionValue = userInfo[kMXKRoomBubbleCellUrlItemInteraction]; RoomMessageURLType roomMessageURLType = RoomMessageURLTypeUnknown; if (url) { roomMessageURLType = [self.roomMessageURLParser parseURL:url]; } // When a link refers to a room alias/id, a user id or an event id, the non-ASCII characters (like '#' in room alias) has been escaped // to be able to convert it into a legal URL string. NSString *absoluteURLString = [url.absoluteString stringByRemovingPercentEncoding]; // If the link can be open it by the app, let it do if ([Tools isUniversalLink:url]) { shouldDoAction = NO; [self handleUniversalLinkURL:url]; } // Open a detail screen about the clicked user else if ([MXTools isMatrixUserIdentifier:absoluteURLString]) { shouldDoAction = NO; NSString *userId = absoluteURLString; MXRoomMember* member = [self.roomDataSource.roomState.members memberWithUserId:userId]; if (member) { // Use the room member detail VC for room members [self showMemberDetails:member]; } else { // Use the contact detail VC for other users MXUser *user = [self.roomDataSource.room.mxSession userWithUserId:userId]; if (user) { selectedContact = [[MXKContact alloc] initMatrixContactWithDisplayName:((user.displayname.length > 0) ? user.displayname : user.userId) andMatrixID:user.userId]; } else { selectedContact = [[MXKContact alloc] initMatrixContactWithDisplayName:userId andMatrixID:userId]; } [self performSegueWithIdentifier:@"showContactDetails" sender:self]; } } // Open the clicked room else if ([MXTools isMatrixRoomIdentifier:absoluteURLString] || [MXTools isMatrixRoomAlias:absoluteURLString]) { shouldDoAction = NO; NSString *roomIdOrAlias = absoluteURLString; // Create a permalink to open or preview the room. NSString *permalink = [MXTools permalinkToRoom:roomIdOrAlias]; NSURL *permalinkURL = [NSURL URLWithString:permalink]; [self handleUniversalLinkURL:permalinkURL]; } else if ([absoluteURLString hasPrefix:EventFormatterOnReRequestKeysLinkAction]) { NSArray *arguments = [absoluteURLString componentsSeparatedByString:EventFormatterLinkActionSeparator]; if (arguments.count > 1) { NSString *eventId = arguments[1]; MXEvent *event = [self.roomDataSource eventWithEventId:eventId]; if (event) { [self reRequestKeysAndShowExplanationAlert:event]; } } } else if ([absoluteURLString hasPrefix:EventFormatterEditedEventLinkAction]) { NSArray *arguments = [absoluteURLString componentsSeparatedByString:EventFormatterLinkActionSeparator]; if (arguments.count > 1) { NSString *eventId = arguments[1]; [self showEditHistoryForEventId:eventId animated:YES]; } shouldDoAction = NO; } else if (url && urlItemInteractionValue) { // Fallback case for external links switch (urlItemInteractionValue.integerValue) { case UITextItemInteractionInvokeDefaultAction: { switch (roomMessageURLType) { case RoomMessageURLTypeAppleDataDetector: // Keep the default OS behavior on single tap when UITextView data detector detect a known type. shouldDoAction = YES; break; case RoomMessageURLTypeDummy: // Do nothing for dummy links shouldDoAction = NO; break; case RoomMessageURLTypeHttp: shouldDoAction = YES; break; default: { MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; URLValidationResult *result = [URLValidator validateTappedURL:url in:tappedEvent]; if (result.shouldShowConfirmationAlert) { [self showDifferentURLsAlertFor:url visibleURLString:result.visibleURLString]; return NO; } // Try to open the link [[UIApplication sharedApplication] vc_open:url completionHandler:^(BOOL success) { if (!success) { [self showUnableToOpenLinkErrorAlert]; } }]; shouldDoAction = NO; break; } } } break; case UITextItemInteractionPresentActions: { if (roomMessageURLType == RoomMessageURLTypeHttp) { shouldDoAction = YES; } else { // Retrieve the tapped event MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; if (tappedEvent) { // Long press on link, present room contextual menu. [self showContextualMenuForEvent:tappedEvent fromSingleTapGesture:NO cell:cell animated:YES]; } shouldDoAction = NO; } } break; case UITextItemInteractionPreview: // Force touch on link, let MXKRoomBubbleTableViewCell UITextView use default peek and pop behavior. break; default: break; } } else { [self showUnableToOpenLinkErrorAlert]; } } return shouldDoAction; } - (void)selectEventWithId:(NSString*)eventId { [self selectEventWithId:eventId inputToolBarSendMode:RoomInputToolbarViewSendModeSend showTimestamp:YES]; } - (void)selectEventWithId:(NSString*)eventId inputToolBarSendMode:(RoomInputToolbarViewSendMode)inputToolBarSendMode showTimestamp:(BOOL)showTimestamp { [self setInputToolBarSendMode:inputToolBarSendMode forEventWithId:eventId]; self.customizedRoomDataSource.showBubbleDateTimeOnSelection = showTimestamp; self.customizedRoomDataSource.selectedEventId = eventId; // Force table refresh [self dataSource:self.roomDataSource didCellChange:nil]; } - (void)cancelEventSelection { [self setInputToolBarSendMode:RoomInputToolbarViewSendModeSend forEventWithId:nil]; if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } self.customizedRoomDataSource.showBubbleDateTimeOnSelection = YES; self.customizedRoomDataSource.selectedEventId = nil; self.customizedRoomDataSource.highlightedEventId = nil; [self restoreTextMessageBeforeEditing]; // Force table refresh [self dataSource:self.roomDataSource didCellChange:nil]; } - (void)showUnableToOpenLinkErrorAlert { [self showAlertWithTitle:[VectorL10n error] message:[VectorL10n roomMessageUnableOpenLinkErrorMessage]]; } - (void)editEventContentWithId:(NSString*)eventId { MXEvent *event = [self.roomDataSource eventWithEventId:eventId]; if ([self inputToolbarConformsToHtmlToolbarViewProtocol]) { MXKRoomInputToolbarView *htmlInputToolBarView = (MXKRoomInputToolbarView *) self.inputToolbarView; self.htmlTextBeforeEditing = htmlInputToolBarView.htmlContent; htmlInputToolBarView.htmlContent = [self.customizedRoomDataSource editableHtmlTextMessageFor:event]; } else if ([self inputToolbarConformsToToolbarViewProtocol]) { self.textMessageBeforeEditing = self.inputToolbarView.attributedTextMessage; self.inputToolbarView.attributedTextMessage = [self.customizedRoomDataSource editableAttributedTextMessageFor:event]; } [self selectEventWithId:eventId inputToolBarSendMode:RoomInputToolbarViewSendModeEdit showTimestamp:YES]; } - (void)restoreTextMessageBeforeEditing { if (self.htmlTextBeforeEditing && [self inputToolbarConformsToHtmlToolbarViewProtocol]) { MXKRoomInputToolbarView *htmlInputToolBarView = (MXKRoomInputToolbarView *) self.inputToolbarView; htmlInputToolBarView.htmlContent = self.htmlTextBeforeEditing; } else if (self.textMessageBeforeEditing && [self inputToolbarConformsToToolbarViewProtocol]) { self.inputToolbarView.attributedTextMessage = self.textMessageBeforeEditing; } self.textMessageBeforeEditing = nil; self.htmlTextBeforeEditing = nil; } - (BOOL)inputToolbarConformsToHtmlToolbarViewProtocol { return [self.inputToolbarView conformsToProtocol:@protocol(HtmlRoomInputToolbarViewProtocol)]; } - (BOOL)inputToolbarConformsToToolbarViewProtocol { return [self.inputToolbarView conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]; } - (void)showDifferentURLsAlertFor:(NSURL *)url visibleURLString:(NSString *)visibleURLString { // urls are different, show confirmation alert UIAlertController *alert = [UIAlertController alertControllerWithTitle:[VectorL10n externalLinkConfirmationTitle] message:[VectorL10n externalLinkConfirmationMessage:visibleURLString :url.absoluteString] preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction *continueAction = [UIAlertAction actionWithTitle:[BWIL10n continue] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { // Try to open the link [[UIApplication sharedApplication] vc_open:url completionHandler:^(BOOL success) { if (!success) { [self showUnableToOpenLinkErrorAlert]; } }]; }]; UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:nil]; [alert addAction:continueAction]; [alert addAction:cancelAction]; [self presentViewController:alert animated:YES completion:nil]; } #pragma mark - RoomDataSourceDelegate - (void)roomDataSourceDidUpdateEncryptionTrustLevel:(RoomDataSource *)roomDataSource { [self updateInputToolbarEncryptionDecoration]; [self updateTitleViewEncryptionDecoration]; } - (void)roomDataSource:(RoomDataSource *)roomDataSource didTapThread:(id)thread { [self openThreadWithId:thread.id]; [Analytics.shared trackInteraction:AnalyticsUIElementRoomThreadSummaryItem]; } - (void)roomDataSourceDidUpdateCurrentUserSharingLocationStatus:(RoomDataSource *)roomDataSource { [self updateLiveLocationBannerViewVisibility]; } #pragma mark - Segues - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { // Keep ref on destinationViewController [super prepareForSegue:segue sender:sender]; id pushedViewController = [segue destinationViewController]; if ([[segue identifier] isEqualToString:@"showRoomSearch"]) { // Dismiss keyboard [self dismissKeyboard]; RoomSearchViewController* roomSearchViewController = (RoomSearchViewController*)pushedViewController; // Add the current data source to be able to search messages. roomSearchViewController.roomDataSource = self.roomDataSource; } else if ([[segue identifier] isEqualToString:@"showContactDetails"]) { if (selectedContact) { ContactDetailsViewController *contactDetailsViewController = segue.destinationViewController; contactDetailsViewController.enableVoipCall = NO; contactDetailsViewController.contact = selectedContact; selectedContact = nil; } } else if ([[segue identifier] isEqualToString:@"showUnknownDevices"]) { if (unknownDevices) { UsersDevicesViewController *usersDevicesViewController = (UsersDevicesViewController *)segue.destinationViewController.childViewControllers.firstObject; [usersDevicesViewController displayUsersDevices:unknownDevices andMatrixSession:self.roomDataSource.mxSession onComplete:nil]; unknownDevices = nil; } } } #pragma mark - VoIP - (void)placeCallWithVideo:(BOOL)video { __weak __typeof(self) weakSelf = self; // Check app permissions first [MXKTools checkAccessForCall:video manualChangeMessageForAudio:[VectorL10n microphoneAccessNotGrantedForCall:AppInfo.current.displayName] manualChangeMessageForVideo:[VectorL10n cameraAccessNotGrantedForCall:AppInfo.current.displayName] showPopUpInViewController:self completionHandler:^(BOOL granted) { if (weakSelf) { typeof(self) self = weakSelf; if (granted) { if (video) { [self placeCallWithVideo2:video]; } else if (self.mainSession.callManager.supportsPSTN) { [self showVoiceCallActionSheet]; } else { [self placeCallWithVideo2:NO]; } } else { MXLogDebug(@"RoomViewController: Warning: The application does not have the permission to place the call"); } } }]; } - (void)showVoiceCallActionSheet { // Ask the user the kind of the call: voice or dialpad? UIAlertController *callActionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; __weak typeof(self) weakSelf = self; [callActionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n roomPlaceVoiceCall] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self placeCallWithVideo2:NO]; } }]]; [callActionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n roomOpenDialpad] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self openDialpad]; } }]]; [callActionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; } }]]; [callActionSheet popoverPresentationController].barButtonItem = self.navigationItem.rightBarButtonItems.firstObject; [callActionSheet popoverPresentationController].permittedArrowDirections = UIPopoverArrowDirectionUp; [self presentViewController:callActionSheet animated:YES completion:nil]; currentAlert = callActionSheet; } - (void)placeCallWithVideo2:(BOOL)video { Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; if (jitsiWidget) { // If there is already a Jitsi call, join it [self showJitsiCallWithWidget:jitsiWidget]; } else { if (self.roomDataSource.room.summary.membersCount.joined == 2 && self.roomDataSource.room.isDirect && !self.mainSession.vc_homeserverConfiguration.jitsi.useFor1To1Calls) { // Matrix call [self.roomDataSource.room placeCallWithVideo:video success:nil failure:nil]; } else { // Jitsi call if (self.canEditJitsiWidget) { // User has right to add a Jitsi widget // Create the Jitsi widget and open it directly [self startActivityIndicator]; MXWeakify(self); [[WidgetManager sharedManager] createJitsiWidgetInRoom:self.roomDataSource.room withVideo:video success:^(Widget *jitsiWidget) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; [self showJitsiCallWithWidget:jitsiWidget]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; [self showJitsiErrorAsAlert:error]; }]; } else { // Insufficient privileges to add a Jitsi widget MXWeakify(self); [currentAlert dismissViewControllerAnimated:NO completion:nil]; UIAlertController *unprivilegedAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomNoPrivilegesToCreateGroupCall] message:nil preferredStyle:UIAlertControllerStyleAlert]; [unprivilegedAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [unprivilegedAlert mxk_setAccessibilityIdentifier:@"RoomVCCallAlert"]; [self presentViewController:unprivilegedAlert animated:YES completion:nil]; currentAlert = unprivilegedAlert; } } } } - (void)hangupCall { MXCall *callInRoom = [self.roomDataSource.mxSession.callManager callInRoom:self.roomDataSource.roomId]; if (callInRoom) { [callInRoom hangup]; } else if (self.isRoomHavingAJitsiCall) { [self endActiveJitsiCall]; [self reloadBubblesTable:YES]; } [self refreshActivitiesViewDisplay]; [self refreshRoomInputToolbar]; } #pragma mark - MXKRoomInputToolbarViewDelegate - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing { [super roomInputToolbarView:toolbarView isTyping:typing]; // TODO: Improve so we don't save partial message twice. RoomInputToolbarView *inputToolbar = (RoomInputToolbarView *)toolbarView; if (self.saveProgressTextInput && self.roomDataSource && inputToolbar) { // Store the potential message partially typed in text input self.roomDataSource.partialAttributedTextMessage = inputToolbar.attributedTextMessage; } // Cancel potential selected event (to leave edition mode) NSString *selectedEventId = self.customizedRoomDataSource.selectedEventId; if (typing && selectedEventId && ![self.roomDataSource canReplyToEventWithId:selectedEventId]) { [self cancelEventSelection]; } } - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion { if (self.roomInputToolbarContainerHeightConstraint.constant != height) { [super roomInputToolbarView:toolbarView heightDidChanged:height completion:^(BOOL finished) { if (completion) { completion (finished); } }]; } } - (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView*)toolbarView { [self cancelEventSelection]; } - (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView *)toolbarView { [self.completionSuggestionCoordinator processTextMessage:toolbarView.textMessage]; } - (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern { [self.completionSuggestionCoordinator processSuggestionPattern:suggestionPattern]; } - (CompletionSuggestionViewModelContextWrapper *)completionSuggestionContext { return [self.completionSuggestionCoordinator sharedContext]; } - (MXMediaManager *)mediaManager { return self.mainSession.mediaManager; } - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView { // Consider opening the action menu as beginning to type and share encryption keys if requested. if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenTyping) { [self shareEncryptionKeys]; } } - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { [self sendFormattedTextMessage:rawText htmlMsg:formattedTextMessage]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendCommand:(NSString *)commandText { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { if (![self sendAsIRCStyleCommandIfPossible:commandText]) { // Display an error for unknown command UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:[VectorL10n roomCommandErrorUnknownCommand] preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:nil]]; [self presentViewController:alert animated:YES completion:nil]; } } }]; } - (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView { NSMutableArray *actionItems = [NSMutableArray new]; if (RiotSettings.shared.roomScreenAllowMediaLibraryAction) { [actionItems addObject:@(ComposerCreateActionPhotoLibrary)]; } if (RiotSettings.shared.roomScreenAllowStickerAction && !self.isNewDirectChat) { [actionItems addObject:@(ComposerCreateActionStickers)]; } if (RiotSettings.shared.roomScreenAllowFilesAction) { [actionItems addObject:@(ComposerCreateActionAttachments)]; } if (RiotSettings.shared.enableVoiceBroadcast && !self.isNewDirectChat) { [actionItems addObject:@(ComposerCreateActionVoiceBroadcast)]; } if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat) { [actionItems addObject:@(ComposerCreateActionPolls)]; } if ([self bwiShowLocationSharingUI:self.roomDataSource.mxSession] && !self.isNewDirectChat) { [actionItems addObject:@(ComposerCreateActionLocation)]; } if (RiotSettings.shared.roomScreenAllowCameraAction) { [actionItems addObject:@(ComposerCreateActionCamera)]; } self.composerCreateActionListBridgePresenter = [[ComposerCreateActionListBridgePresenter alloc] initWithActions:actionItems wysiwygEnabled:RiotSettings.shared.enableWysiwygComposer textFormattingEnabled:RiotSettings.shared.enableWysiwygTextFormatting]; self.composerCreateActionListBridgePresenter.delegate = self; [self.composerCreateActionListBridgePresenter presentFrom:self animated:YES]; } - (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { BOOL isMessageAHandledCommand = NO; // "/me" command is supported with Pills in RoomDataSource. if (![attributedTextMessage.string hasPrefix:[MXKSlashCommandsHelper commandNameFor:MXKSlashCommandEmote]]) { // Other commands currently work with identifiers (e.g. ban, invite, op, etc). NSString *message; if (@available(iOS 15.0, *)) { message = [PillsFormatter stringByReplacingPillsIn:attributedTextMessage mode:PillsReplacementTextModeIdentifier]; } else { message = attributedTextMessage.string; } // Try to send the slash command isMessageAHandledCommand = [self sendAsIRCStyleCommandIfPossible:message]; } if (!isMessageAHandledCommand) { [self sendAttributedTextMessage:attributedTextMessage]; } } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView shouldStorePartialContent:(NSAttributedString *)partialAttributedTextMessage { self.roomDataSource.partialAttributedTextMessage = partialAttributedTextMessage; } #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion { [self startChatWithUserId:matrixId completion:completion]; } - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController mention:(MXRoomMember*)member { [self mention:member]; } #pragma mark - Action - (IBAction)onVoiceCallPressed:(id)sender { // Manage case of a Voice broadcast listening -> Pause Voice broadcast playback [VoiceBroadcastPlaybackProvider.shared pausePlaying]; if (VoiceBroadcastRecorderProvider.shared.isVoiceBroadcastRecording) { [[AppDelegate theDelegate] showAlertWithTitle:VectorL10n.voiceBroadcastVoipCannotStartTitle message:VectorL10n.voiceBroadcastVoipCannotStartDescription]; } else if (self.isCallActive) { [self hangupCall]; } else { [self placeCallWithVideo:NO]; } } - (IBAction)onVideoCallPressed:(id)sender { // Manage case of a Voice broadcast listening -> Pause Voice broadcast playback [VoiceBroadcastPlaybackProvider.shared pausePlaying]; if (VoiceBroadcastRecorderProvider.shared.isVoiceBroadcastRecording) { [[AppDelegate theDelegate] showAlertWithTitle:VectorL10n.voiceBroadcastVoipCannotStartTitle message:VectorL10n.voiceBroadcastVoipCannotStartDescription]; } else { [self placeCallWithVideo:YES]; } } - (IBAction)onThreadListTapped:(id)sender { self.threadsBridgePresenter = [self.delegate threadsCoordinatorForRoomViewController:self threadId:nil]; self.threadsBridgePresenter.delegate = self; [self.threadsBridgePresenter pushFrom:self.navigationController animated:YES]; [Analytics.shared trackInteraction:AnalyticsUIElementRoomThreadListButton]; } - (IBAction)onIntegrationsPressed:(id)sender { WidgetPickerViewController *widgetPicker = [[WidgetPickerViewController alloc] initForMXSession:self.roomDataSource.mxSession inRoom:self.roomDataSource.roomId]; [widgetPicker showInViewController:self]; } - (void)scrollToBottomAction:(id)sender { [self goBackToLive]; } - (IBAction)onButtonPressed:(id)sender { if (sender == self.jumpToLastUnreadButton) { // Dismiss potential keyboard. [self dismissKeyboard]; NSString *eventId = self.roomDataSource.room.accountData.readMarkerEventId; NSString *threadId = self.roomDataSource.threadId; [self reloadRoomWihtEventId:eventId threadId:threadId forceUpdateRoomMarker:YES]; } else if (sender == self.resetReadMarkerButton) { // Move the read marker to the current read receipt position. [self.roomDataSource.room forgetReadMarker]; // Hide the banner self.jumpToLastUnreadBannerContainer.hidden = YES; } } - (void)handleReportRoom { // Prompt user to enter a description of the problem content. UIAlertController *reportReasonAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomActionReportPromptReason] message:nil preferredStyle:UIAlertControllerStyleAlert]; [reportReasonAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) { textField.secureTextEntry = NO; textField.placeholder = nil; textField.keyboardType = UIKeyboardTypeDefault; }]; MXWeakify(self); [reportReasonAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); NSString *text = [self->currentAlert textFields].firstObject.text; self->currentAlert = nil; [self startActivityIndicator]; [self.roomDataSource.mxSession.matrixRestClient reportRoom:self.roomDataSource.roomId reason:text success:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Report room (%@) failed", self.roomDataSource.roomId); //Alert user [self showError:error]; }]; }]]; [reportReasonAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [self presentViewController:reportReasonAlert animated:YES completion:nil]; self->currentAlert = reportReasonAlert; } #pragma mark - UITableViewDelegate - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { cell.backgroundColor = ThemeService.shared.theme.backgroundColor; // Update the selected background view if (ThemeService.shared.theme.selectedBackgroundColor) { cell.selectedBackgroundView = [[UIView alloc] init]; cell.selectedBackgroundView.backgroundColor = ThemeService.shared.theme.selectedBackgroundColor; } else { if (tableView.style == UITableViewStylePlain) { cell.selectedBackgroundView = nil; } else { cell.selectedBackgroundView.backgroundColor = nil; } } if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && ![cell isKindOfClass:MXKRoomEmptyBubbleTableViewCell.class]) { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell*)cell; if (roomBubbleTableViewCell.readMarkerView) { readMarkerTableViewCell = roomBubbleTableViewCell; dispatch_async(dispatch_get_main_queue(), ^{ [self checkReadMarkerVisibility]; }); } } } - (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath { if (cell == readMarkerTableViewCell) { readMarkerTableViewCell = nil; } [super tableView:tableView didEndDisplayingCell:cell forRowAtIndexPath:indexPath]; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [super tableView:tableView didSelectRowAtIndexPath:indexPath]; } #pragma mark - - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [super scrollViewDidScroll:scrollView]; [self checkReadMarkerVisibility]; // Switch back to the live mode when the user scrolls to the bottom of the non live timeline. if (!self.roomDataSource.isLive && ![self isRoomPreview] && !self.isNewDirectChat) { CGFloat contentBottomPosY = self.bubblesTableView.contentOffset.y + self.bubblesTableView.frame.size.height - self.bubblesTableView.adjustedContentInset.bottom; if (contentBottomPosY >= self.bubblesTableView.contentSize.height && ![self.roomDataSource.timeline canPaginate:MXTimelineDirectionForwards]) { [self goBackToLive]; } } } - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { if ([MXKRoomViewController instancesRespondToSelector:@selector(scrollViewWillBeginDragging:)]) { [super scrollViewWillBeginDragging:scrollView]; } [self cancelEventHighlight]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if ([MXKRoomViewController instancesRespondToSelector:@selector(scrollViewDidEndDragging:willDecelerate:)]) { [super scrollViewDidEndDragging:scrollView willDecelerate:decelerate]; } if (decelerate == NO) { // Handle swipe on expanded header [self onScrollViewDidEndScrolling:scrollView]; [self refreshActivitiesViewDisplay]; [self refreshJumpToLastUnreadBannerDisplay]; } else { // Dispatch async the expanded header handling in order to let the deceleration go first. dispatch_async(dispatch_get_main_queue(), ^{ // Handle swipe on expanded header [self onScrollViewDidEndScrolling:scrollView]; }); } } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { if ([MXKRoomViewController instancesRespondToSelector:@selector(scrollViewDidEndDecelerating:)]) { [super scrollViewDidEndDecelerating:scrollView]; } [self refreshActivitiesViewDisplay]; [self refreshJumpToLastUnreadBannerDisplay]; } - (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView { if ([MXKRoomViewController instancesRespondToSelector:@selector(scrollViewDidEndScrollingAnimation:)]) { [super scrollViewDidEndScrollingAnimation:scrollView]; } [self refreshActivitiesViewDisplay]; [self refreshJumpToLastUnreadBannerDisplay]; } - (void)onScrollViewDidEndScrolling:(UIScrollView *)scrollView { } #pragma mark - MXKRoomTitleViewDelegate - (BOOL)roomTitleViewShouldBeginEditing:(MXKRoomTitleView*)titleView { // Disable room name edition return NO; } #pragma mark - RoomTitleViewTapGestureDelegate - (void)roomTitleView:(RoomTitleView*)titleView recognizeTapGesture:(UITapGestureRecognizer*)tapGestureRecognizer { UIView *tappedView = tapGestureRecognizer.view; if (tappedView == titleView.titleMask) { [self showRoomInfo]; } else if (tappedView == previewHeader.rightButton) { // 'Join' button has been pressed if (!roomPreviewData) { [self joinRoom:^(MXKRoomViewControllerJoinRoomResult result) { switch (result) { case MXKRoomViewControllerJoinRoomResultSuccess: [self refreshRoomTitle]; break; case MXKRoomViewControllerJoinRoomResultFailureRoomEmpty: [self declineRoomInvitation]; break; default: break; } }]; return; } // Attempt to join the room (keep reference on the potential eventId, the preview data will be removed automatically in case of success). NSString *eventId = roomPreviewData.eventId; // We promote here join by room alias instead of room id when an alias is available. NSString *roomIdOrAlias = roomPreviewData.roomId; if (roomPreviewData.roomCanonicalAlias.length) { roomIdOrAlias = roomPreviewData.roomCanonicalAlias; } else if (roomPreviewData.roomAliases.count) { roomIdOrAlias = roomPreviewData.roomAliases.firstObject; } // Note in case of simple link to a room the signUrl param is nil [self joinRoomWithRoomIdOrAlias:roomIdOrAlias viaServers:roomPreviewData.viaServers andSignUrl:roomPreviewData.emailInvitation.signUrl completion:^(MXKRoomViewControllerJoinRoomResult result) { switch (result) { case MXKRoomViewControllerJoinRoomResultSuccess: { // If an event was specified, replace the datasource by a non live datasource showing the event if (eventId) { MXWeakify(self); [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId initialEventId:eventId threadId:self.roomDataSource.threadId andMatrixSession:self.mainSession onComplete:^(id roomDataSource) { MXStrongifyAndReturnIfNil(self); [roomDataSource finalizeInitialization]; ((RoomDataSource*)roomDataSource).markTimelineInitialEvent = YES; [self displayRoom:roomDataSource]; self.hasRoomDataSourceOwnership = YES; }]; } else { // Enable back the text input [self setRoomInputToolbarViewClass:[RoomViewController mainToolbarClass]]; [self updateInputToolBarViewHeight]; // And the extra area [self setRoomActivitiesViewClass:RoomActivitiesView.class]; [self refreshRoomTitle]; [self refreshRoomInputToolbar]; } break; } case MXKRoomViewControllerJoinRoomResultFailureRoomEmpty: [self declineRoomInvitation]; break; default: break; } }]; } else if (tappedView == previewHeader.leftButton) { [self presentDeclineOptionsFromView:tappedView]; } else if (tappedView == previewHeader.reportButton) { [self handleReportRoom]; } } - (void)presentDeclineOptionsFromView:(UIView *)view { UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:[VectorL10n roomPreviewDeclineInvitationOptions] message:nil preferredStyle:UIAlertControllerStyleActionSheet]; [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n decline] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [self declineRoomInvitation]; }]]; [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n ignoreUser] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) { [self ignoreInviteSender]; }]]; [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:nil]]; actionSheet.popoverPresentationController.sourceView = view; [self presentViewController:actionSheet animated:YES completion:nil]; } - (void)declineRoomInvitation { // 'Decline' button has been pressed if (roomPreviewData) { [self roomPreviewDidTapCancelAction]; } else { [self startActivityIndicator]; MXWeakify(self); [self.roomDataSource.room leave:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; [self popToHomeViewController]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Failed to reject an invited room (%@) failed", self.roomDataSource.room.roomId); }]; } } - (void)ignoreInviteSender { [self startActivityIndicator]; MXWeakify(self); [self.roomDataSource.room ignoreInviteSender:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; [self popToHomeViewController]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Failed to ignore inviter in room (%@)", self.roomDataSource.room.roomId); }]; } - (void)popToHomeViewController { // We remove the current view controller. // Pop to homes view controller [[AppDelegate theDelegate] restoreInitialDisplay:^{}]; } #pragma mark - Typing management - (void)removeTypingNotificationsListener { if (self.roomDataSource) { // Remove the previous live listener if (typingNotifListener) { MXWeakify(self); [self.roomDataSource.room liveTimeline:^(id liveTimeline) { MXStrongifyAndReturnIfNil(self); [liveTimeline removeListener:self->typingNotifListener]; self->typingNotifListener = nil; }]; } } currentTypingUsers = nil; } - (void)listenTypingNotifications { if (self.roomDataSource) { // Add typing notification listener MXWeakify(self); self->typingNotifListener = [self.roomDataSource.room listenToEventsOfTypes:@[kMXEventTypeStringTypingNotification] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { MXStrongifyAndReturnIfNil(self); // Handle only live events if (direction == MXTimelineDirectionForwards) { // Retrieve typing users list NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self.roomDataSource.room.typingUsers]; // Remove typing info for the current user NSUInteger index = [typingUsers indexOfObject:self.mainSession.myUser.userId]; if (index != NSNotFound) { [typingUsers removeObjectAtIndex:index]; } // bwi remove ignored users from typing users if (BWIBuildSettings.shared.bwiBetterIgnoredUsers) { for (NSString* ignoredId in self.mainSession.ignoredUsers) { NSUInteger index = [typingUsers indexOfObject:ignoredId]; if (index != NSNotFound) { [typingUsers removeObjectAtIndex:index]; } } } // Ignore this notification if both arrays are empty if (self->currentTypingUsers.count || typingUsers.count) { self->currentTypingUsers = typingUsers; [self refreshActivitiesViewDisplay]; } } }]; // Retrieve the current typing users list NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self.roomDataSource.room.typingUsers]; // Remove typing info for the current user NSUInteger index = [typingUsers indexOfObject:self.mainSession.myUser.userId]; if (index != NSNotFound) { [typingUsers removeObjectAtIndex:index]; } currentTypingUsers = typingUsers; [self refreshActivitiesViewDisplay]; } } - (void)refreshTypingNotification { RoomDataSource *roomDataSource = (RoomDataSource *) self.roomDataSource; BOOL needsUpdate = currentTypingUsers.count != roomDataSource.currentTypingUsers.count; NSMutableArray *typingUsers = [NSMutableArray new]; for (NSUInteger i = 0 ; i < currentTypingUsers.count ; i++) { NSString *userId = currentTypingUsers[i]; MXRoomMember* member = [self.roomDataSource.roomState.members memberWithUserId:userId]; TypingUserInfo *userInfo; if (member) { userInfo = [[TypingUserInfo alloc] initWithMember: member]; } else { userInfo = [[TypingUserInfo alloc] initWithUserId: userId]; } [typingUsers addObject:userInfo]; needsUpdate = needsUpdate || userInfo.userId != ((MXRoomMember *) roomDataSource.currentTypingUsers[i]).userId; } if (needsUpdate) { // BOOL needsReload = roomDataSource.currentTypingUsers == nil; // Quick fix for https://github.com/vector-im/element-ios/issues/4230 BOOL needsReload = YES; roomDataSource.currentTypingUsers = typingUsers; if (needsReload) { [self.bubblesTableView reloadData]; } else { NSInteger count = [self.bubblesTableView numberOfRowsInSection:0]; NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:count - 1 inSection:0]; [self.bubblesTableView reloadRowsAtIndexPaths:@[lastIndexPath] withRowAnimation:UITableViewRowAnimationFade]; } if (self.isScrollToBottomHidden && !self.bubblesTableView.isDragging && !self.bubblesTableView.isDecelerating) { NSInteger count = [self.bubblesTableView numberOfRowsInSection:0]; if (count) { [self scrollBubblesTableViewToBottomAnimated:YES]; } } } } #pragma mark - Call notifications management - (void)removeCallNotificationsListeners { if (kMXCallStateDidChangeObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kMXCallStateDidChangeObserver]; kMXCallStateDidChangeObserver = nil; } if (kMXCallManagerConferenceStartedObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kMXCallManagerConferenceStartedObserver]; kMXCallManagerConferenceStartedObserver = nil; } if (kMXCallManagerConferenceFinishedObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kMXCallManagerConferenceFinishedObserver]; kMXCallManagerConferenceFinishedObserver = nil; } } - (void)listenCallNotifications { MXWeakify(self); kMXCallStateDidChangeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXCallStateDidChange object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); MXCall *call = notif.object; if ([call.room.roomId isEqualToString:self.customizedRoomDataSource.roomId]) { [self refreshActivitiesViewDisplay]; [self refreshRoomInputToolbar]; } }]; kMXCallManagerConferenceStartedObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXCallManagerConferenceStarted object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); NSString *roomId = notif.object; if ([roomId isEqualToString:self.customizedRoomDataSource.roomId]) { [self refreshActivitiesViewDisplay]; } }]; kMXCallManagerConferenceFinishedObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXCallManagerConferenceFinished object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); NSString *roomId = notif.object; if ([roomId isEqualToString:self.customizedRoomDataSource.roomId]) { [self refreshActivitiesViewDisplay]; [self refreshRoomInputToolbar]; } }]; } #pragma mark - Server notices management - (void)removeServerNoticesListener { if (serverNotices) { [serverNotices close]; serverNotices = nil; } } - (void)listenToServerNotices { if (!serverNotices) { serverNotices = [[MXServerNotices alloc] initWithMatrixSession:self.roomDataSource.mxSession]; serverNotices.delegate = self; } } - (void)serverNoticesDidChangeState:(MXServerNotices *)serverNotices { [self refreshActivitiesViewDisplay]; } #pragma mark - Widget notifications management - (void)removeWidgetNotificationsListeners { if (kMXKWidgetManagerDidUpdateWidgetObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kMXKWidgetManagerDidUpdateWidgetObserver]; kMXKWidgetManagerDidUpdateWidgetObserver = nil; } } - (void)listenWidgetNotifications { if (!self.displayConfiguration.jitsiWidgetRemoverEnabled) { return; } MXWeakify(self); kMXKWidgetManagerDidUpdateWidgetObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kWidgetManagerDidUpdateWidgetNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); Widget *widget = notif.object; if (widget.mxSession == self.roomDataSource.mxSession && [widget.roomId isEqualToString:self.customizedRoomDataSource.roomId]) { // Call button update [self refreshRoomTitle]; // Remove Jitsi widget view update [self refreshRemoveJitsiWidgetView]; } }]; } - (void)showJitsiErrorAsAlert:(NSError*)error { // Customise the error for permission issues if ([error.domain isEqualToString:WidgetManagerErrorDomain] && error.code == WidgetManagerErrorCodeNotEnoughPower) { error = [NSError errorWithDomain:error.domain code:error.code userInfo:@{ NSLocalizedDescriptionKey: [VectorL10n roomConferenceCallNoPower] }]; } // Alert user [self showError:error]; } - (NSUInteger)widgetsCount:(BOOL)includeUserWidgets { if (!self.displayConfiguration.integrationsEnabled) { return 0; } NSUInteger widgetsCount = [[WidgetManager sharedManager] widgetsNotOfTypes:@[kWidgetTypeJitsiV1, kWidgetTypeJitsiV2] inRoom:self.roomDataSource.room withRoomState:self.roomDataSource.roomState].count; if (includeUserWidgets) { widgetsCount += [[WidgetManager sharedManager] userWidgets:self.roomDataSource.room.mxSession].count; } return widgetsCount; } #pragma mark - Unreachable Network Handling - (void)refreshActivitiesViewDisplay { if ([self.activitiesView isKindOfClass:RoomActivitiesView.class]) { RoomActivitiesView *roomActivitiesView = (RoomActivitiesView*)self.activitiesView; // Reset gesture recognizers while (roomActivitiesView.gestureRecognizers.count) { [roomActivitiesView removeGestureRecognizer:roomActivitiesView.gestureRecognizers[0]]; } if ([self.roomDataSource.mxSession.syncError.errcode isEqualToString:kMXErrCodeStringResourceLimitExceeded]) { self.activitiesViewExpanded = YES; [roomActivitiesView showResourceLimitExceededError:self.roomDataSource.mxSession.syncError.userInfo onAdminContactTapped:^(NSURL *adminContactURL) { [[UIApplication sharedApplication] vc_open:adminContactURL completionHandler:^(BOOL success) { if (!success) { MXLogDebug(@"[RoomVC] refreshActivitiesViewDisplay: adminContact(%@) cannot be opened", adminContactURL); } }]; }]; } else if ([AppDelegate theDelegate].isOffline) { } else if (self.customizedRoomDataSource.roomState.isObsolete) { self.activitiesViewExpanded = YES; MXWeakify(self); [roomActivitiesView displayRoomReplacementWithRoomLinkTappedHandler:^{ MXStrongifyAndReturnIfNil(self); MXEvent *stoneTombEvent = [self.customizedRoomDataSource.roomState stateEventsWithType:kMXEventTypeStringRoomTombStone].lastObject; NSString *replacementRoomId = self.customizedRoomDataSource.roomState.tombStoneContent.replacementRoomId; if ([self.roomDataSource.mxSession roomWithRoomId:replacementRoomId]) { // Open the room if it is already joined [self showRoomWithId:replacementRoomId]; } else { // Else auto join it via the server that sent the event MXLogDebug(@"[RoomVC] Auto join an upgraded room: %@ -> %@. Sender: %@", self.customizedRoomDataSource.roomState.roomId, replacementRoomId, stoneTombEvent.sender); NSString *viaSenderServer = [MXTools serverNameInMatrixIdentifier:stoneTombEvent.sender]; if (viaSenderServer) { [self startActivityIndicator]; [self.roomDataSource.mxSession joinRoom:replacementRoomId viaServers:@[viaSenderServer] success:^(MXRoom *room) { [self stopActivityIndicator]; [self showRoomWithId:replacementRoomId]; } failure:^(NSError *error) { [self stopActivityIndicator]; MXLogDebug(@"[RoomVC] Failed to join an upgraded room. Error: %@", error); [self showError:error]; }]; } } }]; } else if ([self checkUnsentMessages] == NO) { // Show "scroll to bottom" icon when the most recent message is not visible, // or when the timelime is not live (this icon is used to go back to live). // Note: we check if `currentEventIdAtTableBottom` is set to know whether the table has been rendered at least once. if (!self.roomDataSource.isLive || (currentEventIdAtTableBottom && [self isBubblesTableScrollViewAtTheBottom] == NO)) { if (self.roomDataSource.room) { // Retrieve the unread messages count on the current thread NSUInteger unreadCount = [self.mainSession.store localUnreadEventCount:self.roomDataSource.room.roomId threadId:self.roomDataSource.threadId ?: kMXEventTimelineMain withTypeIn:self.mainSession.unreadEventTypes]; self.scrollToBottomBadgeLabel.text = unreadCount ? [NSString stringWithFormat:@"%lu", unreadCount] : nil; self.scrollToBottomHidden = NO; } else { // will be here for left rooms self.scrollToBottomBadgeLabel.text = nil; self.scrollToBottomHidden = YES; } } else if (serverNotices.usageLimit && serverNotices.usageLimit.isServerNoticeUsageLimit) { self.scrollToBottomHidden = YES; self.activitiesViewExpanded = YES; [roomActivitiesView showResourceUsageLimitNotice:serverNotices.usageLimit onAdminContactTapped:^(NSURL *adminContactURL) { [[UIApplication sharedApplication] vc_open:adminContactURL completionHandler:^(BOOL success) { if (!success) { MXLogDebug(@"[RoomVC] refreshActivitiesViewDisplay: adminContact(%@) cannot be opened", adminContactURL); } }]; }]; } else { self.scrollToBottomHidden = YES; self.activitiesViewExpanded = NO; [self refreshTypingNotification]; } } // Recognize swipe downward to dismiss keyboard if any UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(onSwipeGesture:)]; [swipe setNumberOfTouchesRequired:1]; [swipe setDirection:UISwipeGestureRecognizerDirectionDown]; [roomActivitiesView addGestureRecognizer:swipe]; } } - (void)goBackToLive { if (self.roomDataSource.isLive) { // Enable the read marker display, and disable its update (in order to not mark as read all the new messages by default). self.roomDataSource.showReadMarker = YES; self.updateRoomReadMarker = NO; [self scrollBubblesTableViewToBottomAnimated:YES]; [self cancelEventHighlight]; } else { MXWeakify(self); void(^continueBlock)(MXKRoomDataSource *, BOOL) = ^(MXKRoomDataSource *roomDataSource, BOOL hasRoomDataSourceOwnership){ MXStrongifyAndReturnIfNil(self); [roomDataSource finalizeInitialization]; // Scroll to bottom the bubble history on the display refresh. self->shouldScrollToBottomOnTableRefresh = YES; [self displayRoom:roomDataSource]; // Set the room view controller has the data source ownership here. self.hasRoomDataSourceOwnership = hasRoomDataSourceOwnership; [self refreshActivitiesViewDisplay]; [self refreshJumpToLastUnreadBannerDisplay]; if (self.saveProgressTextInput) { // Restore the potential message partially typed before jump to last unread messages. [self.inputToolbarView setPartialContent:roomDataSource.partialAttributedTextMessage]; } }; if (self.roomDataSource.threadId) { [ThreadDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId initialEventId:nil threadId:self.roomDataSource.threadId andMatrixSession:self.mainSession onComplete:^(ThreadDataSource *threadDataSource) { continueBlock(threadDataSource, YES); }]; } else if (self.roomDataSource.roomId) { if (self.isContextPreview) { [RoomPreviewDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId threadId:nil andMatrixSession:self.mainSession onComplete:^(RoomPreviewDataSource *roomDataSource) { continueBlock(roomDataSource, YES); }]; } else { // Switch back to the room live timeline managed by MXKRoomDataSourceManager MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession]; [roomDataSourceManager roomDataSourceForRoom:self.roomDataSource.roomId create:YES onComplete:^(MXKRoomDataSource *roomDataSource) { continueBlock(roomDataSource, NO); }]; } } } } #pragma mark - Missed discussions handling - (void)refreshMissedDiscussionsCount:(BOOL)force { // Ignore this action when no room is displayed if (!self.showMissedDiscussionsBadge || !self.roomDataSource || !missedDiscussionsBadgeLabel || [UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPhone || ([[UIScreen mainScreen] nativeBounds].size.height > 2532 && UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation))) { self.missedDiscussionsBadgeHidden = YES; return; } self.missedDiscussionsBadgeHidden = NO; NSUInteger highlightCount = 0; NSUInteger missedCount = [[AppDelegate theDelegate].masterTabBarController missedDiscussionsCount]; // Compute the missed notifications count of the current room by considering its notification mode in Riot. NSUInteger roomNotificationCount = self.roomDataSource.room.summary.notificationCount; if (self.roomDataSource.room.isMentionsOnly) { // Only the highlighted missed messages must be considered here. roomNotificationCount = self.roomDataSource.room.summary.highlightCount; } // Remove the current room from the missed discussion counter. if (missedCount && roomNotificationCount) { missedCount--; } if (missedCount) { // Compute the missed highlight count highlightCount = [[AppDelegate theDelegate].masterTabBarController missedHighlightDiscussionsCount]; if (highlightCount && self.roomDataSource.room.summary.highlightCount) { // Remove the current room from the missed highlight counter highlightCount--; } } if (force || missedDiscussionsCount != missedCount || missedHighlightCount != highlightCount) { missedDiscussionsCount = missedCount; missedHighlightCount = highlightCount; if (missedCount) { // Refresh missed discussions count label if (missedCount > 99) { missedDiscussionsBadgeLabel.text = @"99+"; } else { missedDiscussionsBadgeLabel.text = [NSString stringWithFormat:@"%tu", missedCount]; } missedDiscussionsDotView.alpha = highlightCount == 0 ? 0 : 1; } else { missedDiscussionsBadgeLabel.text = nil; } } } #pragma mark - Unsent Messages Handling -(BOOL)checkUnsentMessages { MXRoomSummarySentStatus sentStatus = MXRoomSummarySentStatusOk; if ([self.activitiesView isKindOfClass:RoomActivitiesView.class]) { sentStatus = self.roomDataSource.room.summary.sentStatus; if (sentStatus != MXRoomSummarySentStatusOk) { NSString *notification = sentStatus == MXRoomSummarySentStatusSentFailedDueToUnknownDevices ? [VectorL10n roomUnsentMessagesUnknownDevicesNotification] : [VectorL10n roomUnsentMessagesNotification]; MXWeakify(self); RoomActivitiesView *roomActivitiesView = (RoomActivitiesView*) self.activitiesView; self.activitiesViewExpanded = YES; [roomActivitiesView displayUnsentMessagesNotification:notification withResendLink:^{ [self resendAllUnsentMessages]; } andCancelLink:^{ [self cancelAllUnsentMessages]; } andIconTapGesture:^{ MXStrongifyAndReturnIfNil(self); if (self->currentAlert) { [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; } MXWeakify(self); UIAlertController *resendAlert = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; [resendAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n roomResendUnsentMessages] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self resendAllUnsentMessages]; self->currentAlert = nil; }]]; [resendAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n roomDeleteUnsentMessages] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [self cancelAllUnsentMessages]; self->currentAlert = nil; }]]; [resendAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [resendAlert mxk_setAccessibilityIdentifier:@"RoomVCUnsentMessagesMenuAlert"]; [resendAlert popoverPresentationController].sourceView = roomActivitiesView; [resendAlert popoverPresentationController].sourceRect = roomActivitiesView.bounds; [self presentViewController:resendAlert animated:YES completion:nil]; self->currentAlert = resendAlert; }]; } } return sentStatus != MXRoomSummarySentStatusOk; } - (void)eventDidChangeSentState:(NSNotification *)notif { // We are only interested by event that has just failed in their encryption // because of unknown devices in the room MXEvent *event = notif.object; if (event.sentState == MXEventSentStateFailed && [event.roomId isEqualToString:self.roomDataSource.roomId] && [event.sentError.domain isEqualToString:MXEncryptingErrorDomain] && event.sentError.code == MXEncryptingErrorUnknownDeviceCode && !unknownDevices) // Show the alert once in case of resending several events { __weak __typeof(self) weakSelf = self; [self dismissTemporarySubViews]; // List all unknown devices unknownDevices = [[MXUsersDevicesMap alloc] init]; NSArray *outgoingMsgs = self.roomDataSource.room.outgoingMessages; for (MXEvent *event in outgoingMsgs) { if (event.sentState == MXEventSentStateFailed && [event.sentError.domain isEqualToString:MXEncryptingErrorDomain] && event.sentError.code == MXEncryptingErrorUnknownDeviceCode) { MXUsersDevicesMap *eventUnknownDevices = event.sentError.userInfo[MXEncryptingErrorUnknownDeviceDevicesKey]; [unknownDevices addEntriesFromMap:eventUnknownDevices]; } } UIAlertController *unknownDevicesAlert = [UIAlertController alertControllerWithTitle:[VectorL10n unknownDevicesAlertTitle] message:[VectorL10n unknownDevicesAlert] preferredStyle:UIAlertControllerStyleAlert]; [unknownDevicesAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n unknownDevicesVerify] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self performSegueWithIdentifier:@"showUnknownDevices" sender:self]; } }]]; [unknownDevicesAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n unknownDevicesSendAnyway] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; // Acknowledge the existence of all devices self->unknownDevices = nil; // And resend pending messages [self resendAllUnsentMessages]; } }]]; [unknownDevicesAlert mxk_setAccessibilityIdentifier:@"RoomVCUnknownDevicesAlert"]; [self presentViewController:unknownDevicesAlert animated:YES completion:nil]; currentAlert = unknownDevicesAlert; } } - (void)eventDidChangeIdentifier:(NSNotification *)notif { MXEvent *event = notif.object; NSString *previousId = notif.userInfo[kMXEventIdentifierKey]; if ([self.customizedRoomDataSource.selectedEventId isEqualToString:previousId]) { MXLogDebug(@"[RoomVC] eventDidChangeIdentifier: Update selectedEventId"); self.customizedRoomDataSource.selectedEventId = event.eventId; } } - (void)resendAllUnsentMessages { // List unsent event ids NSArray *outgoingMsgs = self.roomDataSource.room.outgoingMessages; NSMutableArray *failedEventIds = [NSMutableArray arrayWithCapacity:outgoingMsgs.count]; for (MXEvent *event in outgoingMsgs) { if (event.sentState == MXEventSentStateFailed) { [failedEventIds addObject:event.eventId]; } } // Launch iterative operation [self resendFailedEvent:0 inArray:failedEventIds]; } - (void)resendFailedEvent:(NSUInteger)index inArray:(NSArray*)failedEventIds { if (index < failedEventIds.count) { NSString *failedEventId = failedEventIds[index]; NSUInteger nextIndex = index + 1; // Let the datasource resend. It will manage local echo, etc. [self.roomDataSource resendEventWithEventId:failedEventId success:^(NSString *eventId) { [self resendFailedEvent:nextIndex inArray:failedEventIds]; } failure:^(NSError *error) { [self resendFailedEvent:nextIndex inArray:failedEventIds]; }]; return; } // Refresh activities view [self refreshActivitiesViewDisplay]; } - (void)cancelAllUnsentMessages { UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomUnsentMessagesCancelTitle] message:[VectorL10n roomUnsentMessagesCancelMessage] preferredStyle:UIAlertControllerStyleAlert]; MXWeakify(self); [cancelAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [cancelAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n delete] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); // Remove unsent event ids for (NSUInteger index = 0; index < self.roomDataSource.room.outgoingMessages.count;) { MXEvent *event = self.roomDataSource.room.outgoingMessages[index]; if (event.sentState == MXEventSentStateFailed) { [self.roomDataSource removeEventWithEventId:event.eventId]; } else { index ++; } } [self refreshActivitiesViewDisplay]; self->currentAlert = nil; }]]; [self presentViewController:cancelAlert animated:YES completion:nil]; currentAlert = cancelAlert; } # pragma mark - Encryption Information view - (void)showEncryptionInformation:(MXEvent *)event { [self dismissKeyboard]; // Remove potential existing subviews [self dismissTemporarySubViews]; EncryptionInfoView *encryptionInfoView = [[EncryptionInfoView alloc] initWithEvent:event andMatrixSession:self.roomDataSource.mxSession]; // Add shadow on added view encryptionInfoView.layer.cornerRadius = 5; encryptionInfoView.layer.shadowOffset = CGSizeMake(0, 1); encryptionInfoView.layer.shadowOpacity = 0.5f; // Add the view and define edge constraints [self.view addSubview:encryptionInfoView]; self->encryptionInfoView = encryptionInfoView; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated" [self.view addConstraint:[NSLayoutConstraint constraintWithItem:encryptionInfoView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.topLayoutGuide attribute:NSLayoutAttributeBottom multiplier:1.0f constant:10.0f]]; [self.view addConstraint:[NSLayoutConstraint constraintWithItem:encryptionInfoView 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:encryptionInfoView attribute:NSLayoutAttributeLeading multiplier:1.0f constant:-10.0f]]; [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view attribute:NSLayoutAttributeTrailing relatedBy:NSLayoutRelationEqual toItem:encryptionInfoView attribute:NSLayoutAttributeTrailing multiplier:1.0f constant:10.0f]]; [self.view setNeedsUpdateConstraints]; } #pragma mark - Read marker handling - (void)checkReadMarkerVisibility { if (readMarkerTableViewCell && isAppeared && !self.isBubbleTableViewDisplayInTransition) { // Check whether the read marker is visible CGFloat contentTopPosY = self.bubblesTableView.contentOffset.y + self.bubblesTableView.adjustedContentInset.top; CGFloat readMarkerViewPosY = readMarkerTableViewCell.frame.origin.y + readMarkerTableViewCell.readMarkerView.frame.origin.y; if (contentTopPosY <= readMarkerViewPosY) { // Compute the max vertical position visible according to contentOffset CGFloat contentBottomPosY = self.bubblesTableView.contentOffset.y + self.bubblesTableView.frame.size.height - self.bubblesTableView.adjustedContentInset.bottom; if (readMarkerViewPosY <= contentBottomPosY) { // Launch animation [self animateReadMarkerView]; // Disable the read marker display when it has been rendered once. self.roomDataSource.showReadMarker = NO; [self refreshJumpToLastUnreadBannerDisplay]; // Update the read marker position according the events acknowledgement in this view controller. self.updateRoomReadMarker = YES; if (self.roomDataSource.isLive) { // Move the read marker to the current read receipt position. [self.roomDataSource.room forgetReadMarker]; } } } } } - (void)animateReadMarkerView { // Check whether the cell with the read marker is known and if the marker is not animated yet. if (!readMarkerTableViewCell || readMarkerTableViewCell.readMarkerView.isHidden == NO) { return; } RoomBubbleCellData *cellData = (RoomBubbleCellData*)readMarkerTableViewCell.bubbleData; id cellDecorator = [RoomTimelineConfiguration shared].currentStyle.cellDecorator; [cellDecorator dissmissReadMarkerViewForCell:readMarkerTableViewCell cellData:cellData animated:YES completion:^{ self->readMarkerTableViewCell = nil; }]; } - (void)refreshRemoveJitsiWidgetView { if (!self.displayConfiguration.jitsiWidgetRemoverEnabled) { return; } if (self.roomDataSource.isLive && !self.roomDataSource.isPeeking) { Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; if (jitsiWidget && self.canEditJitsiWidget) { [self.removeJitsiWidgetView reset]; self.removeJitsiWidgetContainer.hidden = NO; self.removeJitsiWidgetView.delegate = self; } else { self.removeJitsiWidgetContainer.hidden = YES; self.removeJitsiWidgetView.delegate = nil; } } else { [self.removeJitsiWidgetView reset]; self.removeJitsiWidgetContainer.hidden = YES; self.removeJitsiWidgetView.delegate = self; } } - (void)refreshJumpToLastUnreadBannerDisplay { // This banner is only displayed when the room timeline is in live (and no peeking). // Check whether the read marker exists and has not been rendered yet. if (self.roomDataSource.isLive && !self.roomDataSource.isPeeking && self.roomDataSource.showReadMarker && self.roomDataSource.room.accountData.readMarkerEventId) { NSPredicate *predicate = [NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { return [evaluatedObject isKindOfClass:MXKRoomBubbleTableViewCell.class]; }]; NSArray *visibleCells = [[self.bubblesTableView visibleCells] filteredArrayUsingPredicate:predicate]; UITableViewCell *cell = visibleCells.firstObject; if (cell) { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell*)cell; // Check whether the read marker is inside the first displayed cell. if (roomBubbleTableViewCell.readMarkerView) { // The read marker display is still enabled (see roomDataSource.showReadMarker flag), // this means the read marker was not been visible yet. // We show the banner if the marker is located in the top hidden part of the cell. CGFloat contentTopPosY = self.bubblesTableView.contentOffset.y + self.bubblesTableView.adjustedContentInset.top; CGFloat readMarkerViewPosY = roomBubbleTableViewCell.frame.origin.y + roomBubbleTableViewCell.readMarkerView.frame.origin.y; self.jumpToLastUnreadBannerContainer.hidden = (contentTopPosY < readMarkerViewPosY); } else { // Check whether the read marker event is anterior to the first event displayed in the first rendered cell. MXKRoomBubbleComponent *component = roomBubbleTableViewCell.bubbleData.bubbleComponents.firstObject; MXEvent *firstDisplayedEvent = component.event; MXEvent *currentReadMarkerEvent = [self.roomDataSource.mxSession.store eventWithEventId:self.roomDataSource.room.accountData.readMarkerEventId inRoom:self.roomDataSource.roomId]; if (!currentReadMarkerEvent || (currentReadMarkerEvent.originServerTs < firstDisplayedEvent.originServerTs)) { self.jumpToLastUnreadBannerContainer.hidden = NO; } else { self.jumpToLastUnreadBannerContainer.hidden = YES; // Force the read marker position in order to not depend on the read marker animation (https://github.com/vector-im/element-ios/issues/7420) self.updateRoomReadMarker = YES; } } } } else { self.jumpToLastUnreadBannerContainer.hidden = YES; // Initialize the read marker if it does not exist yet, only in case of live timeline. if (!self.roomDataSource.room.accountData.readMarkerEventId && self.roomDataSource.isLive && !self.roomDataSource.isPeeking) { // Move the read marker to the current read receipt position by default. [self.roomDataSource.room forgetReadMarker]; } } } #pragma mark - ContactsTableViewControllerDelegate - (void)contactsTableViewController:(ContactsTableViewController *)contactsTableViewController didSelectContact:(MXKContact*)contact { __weak typeof(self) weakSelf = self; if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } // Invite ? NSString *promptMsg = [VectorL10n roomParticipantsInvitePromptMsg:contact.displayName]; UIAlertController *invitePrompt = [UIAlertController alertControllerWithTitle:[VectorL10n roomParticipantsInvitePromptTitle] message:promptMsg preferredStyle:UIAlertControllerStyleAlert]; [invitePrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; } }]]; [invitePrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n invite] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { // Sanity check if (!weakSelf) { return; } typeof(self) self = weakSelf; self->currentAlert = nil; MXSession* session = self.roomDataSource.mxSession; NSString* roomId = self.roomDataSource.roomId; MXRoom *room = [session roomWithRoomId:roomId]; NSArray *identifiers = contact.matrixIdentifiers; NSString *participantId; if (identifiers.count) { participantId = identifiers.firstObject; // Invite this user if a room is defined [room inviteUser:participantId success:^{ // Refresh display by removing the contacts picker [contactsTableViewController withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { MXLogDebug(@"[RoomVC] Invite %@ failed", participantId); // Alert user [self showError:error]; }]; } else { if (contact.emailAddresses.count) { // This is a local contact, consider the first email by default. // TODO: Prompt the user to select the right email. MXKEmail *email = contact.emailAddresses.firstObject; participantId = email.emailAddress; } else { // This is the text filled by the user. participantId = contact.displayName; } // Is it an email or a Matrix user ID? if ([MXTools isEmailAddress:participantId]) { [room inviteUserByEmail:participantId success:^{ // Refresh display by removing the contacts picker [contactsTableViewController withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { MXLogDebug(@"[RoomVC] Invite be email %@ failed", participantId); // Alert user if ([error.domain isEqualToString:kMXRestClientErrorDomain] && error.code == MXRestClientErrorMissingIdentityServer) { [self showAlertWithTitle:[VectorL10n errorInvite3pidWithNoIdentityServer] message:nil]; } else { [self showError:error]; } }]; } else //if ([MXTools isMatrixUserIdentifier:participantId]) { [room inviteUser:participantId success:^{ // Refresh display by removing the contacts picker [contactsTableViewController withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { MXLogDebug(@"[RoomVC] Invite %@ failed", participantId); // Alert user [self showError:error]; }]; } } }]]; [invitePrompt mxk_setAccessibilityIdentifier:@"RoomVCInviteAlert"]; [self presentViewController:invitePrompt animated:YES completion:nil]; currentAlert = invitePrompt; } #pragma mark - Re-request encryption keys - (void)reRequestKeysAndShowExplanationAlert:(MXEvent*)event { MXWeakify(self); __block UIAlertController *alert; // Force device verification if session has cross-signing activated and device is not yet verified if (self.mainSession.crypto.crossSigning && self.mainSession.crypto.crossSigning.state == MXCrossSigningStateCrossSigningExists) { [self presentReviewUnverifiedSessionsAlert]; return; } // Make the re-request [self.mainSession.crypto reRequestRoomKeyForEvent:event]; // Observe kMXEventDidDecryptNotification to remove automatically the dialog // if the user has shared the keys from another device mxEventDidDecryptNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXEventDidDecryptNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); MXEvent *decryptedEvent = notif.object; if ([decryptedEvent.eventId isEqualToString:event.eventId]) { [[NSNotificationCenter defaultCenter] removeObserver:self->mxEventDidDecryptNotificationObserver]; self->mxEventDidDecryptNotificationObserver = nil; if (self->currentAlert == alert) { [self->currentAlert dismissViewControllerAnimated:YES completion:nil]; self->currentAlert = nil; } } }]; // Show the explanation dialog alert = [UIAlertController alertControllerWithTitle:VectorL10n.rerequestKeysAlertTitle message:[VectorL10n e2eRoomKeyRequestMessage:AppInfo.current.displayName] preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); [[NSNotificationCenter defaultCenter] removeObserver:self->mxEventDidDecryptNotificationObserver]; self->mxEventDidDecryptNotificationObserver = nil; self->currentAlert = nil; }]]; [self presentViewController:alert animated:YES completion:nil]; currentAlert = alert; } - (void)presentReviewUnverifiedSessionsAlert { MXLogDebug(@"[MasterTabBarController] presentReviewUnverifiedSessionsAlertWithSession"); [currentAlert dismissViewControllerAnimated:NO completion:nil]; UIAlertController *alert = [UIAlertController alertControllerWithTitle:[VectorL10n keyVerificationAlertTitle] message:[VectorL10n keyVerificationAlertBody] preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n keyVerificationSelfVerifyUnverifiedSessionsAlertValidateAction] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [self showSettingsSecurityScreen]; }]]; [alert addAction:[UIAlertAction actionWithTitle:[VectorL10n later] style:UIAlertActionStyleCancel handler:nil]]; [self presentViewController:alert animated:YES completion:nil]; currentAlert = alert; } - (void)showSettingsSecurityScreen { if (self.delegate) { [self.delegate roomViewController:self showCompleteSecurityForSession:self.mainSession]; } else { [[AppDelegate theDelegate] presentCompleteSecurityForSession: self.mainSession]; } } #pragma mark Tombstone event - (void)listenTombstoneEventNotifications { // Room is already obsolete do not listen to tombstone event if (self.roomDataSource.roomState.isObsolete) { return; } MXWeakify(self); tombstoneEventNotificationsListener = [self.roomDataSource.room listenToEventsOfTypes:@[kMXEventTypeStringRoomTombStone] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { MXStrongifyAndReturnIfNil(self); // Update activitiesView with room replacement information [self refreshActivitiesViewDisplay]; // Hide inputToolbarView [self updateRoomInputToolbarViewClassIfNeeded]; }]; } - (void)removeTombstoneEventNotificationsListener { if (self.roomDataSource) { // Remove the previous live listener if (tombstoneEventNotificationsListener) { [self.roomDataSource.room removeListener:tombstoneEventNotificationsListener]; tombstoneEventNotificationsListener = nil; } } } #pragma mark MXSession state change - (void)listenMXSessionStateChangeNotifications { MXWeakify(self); kMXSessionStateDidChangeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:self.roomDataSource.mxSession queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); if (self.roomDataSource.mxSession.state == MXSessionStateSyncError || self.roomDataSource.mxSession.state == MXSessionStateRunning) { [self refreshActivitiesViewDisplay]; // update inputToolbarView [self updateRoomInputToolbarViewClassIfNeeded]; } }]; } - (void)removeMXSessionStateChangeNotificationsListener { if (kMXSessionStateDidChangeObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kMXSessionStateDidChangeObserver]; kMXSessionStateDidChangeObserver = nil; } } #pragma mark - Contextual Menu - (NSArray*)contextualMenuItemsForEvent:(MXEvent*)event andCell:(id)cell { if (event.sentState == MXEventSentStateFailed) { return @[ [self resendMenuItemWithEvent:event], [self deleteMenuItemWithEvent:event], [self editMenuItemWithEvent:event], [self copyMenuItemWithEvent:event andCell:cell] ]; } BOOL showMoreOption = (event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForStates) || (!event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForMessages); BOOL showThreadOption = [self showThreadOptionForEvent:event]; NSMutableArray *items = [NSMutableArray arrayWithCapacity:5]; [items addObject:[self replyMenuItemWithEvent:event]]; if (showThreadOption) { // add "Thread" option only if not already in a thread [items addObject:[self replyInThreadMenuItemWithEvent:event]]; } [items addObject:[self editMenuItemWithEvent:event]]; if (!showThreadOption) { [items addObject:[self copyMenuItemWithEvent:event andCell:cell]]; } if (showMoreOption) { [items addObject:[self moreMenuItemWithEvent:event andCell:cell]]; } return items; } - (void)showContextualMenuForEvent:(MXEvent*)event fromSingleTapGesture:(BOOL)usedSingleTapGesture cell:(id)cell animated:(BOOL)animated { if (self.roomContextualMenuPresenter.isPresenting) { return; } NSString *selectedEventId = event.eventId; NSArray* contextualMenuItems = [self contextualMenuItemsForEvent:event andCell:cell]; ReactionsMenuViewModel *reactionsMenuViewModel; CGRect bubbleComponentFrameInOverlayView = CGRectNull; if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class] && [self.roomDataSource canReactToEventWithId:event.eventId]) { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell*)cell; MXKRoomBubbleCellData *bubbleCellData = roomBubbleTableViewCell.bubbleData; NSArray *bubbleComponents = bubbleCellData.bubbleComponents; NSInteger foundComponentIndex = [bubbleCellData bubbleComponentIndexForEventId:event.eventId]; CGRect bubbleComponentFrame; if (bubbleComponents.count > 0) { NSInteger selectedComponentIndex = foundComponentIndex != NSNotFound ? foundComponentIndex : 0; bubbleComponentFrame = [roomBubbleTableViewCell surroundingFrameInTableViewForComponentIndex:selectedComponentIndex]; } else { bubbleComponentFrame = roomBubbleTableViewCell.frame; } bubbleComponentFrameInOverlayView = [self.bubblesTableView convertRect:bubbleComponentFrame toView:self.overlayContainerView]; NSString *roomId = self.roomDataSource.roomId; MXAggregations *aggregations = self.mainSession.aggregations; MXAggregatedReactions *aggregatedReactions = [aggregations aggregatedReactionsOnEvent:selectedEventId inRoom:roomId]; reactionsMenuViewModel = [[ReactionsMenuViewModel alloc] initWithAggregatedReactions:aggregatedReactions eventId:selectedEventId]; reactionsMenuViewModel.coordinatorDelegate = self; } if (!self.roomContextualMenuViewController) { self.roomContextualMenuViewController = [RoomContextualMenuViewController instantiate]; self.roomContextualMenuViewController.delegate = self; } [self.roomContextualMenuViewController updateWithContextualMenuItems:contextualMenuItems reactionsMenuViewModel:reactionsMenuViewModel]; [self enableOverlayContainerUserInteractions:YES]; [self.roomContextualMenuPresenter presentWithRoomContextualMenuViewController:self.roomContextualMenuViewController from:self on:self.overlayContainerView contentToReactFrame:bubbleComponentFrameInOverlayView fromSingleTapGesture:usedSingleTapGesture animated:animated completion:^{ }]; preventBubblesTableViewScroll = YES; [self selectEventWithId:selectedEventId]; } - (void)hideContextualMenuAnimated:(BOOL)animated { [self hideContextualMenuAnimated:animated completion:nil]; } - (void)hideContextualMenuAnimated:(BOOL)animated completion:(void(^)(void))completion { [self hideContextualMenuAnimated:animated cancelEventSelection:YES completion:completion]; } - (void)hideContextualMenuAnimated:(BOOL)animated cancelEventSelection:(BOOL)cancelEventSelection completion:(void(^)(void))completion { if (!self.roomContextualMenuPresenter.isPresenting) { return; } if (cancelEventSelection) { [self cancelEventSelection]; } preventBubblesTableViewScroll = NO; [self.roomContextualMenuPresenter hideContextualMenuWithAnimated:animated completion:^{ [self enableOverlayContainerUserInteractions:NO]; if (completion) { completion(); } }]; } - (void)enableOverlayContainerUserInteractions:(BOOL)enableOverlayContainerUserInteractions { self.inputToolbarView.editable = !enableOverlayContainerUserInteractions; self.bubblesTableView.scrollsToTop = !enableOverlayContainerUserInteractions; self.overlayContainerView.userInteractionEnabled = enableOverlayContainerUserInteractions; } - (RoomContextualMenuItem *)resendMenuItemWithEvent:(MXEvent*)event { MXWeakify(self); RoomContextualMenuItem *resendMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionResend]; resendMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil]; [self cancelEventSelection]; [self.roomDataSource resendEventWithEventId:event.eventId success:nil failure:nil]; }; return resendMenuItem; } - (RoomContextualMenuItem *)deleteMenuItemWithEvent:(MXEvent*)event { MXWeakify(self); RoomContextualMenuItem *deleteMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionDelete]; deleteMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); MXWeakify(self); [self hideContextualMenuAnimated:YES cancelEventSelection:YES completion:^{ MXStrongifyAndReturnIfNil(self); UIAlertController *deleteConfirmation = [UIAlertController alertControllerWithTitle:[VectorL10n roomEventActionDeleteConfirmationTitle] message:[VectorL10n roomEventActionDeleteConfirmationMessage] preferredStyle:UIAlertControllerStyleAlert]; [deleteConfirmation addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { }]]; [deleteConfirmation addAction:[UIAlertAction actionWithTitle:[VectorL10n delete] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * action) { [self.roomDataSource removeEventWithEventId:event.eventId]; }]]; [self presentViewController:deleteConfirmation animated:YES completion:nil]; self->currentAlert = deleteConfirmation; }]; }; return deleteMenuItem; } - (RoomContextualMenuItem *)editMenuItemWithEvent:(MXEvent*)event { MXWeakify(self); RoomContextualMenuItem *editMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionEdit]; switch (event.eventType) { case MXEventTypePollStart: { editMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES cancelEventSelection:YES completion:nil]; [self.delegate roomViewController:self didRequestEditForPollWithStartEvent:event]; }; editMenuItem.isEnabled = [self.delegate roomViewController:self canEditPollWithEventIdentifier:event.eventId]; break; } default: { editMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil]; [self editEventContentWithId:event.eventId]; // And display the keyboard [self.inputToolbarView becomeFirstResponder]; }; editMenuItem.isEnabled = [self.roomDataSource canEditEventWithId:event.eventId]; break; } } return editMenuItem; } - (RoomContextualMenuItem *)copyMenuItemWithEvent:(MXEvent*)event andCell:(id)cell { MXWeakify(self); RoomContextualMenuItem *copyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionCopy]; copyMenuItem.isEnabled = [self canCopyEvent:event andCell:cell]; MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKRoomBubbleCellData *cellData = roomBubbleTableViewCell.bubbleData; copyMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); [self copyEvent:event inCell:cell withCellData:cellData]; }; return copyMenuItem; } - (BOOL)canCopyEvent:(MXEvent*)event andCell:(id)cell { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; BOOL result = !attachment || attachment.type != MXKAttachmentTypeSticker; if (attachment && !BWIBuildSettings.shared.messageDetailsAllowCopyMedia) { result = NO; } if (result) { switch (event.eventType) { case MXEventTypeRoomMessage: { NSString *messageType = event.content[kMXMessageTypeKey]; if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest]) { result = NO; } break; } case MXEventTypeKeyVerificationStart: case MXEventTypeKeyVerificationAccept: case MXEventTypeKeyVerificationKey: case MXEventTypeKeyVerificationMac: case MXEventTypeKeyVerificationDone: case MXEventTypeKeyVerificationCancel: case MXEventTypePollStart: case MXEventTypePollEnd: case MXEventTypeBeaconInfo: result = NO; break; case MXEventTypeCustom: if ([event.type isEqualToString:kWidgetMatrixEventTypeString] || [event.type isEqualToString:kWidgetModularEventTypeString]) { Widget *widget = [[Widget alloc] initWithWidgetEvent:event inMatrixSession:self.roomDataSource.mxSession]; if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || [widget.type isEqualToString:kWidgetTypeJitsiV2]) { result = NO; } } default: break; } } return result; } - (void)copyEvent:(MXEvent*)event inCell:(id)cell withCellData:(MXKRoomBubbleCellData *)cellData { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKAttachment *attachment = cellData.attachment; if (!attachment) { NSArray *components = cellData.bubbleComponents; MXKRoomBubbleComponent *selectedComponent; for (selectedComponent in components) { if ([selectedComponent.event.eventId isEqualToString:event.eventId]) { break; } selectedComponent = nil; } NSAttributedString *attributedTextMessage = selectedComponent.attributedTextMessage; if (attributedTextMessage) { if (@available(iOS 15.0, *)) { MXKPasteboardManager.shared.pasteboard.string = [PillsFormatter stringByReplacingPillsIn:attributedTextMessage mode:PillsReplacementTextModeMarkdown]; } else { MXKPasteboardManager.shared.pasteboard.string = attributedTextMessage.string; } } else { MXLogDebug(@"[RoomViewController] Contextual menu copy failed. Text is nil for room id/event id: %@/%@", selectedComponent.event.roomId, selectedComponent.event.eventId); } [self hideContextualMenuAnimated:YES]; } else if (attachment.type != MXKAttachmentTypeSticker) { [self hideContextualMenuAnimated:YES completion:^{ [self startActivityIndicator]; [attachment copy:^{ [self stopActivityIndicator]; } failure:^(NSError *error) { [self stopActivityIndicator]; //Alert user [self showError:error]; }]; // Start animation in case of download during attachment preparing [roomBubbleTableViewCell startProgressUI]; }]; } } - (RoomContextualMenuItem *)replyMenuItemWithEvent:(MXEvent*)event { MXWeakify(self); RoomContextualMenuItem *replyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionReply]; replyMenuItem.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId] && !self.voiceMessageController.isRecordingAudio; replyMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil]; [self selectEventWithId:event.eventId inputToolBarSendMode:RoomInputToolbarViewSendModeReply showTimestamp:NO]; // And display the keyboard [self.inputToolbarView becomeFirstResponder]; }; return replyMenuItem; } - (RoomContextualMenuItem *)replyInThreadMenuItemWithEvent:(MXEvent*)event { MXWeakify(self); RoomContextualMenuItem *item = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionReplyInThread]; item.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId] && !self.voiceMessageController.isRecordingAudio; item.action = ^{ MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil]; // bwi: check if threads are enabled if (self.showThreadOption) { [self openThreadWithId:event.eventId]; } else { [self showThreadsBetaForEvent:event]; } }; return item; } - (RoomContextualMenuItem *)moreMenuItemWithEvent:(MXEvent*)event andCell:(id)cell { MXWeakify(self); RoomContextualMenuItem *moreMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionMore]; moreMenuItem.action = ^{ MXStrongifyAndReturnIfNil(self); [self hideContextualMenuAnimated:YES completion:nil]; [self showAdditionalActionsMenuForEvent:event inCell:cell animated:YES]; }; return moreMenuItem; } #pragma mark - Threads // bwi: check if threads are enabled - (BOOL)showThreadOption { return (RiotSettings.shared.enableThreads || self.mainSession.store.supportedMatrixVersions.supportsThreads) && BWIBuildSettings.shared.bwiShowThreads; } - (BOOL)showThreadOptionForEvent:(MXEvent*)event { return !self.roomDataSource.threadId && !event.threadId && (RiotSettings.shared.enableThreads || self.mainSession.store.supportedMatrixVersions.supportsThreads) && BWIBuildSettings.shared.bwiShowThreads; } - (void)showThreadsNotice { if (!self.threadsNoticeModalPresenter) { self.threadsNoticeModalPresenter = [SlidingModalPresenter new]; } [self.threadsNoticeModalPresenter dismissWithAnimated:NO completion:nil]; ThreadsNoticeViewController *threadsNoticeVC = [ThreadsNoticeViewController instantiate]; MXWeakify(self); threadsNoticeVC.didTapDoneButton = ^{ MXStrongifyAndReturnIfNil(self); [self.threadsNoticeModalPresenter dismissWithAnimated:YES completion:^{ RiotSettings.shared.threadsNoticeDisplayed = YES; }]; }; [self.threadsNoticeModalPresenter present:threadsNoticeVC from:self.presentedViewController?:self animated:YES options:SlidingModalPresenter.SpanningOption completion:nil]; } - (void)showThreadsBetaForEvent:(MXEvent *)event { if (self.threadsBetaBridgePresenter) { [self.threadsBetaBridgePresenter dismissWithAnimated:YES completion:nil]; self.threadsBetaBridgePresenter = nil; } self.threadsBetaBridgePresenter = [[ThreadsBetaCoordinatorBridgePresenter alloc] initWithThreadId:event.eventId infoText:VectorL10n.threadsBetaInformation additionalText:nil]; self.threadsBetaBridgePresenter.delegate = self; [self.threadsBetaBridgePresenter presentFrom:self.presentedViewController?:self animated:YES]; } - (void)openThreadWithId:(NSString *)threadId { if (self.threadsBridgePresenter) { [self.threadsBridgePresenter dismissWithAnimated:YES completion:nil]; self.threadsBridgePresenter = nil; } self.threadsBridgePresenter = [self.delegate threadsCoordinatorForRoomViewController:self threadId:threadId]; self.threadsBridgePresenter.delegate = self; [self.threadsBridgePresenter pushFrom:self.navigationController animated:YES]; } - (void)highlightAndDisplayEvent:(NSString *)eventId completion:(void (^)(void))completion { NSInteger row = [self.roomDataSource indexOfCellDataWithEventId:eventId]; if (row == NSNotFound) { // event with eventId is not loaded into data source yet, load another data source and display it [self startActivityIndicator]; MXWeakify(self); [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId initialEventId:eventId threadId:nil andMatrixSession:self.roomDataSource.mxSession onComplete:^(RoomDataSource *roomDataSource) { MXStrongifyAndReturnIfNil(self); [roomDataSource finalizeInitialization]; [self stopActivityIndicator]; roomDataSource.markTimelineInitialEvent = YES; [self displayRoom:roomDataSource]; // Give the data source ownership to the room view controller. self.hasRoomDataSourceOwnership = YES; if (completion) { completion(); } }]; return; } NSMutableArray *rowsToReload = [[NSMutableArray alloc] init]; // Get the current hightlighted event because we will need to reload it NSString *currentHiglightedEventId = self.customizedRoomDataSource.highlightedEventId; if (currentHiglightedEventId) { NSInteger currentHiglightedRow = [self.roomDataSource indexOfCellDataWithEventId:currentHiglightedEventId]; if (currentHiglightedRow != NSNotFound) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:currentHiglightedRow inSection:0]; if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) { [rowsToReload addObject:indexPath]; } } } self.customizedRoomDataSource.highlightedEventId = eventId; // Add the new highligted event to the list of rows to reload NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; BOOL indexPathIsVisible = [[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]; if (indexPathIsVisible) { [rowsToReload addObject:indexPath]; } // Reload rows if (rowsToReload.count > 0) { [self.bubblesTableView reloadRowsAtIndexPaths:rowsToReload withRowAnimation:UITableViewRowAnimationNone]; } // Scroll to the newly highlighted row if (indexPathIsVisible || [self.bubblesTableView vc_hasIndexPath:indexPath]) { [self.bubblesTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionMiddle animated:YES]; } if (completion) { completion(); } } - (void)cancelEventHighlight { // if data source is highlighting an event, dismiss the highlight when user dragges the table view if (self.customizedRoomDataSource.highlightedEventId) { NSInteger row = [self.roomDataSource indexOfCellDataWithEventId:self.customizedRoomDataSource.highlightedEventId]; if (row == NSNotFound) { self.customizedRoomDataSource.highlightedEventId = nil; return; } NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) { self.customizedRoomDataSource.highlightedEventId = nil; [self.bubblesTableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; } } } - (void)updateThreadListBarButtonBadgeWith:(MXThreadingService *)service { [self updateThreadListBarButtonItem:nil with:service]; } - (void)updateThreadListBarButtonItem:(UIBarButtonItem *)barButtonItem with:(MXThreadingService *)service { if (!service || _isWaitingForOtherParticipants) { return; } __block NSInteger replaceIndex = NSNotFound; [self.navigationItem.rightBarButtonItems enumerateObjectsUsingBlock:^(UIBarButtonItem * _Nonnull item, NSUInteger index, BOOL * _Nonnull stop) { if (item.tag == kThreadListBarButtonItemTag) { replaceIndex = index; *stop = YES; } }]; if (!barButtonItem && replaceIndex == NSNotFound) { // there is no thread list bar button item, and not provided another to update // ignore return; } UIBarButtonItem *threadListBarButtonItem = barButtonItem ?: [self threadListBarButtonItem]; UIButton *button = (UIButton *)threadListBarButtonItem.customView; MXThreadNotificationsCount *notificationsCount = [service notificationsCountForRoom:self.roomDataSource.roomId]; UIImage *buttonIcon = [AssetImages.threadsIcon.image vc_resizedWith:kThreadListBarButtonItemImageSize]; [button setImage:buttonIcon forState:UIControlStateNormal]; button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsNoDot; if (notificationsCount.notificationsNumber > 0) { BadgeLabel *badgeLabel = [[BadgeLabel alloc] init]; badgeLabel.text = notificationsCount.notificationsNumber > 99 ? @"99+" : [NSString stringWithFormat:@"%lu", notificationsCount.notificationsNumber]; id theme = ThemeService.shared.theme; badgeLabel.font = theme.fonts.caption1SB; badgeLabel.textColor = theme.colors.navigation; badgeLabel.badgeColor = notificationsCount.numberOfHighlightedThreads ? theme.colors.alert : theme.colors.secondaryContent; [button addSubview:badgeLabel]; [badgeLabel layoutIfNeeded]; badgeLabel.translatesAutoresizingMaskIntoConstraints = NO; [badgeLabel.centerYAnchor constraintEqualToAnchor:button.centerYAnchor constant:badgeLabel.bounds.size.height - buttonIcon.size.height / 2].active = YES; [badgeLabel.centerXAnchor constraintEqualToAnchor:button.centerXAnchor constant:badgeLabel.bounds.size.width + buttonIcon.size.width / 2].active = YES; } if (replaceIndex == NSNotFound) { // there is no thread list bar button item, this was only an update return; } UIBarButtonItem *originalItem = self.navigationItem.rightBarButtonItems[replaceIndex]; UIButton *originalButton = (UIButton *)originalItem.customView; if ([originalButton imageForState:UIControlStateNormal] == [button imageForState:UIControlStateNormal] && UIEdgeInsetsEqualToEdgeInsets(originalButton.contentEdgeInsets, button.contentEdgeInsets)) { // no need to replace, it's the same return; } NSMutableArray *items = [self.navigationItem.rightBarButtonItems mutableCopy]; items[replaceIndex] = threadListBarButtonItem; self.navigationItem.rightBarButtonItems = items; } #pragma mark - RoomContextualMenuViewControllerDelegate - (void)roomContextualMenuViewControllerDidTapBackgroundOverlay:(RoomContextualMenuViewController *)viewController { [self hideContextualMenuAnimated:YES]; } #pragma mark - ReactionsMenuViewModelCoordinatorDelegate - (void)reactionsMenuViewModel:(ReactionsMenuViewModel *)viewModel didAddReaction:(NSString *)reaction forEventId:(NSString *)eventId { MXWeakify(self); [self bwiAddedEmoji:reaction]; [self hideContextualMenuAnimated:YES completion:^{ [self.roomDataSource addReaction:reaction forEventId:eventId success:^{ } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self.errorPresenter presentErrorFromViewController:self forError:error animated:YES handler:nil]; }]; }]; } - (void)reactionsMenuViewModel:(ReactionsMenuViewModel *)viewModel didRemoveReaction:(NSString *)reaction forEventId:(NSString *)eventId { MXWeakify(self); [self hideContextualMenuAnimated:YES completion:^{ [self.roomDataSource removeReaction:reaction forEventId:eventId success:^{ } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self.errorPresenter presentErrorFromViewController:self forError:error animated:YES handler:nil]; }]; }]; } - (void)reactionsMenuViewModelDidTapMoreReactions:(ReactionsMenuViewModel *)viewModel forEventId:(NSString *)eventId { [self hideContextualMenuAnimated:YES]; [self showEmojiPickerForEventId:eventId]; } #pragma mark - - (void)showEditHistoryForEventId:(NSString*)eventId animated:(BOOL)animated { MXEvent *event = [self.roomDataSource eventWithEventId:eventId]; EditHistoryCoordinatorBridgePresenter *presenter = [[EditHistoryCoordinatorBridgePresenter alloc] initWithSession:self.roomDataSource.mxSession event:event]; presenter.delegate = self; [presenter presentFrom:self animated:animated]; self.editHistoryPresenter = presenter; } #pragma mark - EditHistoryCoordinatorBridgePresenterDelegate - (void)editHistoryCoordinatorBridgePresenterDelegateDidComplete:(EditHistoryCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.editHistoryPresenter = nil; } #pragma mark - DocumentPickerPresenterDelegate - (void)documentPickerPresenterWasCancelled:(MXKDocumentPickerPresenter *)presenter { self.documentPickerPresenter = nil; } - (void)documentPickerPresenter:(MXKDocumentPickerPresenter *)presenter didPickDocumentsAt:(NSURL *)url { self.documentPickerPresenter = nil; MXKUTI *fileUTI = [[MXKUTI alloc] initWithLocalFileURL:url]; NSString *mimeType = fileUTI.mimeType; if (mimeType == nil) { mimeType = @""; } if (fileUTI.isImage) { NSData *imageData = [[NSData alloc] initWithContentsOfURL:url]; [self sendImage:imageData mimeType:mimeType]; } else if (fileUTI.isVideo) { [self sendVideo:url]; } else if (fileUTI.isFile) { [self sendFile:url mimeType:mimeType]; } else { MXLogDebug(@"[MXKRoomViewController] File upload using MIME type %@ is not supported.", mimeType); [self showAlertWithTitle:[VectorL10n fileUploadErrorTitle] message:[VectorL10n fileUploadErrorUnsupportedFileTypeMessage]]; } } - (void)sendImage:(NSData *)imageData mimeType:(NSString *)mimeType { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { // Let the datasource send it and manage the local echo [self.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 failed."); }]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)sendVideo:(NSURL * _Nonnull)url { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { // Let the datasource send it and manage the local echo [(RoomDataSource*)self.roomDataSource sendVideo:url success:nil failure:^(NSError *error) { // Nothing to do. The video is marked as unsent in the room history by the datasource MXLogDebug(@"[MXKRoomViewController] sendVideo failed."); }]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)sendFile:(NSURL * _Nonnull)url mimeType:(NSString *)mimeType { // Create before sending the message in case of a discussion (direct chat) MXWeakify(self); [self createDiscussionIfNeeded:^(BOOL readyToSend) { MXStrongifyAndReturnIfNil(self); if (readyToSend) { // Let the datasource send it and manage the local echo [self.roomDataSource sendFile:url 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."); }]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } #pragma mark - EmojiPickerCoordinatorBridgePresenterDelegate - (void)emojiPickerCoordinatorBridgePresenter:(EmojiPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter didAddEmoji:(NSString *)emoji forEventId:(NSString *)eventId { MXWeakify(self); [self bwiAddedEmoji:emoji]; [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self.roomDataSource addReaction:emoji forEventId:eventId success:^{ } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self.errorPresenter presentErrorFromViewController:self forError:error animated:YES handler:nil]; }]; }]; self.emojiPickerCoordinatorBridgePresenter = nil; } - (void)emojiPickerCoordinatorBridgePresenter:(EmojiPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter didRemoveEmoji:(NSString *)emoji forEventId:(NSString *)eventId { MXWeakify(self); [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self.roomDataSource removeReaction:emoji forEventId:eventId success:^{ } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self.errorPresenter presentErrorFromViewController:self forError:error animated:YES handler:nil]; }]; }]; self.emojiPickerCoordinatorBridgePresenter = nil; } - (void)emojiPickerCoordinatorBridgePresenterDidCancel:(EmojiPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.emojiPickerCoordinatorBridgePresenter = nil; } #pragma mark - ReactionHistoryCoordinatorBridgePresenterDelegate - (void)reactionHistoryCoordinatorBridgePresenterDelegateDidClose:(ReactionHistoryCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ self.reactionHistoryCoordinatorBridgePresenter = nil; }]; } #pragma mark - CameraPresenterDelegate - (void)cameraPresenterDidCancel:(CameraPresenter *)cameraPresenter { [cameraPresenter dismissWithAnimated:YES completion:nil]; self.cameraPresenter = nil; } - (void)cameraPresenter:(CameraPresenter *)cameraPresenter didSelectImage:(UIImage *)image { [cameraPresenter dismissWithAnimated:YES completion:nil]; self.cameraPresenter = nil; NSData *imageData = UIImageJPEGRepresentation(image, 1.0); // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { [self.inputToolbarView sendSelectedImage:imageData withMimeType:MXKUTI.jpeg.mimeType andCompressionMode:MediaCompressionHelper.defaultCompressionMode isPhotoLibraryAsset:NO]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)cameraPresenter:(CameraPresenter *)cameraPresenter didSelectVideoAt:(NSURL *)url { [cameraPresenter dismissWithAnimated:YES completion:nil]; self.cameraPresenter = nil; AVURLAsset *selectedVideo = [AVURLAsset assetWithURL:url]; [self sendVideoAsset:selectedVideo isPhotoLibraryAsset:NO]; } #pragma mark - MediaPickerCoordinatorBridgePresenterDelegate - (void)mediaPickerCoordinatorBridgePresenterDidCancel:(MediaPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.mediaPickerPresenter = nil; } - (void)mediaPickerCoordinatorBridgePresenter:(MediaPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter didSelectImageData:(NSData *)imageData withUTI:(MXKUTI *)uti { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.mediaPickerPresenter = nil; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { [self.inputToolbarView sendSelectedImage:imageData withMimeType:uti.mimeType andCompressionMode:MediaCompressionHelper.defaultCompressionMode isPhotoLibraryAsset:YES]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } - (void)mediaPickerCoordinatorBridgePresenter:(MediaPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter didSelectVideo:(AVAsset *)videoAsset { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.mediaPickerPresenter = nil; [self sendVideoAsset:videoAsset isPhotoLibraryAsset:YES]; } - (void)mediaPickerCoordinatorBridgePresenter:(MediaPickerCoordinatorBridgePresenter *)coordinatorBridgePresenter didSelectAssets:(NSArray *)assets { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.mediaPickerPresenter = nil; // Set a 1080p video conversion preset as compression mode only has an effect on the images. [MXSDKOptions sharedInstance].videoConversionPresetName = AVAssetExportPreset1920x1080; // Create before sending the message in case of a discussion (direct chat) [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { [self.inputToolbarView sendSelectedAssets:assets withCompressionMode:MediaCompressionHelper.defaultCompressionMode]; } // Errors are handled at the request level. This should be improved in case of code rewriting. }]; } #pragma mark - PHPickerViewControllerDelegate - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results { [picker dismissViewControllerAnimated:YES completion:nil]; if (results.firstObject) { NSItemProvider *itemProvider = results.firstObject.itemProvider; if ([itemProvider canLoadObjectOfClass:[UIImage class]]) { [itemProvider loadObjectOfClass:[UIImage class] completionHandler:^(id _Nullable object, NSError * _Nullable error) { if (error) { MXLogDebug(@"Uploading photo from photo library failed: %@", error); } else { dispatch_async(dispatch_get_main_queue(), ^{ if ([object isKindOfClass:[UIImage class]]) { UIImage *image = object; [self uploadImage:image]; } }); } }]; } else if ([itemProvider hasItemConformingToTypeIdentifier:@"public.movie"]) { [itemProvider loadFileRepresentationForTypeIdentifier:@"public.movie" completionHandler:^(NSURL *videoURL, NSError *error) { if (error) { MXLogDebug(@"Uploading video from photo library failed: %@", error); } else { // First make a copy of the video in the temp directory because the video at videoURL // is a copy by the iOS photo picker and will be dismissed when this completion block ends NSURL *videoCopy = [self makeVideoCopy:videoURL]; dispatch_async(dispatch_get_main_queue(), ^{ [self uploadVideo:videoCopy]; }); } }]; } } } - (void)uploadImage:(UIImage *)image { [self createDiscussionIfNeeded:^(BOOL readyToSend) { if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol]) { // bwi: use jpeg reprsentation, its smaller and the same as the old picker NSData *imageData = UIImageJPEGRepresentation(image, 1.0); if(imageData) { [self.inputToolbarView sendSelectedImage:imageData withMimeType:MXKUTI.jpeg.mimeType andCompressionMode:MediaCompressionHelper.defaultCompressionMode isPhotoLibraryAsset:YES]; } else { MXLogDebug(@"Uploading photo from photo library failed because imageData is nil") } } }]; } - (void)uploadVideo:(NSURL *)url { if(url) { NSLog(@"uploadVideo: %@", [url absoluteString]); AVURLAsset *asset = [AVURLAsset assetWithURL:url]; if(asset) { [self sendVideoAssetWithoutCompression:asset isPhotoLibraryAsset:YES]; } else { MXLogDebug(@"Uploading video from photo library failed because asset is nil") } } } - (NSURL *)makeVideoCopy:(NSURL *)sourceURL { if (sourceURL == nil) { return nil; } NSString *sourceFilename = [sourceURL lastPathComponent]; NSUUID *uuid = [NSUUID UUID]; NSString *uuidString = [uuid UUIDString]; NSString *destinationFilename = [NSString stringWithFormat:@"%@-%@", uuidString, sourceFilename]; NSURL *tempDirURL = [NSURL fileURLWithPath:NSTemporaryDirectory()]; NSURL *destinationURL = [tempDirURL URLByAppendingPathComponent:destinationFilename]; NSFileManager *fileManager = [NSFileManager defaultManager]; NSError *error = nil; if ([fileManager copyItemAtURL:sourceURL toURL:destinationURL error:&error]) { MXLogDebug(@"Successfully copied video to a temp destination %@", [destinationURL absoluteString]); return destinationURL; } else { MXLogDebug(@"Failed to make a temp copy of the video file: %@", error); return nil; } } - (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]) } } #pragma mark - RoomCreationModalCoordinatorBridgePresenter - (void)roomCreationModalCoordinatorBridgePresenterDelegateDidComplete:(RoomCreationModalCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.roomCreationModalCoordinatorBridgePresenter = nil; } #pragma mark - RoomInfoCoordinatorBridgePresenterDelegate - (void)roomInfoCoordinatorBridgePresenterDelegateDidComplete:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.roomInfoCoordinatorBridgePresenter = nil; } - (void)roomInfoCoordinatorBridgePresenter:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter didRequestMentionForMember:(MXRoomMember *)member { [self mention:member]; } - (void)roomInfoCoordinatorBridgePresenterDelegateDidLeaveRoom:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter { [self notifyDelegateOnLeaveRoomIfNecessary]; } - (void)roomInfoCoordinatorBridgePresenter:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter didReplaceRoomWithReplacementId:(NSString *)roomId { if (self.delegate) { [self.delegate roomViewController:self didReplaceRoomWithReplacementId:roomId]; } else { ScreenPresentationParameters *presentationParameters = [[ScreenPresentationParameters alloc] initWithRestoreInitialDisplay:YES stackAboveVisibleViews:NO]; RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:nil mxSession:self.mainSession presentationParameters:presentationParameters showSettingsInitially:YES]; [[AppDelegate theDelegate] showRoomWithParameters:parameters]; } } - (void)roomInfoCoordinatorBridgePresenter:(RoomInfoCoordinatorBridgePresenter *)coordinator viewEventInTimeline:(MXEvent *)event { [self.navigationController popToViewController:self animated:true]; [self reloadRoomWihtEventId:event.eventId threadId:event.threadId forceUpdateRoomMarker:NO]; } - (void)roomInfoCoordinatorBridgePresenterDidRequestReportRoom:(RoomInfoCoordinatorBridgePresenter *)coordinatorBridgePresenter { [self handleReportRoom]; } -(void)reloadRoomWihtEventId:(NSString *)eventId threadId:(NSString *)threadId forceUpdateRoomMarker:(BOOL)forceUpdateRoomMarker { // Jump to the last unread event by using a temporary room data source initialized with the last unread event id. MXWeakify(self); [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId initialEventId:eventId threadId:threadId andMatrixSession:self.mainSession onComplete:^(id roomDataSource) { MXStrongifyAndReturnIfNil(self); [roomDataSource finalizeInitialization]; // Center the bubbles table content on the bottom of the read marker event in order to display correctly the read marker view. self.centerBubblesTableViewContentOnTheInitialEventBottom = YES; [self displayRoom:roomDataSource]; // Give the data source ownership to the room view controller. self.hasRoomDataSourceOwnership = YES; // Force the read marker update if needed (e.g if we jumped on the last unread message using the banner). self.updateRoomReadMarker |= forceUpdateRoomMarker; }]; } #pragma mark - RemoveJitsiWidgetViewDelegate - (void)removeJitsiWidgetViewDidCompleteSliding:(RemoveJitsiWidgetView *)view { view.delegate = nil; Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; [self startActivityIndicator]; // close the widget MXWeakify(self); [[WidgetManager sharedManager] closeWidget:jitsiWidget.widgetId inRoom:self.roomDataSource.room success:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; // we can wait for kWidgetManagerDidUpdateWidgetNotification, but we want to be faster self.removeJitsiWidgetContainer.hidden = YES; self.removeJitsiWidgetView.delegate = nil; // end active call if exists if ([self isRoomHavingAJitsiCall]) { [self endActiveJitsiCall]; } } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self showJitsiErrorAsAlert:error]; [self stopActivityIndicator]; }]; } #pragma mark - VoiceMessageControllerDelegate - (void)voiceMessageControllerDidRequestMicrophonePermission:(VoiceMessageController *)voiceMessageController { NSString *message = [VectorL10n microphoneAccessNotGrantedForVoiceMessage:AppInfo.current.displayName]; [MXKTools checkAccessForMediaType:AVMediaTypeAudio manualChangeMessage: message showPopUpInViewController:self completionHandler:^(BOOL granted) { }]; } - (BOOL)voiceMessageControllerDidRequestRecording:(VoiceMessageController *)voiceMessageController { MXSession* session = self.roomDataSource.mxSession; // Check whether the user is not already broadcasting here or in another room if (session.voiceBroadcastService) { [self showAlertWithTitle:[VectorL10n voiceMessageBroadcastInProgressTitle] message:[VectorL10n voiceMessageBroadcastInProgressMessage]]; return NO; } return YES; } - (void)voiceMessageController:(VoiceMessageController *)voiceMessageController didRequestSendForFileAtURL:(NSURL *)url duration:(NSUInteger)duration samples:(NSArray *)samples completion:(void (^)(BOOL))completion { [self.roomDataSource sendVoiceMessage:url additionalContentParams:nil mimeType:nil duration:duration samples:samples success:^(NSString *eventId) { MXLogDebug(@"Success with event id %@", eventId); [self trackVoiceMessage: duration]; completion(YES); } failure:^(NSError *error) { MXLogError(@"Failed sending voice message"); completion(NO); }]; } #pragma mark - SpaceDetailPresenterDelegate - (void)spaceDetailPresenterDidComplete:(SpaceDetailPresenter *)presenter { self.spaceDetailPresenter = nil; } - (void)spaceDetailPresenter:(SpaceDetailPresenter *)presenter didOpenSpaceWithId:(NSString *)spaceId { self.spaceDetailPresenter = nil; [[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId]; } - (void)spaceDetailPresenter:(SpaceDetailPresenter *)presenter didJoinSpaceWithId:(NSString *)spaceId { self.spaceDetailPresenter = nil; [[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId]; } - (void) finishTextMessageProfil:(PerformanceProfile*)profile { [profile stopMeasurement]; if( [profile isLogable] ) { [self.roomDataSource.room members:^(MXRoomMembers *roomMembers) { NSUInteger noOfUsers = roomMembers.joinedMembers.count; [[BWIAnalyticsHelper shared] getRoomDeviceCountWithRoom:self.roomDataSource.room completion:^(NSInteger deviceCount) { [profile log2AnalyticsWithUsers:noOfUsers devices:deviceCount]; }]; } failure:^(NSError *error) { }]; } } // Bwi #4795: voice message - (void) trackVoiceMessage:(NSInteger)duration { [[BWIAnalyticsHelper shared] getRoomDeviceCountWithRoom:self.roomDataSource.room completion:^(NSInteger deviceCount) { NSString *deviceCountString = [[BWIAnalyticsHelper shared] dimensionForDeviceCount: deviceCount]; NSNumber *durationInSeconds = [NSNumber numberWithInteger:(duration / 1000)]; [BWIAnalytics.sharedTracker trackEventWithDimensionWithCategory:@"Feature" action:@"SendVoiceMessage" dimension:deviceCountString value:durationInSeconds name:@"send_voice_message_default"]; }]; } #pragma mark - BWI Emoji History - (void) bwiAddedEmoji:(NSString*)emoji { RecentReactionHistoryDefaultService* service = [[RecentReactionHistoryDefaultService alloc] init]; [service addReactionWithReaction:emoji]; } #pragma mark - BWI Helper // (bwi #4549): Showing Location Sharing in UI depends on BuildSetting and url from well-known. If there is only the default url that was not overwritten by well-known, the UI will not be shown - (BOOL) bwiShowLocationSharingUI:(MXSession*)session { if (session) { return BWIBuildSettings.shared.locationSharingEnabled && ![session.vc_homeserverConfiguration.tileServer.mapStyleURL.absoluteString isEqualToString:BWIBuildSettings.shared.serverConfigDefaultMapstyleURLString]; } else { return NO; } } #pragma mark - BWI Federation // bwi: #5304 show federation decision sheet only for admins and only in rooms / not in DMs - (void)showFederationDecisionSheet { if (BWIBuildSettings.shared.isFederationEnabled) { // Show sheet only when opening room if (!self.wasFederationDecisionSheetShownBefore) { // #5781 Do not show sheet in case of preview if (!self.isRoomPreview) { // Do not show sheet if room DM if (!self.roomDataSource.room.isDirect) { // Do not show sheet if room is personal notes room if (!self.roomDataSource.room.isPersonalNotesRoom) { // Do not show sheet if isFederated room flag is false (default == true) [self.roomDataSource.room getFederatedFlagWithCompletion:^(BOOL isFederated) { if (isFederated) { // Do not show sheet if users power level is lower than admin MXRoomPowerLevels *powerLevels = self.roomDataSource.roomState.powerLevels; if ([powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId] >= RoomPowerLevelAdmin) { // Show sheet if no serverACL have been configured [self.roomDataSource.room getCurrentRoomServerACLSettingsWithCompletion:^(NSString *serverACL) { if (serverACL == nil) { self.wasFederationDecisionSheetShownBefore = true; RoomFederationDecisionSheet *federationDecisionView = [[RoomFederationDecisionSheet alloc] init]; UIImage *roomAvatarImage; MXKImageView *roomAvatarImageView = ((RoomTitleView*)self.titleView).pictureView; if (roomAvatarImageView && roomAvatarImageView.image) { roomAvatarImage = roomAvatarImageView.image; } else { roomAvatarImage = [AvatarGenerator generateAvatarForMatrixItem:self.roomDataSource.roomId withDisplayName:self.roomDataSource.room.summary.displayName]; } UIViewController *sheetViewController = [federationDecisionView makeViewControllerWithRoom:self.roomDataSource.room roomAvatarImage: roomAvatarImage]; sheetViewController.modalPresentationStyle = UIModalPresentationFormSheet; [self presentViewController:sheetViewController animated:YES completion:nil]; } }]; } } }]; } } } } } } #pragma mark - UserSuggestionCoordinatorBridgeDelegate - (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didRequestMentionForMember:(MXRoomMember *)member textTrigger:(NSString *)textTrigger { [self removeTriggerTextFromComposer:textTrigger]; [self mention:member]; } - (void)completionSuggestionCoordinatorBridgeDidRequestMentionForRoom:(CompletionSuggestionCoordinatorBridge *)coordinator textTrigger:(NSString *)textTrigger { [self removeTriggerTextFromComposer:textTrigger]; [self.inputToolbarView pasteText:[CompletionSuggestionUserID.room stringByAppendingString:@" "]]; } - (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didRequestCommand:(NSString *)command textTrigger:(NSString *)textTrigger { [self removeTriggerTextFromComposer:textTrigger]; [self setCommand:command]; } - (void)removeTriggerTextFromComposer:(NSString *)textTrigger { RoomInputToolbarView *toolbar = (RoomInputToolbarView *)self.inputToolbarView; Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass]; // RTE handles removing the text trigger by itself. if (roomInputToolbarViewClass == WysiwygInputToolbarView.class && RiotSettings.shared.enableWysiwygTextFormatting) { return; } if (toolbar && textTrigger.length) { NSMutableAttributedString *attributedTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:toolbar.attributedTextMessage]; [[attributedTextMessage mutableString] replaceOccurrencesOfString:textTrigger withString:@"" options:NSBackwardsSearch | NSAnchoredSearch range:NSMakeRange(0, attributedTextMessage.length)]; [toolbar setAttributedTextMessage:attributedTextMessage]; } } - (void)completionSuggestionCoordinatorBridge:(CompletionSuggestionCoordinatorBridge *)coordinator didUpdateViewHeight:(CGFloat)height { if (self.completionSuggestionContainerHeightConstraint.constant != height) { self.completionSuggestionContainerHeightConstraint.constant = height; [self.view layoutIfNeeded]; } } #pragma mark - ThreadsCoordinatorBridgePresenterDelegate - (void)threadsCoordinatorBridgePresenterDelegateDidComplete:(ThreadsCoordinatorBridgePresenter *)coordinatorBridgePresenter { self.threadsBridgePresenter = nil; } - (void)threadsCoordinatorBridgePresenterDelegateDidSelect:(ThreadsCoordinatorBridgePresenter *)coordinatorBridgePresenter roomId:(NSString *)roomId eventId:(NSString *)eventId { MXWeakify(self); [self.threadsBridgePresenter dismissWithAnimated:YES completion:^{ MXStrongifyAndReturnIfNil(self); if (eventId) { [self highlightAndDisplayEvent:eventId completion:nil]; } }]; } - (void)threadsCoordinatorBridgePresenterDidDismissInteractively:(ThreadsCoordinatorBridgePresenter *)coordinatorBridgePresenter { self.threadsBridgePresenter = nil; } #pragma mark - ThreadsBetaCoordinatorBridgePresenterDelegate - (void)threadsBetaCoordinatorBridgePresenterDelegateDidTapEnable:(ThreadsBetaCoordinatorBridgePresenter *)coordinatorBridgePresenter { MXWeakify(self); [self.threadsBetaBridgePresenter dismissWithAnimated:YES completion:^{ MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; [self.roomDataSource reload]; [self openThreadWithId:coordinatorBridgePresenter.threadId]; }]; } - (void)threadsBetaCoordinatorBridgePresenterDelegateDidTapCancel:(ThreadsBetaCoordinatorBridgePresenter *)coordinatorBridgePresenter { MXWeakify(self); [self.threadsBetaBridgePresenter dismissWithAnimated:YES completion:^{ MXStrongifyAndReturnIfNil(self); [self cancelEventSelection]; }]; } #pragma mark - MXThreadingServiceDelegate - (void)threadingServiceDidUpdateThreads:(MXThreadingService *)service { [self updateThreadListBarButtonBadgeWith:service]; } #pragma mark - RoomParticipantsInviteCoordinatorBridgePresenterDelegate - (void)roomParticipantsInviteCoordinatorBridgePresenterDidComplete:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter { self.participantsInvitePresenter = nil; } - (void)roomParticipantsInviteCoordinatorBridgePresenterDidStartLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter { [self startActivityIndicator]; } - (void)roomParticipantsInviteCoordinatorBridgePresenterDidEndLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter { [self stopActivityIndicator]; } #pragma mark - Pills /// Register provider for Pills. - (void)registerPillAttachmentViewProviderIfNeeded { if (@available(iOS 15.0, *)) { if (![NSTextAttachment textAttachmentViewProviderClassForFileType:PillsFormatter.pillUTType]) { [NSTextAttachment registerTextAttachmentViewProviderClass:PillAttachmentViewProvider.class forFileType:PillsFormatter.pillUTType]; } } } #pragma mark - ComposerCreateActionListBridgePresenter - (void)composerCreateActionListBridgePresenterDelegateDidComplete:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter action:(enum ComposerCreateAction)action { [coordinatorBridgePresenter dismissWithAnimated:true completion:^{ switch (action) { case ComposerCreateActionPhotoLibrary: [self showMediaPickerAnimated:YES]; break; case ComposerCreateActionStickers: [self roomInputToolbarViewPresentStickerPicker]; break; case ComposerCreateActionAttachments: [self roomInputToolbarViewDidTapFileUpload]; break; case ComposerCreateActionVoiceBroadcast: [self roomInputToolbarViewDidTapVoiceBroadcast]; break; case ComposerCreateActionPolls: [self.delegate roomViewControllerDidRequestPollCreationFormPresentation:self]; break; case ComposerCreateActionLocation: [self.delegate roomViewControllerDidRequestLocationSharingFormPresentation:self]; break; case ComposerCreateActionCamera: [self showCameraControllerAnimated:YES]; break; } self.composerCreateActionListBridgePresenter = nil; }]; } - (void)composerCreateActionListBridgePresenterDelegateDidToggleTextFormatting:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter enabled:(BOOL)enabled { [self togglePlainTextMode]; } - (void)composerCreateActionListBridgePresenterDidDismissInteractively:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter { self.composerCreateActionListBridgePresenter = nil; } @end