/* Copyright 2015 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ #import "MXKRecentListViewController.h" #import "MXKRoomDataSourceManager.h" #import "MXKInterleavedRecentsDataSource.h" #import "MXKInterleavedRecentTableViewCell.h" #import "MXKSwiftHeader.h" @interface MXKRecentListViewController () { /** The data source providing UITableViewCells */ MXKRecentsDataSource *dataSource; /** Search handling */ UIBarButtonItem *searchButton; BOOL ignoreSearchRequest; /** The reconnection animated view. */ __weak UIView* reconnectingView; /** The current table view header if any. */ UIView* tableViewHeaderView; /** The latest server sync date */ NSDate* latestServerSync; /** The restart the event connnection */ BOOL restartConnection; } @end @implementation MXKRecentListViewController @synthesize dataSource; #pragma mark - Class methods + (UINib *)nib { return [UINib nibWithNibName:NSStringFromClass([MXKRecentListViewController class]) bundle:[NSBundle bundleForClass:[MXKRecentListViewController class]]]; } + (instancetype)recentListViewController { return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRecentListViewController class]) bundle:[NSBundle bundleForClass:[MXKRecentListViewController class]]]; } #pragma mark - - (void)finalizeInit { [super finalizeInit]; _recentsUpdateEnabled = YES; _enableBarButtonSearch = YES; } - (void)viewDidLoad { [super viewDidLoad]; // Check whether the view controller has been pushed via storyboard if (!_recentsTableView) { // Instantiate view controller objects [[[self class] nib] instantiateWithOwner:self options:nil]; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated" // Adjust search bar Top constraint to take into account potential navBar. if (_recentsSearchBarTopConstraint) { _recentsSearchBarTopConstraint.active = NO; _recentsSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.recentsSearchBar attribute:NSLayoutAttributeTop multiplier:1.0f constant:0.0f]; _recentsSearchBarTopConstraint.active = YES; } // Adjust table view Bottom constraint to take into account tabBar. if (_recentsTableViewBottomConstraint) { _recentsTableViewBottomConstraint.active = NO; _recentsTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.recentsTableView attribute:NSLayoutAttributeBottom multiplier:1.0f constant:0.0f]; _recentsTableViewBottomConstraint.active = YES; } #pragma clang diagnostic pop // Hide search bar by default [self hideSearchBar:YES]; // Apply search option in navigation bar self.enableBarButtonSearch = _enableBarButtonSearch; // Add an accessory view to the search bar in order to retrieve keyboard view. self.recentsSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; // Finalize table view configuration self.recentsTableView.delegate = self; self.recentsTableView.dataSource = dataSource; // Note: dataSource may be nil here // Set up classes to use for cells [self.recentsTableView registerNib:MXKRecentTableViewCell.nib forCellReuseIdentifier:MXKRecentTableViewCell.defaultReuseIdentifier]; // Consider here the specific case where interleaved recents are supported [self.recentsTableView registerNib:MXKInterleavedRecentTableViewCell.nib forCellReuseIdentifier:MXKInterleavedRecentTableViewCell.defaultReuseIdentifier]; // Add a top view which will be displayed in case of vertical bounce. CGFloat height = self.recentsTableView.frame.size.height; UIView *topview = [[UIView alloc] initWithFrame:CGRectMake(0,-height,self.recentsTableView.frame.size.width,height)]; topview.autoresizingMask = UIViewAutoresizingFlexibleWidth; topview.backgroundColor = [UIColor groupTableViewBackgroundColor]; [self.recentsTableView addSubview:topview]; self->topview = topview; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // Restore search mechanism (if enabled) ignoreSearchRequest = NO; // Observe server sync at room data source level too [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; // Observe the server sync [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; self.recentsUpdateEnabled = YES; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; // The user may still press search button whereas the view disappears ignoreSearchRequest = YES; [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; [self removeReconnectingView]; } - (void)dealloc { self.recentsSearchBar.inputAccessoryView = nil; searchButton = nil; } - (void)didReceiveMemoryWarning { [super didReceiveMemoryWarning]; // Dispose of any resources that can be recreated. } #pragma mark - Override MXKViewController - (void)onMatrixSessionChange { [super onMatrixSessionChange]; // Check whether no server sync is in progress in room data sources NSArray *mxSessions = self.mxSessions; for (MXSession *mxSession in mxSessions) { if ([MXKRoomDataSourceManager sharedManagerForMatrixSession:mxSession].isServerSyncInProgress) { // sync is in progress for at least one data source, keep running the loading wheel [self startActivityIndicator]; break; } } } - (void)onKeyboardShowAnimationComplete { // Report the keyboard view in order to track keyboard frame changes self.keyboardView = _recentsSearchBar.inputAccessoryView.superview; } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated" - (void)setKeyboardHeight:(CGFloat)keyboardHeight { // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; // Check whether the keyboard is over the tabBar if (tableViewBottomConst < 0) { tableViewBottomConst = 0; } // Update constraints _recentsTableViewBottomConstraint.constant = tableViewBottomConst; // Force layout immediately to take into account new constraint [self.view layoutIfNeeded]; } #pragma clang diagnostic pop - (void)destroy { self.recentsTableView.dataSource = nil; self.recentsTableView.delegate = nil; self.recentsTableView = nil; dataSource.delegate = nil; dataSource = nil; _delegate = nil; [topview removeFromSuperview]; topview = nil; [super destroy]; } #pragma mark - - (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch { _enableBarButtonSearch = enableBarButtonSearch; if (enableBarButtonSearch) { if (!searchButton) { searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; } // Add it in right bar items NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; } else { NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; [rightBarButtonItems removeObject:searchButton]; self.navigationItem.rightBarButtonItems = rightBarButtonItems; } } - (void)displayList:(MXKRecentsDataSource *)listDataSource { // Cancel registration on existing dataSource if any if (dataSource) { dataSource.delegate = nil; // Remove associated matrix sessions NSArray *mxSessions = self.mxSessions; for (MXSession *mxSession in mxSessions) { [self removeMatrixSession:mxSession]; } } dataSource = listDataSource; dataSource.delegate = self; // Report all matrix sessions at view controller level to update UI according to sessions state NSArray *mxSessions = listDataSource.mxSessions; for (MXSession *mxSession in mxSessions) { [self addMatrixSession:mxSession]; } if (self.recentsTableView) { // Set up table data source self.recentsTableView.dataSource = dataSource; } } - (void)refreshRecentsTable { if (!self.recentsUpdateEnabled) return; isRefreshNeeded = NO; // For now, do a simple full reload [self.recentsTableView reloadData]; } - (void)hideSearchBar:(BOOL)hidden { self.recentsSearchBar.hidden = hidden; self.recentsSearchBarHeightConstraint.constant = hidden ? 0 : 44; [self.view setNeedsUpdateConstraints]; } - (void)setRecentsUpdateEnabled:(BOOL)activeUpdate { _recentsUpdateEnabled = activeUpdate; if (_recentsUpdateEnabled && isRefreshNeeded) { [self refreshRecentsTable]; } } #pragma mark - Action - (IBAction)search:(id)sender { // The user may have pressed search button whereas the view controller was disappearing if (ignoreSearchRequest) { return; } if (self.recentsSearchBar.isHidden) { // Check whether there are data in which search if ([self.dataSource numberOfSectionsInTableView:self.recentsTableView]) { [self hideSearchBar:NO]; // Create search bar [self.recentsSearchBar becomeFirstResponder]; } } else { [self searchBarCancelButtonClicked: self.recentsSearchBar]; } } #pragma mark - MXKDataSourceDelegate - (Class)cellViewClassForCellData:(MXKCellData*)cellData { // Consider here the specific case where interleaved recents are supported if ([dataSource isKindOfClass:MXKInterleavedRecentsDataSource.class]) { return MXKInterleavedRecentTableViewCell.class; } // Return the default recent table view cell return MXKRecentTableViewCell.class; } - (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData { // Consider here the specific case where interleaved recents are supported if ([dataSource isKindOfClass:MXKInterleavedRecentsDataSource.class]) { return MXKInterleavedRecentTableViewCell.defaultReuseIdentifier; } // Return the default recent table view cell return MXKRecentTableViewCell.defaultReuseIdentifier; } - (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes { if (!_recentsUpdateEnabled) { isRefreshNeeded = YES; return; } // For now, do a simple full reload [self refreshRecentsTable]; } - (void)dataSource:(MXKDataSource *)dataSource didAddMatrixSession:(MXSession *)mxSession { [self addMatrixSession:mxSession]; } - (void)dataSource:(MXKDataSource *)dataSource didRemoveMatrixSession:(MXSession *)mxSession { [self removeMatrixSession:mxSession]; } #pragma mark - UITableView delegate - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return [dataSource cellHeightAtIndexPath:indexPath]; } - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { // Section header is required only when several recent lists are displayed. if (self.dataSource.displayedRecentsDataSourcesCount > 1) { return 35; } return 0; } - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { // Let dataSource provide the section header. return [dataSource viewForHeaderInSection:section withFrame:[tableView rectForHeaderInSection:section] inTableView:tableView]; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { if (_delegate) { UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; if ([selectedCell conformsToProtocol:@protocol(MXKCellRendering)]) { id cell = (id)selectedCell; if ([cell respondsToSelector:@selector(renderedCellData)]) { MXKCellData *cellData = cell.renderedCellData; if ([cellData conformsToProtocol:@protocol(MXKRecentCellDataStoring)]) { id recentCellData = (id)cellData; if (recentCellData.isSuggestedRoom) { [_delegate recentListViewController:self didSelectSuggestedRoom:recentCellData.roomSummary.spaceChildInfo from:selectedCell]; } else { [_delegate recentListViewController:self didSelectRoom:recentCellData.roomIdentifier inMatrixSession:recentCellData.mxSession]; } } } } } // Hide the keyboard when user select a room // do not hide the searchBar until the view controller disappear // on tablets / iphone 6+, the user could expect to search again while looking at a room [self.recentsSearchBar resignFirstResponder]; } - (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath { // Release here resources, and restore reusable cells if ([cell respondsToSelector:@selector(didEndDisplay)]) { [(id)cell didEndDisplay]; } } - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { // Detect vertical bounce at the top of the tableview to trigger reconnection. if (scrollView == _recentsTableView) { [self detectPullToKick:scrollView]; } } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { if (scrollView == _recentsTableView) { [self managePullToKick:scrollView]; } } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { if (scrollView == _recentsTableView) { if (scrollView.contentOffset.y + scrollView.adjustedContentInset.top == 0) { [self managePullToKick:scrollView]; } } } #pragma mark - UISearchBarDelegate - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { // Apply filter if (searchText.length) { [self.dataSource searchWithPatterns:@[searchText]]; } else { [self.dataSource searchWithPatterns:nil]; } } - (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { // "Done" key has been pressed [searchBar resignFirstResponder]; } - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar { // Leave search [searchBar resignFirstResponder]; [self hideSearchBar:YES]; self.recentsSearchBar.text = nil; // Refresh display [self.dataSource searchWithPatterns:nil]; } #pragma mark - resync management - (void)onSyncNotification { latestServerSync = [NSDate date]; [self removeReconnectingView]; } - (BOOL)canReconnect { // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; return (interval > 1) && [self.mainSession reconnect]; } - (void)addReconnectingView { if (!reconnectingView) { UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; spinner.transform = CGAffineTransformMakeScale(0.75f, 0.75f); CGRect frame = spinner.frame; frame.size.height = 80; // 80 * 0.75 = 60 spinner.bounds = frame; spinner.color = [UIColor darkGrayColor]; spinner.hidesWhenStopped = NO; spinner.backgroundColor = _recentsTableView.backgroundColor; [spinner startAnimating]; // no need to manage constraints here, IOS defines them. tableViewHeaderView = _recentsTableView.tableHeaderView; _recentsTableView.tableHeaderView = reconnectingView = spinner; } } - (void)removeReconnectingView { if (reconnectingView && !restartConnection) { _recentsTableView.tableHeaderView = tableViewHeaderView; reconnectingView = nil; } } /** Detect if the current connection must be restarted. The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). */ - (void)detectPullToKick:(UIScrollView *)scrollView { if (!reconnectingView) { // detect if the user scrolls over the tableview top restartConnection = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top < -128); if (restartConnection) { // wait that list decelerate to display / hide it [self addReconnectingView]; } } } /** Restarts the current connection if it is required. The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. */ - (void)managePullToKick:(UIScrollView *)scrollView { // the current connection must be restarted if (restartConnection) { // display at least 0.3s the spinner to show to the user that something is pending // else the UI is flickering dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ self->restartConnection = NO; if (![self canReconnect]) { // if the event stream has not been restarted // hide the spinner [self removeReconnectingView]; } // else wait that onSyncNotification is called. }); } } @end