/* Copyright 2018-2024 New Vector Ltd. Copyright 2017 Vector Creations Ltd Copyright 2015 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 "RecentsViewController.h" #import "RecentsDataSource.h" #import "RecentTableViewCell.h" #import "UnifiedSearchViewController.h" #import "MXRoom+Riot.h" #import "RoomViewController.h" #import "InviteRecentTableViewCell.h" #import "DirectoryRecentTableViewCell.h" #import "RoomIdOrAliasTableViewCell.h" #import "TableViewCellWithCollectionView.h" #import "SectionHeaderView.h" #import "GeneratedInterface-Swift.h" NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewControllerDataReadyNotification"; @interface RecentsViewController () { // Tell whether a recents refresh is pending (suspended during editing mode). BOOL isRefreshPending; // Recents drag and drop management UILongPressGestureRecognizer *longPressGestureRecognizer; UIImageView *cellSnapshot; NSIndexPath* movingCellPath; MXRoom* movingRoom; NSIndexPath* lastPotentialCellPath; // Observe UIApplicationDidEnterBackgroundNotification to cancel editing mode when app leaves the foreground state. __weak id UIApplicationDidEnterBackgroundNotificationObserver; // Observe kAppDelegateDidTapStatusBarNotification to handle tap on clock status bar. __weak id kAppDelegateDidTapStatusBarNotificationObserver; // Observe kMXNotificationCenterDidUpdateRules to update missed messages counts. __weak id kMXNotificationCenterDidUpdateRulesObserver; MXHTTPOperation *currentRequest; // The fake search bar displayed at the top of the recents table. We switch on the actual search bar (self.recentsSearchBar) // when the user selects it. UISearchBar *tableSearchBar; // Flag indicating whether the view controller is (at least partially) visible and not dissapearing BOOL isViewVisible; // Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change. __weak id kThemeServiceDidChangeThemeNotificationObserver; // Cancel handler of any ongoing loading indicator UserIndicatorCancel loadingIndicatorCancel; } @property (nonatomic, strong) CreateRoomCoordinatorBridgePresenter *createRoomCoordinatorBridgePresenter; @property (nonatomic, strong) RoomsDirectoryCoordinatorBridgePresenter *roomsDirectoryCoordinatorBridgePresenter; @property (nonatomic, strong) ExploreRoomCoordinatorBridgePresenter *exploreRoomsCoordinatorBridgePresenter; @property (nonatomic, strong) SpaceFeatureUnavailablePresenter *spaceFeatureUnavailablePresenter; @property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController; @property (nonatomic, strong) RoomNotificationSettingsCoordinatorBridgePresenter *roomNotificationSettingsCoordinatorBridgePresenter; @property (nonatomic, strong) SpaceChildRoomDetailBridgePresenter *spaceChildPresenter; @end @implementation RecentsViewController #pragma mark - Class methods + (UINib *)nib { return [UINib nibWithNibName:NSStringFromClass([RecentsViewController class]) bundle:[NSBundle bundleForClass:[RecentsViewController class]]]; } + (instancetype)recentListViewController { return [[[self class] alloc] initWithNibName:NSStringFromClass([RecentsViewController class]) bundle:[NSBundle bundleForClass:[RecentsViewController class]]]; } #pragma mark - - (void)finalizeInit { [super finalizeInit]; // Setup `MXKViewControllerHandling` properties self.enableBarTintColorStatusChange = NO; self.rageShakeManager = [RageShakeManager sharedManager]; // Enable the search bar in the recents table, and remove the search option from the navigation bar. _enableSearchBar = YES; self.enableBarButtonSearch = NO; _enableDragging = NO; _enableStickyHeaders = NO; _stickyHeaderHeight = 30.0; // Create the fake search bar tableSearchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, 600, 44)]; tableSearchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth; tableSearchBar.showsCancelButton = NO; tableSearchBar.placeholder = [VectorL10n searchFilterPlaceholder]; [tableSearchBar setImage:AssetImages.filterOff.image forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; tableSearchBar.delegate = self; displayedSectionHeaders = [NSMutableArray array]; _contextMenuProvider = [RecentCellContextMenuProvider new]; self.contextMenuProvider.serviceDelegate = self; self.contextMenuProvider.menuProviderDelegate = self; // Set itself as delegate by default. self.delegate = self; } - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. self.recentsTableView.accessibilityIdentifier = @"RecentsVCTableView"; // Register here the customized cell view class used to render recents [self.recentsTableView registerNib:RecentTableViewCell.nib forCellReuseIdentifier:RecentTableViewCell.defaultReuseIdentifier]; [self.recentsTableView registerNib:InviteRecentTableViewCell.nib forCellReuseIdentifier:InviteRecentTableViewCell.defaultReuseIdentifier]; // Register key backup banner cells [self.recentsTableView registerNib:SecureBackupBannerCell.nib forCellReuseIdentifier:SecureBackupBannerCell.defaultReuseIdentifier]; // Register key verification banner cells [self.recentsTableView registerNib:CrossSigningSetupBannerCell.nib forCellReuseIdentifier:CrossSigningSetupBannerCell.defaultReuseIdentifier]; [self.recentsTableView registerClass:SectionHeaderView.class forHeaderFooterViewReuseIdentifier:SectionHeaderView.defaultReuseIdentifier]; // Hide line separators of empty cells self.recentsTableView.tableFooterView = [[UIView alloc] init]; // Apply dragging settings self.enableDragging = _enableDragging; MXWeakify(self); // Observe UIApplicationDidEnterBackgroundNotification to refresh bubbles when app leaves the foreground state. UIApplicationDidEnterBackgroundNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); // Leave potential editing mode [self cancelEditionMode:self->isRefreshPending]; }]; self.recentsSearchBar.autocapitalizationType = UITextAutocapitalizationTypeNone; self.recentsSearchBar.placeholder = [VectorL10n searchFilterPlaceholder]; [self.recentsSearchBar setImage:AssetImages.filterOff.image forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; // Observe user interface theme change. kThemeServiceDidChangeThemeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kThemeServiceDidChangeThemeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); [self userInterfaceThemeDidChange]; }]; [self userInterfaceThemeDidChange]; } - (void)userInterfaceThemeDidChange { [ThemeService.shared.theme applyStyleOnNavigationBar:self.navigationController.navigationBar]; self.activityIndicator.backgroundColor = ThemeService.shared.theme.overlayBackgroundColor; // Use the primary bg color for the recents table view in plain style. self.recentsTableView.backgroundColor = ThemeService.shared.theme.backgroundColor; self.recentsTableView.separatorColor = ThemeService.shared.theme.lineBreakColor; topview.backgroundColor = ThemeService.shared.theme.headerBackgroundColor; self.view.backgroundColor = ThemeService.shared.theme.backgroundColor; [ThemeService.shared.theme applyStyleOnSearchBar:tableSearchBar]; [ThemeService.shared.theme applyStyleOnSearchBar:self.recentsSearchBar]; // Force table refresh [self.recentsTableView reloadData]; [self.emptyView updateWithTheme:ThemeService.shared.theme]; [self setNeedsStatusBarAppearanceUpdate]; } - (UIStatusBarStyle)preferredStatusBarStyle { return ThemeService.shared.theme.statusBarStyle; } - (void)destroy { [super destroy]; longPressGestureRecognizer = nil; if (currentRequest) { [currentRequest cancel]; currentRequest = nil; } if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } if (UIApplicationDidEnterBackgroundNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationDidEnterBackgroundNotificationObserver]; UIApplicationDidEnterBackgroundNotificationObserver = nil; } if (kThemeServiceDidChangeThemeNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kThemeServiceDidChangeThemeNotificationObserver]; kThemeServiceDidChangeThemeNotificationObserver = nil; } } - (void)setEditing:(BOOL)editing animated:(BOOL)animated { [super setEditing:editing animated:animated]; self.recentsTableView.editing = editing; } - (void)didReceiveMemoryWarning { [super didReceiveMemoryWarning]; // Dispose of any resources that can be recreated. } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; isViewVisible = YES; [self.screenTracker trackScreen]; // Reset back user interactions self.userInteractionEnabled = YES; // Deselect the current selected row, it will be restored on viewDidAppear (if any) NSIndexPath *indexPath = [self.recentsTableView indexPathForSelectedRow]; if (indexPath) { [self.recentsTableView deselectRowAtIndexPath:indexPath animated:NO]; } MXWeakify(self); // Observe kAppDelegateDidTapStatusBarNotificationObserver. kAppDelegateDidTapStatusBarNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kAppDelegateDidTapStatusBarNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); [self scrollToTop:YES]; }]; // Observe kMXNotificationCenterDidUpdateRules to refresh missed messages counts kMXNotificationCenterDidUpdateRulesObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterDidUpdateRules object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { MXStrongifyAndReturnIfNil(self); [self refreshRecentsTable]; }]; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; isViewVisible = NO; // Leave potential editing mode [self cancelEditionMode:NO]; if (kAppDelegateDidTapStatusBarNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kAppDelegateDidTapStatusBarNotificationObserver]; kAppDelegateDidTapStatusBarNotificationObserver = nil; } if (kMXNotificationCenterDidUpdateRulesObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kMXNotificationCenterDidUpdateRulesObserver]; kMXNotificationCenterDidUpdateRulesObserver = nil; } [self stopActivityIndicator]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // Release the current selected item (if any) except if the second view controller is still visible. if (self.splitViewController.isCollapsed) { // Release the current selected room (if any). [[AppDelegate theDelegate].masterTabBarController releaseSelectedItem]; } else { // In case of split view controller where the primary and secondary view controllers are displayed side-by-side onscreen, // the selected room (if any) is highlighted. [self refreshCurrentSelectedCell:YES]; } if (self.recentsDataSource) { [self refreshRecentsTable]; [self showEmptyViewIfNeeded]; } } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; dispatch_async(dispatch_get_main_queue(), ^{ [self refreshStickyHeadersContainersHeight]; }); } #pragma mark - Override MXKRecentListViewController - (void)refreshRecentsTable { if (!self.recentsUpdateEnabled) { isRefreshNeeded = YES; return; } isRefreshNeeded = NO; // Refresh the tabBar icon badges if (!BuildSettings.newAppLayoutEnabled) { // Refresh the tabBar icon badges [[AppDelegate theDelegate].masterTabBarController refreshTabBarBadges]; } // do not refresh if there is a pending recent drag and drop if (movingCellPath) { return; } isRefreshPending = NO; if (editedRoomId) { // Check whether the user didn't leave the room MXRoom *room = [self.mainSession roomWithRoomId:editedRoomId]; if (room) { isRefreshPending = YES; return; } else { // Cancel the editing mode, a new refresh will be triggered. [self cancelEditionMode:YES]; return; } } // Force reset existing sticky headers if any [self resetStickyHeaders]; [self.recentsTableView reloadData]; // Check conditions to display the fake search bar into the table header if (_enableSearchBar && self.recentsSearchBar.isHidden && self.recentsTableView.tableHeaderView == nil) { // Add the search bar by hiding it by default. self.recentsTableView.tableHeaderView = tableSearchBar; self.recentsTableView.contentOffset = CGPointMake(0, self.recentsTableView.contentOffset.y + tableSearchBar.frame.size.height); } if (_shouldScrollToTopOnRefresh) { [self scrollToTop:NO]; _shouldScrollToTopOnRefresh = NO; } [self prepareStickyHeaders]; // In case of split view controller where the primary and secondary view controllers are displayed side-by-side on screen, // the selected room (if any) is updated. if (!self.splitViewController.isCollapsed) { [self refreshCurrentSelectedCell:NO]; } } - (void)hideSearchBar:(BOOL)hidden { [super hideSearchBar:hidden]; if (!hidden) { // Remove the fake table header view if any self.recentsTableView.tableHeaderView = nil; self.recentsTableView.contentInset = UIEdgeInsetsZero; } } #pragma mark - - (void)refreshCurrentSelectedCell:(BOOL)forceVisible { // Update here the index of the current selected cell (if any) - Useful in landscape mode with split view controller. NSIndexPath *currentSelectedCellIndexPath = nil; MasterTabBarController *masterTabBarController = [AppDelegate theDelegate].masterTabBarController; if (masterTabBarController.selectedRoomId) { // Look for the rank of this selected room in displayed recents currentSelectedCellIndexPath = [self.dataSource cellIndexPathWithRoomId:masterTabBarController.selectedRoomId andMatrixSession:masterTabBarController.selectedRoomSession]; } if (currentSelectedCellIndexPath) { // Select the right row [self.recentsTableView selectRowAtIndexPath:currentSelectedCellIndexPath animated:YES scrollPosition:UITableViewScrollPositionNone]; if (forceVisible) { // Scroll table view to make the selected row appear at second position NSInteger topCellIndexPathRow = currentSelectedCellIndexPath.row ? currentSelectedCellIndexPath.row - 1: currentSelectedCellIndexPath.row; NSIndexPath* indexPath = [NSIndexPath indexPathForRow:topCellIndexPathRow inSection:currentSelectedCellIndexPath.section]; if ([self.recentsTableView vc_hasIndexPath:indexPath]) { [self.recentsTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionTop animated:NO]; } } } else { NSIndexPath *indexPath = [self.recentsTableView indexPathForSelectedRow]; if (indexPath) { [self.recentsTableView deselectRowAtIndexPath:indexPath animated:NO]; } } } - (void)cancelEditionMode:(BOOL)forceRefresh { if (self.recentsTableView.isEditing || self.isEditing) { // Leave editing mode first isRefreshPending = forceRefresh; [self setEditing:NO]; } else { // Clean editedRoomId = nil; if (forceRefresh) { [self refreshRecentsTable]; } } } - (void)joinRoom:(MXRoom*)room completion:(void(^)(BOOL succeed))completion { [room join:^{ // `recentsTableView` will be reloaded `roomChangeMembershipStateDataSourceDidChangeRoomMembershipState` function if (completion) { completion(YES); } } failure:^(NSError * _Nonnull error) { MXLogDebug(@"[RecentsViewController] Failed to join an invited room (%@)", room.roomId); [self presentRoomJoinFailedAlertForError:error completion:^{ if (completion) { completion(NO); } }]; }]; } - (void)leaveRoom:(MXRoom*)room completion:(void(^)(BOOL succeed))completion { // Decline the invitation [room leave:^{ // `recentsTableView` will be reloaded `roomChangeMembershipStateDataSourceDidChangeRoomMembershipState` function if (completion) { completion(YES); } } failure:^(NSError * _Nonnull error) { MXLogDebug(@"[RecentsViewController] Failed to reject an invited room (%@)", room.roomId); [[AppDelegate theDelegate] showErrorAsAlert:error]; if (completion) { completion(NO); } }]; } - (void)presentRoomJoinFailedAlertForError:(NSError*)error completion:(void(^)(void))completion { MXWeakify(self); NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; if ([msg isEqualToString:@"No known servers"]) { // minging kludge until https://matrix.org/jira/browse/SYN-678 is fixed // 'Error when trying to join an empty room should be more explicit' msg = [VectorL10n roomErrorJoinFailedEmptyRoom]; } else if ([msg isEqualToString:@"Server is banned from room"]) // bwi: #5716 show custom error message when the federation was disabled after the user has been invited to a federated room { msg = BWIL10n.roomErrorJoinFailedFederationDisabledMessage; } [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomErrorJoinFailedTitle] message:msg preferredStyle:UIAlertControllerStyleAlert]; [errorAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; if (completion) { completion(); } }]]; [self presentViewController:errorAlert animated:YES completion:nil]; currentAlert = errorAlert; } #pragma mark - Sticky Headers - (void)setEnableStickyHeaders:(BOOL)enableStickyHeaders { _enableStickyHeaders = enableStickyHeaders; // Refresh the table display if it is already rendered. if (self.recentsTableView.contentSize.height) { [self refreshRecentsTable]; } } - (void)setStickyHeaderHeight:(CGFloat)stickyHeaderHeight { if (_stickyHeaderHeight != stickyHeaderHeight) { _stickyHeaderHeight = stickyHeaderHeight; // Force a sticky headers refresh self.enableStickyHeaders = _enableStickyHeaders; } } - (UIView *)tableView:(UITableView *)tableView viewForStickyHeaderInSection:(NSInteger)section { // Return the section header by default. return [self tableView:tableView viewForHeaderInSection:section]; } - (void)resetStickyHeaders { // Release sticky header _stickyHeadersTopContainerHeightConstraint.constant = 0; _stickyHeadersBottomContainerHeightConstraint.constant = 0; for (UIView *view in _stickyHeadersTopContainer.subviews) { [view removeFromSuperview]; } for (UIView *view in _stickyHeadersBottomContainer.subviews) { [view removeFromSuperview]; } [displayedSectionHeaders removeAllObjects]; self.recentsTableView.contentInset = UIEdgeInsetsZero; } - (void)prepareStickyHeaders { // We suppose here [resetStickyHeaders] has been already called if need. NSInteger sectionsCount = self.recentsTableView.numberOfSections; if (self.enableStickyHeaders && sectionsCount) { NSUInteger topContainerOffset = 0; NSUInteger bottomContainerOffset = 0; CGRect frame; UIView *stickyHeader = [self viewForStickyHeaderInSection:0 withSwipeGestureRecognizerInDirection:UISwipeGestureRecognizerDirectionDown]; frame = stickyHeader.frame; frame.origin.y = topContainerOffset; stickyHeader.frame = frame; [self.stickyHeadersTopContainer addSubview:stickyHeader]; topContainerOffset = stickyHeader.frame.size.height; for (NSUInteger index = 1; index < sectionsCount; index++) { stickyHeader = [self viewForStickyHeaderInSection:index withSwipeGestureRecognizerInDirection:UISwipeGestureRecognizerDirectionDown]; frame = stickyHeader.frame; frame.origin.y = topContainerOffset; stickyHeader.frame = frame; [self.stickyHeadersTopContainer addSubview:stickyHeader]; topContainerOffset += frame.size.height; stickyHeader = [self viewForStickyHeaderInSection:index withSwipeGestureRecognizerInDirection:UISwipeGestureRecognizerDirectionUp]; frame = stickyHeader.frame; frame.origin.y = bottomContainerOffset; stickyHeader.frame = frame; [self.stickyHeadersBottomContainer addSubview:stickyHeader]; bottomContainerOffset += frame.size.height; } [self refreshStickyHeadersContainersHeight]; } } - (UIView *)viewForStickyHeaderInSection:(NSInteger)section withSwipeGestureRecognizerInDirection:(UISwipeGestureRecognizerDirection)swipeDirection { UIView *stickyHeader = [self tableView:self.recentsTableView viewForStickyHeaderInSection:section]; stickyHeader.tag = section; stickyHeader.autoresizingMask = UIViewAutoresizingFlexibleWidth; // Remove existing gesture recognizers while (stickyHeader.gestureRecognizers.count) { UIGestureRecognizer *gestureRecognizer = stickyHeader.gestureRecognizers.lastObject; [stickyHeader removeGestureRecognizer:gestureRecognizer]; } // Handle tap gesture, the section is moved up on the tap. UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapOnSectionHeader:)]; [tap setNumberOfTouchesRequired:1]; [tap setNumberOfTapsRequired:1]; [stickyHeader addGestureRecognizer:tap]; // Handle vertical swipe gesture with the provided direction, by default the section will be moved up on this swipe. UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(didSwipeOnSectionHeader:)]; [swipe setNumberOfTouchesRequired:1]; [swipe setDirection:swipeDirection]; [stickyHeader addGestureRecognizer:swipe]; return stickyHeader; } - (void)didTapOnSectionHeader:(UIGestureRecognizer*)gestureRecognizer { UIView *view = gestureRecognizer.view; NSInteger section = view.tag; // Scroll to the top of this section if ([self.recentsTableView numberOfRowsInSection:section] > 0) { [self.recentsTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section] atScrollPosition:UITableViewScrollPositionTop animated:YES]; } } - (void)didSwipeOnSectionHeader:(UISwipeGestureRecognizer*)gestureRecognizer { UIView *view = gestureRecognizer.view; NSInteger section = view.tag; if ([self.recentsTableView numberOfRowsInSection:section] > 0) { // Check whether the first cell of this section is already visible. UITableViewCell *firstSectionCell = [self.recentsTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section]]; if (firstSectionCell) { // Scroll to the top of the previous section (if any) if (section && [self.recentsTableView numberOfRowsInSection:(section - 1)] > 0) { [self.recentsTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:(section - 1)] atScrollPosition:UITableViewScrollPositionTop animated:YES]; } } else { // Scroll to the top of this section [self.recentsTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section] atScrollPosition:UITableViewScrollPositionTop animated:YES]; } } } - (void)refreshStickyHeadersContainersHeight { if (_enableStickyHeaders) { NSUInteger lowestSectionInBottomStickyHeader = NSNotFound; CGFloat containerHeight; // Retrieve the first header actually visible in the recents table view. // Caution: In some cases like the screen rotation, some displayed section headers are temporarily not visible. UIView *firstDisplayedSectionHeader; for (UIView *header in displayedSectionHeaders) { if (header.frame.origin.y + header.frame.size.height > self.recentsTableView.contentOffset.y) { firstDisplayedSectionHeader = header; break; } } if (firstDisplayedSectionHeader) { // Initialize the top container height by considering the headers which are before the first visible section header. containerHeight = 0; for (UIView *header in _stickyHeadersTopContainer.subviews) { if (header.tag < firstDisplayedSectionHeader.tag) { containerHeight += self.stickyHeaderHeight; } } // Check whether the first visible section header is partially hidden. if (firstDisplayedSectionHeader.frame.origin.y < self.recentsTableView.contentOffset.y) { // Compute the height of the hidden part. CGFloat delta = self.recentsTableView.contentOffset.y - firstDisplayedSectionHeader.frame.origin.y; if (delta < self.stickyHeaderHeight) { containerHeight += delta; } else { containerHeight += self.stickyHeaderHeight; } } if (containerHeight) { self.stickyHeadersTopContainerHeightConstraint.constant = containerHeight; self.recentsTableView.contentInset = UIEdgeInsetsMake(-self.stickyHeaderHeight, 0, 0, 0); } else { self.stickyHeadersTopContainerHeightConstraint.constant = 0; self.recentsTableView.contentInset = UIEdgeInsetsZero; } // Look for the lowest section index visible in the bottom sticky headers. CGFloat maxVisiblePosY = self.recentsTableView.contentOffset.y + self.recentsTableView.frame.size.height - self.recentsTableView.adjustedContentInset.bottom; UIView *lastDisplayedSectionHeader = displayedSectionHeaders.lastObject; for (UIView *header in _stickyHeadersBottomContainer.subviews) { if (header.tag > lastDisplayedSectionHeader.tag) { maxVisiblePosY -= self.stickyHeaderHeight; } } for (NSInteger index = displayedSectionHeaders.count; index > 0;) { lastDisplayedSectionHeader = displayedSectionHeaders[--index]; if (lastDisplayedSectionHeader.frame.origin.y + self.stickyHeaderHeight > maxVisiblePosY) { maxVisiblePosY -= self.stickyHeaderHeight; } else { lowestSectionInBottomStickyHeader = lastDisplayedSectionHeader.tag + 1; break; } } } else { // Handle here the case where no section header is currently displayed in the table. // No more than one section is then displayed, we retrieve this section by checking the first visible cell. NSIndexPath *firstCellIndexPath = [self.recentsTableView indexPathForRowAtPoint:CGPointMake(0, self.recentsTableView.contentOffset.y)]; if (firstCellIndexPath) { NSInteger section = firstCellIndexPath.section; // Refresh top container of the sticky headers CGFloat containerHeight = 0; for (UIView *header in _stickyHeadersTopContainer.subviews) { if (header.tag <= section) { containerHeight += header.frame.size.height; } } self.stickyHeadersTopContainerHeightConstraint.constant = containerHeight; if (containerHeight) { self.recentsTableView.contentInset = UIEdgeInsetsMake(-self.stickyHeaderHeight, 0, 0, 0); } else { self.recentsTableView.contentInset = UIEdgeInsetsZero; } // Set the lowest section index visible in the bottom sticky headers. lowestSectionInBottomStickyHeader = section + 1; } } // Update here the height of the bottom container of the sticky headers thanks to lowestSectionInBottomStickyHeader. containerHeight = 0; CGRect bounds = _stickyHeadersBottomContainer.frame; bounds.origin.y = 0; for (UIView *header in _stickyHeadersBottomContainer.subviews) { if (header.tag > lowestSectionInBottomStickyHeader) { containerHeight += self.stickyHeaderHeight; } else if (header.tag == lowestSectionInBottomStickyHeader) { containerHeight += self.stickyHeaderHeight; bounds.origin.y = header.frame.origin.y; } } if (self.stickyHeadersBottomContainerHeightConstraint.constant != containerHeight) { self.stickyHeadersBottomContainerHeightConstraint.constant = containerHeight; self.stickyHeadersBottomContainer.bounds = bounds; } } } #pragma mark - Internal methods - (void)showPublicRoomsDirectory { // Here the recents view controller is displayed inside a unified search view controller. // Sanity check if (self.parentViewController && [self.parentViewController isKindOfClass:UnifiedSearchViewController.class]) { AccountRestrictionService *service = [[AccountRestrictionService alloc] initWithMxSession:self.mainSession]; if (![service isRoomAccessRestriction]) { // Show the directory screen [((UnifiedSearchViewController*)self.parentViewController) showPublicRoomsDirectory]; } } } - (void)showRoomWithRoomId:(NSString*)roomId inMatrixSession:(MXSession*)matrixSession { [self showRoomWithRoomId:roomId andAutoJoinInvitedRoom:false inMatrixSession:matrixSession]; } - (void)showRoomWithRoomId:(NSString*)roomId andAutoJoinInvitedRoom:(BOOL)autoJoinInvitedRoom inMatrixSession:(MXSession*)matrixSession { MXRoom *room = [matrixSession roomWithRoomId:roomId]; if (room.summary.membership == MXMembershipInvite) { Analytics.shared.joinedRoomTrigger = AnalyticsJoinedRoomTriggerInvite; } // Avoid multiple openings of rooms self.userInteractionEnabled = NO; // Do not stack views when showing room ScreenPresentationParameters *presentationParameters = [[ScreenPresentationParameters alloc] initWithRestoreInitialDisplay:NO stackAboveVisibleViews:NO]; RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:nil mxSession:matrixSession threadParameters:nil presentationParameters:presentationParameters autoJoinInvitedRoom:autoJoinInvitedRoom]; [[AppDelegate theDelegate] showRoomWithParameters:parameters completion:^{ self.userInteractionEnabled = YES; }]; } - (void)showRoomPreviewWithData:(RoomPreviewData*)roomPreviewData { Analytics.shared.joinedRoomTrigger = AnalyticsJoinedRoomTriggerRoomDirectory; // Do not stack views when showing room ScreenPresentationParameters *presentationParameters = [[ScreenPresentationParameters alloc] initWithRestoreInitialDisplay:NO stackAboveVisibleViews:NO sender:nil sourceView:nil]; RoomPreviewNavigationParameters *parameters = [[RoomPreviewNavigationParameters alloc] initWithPreviewData:roomPreviewData presentationParameters:presentationParameters]; [[AppDelegate theDelegate] showRoomPreviewWithParameters:parameters]; } // Disable UI interactions in this screen while we are going to open another screen. // Interactions on reset on viewWillAppear. - (void)setUserInteractionEnabled:(BOOL)userInteractionEnabled { self.view.userInteractionEnabled = userInteractionEnabled; } - (RecentsDataSource*)recentsDataSource { RecentsDataSource* recentsDataSource = nil; if ([self.dataSource isKindOfClass:[RecentsDataSource class]]) { recentsDataSource = (RecentsDataSource*)self.dataSource; } return recentsDataSource; } - (void)showSpaceInviteNotAvailable { if (!self.spaceFeatureUnavailablePresenter) { self.spaceFeatureUnavailablePresenter = [SpaceFeatureUnavailablePresenter new]; } [self.spaceFeatureUnavailablePresenter presentUnavailableFeatureFrom:self animated:YES]; } #pragma mark - MXKDataSourceDelegate - (Class)cellViewClassForCellData:(MXKCellData*)cellData { id cellDataStoring = (id )cellData; if (cellDataStoring.roomSummary.membership != MXMembershipInvite) { return RecentTableViewCell.class; } else { return InviteRecentTableViewCell.class; } } - (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData { Class class = [self cellViewClassForCellData:cellData]; if ([class respondsToSelector:@selector(defaultReuseIdentifier)]) { return [class defaultReuseIdentifier]; } return nil; } - (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo { // Handle here user actions on recents for Riot app if ([actionIdentifier isEqualToString:kInviteRecentTableViewCellPreviewButtonPressed]) { // Retrieve the invited room MXRoom *invitedRoom = userInfo[kInviteRecentTableViewCellRoomKey]; if (invitedRoom.summary.roomType == MXRoomTypeSpace) { // Indicates that spaces are not supported [self showSpaceInviteNotAvailable]; return; } // Display the room preview [self showRoomWithRoomId:invitedRoom.roomId inMatrixSession:invitedRoom.mxSession]; } else if ([actionIdentifier isEqualToString:kInviteRecentTableViewCellAcceptButtonPressed]) { // Retrieve the invited room MXRoom *invitedRoom = userInfo[kInviteRecentTableViewCellRoomKey]; if (invitedRoom.summary.roomType == MXRoomTypeSpace) { // Indicates that spaces are not supported [self showSpaceInviteNotAvailable]; return; } // Accept invitation and display the room Analytics.shared.joinedRoomTrigger = AnalyticsJoinedRoomTriggerInvite; [self showRoomWithRoomId:invitedRoom.roomId andAutoJoinInvitedRoom:true inMatrixSession:invitedRoom.mxSession]; } else if ([actionIdentifier isEqualToString:kInviteRecentTableViewCellDeclineButtonPressed]) { // Retrieve the invited room MXRoom *invitedRoom = userInfo[kInviteRecentTableViewCellRoomKey]; [self cancelEditionMode:isRefreshPending]; // Decline the invitation [self leaveRoom:invitedRoom completion:nil]; } else { // Keep default implementation for other actions if any if ([super respondsToSelector:@selector(cell:didRecognizeAction:userInfo:)]) { [super dataSource:dataSource didRecognizeAction:actionIdentifier inCell:cell userInfo:userInfo]; } } } - (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes { if (!self.recentsUpdateEnabled) { [super dataSource:dataSource didCellChange:changes]; return; } if ([changes isKindOfClass:NSIndexPath.class]) { NSIndexPath *indexPath = (NSIndexPath *)changes; UITableViewCell *cell = [self.recentsTableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:TableViewCellWithCollectionView.class]) { MXLogDebug(@"[RecentsViewController]: Reloading nested collection view cell in section %ld", indexPath.section); TableViewCellWithCollectionView *collectionViewCell = (TableViewCellWithCollectionView *)cell; [collectionViewCell.collectionView reloadData]; CGRect headerFrame = [self.recentsTableView rectForHeaderInSection:indexPath.section]; UIView *headerView = [self.recentsTableView headerViewForSection:indexPath.section]; UIView *updatedHeaderView = [self.dataSource viewForHeaderInSection:indexPath.section withFrame:headerFrame inTableView:self.recentsTableView]; if ([headerView isKindOfClass:SectionHeaderView.class] && [updatedHeaderView isKindOfClass:SectionHeaderView.class]) { SectionHeaderView *sectionHeaderView = (SectionHeaderView *)headerView; SectionHeaderView *updatedSectionHeaderView = (SectionHeaderView *)updatedHeaderView; sectionHeaderView.headerLabel = updatedSectionHeaderView.headerLabel; sectionHeaderView.accessoryView = updatedSectionHeaderView.accessoryView; sectionHeaderView.rightAccessoryView = updatedSectionHeaderView.rightAccessoryView; } } else { // Ideally we would call tableView.reloadSections, but this can lead to crashes if multiple sections need such an update and they // vertically depend on each other. It is unclear whether this is due to further issues in the data model (e.g. data race) // or some undocumented table view behavior. To avoid this we reload the entire table view, even if this means reloading // multiple times for several section updates. MXLogDebug(@"[RecentsViewController]: Reloading the entire table view due to updates in section %ld", indexPath.section); [self refreshRecentsTable]; } } else if (!changes) { MXLogDebug(@"[RecentsViewController]: Reloading the entire table view"); [self refreshRecentsTable]; } if (!BuildSettings.newAppLayoutEnabled) { // Since we've enabled room list pagination, `refreshRecentsTable` not called in this case. // Refresh tab bar badges separately. [[AppDelegate theDelegate].masterTabBarController refreshTabBarBadges]; } [self showEmptyViewIfNeeded]; if (dataSource.state == MXKDataSourceStateReady) { [[NSNotificationCenter defaultCenter] postNotificationName:RecentsViewControllerDataReadyNotification object:self]; } } #pragma mark - Swipe actions - (void)leaveEditedRoom { if (editedRoomId) { NSString *currentRoomId = editedRoomId; MXWeakify(self); MXRoom *room = [self.mainSession roomWithRoomId:currentRoomId]; __weak typeof(room) weakRoom = room; [room isLastOwnerWithCompletionHandler:^(BOOL isLastOwner, NSError* error){ if (isLastOwner) { UIAlertController *isLastOwnerPrompt = [UIAlertController alertControllerWithTitle:[VectorL10n error] message:[VectorL10n roomParticipantsLeaveNotAllowedForLastOwnerMsg] preferredStyle:UIAlertControllerStyleAlert]; [isLastOwnerPrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n ok] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; MXStrongifyAndReturnIfNil(self); dispatch_async(dispatch_get_main_queue(), ^{ [self presentViewController:isLastOwnerPrompt animated:YES completion:nil]; self->currentAlert = isLastOwnerPrompt; }); } else { NSString *title, *message; MXStrongifyAndReturnIfNil(self); if ([self.mainSession roomWithRoomId:currentRoomId].isDirect) { title = [VectorL10n roomParticipantsLeavePromptTitleForDm]; message = [VectorL10n roomParticipantsLeavePromptMsgForDm]; } else { title = [VectorL10n roomParticipantsLeavePromptTitle]; message = [VectorL10n roomParticipantsLeavePromptMsg]; } // confirm leave UIAlertController *leavePrompt = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; MXWeakify(self); [leavePrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [leavePrompt addAction:[UIAlertAction actionWithTitle:[VectorL10n leave] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; // Check whether the user didn't leave the room yet // TODO: Handle multi-account if (weakRoom) { [self startActivityIndicatorWithLabel:[VectorL10n roomParticipantsLeaveProcessing]]; // cancel pending uploads/downloads // they are useless by now [MXMediaManager cancelDownloadsInCacheFolder:weakRoom.roomId]; // TODO GFO cancel pending uploads related to this room MXLogDebug(@"[RecentsViewController] Leave room (%@)", weakRoom.roomId); MXWeakify(self); [weakRoom leave:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; [self.userIndicatorStore presentSuccessWithLabel:[VectorL10n roomParticipantsLeaveSuccess]]; // Force table refresh [self cancelEditionMode:YES]; } failure:^(NSError *error) { MXLogDebug(@"[RecentsViewController] Failed to leave room"); MXStrongifyAndReturnIfNil(self); // Notify the end user NSString *userId = room.mxSession.myUser.userId; [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:userId ? @{kMXKErrorUserIdKey: userId} : nil]; [self stopActivityIndicator]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; }]; } else { // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; } }]]; [leavePrompt mxk_setAccessibilityIdentifier:@"LeaveEditedRoomAlert"]; dispatch_async(dispatch_get_main_queue(), ^{ [self presentViewController:leavePrompt animated:YES completion:nil]; self->currentAlert = leavePrompt; }); } }]; } } - (void)updateEditedRoomTag:(NSString*)tag { if (editedRoomId) { // Check whether the user didn't leave the room MXRoom *room = [self.mainSession roomWithRoomId:editedRoomId]; if (room) { [self startActivityIndicator]; [room setRoomTag:tag completion:^{ [self stopActivityIndicator]; // Force table refresh [self cancelEditionMode:YES]; }]; } else { // Leave editing mode [self cancelEditionMode:isRefreshPending]; } } } - (void)makeDirectEditedRoom:(BOOL)isDirect { if (editedRoomId) { // Check whether the user didn't leave the room // TODO: handle multi-account MXRoom *room = [self.mainSession roomWithRoomId:editedRoomId]; if (room) { [self startActivityIndicator]; MXWeakify(self); [room setIsDirect:isDirect withUserId:nil success:^{ MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; MXLogDebug(@"[RecentsViewController] Failed to update direct tag of the room (%@)", self->editedRoomId); // Notify the end user NSString *userId = self.mainSession.myUser.userId; // TODO: handle multi-account [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:userId ? @{kMXKErrorUserIdKey: userId} : nil]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; }]; } else { // Leave editing mode [self cancelEditionMode:isRefreshPending]; } } } - (void)changeEditedRoomNotificationSettings { if (editedRoomId) { // Check whether the user didn't leave the room MXRoom *room = [self.mainSession roomWithRoomId:editedRoomId]; if (room) { // navigate self.roomNotificationSettingsCoordinatorBridgePresenter = [[RoomNotificationSettingsCoordinatorBridgePresenter alloc] initWithRoom:room]; self.roomNotificationSettingsCoordinatorBridgePresenter.delegate = self; [self.roomNotificationSettingsCoordinatorBridgePresenter presentFrom:self animated:YES]; } [self cancelEditionMode:isRefreshPending]; } } - (void)muteEditedRoomNotifications:(BOOL)mute { if (editedRoomId) { // Check whether the user didn't leave the room MXRoom *room = [self.mainSession roomWithRoomId:editedRoomId]; if (room) { [self startActivityIndicator]; if (mute) { [room mentionsOnly:^{ [self stopActivityIndicator]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; }]; } else { [room allMessages:^{ [self stopActivityIndicator]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; }]; } } else { // Leave editing mode [self cancelEditionMode:isRefreshPending]; } } } #pragma mark - UITableView delegate - (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; } } } - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { return 30.0f; } - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { UIView *sectionHeader = [super tableView:tableView viewForHeaderInSection:section]; sectionHeader.tag = section; while (sectionHeader.gestureRecognizers.count) { UIGestureRecognizer *gestureRecognizer = sectionHeader.gestureRecognizers.lastObject; [sectionHeader removeGestureRecognizer:gestureRecognizer]; } // Handle tap gesture UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapOnSectionHeader:)]; [tap setNumberOfTouchesRequired:1]; [tap setNumberOfTapsRequired:1]; [sectionHeader addGestureRecognizer:tap]; return sectionHeader; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell* cell = [self.recentsTableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[InviteRecentTableViewCell class]]) { id cellData = [self.dataSource cellDataAtIndexPath:indexPath]; // Retrieve the invited room if (cellData.roomSummary.roomType == MXRoomTypeSpace) { // Indicates that spaces are not supported [self showSpaceInviteNotAvailable]; } // Check if can show preview for the invited room else if ([self canShowRoomPreviewFor:cellData.roomSummary]) { // Display the room preview [self showRoomWithRoomId:cellData.roomIdentifier inMatrixSession:cellData.mxSession]; } else { [tableView deselectRowAtIndexPath:indexPath animated:NO]; } } else if ([cell isKindOfClass:[DirectoryRecentTableViewCell class]]) { [self showPublicRoomsDirectory]; } else if ([cell isKindOfClass:[RoomIdOrAliasTableViewCell class]]) { NSString *roomIdOrAlias = ((RoomIdOrAliasTableViewCell*)cell).titleLabel.text; if (roomIdOrAlias.length) { // Create a permalink to open or preview the room. NSString *permalink = [MXTools permalinkToRoom:roomIdOrAlias]; NSURL *permalinkURL = [NSURL URLWithString:permalink]; [[AppDelegate theDelegate] handleUniversalLinkURL:permalinkURL]; } [tableView deselectRowAtIndexPath:indexPath animated:NO]; } else { [super tableView:tableView didSelectRowAtIndexPath:indexPath]; } } - (void)tableView:(UITableView *)tableView willDisplayHeaderView:(UIView *)view forSection:(NSInteger)section { if (_enableStickyHeaders) { view.tag = section; UIView *firstDisplayedSectionHeader = displayedSectionHeaders.firstObject; if (!firstDisplayedSectionHeader || section < firstDisplayedSectionHeader.tag) { [displayedSectionHeaders insertObject:view atIndex:0]; } else { [displayedSectionHeaders addObject:view]; } [self refreshStickyHeadersContainersHeight]; } } - (void)tableView:(UITableView *)tableView didEndDisplayingHeaderView:(UIView *)view forSection:(NSInteger)section { if (_enableStickyHeaders) { UIView *firstDisplayedSectionHeader = displayedSectionHeaders.firstObject; if (firstDisplayedSectionHeader) { if (section == firstDisplayedSectionHeader.tag) { [displayedSectionHeaders removeObjectAtIndex:0]; [self refreshStickyHeadersContainersHeight]; } else { // This section header is the last displayed one. // Add a sanity check in case of the header has been already removed. UIView *lastDisplayedSectionHeader = displayedSectionHeaders.lastObject; if (section == lastDisplayedSectionHeader.tag) { [displayedSectionHeaders removeLastObject]; [self refreshStickyHeadersContainersHeight]; } } } } } - (NSString *)tableView:(UITableView *)tableView titleForDeleteConfirmationButtonForRowAtIndexPath:(NSIndexPath *)indexPath { return [VectorL10n leave]; } #pragma mark - UIScrollViewDelegate - (void)scrollViewDidScroll:(UIScrollView *)scrollView { if (!self.recentsSearchBar) { [super scrollViewDidScroll:scrollView]; return; } dispatch_async(dispatch_get_main_queue(), ^{ [self refreshStickyHeadersContainersHeight]; }); [super scrollViewDidScroll:scrollView]; if (scrollView == self.recentsTableView) { if (!self.recentsSearchBar.isHidden) { if (!self.recentsSearchBar.text.length && (scrollView.contentOffset.y + scrollView.adjustedContentInset.top > self.recentsSearchBar.frame.size.height)) { // Hide the search bar [self hideSearchBar:YES]; // Refresh display [self refreshRecentsTable]; } } } } #pragma mark - Recents drag & drop management - (void)setEnableDragging:(BOOL)enableDragging { _enableDragging = enableDragging; if (_enableDragging && !longPressGestureRecognizer && self.recentsTableView) { longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onRecentsLongPress:)]; [self.recentsTableView addGestureRecognizer:longPressGestureRecognizer]; } else if (longPressGestureRecognizer) { [self.recentsTableView removeGestureRecognizer:longPressGestureRecognizer]; longPressGestureRecognizer = nil; } } - (void)onRecentsDragEnd { [cellSnapshot removeFromSuperview]; cellSnapshot = nil; movingCellPath = nil; movingRoom = nil; lastPotentialCellPath = nil; ((RecentsDataSource*)self.dataSource).droppingCellIndexPath = nil; ((RecentsDataSource*)self.dataSource).hiddenCellIndexPath = nil; [self.activityIndicator stopAnimating]; } - (IBAction)onRecentsLongPress:(id)sender { if (sender != longPressGestureRecognizer) { return; } RecentsDataSource* recentsDataSource = nil; if ([self.dataSource isKindOfClass:[RecentsDataSource class]]) { recentsDataSource = (RecentsDataSource*)self.dataSource; } // only support RecentsDataSource if (!recentsDataSource) { return; } UIGestureRecognizerState state = longPressGestureRecognizer.state; // check if there is a moving cell during the long press managemnt if ((state != UIGestureRecognizerStateBegan) && !movingCellPath) { return; } CGPoint location = [longPressGestureRecognizer locationInView:self.recentsTableView]; switch (state) { // step 1 : display the selected cell case UIGestureRecognizerStateBegan: { NSIndexPath *indexPath = [self.recentsTableView indexPathForRowAtPoint:location]; // check if the cell can be moved if (indexPath && [recentsDataSource isDraggableCellAt:indexPath]) { UITableViewCell *cell = [self.recentsTableView cellForRowAtIndexPath:indexPath]; cell.backgroundColor = ThemeService.shared.theme.backgroundColor; // snapshot the cell UIGraphicsBeginImageContextWithOptions(cell.bounds.size, NO, 0); [cell.layer renderInContext:UIGraphicsGetCurrentContext()]; UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); cellSnapshot = [[UIImageView alloc] initWithImage:image]; recentsDataSource.droppingCellBackGroundView = [[UIImageView alloc] initWithImage:image]; // display the selected cell over the tableview CGPoint center = cell.center; center.y = location.y; cellSnapshot.center = center; cellSnapshot.alpha = 0.5f; [self.recentsTableView addSubview:cellSnapshot]; // Store the selected room and the original index path of its cell. movingCellPath = indexPath; movingRoom = [recentsDataSource getRoomAtIndexPath:movingCellPath]; lastPotentialCellPath = indexPath; recentsDataSource.droppingCellIndexPath = indexPath; recentsDataSource.hiddenCellIndexPath = indexPath; } break; } // step 2 : the cell must follow the finger case UIGestureRecognizerStateChanged: { CGPoint center = cellSnapshot.center; CGFloat halfHeight = cellSnapshot.frame.size.height / 2.0f; CGFloat cellTop = location.y - halfHeight; CGFloat cellBottom = location.y + halfHeight; CGPoint contentOffset = self.recentsTableView.contentOffset; CGFloat height = MIN(self.recentsTableView.frame.size.height, self.recentsTableView.contentSize.height); CGFloat bottomOffset = contentOffset.y + height; // check if the moving cell is trying to move under the tableview if (cellBottom > self.recentsTableView.contentSize.height) { // force the cell to stay at the tableview bottom location.y = self.recentsTableView.contentSize.height - halfHeight; } // check if the cell is moving over the displayed tableview bottom else if (cellBottom > bottomOffset) { CGFloat diff = cellBottom - bottomOffset; // moving down the cell location.y -= diff; // scroll up the tableview contentOffset.y += diff; } // the moving is tryin to move over the tableview topmost else if (cellTop < 0) { // force to stay in the topmost contentOffset.y = 0; location.y = contentOffset.y + halfHeight; } // the moving cell is displayed over the current scroll top else if (cellTop < contentOffset.y) { CGFloat diff = contentOffset.y - cellTop; // move up the cell and the table up location.y -= diff; contentOffset.y -= diff; } // move the cell to follow the user finger center.y = location.y; cellSnapshot.center = center; // scroll the tableview if it is required if (contentOffset.y != self.recentsTableView.contentOffset.y) { [self.recentsTableView setContentOffset:contentOffset animated:NO]; } NSIndexPath *indexPath = [self.recentsTableView indexPathForRowAtPoint:location]; if (![indexPath isEqual:lastPotentialCellPath]) { if ([recentsDataSource canCellMoveFrom:movingCellPath to:indexPath]) { [self.recentsTableView beginUpdates]; if (recentsDataSource.droppingCellIndexPath && recentsDataSource.hiddenCellIndexPath) { [self.recentsTableView moveRowAtIndexPath:lastPotentialCellPath toIndexPath:indexPath]; } else if (indexPath) { [self.recentsTableView insertRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self.recentsTableView deleteRowsAtIndexPaths:@[movingCellPath] withRowAnimation:UITableViewRowAnimationNone]; } recentsDataSource.hiddenCellIndexPath = movingCellPath; recentsDataSource.droppingCellIndexPath = indexPath; [self.recentsTableView endUpdates]; } // the cell cannot be moved else if (recentsDataSource.droppingCellIndexPath) { NSIndexPath* pathToDelete = recentsDataSource.droppingCellIndexPath; NSIndexPath* pathToAdd = recentsDataSource.hiddenCellIndexPath; // remove it [self.recentsTableView beginUpdates]; [self.recentsTableView deleteRowsAtIndexPaths:@[pathToDelete] withRowAnimation:UITableViewRowAnimationNone]; [self.recentsTableView insertRowsAtIndexPaths:@[pathToAdd] withRowAnimation:UITableViewRowAnimationNone]; recentsDataSource.droppingCellIndexPath = nil; recentsDataSource.hiddenCellIndexPath = nil; [self.recentsTableView endUpdates]; } lastPotentialCellPath = indexPath; } break; } // step 3 : remove the view // and insert when it is possible. case UIGestureRecognizerStateEnded: { [cellSnapshot removeFromSuperview]; cellSnapshot = nil; [self.activityIndicator startAnimating]; [recentsDataSource moveRoomCell:movingRoom from:movingCellPath to:lastPotentialCellPath success:^{ [self onRecentsDragEnd]; } failure:^(NSError *error) { [self onRecentsDragEnd]; }]; break; } // default behaviour // remove the cell and cancel the insertion default: { [self onRecentsDragEnd]; break; } } } #pragma mark - Room handling - (void)onPlusButtonPressed { __weak typeof(self) weakSelf = self; [currentAlert dismissViewControllerAnimated:NO completion:nil]; UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n roomRecentsStartChatWith] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self startChat]; } }]]; [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n roomRecentsCreateEmptyRoom] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self createNewRoom]; } }]]; [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n roomRecentsJoinRoom] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self joinARoom]; } }]]; if (self.mainSession.callManager.supportsPSTN) { [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n roomOpenDialpad] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; [self openDialpad]; } }]]; } [actionSheet addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { if (weakSelf) { typeof(self) self = weakSelf; self->currentAlert = nil; } }]]; [actionSheet popoverPresentationController].sourceView = plusButtonImageView; [actionSheet popoverPresentationController].sourceRect = plusButtonImageView.bounds; [actionSheet mxk_setAccessibilityIdentifier:@"RecentsVCCreateRoomAlert"]; [self presentViewController:actionSheet animated:YES completion:nil]; currentAlert = actionSheet; } - (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]; } - (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; } - (void)startChat { [self performSegueWithIdentifier:@"presentStartChat" sender:self]; } - (void)scanPermalink { AVAuthorizationStatus authStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; if(authStatus == AVAuthorizationStatusDenied) { UIAlertController* alert = [UIAlertController alertControllerWithTitle:BWIL10n.authenticationServerSelectionQrMissingAuthorizationTitle message:BWIL10n.authenticationServerSelectionQrMissingAuthorizationMessage preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction* primaryAction = [UIAlertAction actionWithTitle:VectorL10n.settingsTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [[UIApplication sharedApplication] openURL: [NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; }]; UIAlertAction* secondaryAction = [UIAlertAction actionWithTitle:VectorL10n.cancel style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) {}]; [alert addAction:primaryAction]; [alert addAction:secondaryAction]; [self presentViewController:alert animated:YES completion:nil]; } else { UIViewController* viewController = [PermalinkQRCodeScannerController createFromSwiftUIView]; [self presentViewController:viewController animated: YES completion: nil]; } } - (void)createNewRoom { // Sanity check if (self.mainSession) { CreateRoomCoordinatorParameter *parameters = [[CreateRoomCoordinatorParameter alloc] initWithSession:self.mainSession parentSpace: self.dataSource.currentSpace]; self.createRoomCoordinatorBridgePresenter = [[CreateRoomCoordinatorBridgePresenter alloc] initWithParameters:parameters]; self.createRoomCoordinatorBridgePresenter.delegate = self; [self.createRoomCoordinatorBridgePresenter presentFrom:self animated:YES]; } } - (void)joinARoom { [self showRoomDirectory]; } - (void)showRoomDirectory { if (!self.self.mainSession) { MXLogDebug(@"[RecentsViewController] Fail to show room directory, session is nil"); return; } if (self.dataSource.currentSpace) { self.exploreRoomsCoordinatorBridgePresenter = [[ExploreRoomCoordinatorBridgePresenter alloc] initWithSession:self.mainSession spaceId:self.dataSource.currentSpace.spaceId]; self.exploreRoomsCoordinatorBridgePresenter.delegate = self; [self.exploreRoomsCoordinatorBridgePresenter presentFrom:self animated:YES]; } else if (RiotSettings.shared.roomsAllowToJoinPublicRooms) { self.roomsDirectoryCoordinatorBridgePresenter = [[RoomsDirectoryCoordinatorBridgePresenter alloc] initWithSession:self.mainSession dataSource:[self.recentsDataSource.publicRoomsDirectoryDataSource copy]]; self.roomsDirectoryCoordinatorBridgePresenter.delegate = self; [self.roomsDirectoryCoordinatorBridgePresenter presentFrom:self animated:YES]; } else { [self createNewRoom]; } } - (void)openPublicRoom:(MXPublicRoom *)publicRoom { if (!self.recentsDataSource) { MXLogDebug(@"[RecentsViewController] Fail to open public room, dataSource is not kind of class MXKRecentsDataSource"); return; } // Check whether the user has already joined the selected public room if ([self.recentsDataSource.publicRoomsDirectoryDataSource.mxSession isJoinedOnRoom:publicRoom.roomId]) { Analytics.shared.viewRoomTrigger = AnalyticsViewRoomTriggerRoomDirectory; // Open the public room [self showRoomWithRoomId:publicRoom.roomId inMatrixSession:self.recentsDataSource.publicRoomsDirectoryDataSource.mxSession]; } else { // Preview the public room if (publicRoom.worldReadable) { RoomPreviewData *roomPreviewData = [[RoomPreviewData alloc] initWithPublicRoom:publicRoom andSession:self.recentsDataSource.publicRoomsDirectoryDataSource.mxSession]; [self startActivityIndicator]; // Try to get more information about the room before opening its preview [roomPreviewData peekInRoom:^(BOOL succeeded) { [self stopActivityIndicator]; [self showRoomPreviewWithData:roomPreviewData]; }]; } else { RoomPreviewData *roomPreviewData = [[RoomPreviewData alloc] initWithPublicRoom:publicRoom andSession:self.recentsDataSource.publicRoomsDirectoryDataSource.mxSession]; [self showRoomPreviewWithData:roomPreviewData]; } } } #pragma mark - Table view scrolling - (void)scrollToTop:(BOOL)animated { [self.recentsTableView setContentOffset:CGPointMake(-self.recentsTableView.adjustedContentInset.left, -self.recentsTableView.adjustedContentInset.top) animated:animated]; } - (void)scrollToTheTopTheNextRoomWithMissedNotificationsInSection:(NSInteger)section { if (section < 0) { return; } UITableViewCell *firstVisibleCell; NSIndexPath *firstVisibleCellIndexPath; UIView *firstSectionHeader = displayedSectionHeaders.firstObject; if (firstSectionHeader && firstSectionHeader.frame.origin.y <= self.recentsTableView.contentOffset.y) { // Compute the height of the hidden part of the section header. CGFloat hiddenPart = self.recentsTableView.contentOffset.y - firstSectionHeader.frame.origin.y; CGFloat firstVisibleCellPosY = self.recentsTableView.contentOffset.y + (firstSectionHeader.frame.size.height - hiddenPart); firstVisibleCellIndexPath = [self.recentsTableView indexPathForRowAtPoint:CGPointMake(0, firstVisibleCellPosY)]; firstVisibleCell = [self.recentsTableView cellForRowAtIndexPath:firstVisibleCellIndexPath]; } else { firstVisibleCell = self.recentsTableView.visibleCells.firstObject; firstVisibleCellIndexPath = [self.recentsTableView indexPathForCell:firstVisibleCell]; } if (firstVisibleCell) { NSInteger nextCellRow = (firstVisibleCellIndexPath.section == section) ? firstVisibleCellIndexPath.row + 1 : 0; // Look for the next room with missed notifications. NSIndexPath *nextIndexPath = [NSIndexPath indexPathForRow:nextCellRow inSection:section]; nextCellRow++; id cellData = [self.dataSource cellDataAtIndexPath:nextIndexPath]; while (cellData) { if (cellData.notificationCount) { [self.recentsTableView scrollToRowAtIndexPath:nextIndexPath atScrollPosition:UITableViewScrollPositionTop animated:YES]; break; } nextIndexPath = [NSIndexPath indexPathForRow:nextCellRow inSection:section]; nextCellRow++; cellData = [self.dataSource cellDataAtIndexPath:nextIndexPath]; } if (!cellData && section < self.recentsTableView.numberOfSections && [self.recentsTableView numberOfRowsInSection:section] > 0) { // Scroll back to the top. [self.recentsTableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section] atScrollPosition:UITableViewScrollPositionTop animated:YES]; } else if (section >= self.recentsTableView.numberOfSections) { NSDictionary *details = @{ @"section": @(section), @"number_of_sections": @(self.recentsTableView.numberOfSections) }; MXLogFailureDetails(@"[RecentsViewController] Section in a table view is invalid", details); } } } #pragma mark - MXKRecentListViewControllerDelegate - (void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectRoom:(NSString *)roomId inMatrixSession:(MXSession *)matrixSession { Analytics.shared.viewRoomTrigger = AnalyticsViewRoomTriggerRoomList; [self showRoomWithRoomId:roomId inMatrixSession:matrixSession]; } - (void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectSuggestedRoom:(MXSpaceChildInfo *)childInfo from:(UIView* _Nullable)sourceView { Analytics.shared.joinedRoomTrigger = AnalyticsJoinedRoomTriggerSpaceHierarchy; self.spaceChildPresenter = [[SpaceChildRoomDetailBridgePresenter alloc] initWithSession:self.mainSession childInfo:childInfo]; self.spaceChildPresenter.delegate = self; [self.spaceChildPresenter presentFrom:self sourceView:sourceView animated:YES]; } #pragma mark - UISearchBarDelegate - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { [super scrollViewWillEndDragging:scrollView withVelocity:velocity targetContentOffset:targetContentOffset]; } - (BOOL)searchBarShouldBeginEditing:(UISearchBar *)searchBar { if (searchBar == tableSearchBar) { [self hideSearchBar:NO]; [self.recentsSearchBar becomeFirstResponder]; return NO; } return YES; } - (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar { dispatch_async(dispatch_get_main_queue(), ^{ [self.recentsSearchBar setShowsCancelButton:YES animated:NO]; }); } - (void)searchBarTextDidEndEditing:(UISearchBar *)searchBar { [self.recentsSearchBar setShowsCancelButton:NO animated:NO]; } - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { [super searchBar:searchBar textDidChange:searchText]; UIImage *filterIcon = searchText.length > 0 ? AssetImages.filterOn.image : AssetImages.filterOff.image; [self.recentsSearchBar setImage:filterIcon forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; } - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar { [self.recentsSearchBar resignFirstResponder]; [self hideSearchBar:YES]; self.recentsTableView.contentOffset = CGPointMake(0, self.recentsSearchBar.frame.size.height); self.recentsTableView.tableHeaderView = nil; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self.recentsDataSource searchWithPatterns:nil]; [self.recentsSearchBar setText:nil]; }); } #pragma mark - CreateRoomCoordinatorBridgePresenterDelegate - (void)createRoomCoordinatorBridgePresenterDelegate:(CreateRoomCoordinatorBridgePresenter *)coordinatorBridgePresenter didCreateNewRoom:(MXRoom *)room { [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ Analytics.shared.viewRoomTrigger = AnalyticsViewRoomTriggerCreated; [self showRoomWithRoomId:room.roomId inMatrixSession:self.mainSession]; }]; coordinatorBridgePresenter = nil; } - (void)createRoomCoordinatorBridgePresenterDelegateDidCancel:(CreateRoomCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; coordinatorBridgePresenter = nil; } - (void)createRoomCoordinatorBridgePresenterDelegate:(CreateRoomCoordinatorBridgePresenter *)coordinatorBridgePresenter didAddRoomsWithIds:(NSArray *)roomIds { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; coordinatorBridgePresenter = nil; } #pragma mark - Empty view management - (void)showEmptyViewIfNeeded { [self showEmptyView:[self shouldShowEmptyView]]; } - (void)showEmptyView:(BOOL)show { if (!self.viewIfLoaded) { return; } if (show && !self.emptyView) { RootTabEmptyView *emptyView = [RootTabEmptyView instantiate]; [emptyView updateWithTheme:ThemeService.shared.theme]; [self addEmptyView:emptyView]; self.emptyView = emptyView; [self updateEmptyView]; } else if (!show) { [self.emptyView removeFromSuperview]; } self.recentsTableView.hidden = show; self.stickyHeadersTopContainer.hidden = show; self.stickyHeadersBottomContainer.hidden = show; } - (void)updateEmptyView { } - (void)addEmptyView:(RootTabEmptyView*)emptyView { if (!self.isViewLoaded) { return; } NSLayoutConstraint *emptyViewBottomConstraint; NSLayoutConstraint *contentViewBottomConstraint; if (plusButtonImageView && plusButtonImageView.isHidden == NO) { [self.view insertSubview:emptyView belowSubview:plusButtonImageView]; contentViewBottomConstraint = [NSLayoutConstraint constraintWithItem:emptyView.contentView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationLessThanOrEqual toItem:plusButtonImageView attribute:NSLayoutAttributeTop multiplier:1.0 constant:0]; } else { [self.view addSubview:emptyView]; } NSLayoutYAxisAnchor *bottomAnchor = self.emptyViewBottomAnchor ?: emptyView.superview.bottomAnchor; emptyViewBottomConstraint = [emptyView.bottomAnchor constraintEqualToAnchor:bottomAnchor constant:-1]; // 1pt spacing for UIToolbar's divider. emptyView.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [emptyView.topAnchor constraintEqualToAnchor:emptyView.superview.topAnchor], [emptyView.leftAnchor constraintEqualToAnchor:emptyView.superview.leftAnchor], [emptyView.rightAnchor constraintEqualToAnchor:emptyView.superview.rightAnchor], emptyViewBottomConstraint ]]; if (contentViewBottomConstraint) { contentViewBottomConstraint.active = YES; } } - (BOOL)shouldShowEmptyView { // Do not present empty screen while searching if (self.recentsDataSource.searchPatternsList.count) { return NO; } return self.recentsDataSource.totalVisibleItemCount == 0; } #pragma mark - RoomsDirectoryCoordinatorBridgePresenterDelegate - (void)roomsDirectoryCoordinatorBridgePresenterDelegateDidComplete:(RoomsDirectoryCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.roomsDirectoryCoordinatorBridgePresenter = nil; } - (void)roomsDirectoryCoordinatorBridgePresenterDelegate:(RoomsDirectoryCoordinatorBridgePresenter *)coordinatorBridgePresenter didSelectRoom:(MXPublicRoom *)room { [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self openPublicRoom:room]; }]; self.roomsDirectoryCoordinatorBridgePresenter = nil; } - (void)roomsDirectoryCoordinatorBridgePresenterDelegateDidTapCreateNewRoom:(RoomsDirectoryCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self createNewRoom]; }]; self.roomsDirectoryCoordinatorBridgePresenter = nil; } - (void)roomsDirectoryCoordinatorBridgePresenterDelegate:(RoomsDirectoryCoordinatorBridgePresenter *)coordinatorBridgePresenter didSelectRoomWithIdOrAlias:(NSString * _Nonnull)roomIdOrAlias { MXRoom *room = [self.mainSession vc_roomWithIdOrAlias:roomIdOrAlias]; if (room) { // Room is known show it directly [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self showRoomWithRoomId:room.roomId inMatrixSession:self.mainSession]; }]; coordinatorBridgePresenter = nil; } else if ([MXTools isMatrixRoomAlias:roomIdOrAlias]) { // Room preview doesn't support room alias [[AppDelegate theDelegate] showAlertWithTitle:[VectorL10n error] message:[VectorL10n roomRecentsUnknownRoomErrorMessage]]; } else { // Try to preview the room from his id RoomPreviewData *roomPreviewData = [[RoomPreviewData alloc] initWithRoomId:roomIdOrAlias andSession:self.mainSession]; [self startActivityIndicator]; // Try to get more information about the room before opening its preview MXWeakify(self); [roomPreviewData peekInRoom:^(BOOL succeeded) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; if (succeeded) { [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ [self showRoomPreviewWithData:roomPreviewData]; }]; self.roomsDirectoryCoordinatorBridgePresenter = nil; } else { [[AppDelegate theDelegate] showAlertWithTitle:[VectorL10n error] message:[VectorL10n roomRecentsUnknownRoomErrorMessage]]; } }]; } } #pragma mark - ExploreRoomCoordinatorBridgePresenterDelegate - (void)exploreRoomCoordinatorBridgePresenterDelegateDidComplete:(ExploreRoomCoordinatorBridgePresenter *)coordinatorBridgePresenter { MXWeakify(self); [coordinatorBridgePresenter dismissWithAnimated:YES completion:^{ MXStrongifyAndReturnIfNil(self); self.exploreRoomsCoordinatorBridgePresenter = nil; }]; } #pragma mark - RoomNotificationSettingsCoordinatorBridgePresenterDelegate -(void)roomNotificationSettingsCoordinatorBridgePresenterDelegateDidComplete:(RoomNotificationSettingsCoordinatorBridgePresenter *)coordinatorBridgePresenter { [coordinatorBridgePresenter dismissWithAnimated:YES completion:nil]; self.roomNotificationSettingsCoordinatorBridgePresenter = nil; } #pragma mark - Bwi Rest Time - (UIContextualAction*) muteActionWithNotificationTime:(MXRoom*)room { if (@available(iOS 14.0, *)) { RiotSharedSettings *sharedSettings = [[RiotSharedSettings alloc] initWithSession:room.mxSession]; [sharedSettings fetchNotificationTimes]; } BOOL isMuted = room.isMute || room.isMentionsOnly; BOOL isRoomNotificationTimesActive = [self isNotficationTimesActive:room]; UIContextualAction *muteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:@"" handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { if ([BWIBuildSettings.shared roomSettingsScreenShowNotificationsV2]) { [self changeEditedRoomNotificationSettings]; } else { [self muteEditedRoomNotifications:!isMuted]; } completionHandler(YES); }]; muteAction.backgroundColor = ThemeService.shared.theme.baseColor; muteAction.image =[self editedRoomNotififcationImageForMuted:isMuted andRoomNotificationTimeActive:isRoomNotificationTimesActive]; return muteAction; } - (void)muteEditedRoomNotificationsWithWorkTime:(BOOL)mute { if (editedRoomId) { // Check whether the user didn't leave the room MXRoom *room = [self.mainSession roomWithRoomId:editedRoomId]; if (room) { [self startActivityIndicator]; if (mute) { [room mentionsOnly:^{ [self stopActivityIndicator]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; }]; } else { [room allMessages:^{ [self stopActivityIndicator]; // Leave editing mode [self cancelEditionMode:self->isRefreshPending]; }]; // WorkTimeService *service = [WorkTimeService workTimeService:self.mainSession.myUserId]; // // if ([service isWorkTimeGlobalyEnabled]) { // [service activateWorkTimeInRoom:editedRoomId]; // } } } else { // Leave editing mode [self cancelEditionMode:isRefreshPending]; } } } - (BOOL) isNotficationTimesActive:(MXRoom*)room { if (@available(iOS 14.0, *)) { return [[NotificationTimes shared] isEnabled] && [[NotificationTimes shared] isEnabledForRoomWithRoomID:room.roomId isDirect:room.isDirect]; } else { return false; } } - (UIImage*)editedRoomNotififcationImageForMuted:(BOOL)isMuted andRoomNotificationTimeActive:(BOOL)isRoomNotificationTimeActive { UIImage *image = nil; if (isRoomNotificationTimeActive && !isMuted) { image = [[UIImage imageNamed:@"room_action_resttime"] vc_tintedImageUsingColor:ThemeService.shared.theme.tintColor]; } else if (isMuted) { image = [[UIImage imageNamed:@"room_action_notification_muted"] vc_tintedImageUsingColor: ThemeService.shared.theme.tabBarUnselectedItemTintColor]; } else { image = [[UIImage imageNamed:@"room_action_notification"] vc_tintedImageUsingColor: ThemeService.shared.theme.tintColor]; } return [image vc_notRenderedImage]; } #pragma mark - Bwi Check App version - (void)checkAppVersionDeprecated { if( BWIBuildSettings.shared.bwiCheckAppVersion && ValidAppVersionsDefaultService.shared.isCurrentAppVersionDeprecated ) { MXWeakify(self); UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:[BWIL10n bwiOutdatedVersionWarningTitle] message:[BWIL10n bwiDeprecatedVersionWarningMessage] preferredStyle:UIAlertControllerStyleAlert]; [errorAlert addAction:[UIAlertAction actionWithTitle:[BWIL10n bwiDeprecatedVersionAppstoreButton] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [self logout]; [self gotoAppStore]; MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [self presentViewController:errorAlert animated:YES completion:nil]; } } - (void)checkAppVersionOutdated { if (BWIBuildSettings.shared.bwiCheckAppVersion && ValidAppVersionsDefaultService.shared.isCurrentAppVersionOutdated && !ValidAppVersionsDefaultService.shared.wasOutdatedAlertShown) { MXWeakify(self); UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:[BWIL10n bwiOutdatedVersionWarningTitle] message:[BWIL10n bwiOutdatedVersionWarningMessage:AppInfo.current.displayName] preferredStyle:UIAlertControllerStyleAlert]; [errorAlert addAction:[UIAlertAction actionWithTitle:[BWIL10n bwiOutdatedVersionAppstoreButton] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [self gotoAppStore]; [ValidAppVersionsDefaultService.shared setOutdatedAlertShown:true]; MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [errorAlert addAction:[UIAlertAction actionWithTitle:[BWIL10n bwiOutdatedVersionLogoutButton] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { [ValidAppVersionsDefaultService.shared setOutdatedAlertShown:true]; }]]; [self presentViewController:errorAlert animated:YES completion:nil]; } // bwi #5276: if outdated was shown but our app version is valid that means we have updated and have to reset outdated shown if (BWIBuildSettings.shared.bwiCheckAppVersion && !ValidAppVersionsDefaultService.shared.isCurrentAppVersionOutdated && ValidAppVersionsDefaultService.shared.wasOutdatedAlertShown) { [ValidAppVersionsDefaultService.shared setOutdatedAlertShown:false]; } } -(void) logout { [self startActivityIndicator]; MXWeakify(self); [[AppDelegate theDelegate] logoutWithConfirmation:NO completion:^(BOOL isLoggedOut) { MXStrongifyAndReturnIfNil(self); [self stopActivityIndicator]; }]; } -(void) gotoAppStore { NSString *iTunesLink = BWIBuildSettings.shared.itunesAppLink; if (iTunesLink) { [[UIApplication sharedApplication] vc_open:[NSURL URLWithString:iTunesLink] completionHandler:nil]; } } #pragma mark - SpaceChildRoomDetailBridgePresenterDelegate - (void)spaceChildRoomDetailBridgePresenterDidCancel:(SpaceChildRoomDetailBridgePresenter *)coordinator { [self.spaceChildPresenter dismissWithAnimated:YES completion:^{ self.spaceChildPresenter = nil; }]; } - (void)spaceChildRoomDetailBridgePresenter:(SpaceChildRoomDetailBridgePresenter *)coordinator didOpenRoomWith:(NSString *)roomId { [self showRoomWithRoomId:roomId inMatrixSession:self.mainSession]; [self.spaceChildPresenter dismissWithAnimated:YES completion:^{ self.spaceChildPresenter = nil; }]; } #pragma mark - Activity Indicator - (BOOL)providesCustomActivityIndicator { return self.userIndicatorStore != nil; } - (void)startActivityIndicatorWithLabel:(NSString *)label { if (self.userIndicatorStore && isViewVisible) { // The app is very liberal with calling `startActivityIndicator` (often not matched by corresponding `stopActivityIndicator`), // so there is no reason to keep adding new indicators if there is one already showing. if (loadingIndicatorCancel) { return; } MXLogDebug(@"[RecentsViewController] Present loading indicator") loadingIndicatorCancel = [self.userIndicatorStore presentLoadingWithLabel:label isInteractionBlocking:NO]; } else { [super startActivityIndicator]; } } - (void)startActivityIndicator { [self startActivityIndicatorWithLabel:[VectorL10n homeSyncing]]; } - (void)stopActivityIndicator { if (self.userIndicatorStore) { if (loadingIndicatorCancel) { MXLogDebug(@"[RecentsViewController] Dismiss loading indicator") loadingIndicatorCancel(); loadingIndicatorCancel = nil; } } else { [super stopActivityIndicator]; } } #pragma mark - Context Menu - (UIContextMenuConfiguration *)tableView:(UITableView *)tableView contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point API_AVAILABLE(ios(13.0)) { id cellData = [self.dataSource cellDataAtIndexPath:indexPath]; UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; if (!cellData || !cell) { return nil; } return [self.contextMenuProvider contextMenuConfigurationWith:cellData from:cell session:self.dataSource.mxSession]; } - (void)tableView:(UITableView *)tableView willPerformPreviewActionForMenuWithConfiguration:(UIContextMenuConfiguration *)configuration animator:(id)animator API_AVAILABLE(ios(13.0)) { NSString *roomId = [self.contextMenuProvider roomIdFrom:configuration.identifier]; if (!roomId) { self.recentsUpdateEnabled = YES; return; } [animator addCompletion:^{ self.recentsUpdateEnabled = YES; [self showRoomWithRoomId:roomId inMatrixSession:self.mainSession]; }]; } - (UITargetedPreview *)tableView:(UITableView *)tableView previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) { self.recentsUpdateEnabled = YES; return nil; } #pragma mark - RoomContextActionServiceDelegate - (void)roomContextActionServiceDidJoinRoom:(id)service { [self showRoomWithRoomId:service.roomId inMatrixSession:service.session]; } - (void)roomContextActionServiceDidLeaveRoom:(id)service { [self.userIndicatorStore presentSuccessWithLabel:VectorL10n.roomParticipantsLeaveSuccess]; } - (void)roomContextActionService:(id)service presentAlert:(UIAlertController *)alertController { [self presentViewController:alertController animated:YES completion:nil]; } - (void)roomContextActionService:(id)service updateActivityIndicator:(BOOL)isActive { if (isActive) { [self startActivityIndicator]; } else if ([self canStopActivityIndicator]) { [self stopActivityIndicator]; } } - (void)roomContextActionService:(id)service showRoomNotificationSettingsForRoomWithId:(NSString *)roomId { editedRoomId = roomId; [self changeEditedRoomNotificationSettings]; editedRoomId = nil; } -(void)roomContextActionServiceDidMarkRoom:(id)service { [self refreshRecentsTable]; } #pragma mark - RecentCellContextMenuProviderDelegate - (void)recentCellContextMenuProviderDidStartShowingPreview:(RecentCellContextMenuProvider *)menuProvider { self.recentsUpdateEnabled = NO; } @end