/* Copyright 2015 OpenMarket Ltd Copyright 2015 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 "RoomParticipantsViewController.h" #import "RoomMemberDetailsViewController.h" #import "AppDelegate.h" #import "Contact.h" #import "MXCallManager.h" #import "ContactTableViewCell.h" #import "RageShakeManager.h" @interface RoomParticipantsViewController () { // Search result NSString *currentSearchText; NSMutableArray *filteredActualParticipants; NSMutableArray *filteredInvitedParticipants; // Mask view while processing a request UIActivityIndicatorView *pendingMaskSpinnerView; // The members events listener. id membersListener; // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. id leaveRoomNotificationObserver; // Observe kMXRoomDidFlushDataNotification to take into account the updated room members when the room history is flushed. id roomDidFlushDataNotificationObserver; RoomMemberDetailsViewController *memberDetailsViewController; ContactsTableViewController *contactsPickerViewController; // Display a gradient view above the screen. CAGradientLayer* tableViewMaskLayer; // Display a button to invite new member. UIImageView* addParticipantButtonImageView; NSLayoutConstraint *addParticipantButtonImageViewBottomConstraint; MXKAlert *currentAlert; } @end @implementation RoomParticipantsViewController #pragma mark - Class methods + (UINib *)nib { return [UINib nibWithNibName:NSStringFromClass([RoomParticipantsViewController class]) bundle:[NSBundle bundleForClass:[RoomParticipantsViewController class]]]; } + (instancetype)roomParticipantsViewController { return [[[self class] alloc] initWithNibName:NSStringFromClass([RoomParticipantsViewController class]) bundle:[NSBundle bundleForClass:[RoomParticipantsViewController class]]]; } #pragma mark - - (void)finalizeInit { [super finalizeInit]; // Setup `MXKViewControllerHandling` properties self.defaultBarTintColor = kVectorNavBarTintColor; self.enableBarTintColorStatusChange = NO; self.rageShakeManager = [RageShakeManager sharedManager]; } - (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view, typically from a nib. // Check whether the view controller has been pushed via storyboard if (!self.tableView) { // Instantiate view controller objects [[[self class] nib] instantiateWithOwner:self options:nil]; } // Adjust Top and Bottom constraints to take into account potential navBar and tabBar. [NSLayoutConstraint deactivateConstraints:@[_searchBarTopConstraint, _tableViewBottomConstraint]]; _searchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.searchBarHeader attribute:NSLayoutAttributeTop multiplier:1.0f constant:0.0f]; _tableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.tableView attribute:NSLayoutAttributeBottom multiplier:1.0f constant:0.0f]; [NSLayoutConstraint activateConstraints:@[_searchBarTopConstraint, _tableViewBottomConstraint]]; self.navigationItem.title = NSLocalizedStringFromTable(@"room_participants_title", @"Vector", nil); _searchBarView.placeholder = NSLocalizedStringFromTable(@"room_participants_filter_room_members", @"Vector", nil); _searchBarView.returnKeyType = UIReturnKeyDone; _searchBarView.autocapitalizationType = UITextAutocapitalizationTypeNone; [self refreshSearchBarItemsColor:_searchBarView]; _searchBarHeaderBorder.backgroundColor = kVectorColorSilver; // Search bar header is hidden when no room is provided _searchBarHeader.hidden = (self.mxRoom == nil); [self setNavBarButtons]; // Hide line separators of empty cells self.tableView.tableFooterView = [[UIView alloc] init]; [self.tableView registerClass:ContactTableViewCell.class forCellReuseIdentifier:@"ParticipantTableViewCellId"]; // Add room creation button programatically [self addAddParticipantButton]; } // This method is called when the viewcontroller is added or removed from a container view controller. - (void)didMoveToParentViewController:(nullable UIViewController *)parent { [super didMoveToParentViewController:parent]; [self setNavBarButtons]; } - (void)destroy { if (leaveRoomNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; leaveRoomNotificationObserver = nil; } if (roomDidFlushDataNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; roomDidFlushDataNotificationObserver = nil; } if (membersListener) { [self.mxRoom.liveTimeline removeListener:membersListener]; membersListener = nil; } if (currentAlert) { [currentAlert dismiss:NO]; currentAlert = nil; } _mxRoom = nil; filteredActualParticipants = nil; filteredInvitedParticipants = nil; actualParticipants = nil; invitedParticipants = nil; userParticipant = nil; [self removePendingActionMask]; [super destroy]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // Screen tracking (via Google Analytics) id tracker = [[GAI sharedInstance] defaultTracker]; if (tracker) { [tracker set:kGAIScreenName value:@"RoomParticipants"]; [tracker send:[[GAIDictionaryBuilder createScreenView] build]]; } if (memberDetailsViewController) { [memberDetailsViewController destroy]; memberDetailsViewController = nil; } if (contactsPickerViewController) { [contactsPickerViewController destroy]; contactsPickerViewController = nil; } // Refresh display [self refreshTableView]; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; if (currentAlert) { [currentAlert dismiss:NO]; currentAlert = nil; } // cancel any pending search [self searchBarCancelButtonClicked:_searchBarView]; } - (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion { // Check whether the current view controller is displayed inside a segmented view controller in order to withdraw the right item if (self.parentViewController && [self.parentViewController isKindOfClass:SegmentedViewController.class]) { [((SegmentedViewController*)self.parentViewController) withdrawViewControllerAnimated:animated completion:completion]; } else { [super withdrawViewControllerAnimated:animated completion:completion]; } } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; // Sanity check if (tableViewMaskLayer) { CGRect currentBounds = tableViewMaskLayer.bounds; CGRect newBounds = CGRectIntegral(self.view.frame); newBounds.size.height -= self.keyboardHeight; // Check if there is an update if (!CGSizeEqualToSize(currentBounds.size, newBounds.size)) { newBounds.origin = CGPointZero; [UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn animations:^{ tableViewMaskLayer.bounds = newBounds; } completion:^(BOOL finished){ }]; } // Hide the addParticipants button on landscape when keyboard is visible BOOL isLandscapeOriented = UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation); addParticipantButtonImageView.hidden = tableViewMaskLayer.hidden = (isLandscapeOriented && self.keyboardHeight); } } #pragma mark - - (void)setMxRoom:(MXRoom *)mxRoom { // Cancel any pending search [self searchBarCancelButtonClicked:_searchBarView]; // Remove previous room registration (if any). if (_mxRoom) { // Remove the previous listener if (leaveRoomNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; leaveRoomNotificationObserver = nil; } if (roomDidFlushDataNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; roomDidFlushDataNotificationObserver = nil; } if (membersListener) { [_mxRoom.liveTimeline removeListener:membersListener]; membersListener = nil; } [self removeMatrixSession:_mxRoom.mxSession]; } _mxRoom = mxRoom; if (_mxRoom) { _searchBarHeader.hidden = NO; // Update the current matrix session. [self addMatrixSession:_mxRoom.mxSession]; // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { // Check whether the user will leave the room related to the displayed participants if (notif.object == _mxRoom.mxSession) { NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; if (roomId && [roomId isEqualToString:_mxRoom.state.roomId]) { // We remove the current view controller. [self withdrawViewControllerAnimated:YES completion:nil]; } } }]; // Observe room history flush (sync with limited timeline, or state event redaction) roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXRoom *room = notif.object; if (_mxRoom.mxSession == room.mxSession && [_mxRoom.state.roomId isEqualToString:room.state.roomId]) { // The existing room history has been flushed during server sync. Take into account the updated room members list. [self refreshParticipantsFromRoomMembers]; [self refreshTableView]; } }]; // Register a listener for events that concern room members NSArray *mxMembersEvents = @[kMXEventTypeStringRoomMember, kMXEventTypeStringRoomThirdPartyInvite, kMXEventTypeStringRoomPowerLevels]; membersListener = [_mxRoom.liveTimeline listenToEventsOfTypes:mxMembersEvents onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) { // Consider only live event if (direction == MXTimelineDirectionForwards) { switch (event.eventType) { case MXEventTypeRoomMember: { // Take into account updated member // Ignore here change related to the current user (this change is handled by leaveRoomNotificationObserver) if ([event.stateKey isEqualToString:self.mxRoom.mxSession.myUser.userId] == NO) { MXRoomMember *mxMember = [self.mxRoom.state memberWithUserId:event.stateKey]; if (mxMember) { // Remove previous occurrence of this member (if any) [self removeParticipantByKey:mxMember.userId]; // If any, remove 3pid invite corresponding to this room member if (mxMember.thirdPartyInviteToken) { [self removeParticipantByKey:mxMember.thirdPartyInviteToken]; } [self handleRoomMember:mxMember]; [self finalizeParticipantsList]; [self refreshTableView]; } } break; } case MXEventTypeRoomThirdPartyInvite: { MXRoomThirdPartyInvite *thirdPartyInvite = [self.mxRoom.state thirdPartyInviteWithToken:event.stateKey]; if (thirdPartyInvite) { [self addRoomThirdPartyInviteToParticipants:thirdPartyInvite]; [self finalizeParticipantsList]; [self refreshTableView]; } break; } case MXEventTypeRoomPowerLevels: { [self refreshParticipantsFromRoomMembers]; [self refreshTableView]; break; } default: break; } } }]; } else { // Search bar header is hidden when no room is provided _searchBarHeader.hidden = YES; } // Refresh the members list. [self refreshParticipantsFromRoomMembers]; [self refreshTableView]; } - (void)setEnableMention:(BOOL)enableMention { if (_enableMention != enableMention) { _enableMention = enableMention; if (memberDetailsViewController) { memberDetailsViewController.enableMention = enableMention; } } } - (void)startActivityIndicator { // Check whether the current view controller is displayed inside a segmented view controller in order to run the right activity view if (self.parentViewController && [self.parentViewController isKindOfClass:SegmentedViewController.class]) { [((SegmentedViewController*)self.parentViewController) startActivityIndicator]; // Force stop the activity view of the view controller [self.activityIndicator stopAnimating]; } else { [super startActivityIndicator]; } } - (void)stopActivityIndicator { // Check whether the current view controller is displayed inside a segmented view controller in order to stop the right activity view if (self.parentViewController && [self.parentViewController isKindOfClass:SegmentedViewController.class]) { [((SegmentedViewController*)self.parentViewController) stopActivityIndicator]; // Force stop the activity view of the view controller [self.activityIndicator stopAnimating]; } else { [super stopActivityIndicator]; } } - (void)setKeyboardHeight:(CGFloat)keyboardHeight { super.keyboardHeight = keyboardHeight; // Update addParticipants button position with animation [UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn animations:^{ addParticipantButtonImageViewBottomConstraint.constant = keyboardHeight + 9; // Force to render the view [self.view layoutIfNeeded]; } completion:^(BOOL finished){ }]; } #pragma mark - Internals - (void)refreshTableView { [self.tableView reloadData]; } - (void)setNavBarButtons { // Check whether the view controller is currently displayed inside a segmented view controller or not. UIViewController* topViewController = ((self.parentViewController) ? self.parentViewController : self); topViewController.navigationItem.rightBarButtonItem = nil; topViewController.navigationItem.leftBarButtonItem = nil; } - (void)addAddParticipantButton { // Add blur mask programatically tableViewMaskLayer = [CAGradientLayer layer]; CGColorRef opaqueWhiteColor = [UIColor colorWithWhite:1.0 alpha:1.0].CGColor; CGColorRef transparentWhiteColor = [UIColor colorWithWhite:1.0 alpha:0].CGColor; tableViewMaskLayer.colors = [NSArray arrayWithObjects:(__bridge id)transparentWhiteColor, (__bridge id)transparentWhiteColor, (__bridge id)opaqueWhiteColor, nil]; // display a gradient to the rencents bottom (20% of the bottom of the screen) tableViewMaskLayer.locations = [NSArray arrayWithObjects: [NSNumber numberWithFloat:0], [NSNumber numberWithFloat:0.85], [NSNumber numberWithFloat:1.0], nil]; tableViewMaskLayer.bounds = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height); tableViewMaskLayer.anchorPoint = CGPointZero; // CAConstraint is not supported on IOS. // it seems only being supported on Mac OS. // so viewDidLayoutSubviews will refresh the layout bounds. [self.view.layer addSublayer:tableViewMaskLayer]; // Add + button addParticipantButtonImageView = [[UIImageView alloc] init]; [addParticipantButtonImageView setTranslatesAutoresizingMaskIntoConstraints:NO]; [self.view addSubview:addParticipantButtonImageView]; addParticipantButtonImageView.backgroundColor = [UIColor clearColor]; addParticipantButtonImageView.contentMode = UIViewContentModeCenter; addParticipantButtonImageView.image = [UIImage imageNamed:@"add_participant"]; CGFloat side = 78.0f; NSLayoutConstraint* widthConstraint = [NSLayoutConstraint constraintWithItem:addParticipantButtonImageView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:side]; NSLayoutConstraint* heightConstraint = [NSLayoutConstraint constraintWithItem:addParticipantButtonImageView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:side]; NSLayoutConstraint* centerXConstraint = [NSLayoutConstraint constraintWithItem:addParticipantButtonImageView attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterX multiplier:1 constant:0]; addParticipantButtonImageViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.view attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:addParticipantButtonImageView attribute:NSLayoutAttributeBottom multiplier:1 constant:self.keyboardHeight + 9]; // Available on iOS 8 and later [NSLayoutConstraint activateConstraints:@[widthConstraint, heightConstraint, centerXConstraint, addParticipantButtonImageViewBottomConstraint]]; addParticipantButtonImageView.userInteractionEnabled = YES; // Handle tap gesture UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAddParticipantButtonPressed)]; [tap setNumberOfTouchesRequired:1]; [tap setNumberOfTapsRequired:1]; [tap setDelegate:self]; [addParticipantButtonImageView addGestureRecognizer:tap]; } - (void)onAddParticipantButtonPressed { // Push the contacts picker. contactsPickerViewController = [ContactsTableViewController contactsTableViewController]; // Set delegate to handle action on member (start chat, mention) contactsPickerViewController.contactsTableViewControllerDelegate = self; contactsPickerViewController.forceMatrixIdInDisplayName = YES; // Add a plus icon to the contact cell in the contacts picker, in order to make it more understandable for the end user. contactsPickerViewController.contactCellAccessoryImage = [UIImage imageNamed:@"plus_icon"]; // List all the participants by their matrix user id, or a room 3pid invite token to ignore them during the contacts search. [contactsPickerViewController.ignoredContactsByMatrixId removeAllObjects]; for (Contact *contact in actualParticipants) { [contactsPickerViewController.ignoredContactsByMatrixId setObject:contact forKey:contact.mxMember.userId]; } for (Contact *contact in invitedParticipants) { if (contact.mxMember) { [contactsPickerViewController.ignoredContactsByMatrixId setObject:contact forKey:contact.mxMember.userId]; } else if (contact.mxThirdPartyInvite) { [contactsPickerViewController.ignoredContactsByMatrixId setObject:contact forKey:contact.mxThirdPartyInvite.token]; } } if (userParticipant) { [contactsPickerViewController.ignoredContactsByMatrixId setObject:userParticipant forKey:userParticipant.mxMember.userId]; } [contactsPickerViewController showSearch:YES]; contactsPickerViewController.searchBar.placeholder = NSLocalizedStringFromTable(@"room_participants_invite_another_user", @"Vector", nil); // Apply the search pattern if any if (currentSearchText) { contactsPickerViewController.searchBar.text = currentSearchText; [contactsPickerViewController searchWithPattern:currentSearchText forceReset:YES complete:nil]; } [self pushViewController:contactsPickerViewController]; } - (void)refreshParticipantsFromRoomMembers { actualParticipants = [NSMutableArray array]; invitedParticipants = [NSMutableArray array]; userParticipant = nil; if (self.mxRoom) { // Retrieve the current members from the room state NSArray *members = [self.mxRoom.state membersWithoutConferenceUser]; NSString *userId = self.mxRoom.mxSession.myUser.userId; NSArray *roomThirdPartyInvites = self.mxRoom.state.thirdPartyInvites; for (MXRoomMember *mxMember in members) { // Update the current participants list if ([mxMember.userId isEqualToString:userId]) { if (mxMember.membership == MXMembershipJoin || mxMember.membership == MXMembershipInvite) { // The user is in this room NSString *displayName = NSLocalizedStringFromTable(@"you", @"Vector", nil); userParticipant = [[Contact alloc] initMatrixContactWithDisplayName:displayName andMatrixID:userId]; userParticipant.mxMember = [self.mxRoom.state memberWithUserId:userId]; } } else { [self handleRoomMember:mxMember]; } } for (MXRoomThirdPartyInvite *roomThirdPartyInvite in roomThirdPartyInvites) { [self addRoomThirdPartyInviteToParticipants:roomThirdPartyInvite]; } [self finalizeParticipantsList]; } } - (void)handleRoomMember:(MXRoomMember*)mxMember { // Add this member after checking his status if (mxMember.membership == MXMembershipJoin || mxMember.membership == MXMembershipInvite) { // Prepare the display name of this member NSString *displayName = mxMember.displayname; if (displayName.length == 0) { // Look for the corresponding MXUser in matrix session MXUser *mxUser = [self.mxRoom.mxSession userWithUserId:mxMember.userId]; if (mxUser) { displayName = ((mxUser.displayname.length > 0) ? mxUser.displayname : mxMember.userId); } else { displayName = mxMember.userId; } } // Create the contact related to this member Contact *contact = [[Contact alloc] initMatrixContactWithDisplayName:displayName andMatrixID:mxMember.userId]; contact.mxMember = mxMember; if (mxMember.membership == MXMembershipInvite) { [invitedParticipants addObject:contact]; } else { [actualParticipants addObject:contact]; } } } - (void)reloadSearchResult { if (currentSearchText.length) { NSString *searchText = currentSearchText; currentSearchText = nil; [self searchBar:_searchBarView textDidChange:searchText]; } } - (void)addRoomThirdPartyInviteToParticipants:(MXRoomThirdPartyInvite*)roomThirdPartyInvite { // If the homeserver has converted the 3pid invite into a room member, do no show it if (![self.mxRoom.state memberWithThirdPartyInviteToken:roomThirdPartyInvite.token]) { Contact *contact = [[Contact alloc] initMatrixContactWithDisplayName:roomThirdPartyInvite.displayname andMatrixID:nil]; contact.isThirdPartyInvite = YES; contact.mxThirdPartyInvite = roomThirdPartyInvite; [invitedParticipants addObject:contact]; } } // key is a room member user id or a room 3pid invite token - (void)removeParticipantByKey:(NSString*)key { NSUInteger index; if (actualParticipants.count) { for (index = 0; index < actualParticipants.count; index++) { Contact *contact = actualParticipants[index]; if (contact.mxMember && [contact.mxMember.userId isEqualToString:key]) { [actualParticipants removeObjectAtIndex:index]; return; } } } if (invitedParticipants.count) { for (index = 0; index < invitedParticipants.count; index++) { Contact *contact = invitedParticipants[index]; if (contact.mxMember && [contact.mxMember.userId isEqualToString:key]) { [invitedParticipants removeObjectAtIndex:index]; return; } if (contact.mxThirdPartyInvite && [contact.mxThirdPartyInvite.token isEqualToString:key]) { [invitedParticipants removeObjectAtIndex:index]; return; } } } } - (void)finalizeParticipantsList { // Sort contacts by last active, with "active now" first. // ...and then by power // ...and then alphabetically. // We could tiebreak instead by "last recently spoken in this room" if we wanted to. NSComparator comparator = ^NSComparisonResult(Contact *contactA, Contact *contactB) { MXUser *userA = [self.mxRoom.mxSession userWithUserId:contactA.mxMember.userId]; MXUser *userB = [self.mxRoom.mxSession userWithUserId:contactB.mxMember.userId]; if (!userA && !userB) { return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; } if (userA && !userB) { return NSOrderedAscending; } if (!userA && userB) { return NSOrderedDescending; } if (userA.currentlyActive && userB.currentlyActive) { // Order first by power levels (admins then moderators then others) MXRoomPowerLevels *powerLevels = [self.mxRoom.state powerLevels]; NSInteger powerLevelA = [powerLevels powerLevelOfUserWithUserID:contactA.mxMember.userId]; NSInteger powerLevelB = [powerLevels powerLevelOfUserWithUserID:contactB.mxMember.userId]; if (powerLevelA == powerLevelB) { // Then order by name if (contactA.sortingDisplayName.length && contactB.sortingDisplayName.length) { return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; } else if (contactA.sortingDisplayName.length) { return NSOrderedAscending; } else if (contactB.sortingDisplayName.length) { return NSOrderedDescending; } return [contactA.displayName compare:contactB.displayName options:NSCaseInsensitiveSearch]; } else { return powerLevelB - powerLevelA; } } if (userA.currentlyActive && !userB.currentlyActive) { return NSOrderedAscending; } if (!userA.currentlyActive && userB.currentlyActive) { return NSOrderedDescending; } // Finally, compare the lastActiveAgo NSUInteger lastActiveAgoA = userA.lastActiveAgo; NSUInteger lastActiveAgoB = userB.lastActiveAgo; if (lastActiveAgoA == lastActiveAgoB) { return NSOrderedSame; } else { return ((lastActiveAgoA > lastActiveAgoB) ? NSOrderedDescending : NSOrderedAscending); } }; // Sort each participants list in alphabetical order [actualParticipants sortUsingComparator:comparator]; [invitedParticipants sortUsingComparator:comparator]; // Reload search result if any [self reloadSearchResult]; } - (void)addPendingActionMask { // Remove potential existing mask [self removePendingActionMask]; // Add a spinner above the tableview to avoid that the user tap on any other button pendingMaskSpinnerView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; pendingMaskSpinnerView.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:0.5]; pendingMaskSpinnerView.frame = self.tableView.frame; pendingMaskSpinnerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; // append it [self.tableView.superview addSubview:pendingMaskSpinnerView]; // animate it [pendingMaskSpinnerView startAnimating]; // Show the spinner after a delay so that if it is removed in a short future, // it is not displayed to the end user. pendingMaskSpinnerView.alpha = 0; [UIView animateWithDuration:0.3 delay:0.3 options:UIViewAnimationOptionBeginFromCurrentState animations:^{ pendingMaskSpinnerView.alpha = 1; } completion:^(BOOL finished) { }]; } - (void)removePendingActionMask { if (pendingMaskSpinnerView) { [pendingMaskSpinnerView removeFromSuperview]; pendingMaskSpinnerView = nil; } } - (void)pushViewController:(UIViewController*)viewController { // Check whether the view controller is displayed inside a segmented one. if (self.parentViewController) { // Hide back button title self.parentViewController.navigationItem.backBarButtonItem =[[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil]; [self.parentViewController.navigationController pushViewController:viewController animated:YES]; } else { // Hide back button title self.navigationItem.backBarButtonItem =[[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil]; [self.navigationController pushViewController:viewController animated:YES]; } } #pragma mark - UITableView data source - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { NSInteger count = 0; participantsSection = invitedSection = -1; if (currentSearchText.length) { if (filteredActualParticipants.count) { participantsSection = count++; } if (filteredInvitedParticipants.count) { invitedSection = count++; } } else { if (userParticipant || actualParticipants.count) { participantsSection = count++; } if (invitedParticipants.count) { invitedSection = count++; } } return count; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { NSInteger count = 0; if (section == participantsSection) { if (currentSearchText.length) { count = filteredActualParticipants.count; } else { count = actualParticipants.count; if (userParticipant) { count++; } } } else if (section == invitedSection) { if (currentSearchText.length) { count = filteredInvitedParticipants.count; } else { count = invitedParticipants.count; } } return count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell; if (indexPath.section == participantsSection || indexPath.section == invitedSection) { ContactTableViewCell* participantCell = [tableView dequeueReusableCellWithIdentifier:@"ParticipantTableViewCellId" forIndexPath:indexPath]; participantCell.selectionStyle = UITableViewCellSelectionStyleNone; participantCell.mxRoom = self.mxRoom; Contact *contact; if ((indexPath.section == participantsSection && userParticipant && indexPath.row == 0) && !currentSearchText.length) { // oneself dedicated cell contact = userParticipant; } else { NSInteger index = indexPath.row; NSArray *participants; if (indexPath.section == participantsSection) { if (currentSearchText.length) { participants = filteredActualParticipants; } else { participants = actualParticipants; if (userParticipant) { index --; } } } else { if (currentSearchText.length) { participants = filteredInvitedParticipants; } else { participants = invitedParticipants; } } if (index < participants.count) { contact = participants[index]; } } if (contact) { [participantCell render:contact]; if (contact.mxMember) { // Update member badge MXRoomPowerLevels *powerLevels = [self.mxRoom.state powerLevels]; NSInteger powerLevel = [powerLevels powerLevelOfUserWithUserID:contact.mxMember.userId]; if (powerLevel >= kVectorRoomAdminLevel) { participantCell.thumbnailBadgeView.image = [UIImage imageNamed:@"admin_icon"]; participantCell.thumbnailBadgeView.hidden = NO; } else if (powerLevel >= kVectorRoomModeratorLevel) { participantCell.thumbnailBadgeView.image = [UIImage imageNamed:@"mod_icon"]; participantCell.thumbnailBadgeView.hidden = NO; } // Update the contact display name by considering the current room state. if (contact.mxMember.userId) { participantCell.contactDisplayNameLabel.text = [self.mxRoom.state memberName:contact.mxMember.userId]; } } } cell = participantCell; } return cell; } - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { if (indexPath.section == participantsSection || indexPath.section == invitedSection) { return YES; } return NO; } - (void)tableView:(UITableView*)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath*)indexPath { // iOS8 requires this method to enable editing (see editActionsForRowAtIndexPath). } #pragma mark - UITableView delegate - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { CGFloat height = 0.0; if (section == invitedSection) { height = 30.0; } return height; } - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { UIView* sectionHeader; if (section == invitedSection) { sectionHeader = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 30)]; sectionHeader.backgroundColor = kVectorColorLightGrey; CGRect frame = sectionHeader.frame; frame.origin.x = 20; frame.origin.y = 5; frame.size.width = sectionHeader.frame.size.width - 10; frame.size.height -= 10; UILabel *headerLabel = [[UILabel alloc] initWithFrame:frame]; headerLabel.font = [UIFont boldSystemFontOfSize:15.0]; headerLabel.backgroundColor = [UIColor clearColor]; headerLabel.text = NSLocalizedStringFromTable(@"room_participants_invited_section", @"Vector", nil); [sectionHeader addSubview:headerLabel]; } return sectionHeader; } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return 74.0; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { // Sanity check if (!self.mxRoom) { return; } Contact *contact; // oneself dedicated cell if ((indexPath.section == participantsSection && userParticipant && indexPath.row == 0) && !currentSearchText.length) { contact = userParticipant; } else { NSInteger index = indexPath.row; NSArray *participants; if (indexPath.section == participantsSection) { if (currentSearchText.length) { participants = filteredActualParticipants; } else { participants = actualParticipants; if (userParticipant) { index --; } } } else { if (currentSearchText.length) { participants = filteredInvitedParticipants; } else { participants = invitedParticipants; } } if (index < participants.count) { contact = participants[index]; } } if (contact.mxMember) { memberDetailsViewController = [RoomMemberDetailsViewController roomMemberDetailsViewController]; // Set delegate to handle action on member (start chat, mention) memberDetailsViewController.delegate = self; memberDetailsViewController.enableMention = _enableMention; memberDetailsViewController.enableVoipCall = NO; [memberDetailsViewController displayRoomMember:contact.mxMember withMatrixRoom:self.mxRoom]; [self pushViewController:memberDetailsViewController]; } [tableView deselectRowAtIndexPath:indexPath animated:YES]; } - (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath { NSMutableArray* actions; // add the swipe to delete only on participants sections if (indexPath.section == participantsSection || indexPath.section == invitedSection) { actions = [[NSMutableArray alloc] init]; // Patch: Force the width of the button by adding whitespace characters into the title string. UITableViewRowAction *leaveAction = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleDestructive title:@" " handler:^(UITableViewRowAction *action, NSIndexPath *indexPath){ [self onDeleteAt:indexPath]; }]; leaveAction.backgroundColor = [MXKTools convertImageToPatternColor:@"remove_icon" backgroundColor:kVectorColorLightGrey patternSize:CGSizeMake(74, 74) resourceSize:CGSizeMake(25, 24)]; [actions insertObject:leaveAction atIndex:0]; } return actions; } #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion { [[AppDelegate theDelegate] createDirectChatWithUserId:matrixId completion:completion]; } - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController mention:(MXRoomMember*)member { if (_delegate) { id delegate = _delegate; // Withdraw the current view controller, and let the delegate mention the member [self withdrawViewControllerAnimated:YES completion:^{ [delegate roomParticipantsViewController:self mention:member]; }]; } } #pragma mark - ContactsTableViewControllerDelegate - (void)contactsTableViewController:(ContactsTableViewController *)contactsTableViewController didSelectContact:(MXKContact*)contact { [self didSelectInvitableContact:contact]; } #pragma mark - Actions - (void)onDeleteAt:(NSIndexPath*)path { NSUInteger section = path.section; NSUInteger row = path.row; if (section == participantsSection || section == invitedSection) { __weak typeof(self) weakSelf = self; if (currentAlert) { [currentAlert dismiss:NO]; currentAlert = nil; } if (section == participantsSection && userParticipant && (0 == row)) { // Leave ? currentAlert = [[MXKAlert alloc] initWithTitle:NSLocalizedStringFromTable(@"room_participants_leave_prompt_title", @"Vector", nil) message:NSLocalizedStringFromTable(@"room_participants_leave_prompt_msg", @"Vector", nil) style:MXKAlertStyleAlert]; currentAlert.cancelButtonIndex = [currentAlert addActionWithTitle:[NSBundle mxk_localizedStringForKey:@"cancel"] style:MXKAlertActionStyleCancel handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; }]; [currentAlert addActionWithTitle:NSLocalizedStringFromTable(@"leave", @"Vector", nil) style:MXKAlertActionStyleDefault handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; [strongSelf addPendingActionMask]; [strongSelf.mxRoom leave:^{ [strongSelf withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { [strongSelf removePendingActionMask]; NSLog(@"[RoomParticipantsVC] Leave room %@ failed", strongSelf.mxRoom.state.roomId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; }]; currentAlert.mxkAccessibilityIdentifier = @"RoomParticipantsVCLeaveAlert"; [currentAlert showInViewController:self]; } else { NSMutableArray *participants; if (section == participantsSection) { participants = actualParticipants; if (userParticipant) { row --; } } else { participants = invitedParticipants; } if (row < participants.count) { Contact *contact = participants[row]; if (contact.mxMember) { NSString *memberUserId = contact.mxMember.userId; // Kick ? NSString *promptMsg = [NSString stringWithFormat:NSLocalizedStringFromTable(@"room_participants_remove_prompt_msg", @"Vector", nil), (contact ? contact.displayName : memberUserId)]; currentAlert = [[MXKAlert alloc] initWithTitle:NSLocalizedStringFromTable(@"room_participants_remove_prompt_title", @"Vector", nil) message:promptMsg style:MXKAlertStyleAlert]; currentAlert.cancelButtonIndex = [currentAlert addActionWithTitle:[NSBundle mxk_localizedStringForKey:@"cancel"] style:MXKAlertActionStyleCancel handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; }]; [currentAlert addActionWithTitle:NSLocalizedStringFromTable(@"remove", @"Vector", nil) style:MXKAlertActionStyleDefault handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; [strongSelf addPendingActionMask]; [strongSelf.mxRoom kickUser:memberUserId reason:nil success:^{ [strongSelf removePendingActionMask]; [participants removeObjectAtIndex:row]; // Refresh display [strongSelf.tableView reloadData]; } failure:^(NSError *error) { [strongSelf removePendingActionMask]; NSLog(@"[RoomParticipantsVC] Kick %@ failed", memberUserId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; }]; } else { // This is a third-party invite, it could not be removed until the api exists currentAlert = [[MXKAlert alloc] initWithTitle:nil message:NSLocalizedStringFromTable(@"room_participants_remove_third_party_invite_msg", @"Vector", nil) style:MXKAlertStyleAlert]; currentAlert.cancelButtonIndex = [currentAlert addActionWithTitle:[NSBundle mxk_localizedStringForKey:@"cancel"] style:MXKAlertActionStyleCancel handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; }]; } currentAlert.mxkAccessibilityIdentifier = @"RoomParticipantsVCKickAlert"; [currentAlert showInViewController:self]; } } } } #pragma mark - - (void)didSelectInvitableContact:(MXKContact*)contact { __weak typeof(self) weakSelf = self; if (currentAlert) { [currentAlert dismiss:NO]; currentAlert = nil; } // Invite ? NSString *promptMsg = [NSString stringWithFormat:NSLocalizedStringFromTable(@"room_participants_invite_prompt_msg", @"Vector", nil), contact.displayName]; currentAlert = [[MXKAlert alloc] initWithTitle:NSLocalizedStringFromTable(@"room_participants_invite_prompt_title", @"Vector", nil) message:promptMsg style:MXKAlertStyleAlert]; currentAlert.cancelButtonIndex = [currentAlert addActionWithTitle:[NSBundle mxk_localizedStringForKey:@"cancel"] style:MXKAlertActionStyleCancel handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; }]; [currentAlert addActionWithTitle:NSLocalizedStringFromTable(@"invite", @"Vector", nil) style:MXKAlertActionStyleDefault handler:^(MXKAlert *alert) { __strong __typeof(weakSelf)strongSelf = weakSelf; strongSelf->currentAlert = nil; NSArray *identifiers = contact.matrixIdentifiers; NSString *participantId; if (identifiers.count) { participantId = identifiers.firstObject; // Invite this user if a room is defined [strongSelf addPendingActionMask]; [strongSelf.mxRoom inviteUser:participantId success:^{ __strong __typeof(weakSelf)strongSelf = weakSelf; [strongSelf removePendingActionMask]; // Refresh display by removing the contacts picker [strongSelf->contactsPickerViewController withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { __strong __typeof(weakSelf)strongSelf = weakSelf; [strongSelf removePendingActionMask]; NSLog(@"[RoomParticipantsVC] Invite %@ failed", participantId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; } else { if (contact.emailAddresses.count) { // This is a local contact, consider the first email by default. // TODO: Prompt the user to select the right email. MXKEmail *email = contact.emailAddresses.firstObject; participantId = email.emailAddress; } else { // This is the text filled by the user. participantId = contact.displayName; } // Is it an email or a Matrix user ID? if ([MXTools isEmailAddress:participantId]) { [strongSelf addPendingActionMask]; [strongSelf.mxRoom inviteUserByEmail:participantId success:^{ __strong __typeof(weakSelf)strongSelf = weakSelf; [strongSelf removePendingActionMask]; // Refresh display by removing the contacts picker [strongSelf->contactsPickerViewController withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { __strong __typeof(weakSelf)strongSelf = weakSelf; [strongSelf removePendingActionMask]; NSLog(@"[RoomParticipantsVC] Invite be email %@ failed", participantId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; } else //if ([MXTools isMatrixUserIdentifier:participantId]) { [strongSelf addPendingActionMask]; [strongSelf.mxRoom inviteUser:participantId success:^{ __strong __typeof(weakSelf)strongSelf = weakSelf; [strongSelf removePendingActionMask]; // Refresh display by removing the contacts picker [strongSelf->contactsPickerViewController withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { __strong __typeof(weakSelf)strongSelf = weakSelf; [strongSelf removePendingActionMask]; NSLog(@"[RoomParticipantsVC] Invite %@ failed", participantId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; } } }]; currentAlert.mxkAccessibilityIdentifier = @"RoomParticipantsVCInviteAlert"; [currentAlert showInViewController:self]; } #pragma mark - UISearchBar delegate - (void)refreshSearchBarItemsColor:(UISearchBar *)searchBar { // bar tint color searchBar.barTintColor = searchBar.tintColor = kVectorColorGreen; searchBar.tintColor = kVectorColorGreen; // FIXME: this all seems incredibly fragile and tied to gutwrenching the current UISearchBar internals. // text color UITextField *searchBarTextField = [searchBar valueForKey:@"_searchField"]; searchBarTextField.textColor = kVectorTextColorGray; // Magnifying glass icon. UIImageView *leftImageView = (UIImageView *)searchBarTextField.leftView; leftImageView.image = [leftImageView.image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; leftImageView.tintColor = kVectorColorGreen; // remove the gray background color UIView *effectBackgroundTop = [searchBarTextField valueForKey:@"_effectBackgroundTop"]; UIView *effectBackgroundBottom = [searchBarTextField valueForKey:@"_effectBackgroundBottom"]; effectBackgroundTop.hidden = YES; effectBackgroundBottom.hidden = YES; // place holder searchBarTextField.attributedPlaceholder = [[NSAttributedString alloc] initWithString:searchBarTextField.placeholder attributes:@{NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle), NSUnderlineColorAttributeName: kVectorColorGreen, NSForegroundColorAttributeName: kVectorColorGreen}]; } - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { // Update search results. NSUInteger index; MXKContact *contact; searchText = [searchText stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; if (!currentSearchText.length || [searchText hasPrefix:currentSearchText] == NO) { // Copy participants and invited participants filteredActualParticipants = [NSMutableArray arrayWithArray:actualParticipants]; filteredInvitedParticipants = [NSMutableArray arrayWithArray:invitedParticipants]; // Add the current user if he belongs to the room members. if (userParticipant) { [filteredActualParticipants addObject:userParticipant]; } } currentSearchText = searchText; // Filter room participants if (currentSearchText.length) { for (index = 0; index < filteredActualParticipants.count;) { contact = filteredActualParticipants[index]; if (![contact matchedWithPatterns:@[currentSearchText]]) { [filteredActualParticipants removeObjectAtIndex:index]; } else { index++; } } for (index = 0; index < filteredInvitedParticipants.count;) { contact = filteredInvitedParticipants[index]; if (![contact matchedWithPatterns:@[currentSearchText]]) { [filteredInvitedParticipants removeObjectAtIndex:index]; } else { index++; } } } else { filteredActualParticipants = nil; filteredInvitedParticipants = nil; } // Refresh display [self refreshTableView]; } - (BOOL)searchBarShouldBeginEditing:(UISearchBar *)searchBar { searchBar.showsCancelButton = YES; return YES; } - (BOOL)searchBarShouldEndEditing:(UISearchBar *)searchBar { searchBar.showsCancelButton = NO; return YES; } - (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { // "Done" key has been pressed. // Dismiss keyboard [_searchBarView resignFirstResponder]; } - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar { searchBar.text = nil; currentSearchText = nil; filteredActualParticipants = nil; filteredInvitedParticipants = nil; [self refreshTableView]; // Leave search [searchBar resignFirstResponder]; } @end