/* Copyright 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 "RoomParticipantsViewController.h" #import "RoomMemberDetailsViewController.h" #import "GeneratedInterface-Swift.h" #import "Contact.h" #import "MXCallManager.h" #import "ContactTableViewCell.h" #import "RageShakeManager.h" @interface RoomParticipantsViewController () { // Add participant FAB image UIImageView *inviteFabImageView; // Search result NSString *currentSearchText; NSMutableArray *filteredActualParticipants; NSMutableArray *filteredInvitedParticipants; NSMutableArray *filteredAdminParticipants; NSMutableArray *filteredModParticipants; // 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; UIAlertController *currentAlert; // Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change. id kThemeServiceDidChangeThemeNotificationObserver; RoomParticipantsInviteCoordinatorBridgePresenter *invitePresenter; } @property (nonatomic, strong) UserLabelDefaultService* bwiUserLabelService; @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.enableBarTintColorStatusChange = NO; self.rageShakeManager = [RageShakeManager sharedManager]; self.showParticipantCustomAccessoryView = YES; self.showInviteUserFab = NO; } - (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]]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated" _searchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.searchBarHeader attribute:NSLayoutAttributeTop multiplier:1.0f constant:0.0f]; #pragma clang diagnostic pop [NSLayoutConstraint activateConstraints:@[_searchBarTopConstraint]]; self.navigationItem.title = [VectorL10n roomParticipantsTitle]; if (self.mxRoom.summary.roomType == MXRoomTypeSpace) { _searchBarView.placeholder = [VectorL10n searchDefaultPlaceholder]; } else if (self.mxRoom.isDirect) { _searchBarView.placeholder = [VectorL10n roomParticipantsFilterRoomMembersForDm]; } else { _searchBarView.placeholder = [VectorL10n roomParticipantsFilterRoomMembers]; } _searchBarView.returnKeyType = UIReturnKeyDone; _searchBarView.autocapitalizationType = UITextAutocapitalizationTypeNone; // 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"]; // bwi: too early to tell if button should be added -> we need mxRoom // if (_showInviteUserFab) // { // // Add invite members button programmatically // [self vc_addFABWithImage:AssetImages.addMemberFloatingAction.image // target:self // action:@selector(onAddParticipantButtonPressed)]; // } // Observe user interface theme change. kThemeServiceDidChangeThemeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kThemeServiceDidChangeThemeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { [self userInterfaceThemeDidChange]; }]; [self userInterfaceThemeDidChange]; } - (void)userInterfaceThemeDidChange { [ThemeService.shared.theme applyStyleOnNavigationBar:self.navigationController.navigationBar]; self.activityIndicator.backgroundColor = ThemeService.shared.theme.overlayBackgroundColor; [self refreshSearchBarItemsColor:_searchBarView]; _searchBarHeaderBorder.backgroundColor = ThemeService.shared.theme.headerBorderColor; // Check the table view style to select its bg color. self.tableView.backgroundColor = ((self.tableView.style == UITableViewStylePlain) ? ThemeService.shared.theme.backgroundColor : ThemeService.shared.theme.headerBackgroundColor); self.view.backgroundColor = self.tableView.backgroundColor; self.tableView.separatorColor = ThemeService.shared.theme.lineBreakColor; if (self.tableView.dataSource) { [self.tableView reloadData]; } [self setNeedsStatusBarAppearanceUpdate]; } - (UIStatusBarStyle)preferredStatusBarStyle { return ThemeService.shared.theme.statusBarStyle; } // 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 (kThemeServiceDidChangeThemeNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:kThemeServiceDidChangeThemeNotificationObserver]; kThemeServiceDidChangeThemeNotificationObserver = nil; } if (leaveRoomNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; leaveRoomNotificationObserver = nil; } if (roomDidFlushDataNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; roomDidFlushDataNotificationObserver = nil; } if (membersListener) { MXWeakify(self); [self.mxRoom liveTimeline:^(id liveTimeline) { MXStrongifyAndReturnIfNil(self); [liveTimeline removeListener:self->membersListener]; self->membersListener = nil; }]; } if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } _mxRoom = nil; filteredActualParticipants = nil; filteredInvitedParticipants = nil; filteredAdminParticipants = nil; filteredModParticipants = nil; actualParticipants = nil; invitedParticipants = nil; userParticipant = nil; adminParticipants = nil; modParticipants = nil; [self removePendingActionMask]; [super destroy]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // Refresh display [self refreshTableView]; [self.screenTracker trackScreen]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; if (memberDetailsViewController) { [memberDetailsViewController destroy]; memberDetailsViewController = nil; } } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; 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]; } } #pragma mark - - (void)setMxRoom:(MXRoom *)mxRoom { // Cancel any pending search [self searchBarCancelButtonClicked:_searchBarView]; // Make sure we can access synchronously to self.mxRoom and mxRoom data // to avoid race conditions MXWeakify(self); [mxRoom.mxSession preloadRoomsData:_mxRoom ? @[_mxRoom.roomId, mxRoom.roomId] : @[mxRoom.roomId] onComplete:^{ MXStrongifyAndReturnIfNil(self); // Remove previous room registration (if any). if (self.mxRoom) { // Remove the previous listener if (self->leaveRoomNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:self->leaveRoomNotificationObserver]; self->leaveRoomNotificationObserver = nil; } if (self->roomDidFlushDataNotificationObserver) { [[NSNotificationCenter defaultCenter] removeObserver:self->roomDidFlushDataNotificationObserver]; self->roomDidFlushDataNotificationObserver = nil; } if (self->membersListener) { MXWeakify(self); [self.mxRoom liveTimeline:^(id liveTimeline) { MXStrongifyAndReturnIfNil(self); [liveTimeline removeListener:self->membersListener]; self->membersListener = nil; }]; } [self removeMatrixSession:self.mxRoom.mxSession]; } self->_mxRoom = mxRoom; if (self.mxRoom) { self.searchBarHeader.hidden = NO; [self updateInviteButtonForRoom:self.mxRoom]; if (self.mxRoom.summary.roomType == MXRoomTypeSpace) { self.searchBarView.placeholder = [VectorL10n searchDefaultPlaceholder]; } else if (self.mxRoom.isDirect) { self.searchBarView.placeholder = [VectorL10n roomParticipantsFilterRoomMembersForDm]; } else { self.searchBarView.placeholder = [VectorL10n roomParticipantsFilterRoomMembers]; } // Update the current matrix session. [self addMatrixSession:self.mxRoom.mxSession]; // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. self->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 == self.mxRoom.mxSession) { NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; if (roomId && [roomId isEqualToString:self.mxRoom.roomId]) { // We remove the current view controller. [self withdrawViewControllerAnimated:YES completion:nil]; } } }]; // Observe room history flush (sync with limited timeline, or state event redaction) self->roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXRoom *room = notif.object; if (self.mxRoom.mxSession == room.mxSession && [self.mxRoom.roomId isEqualToString:room.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]; MXWeakify(self); [self.mxRoom liveTimeline:^(id liveTimeline) { MXStrongifyAndReturnIfNil(self); self->membersListener = [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 = [liveTimeline.state.members 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:liveTimeline.state]; [self refreshTableView]; } } break; } case MXEventTypeRoomThirdPartyInvite: { MXRoomThirdPartyInvite *thirdPartyInvite = [liveTimeline.state thirdPartyInviteWithToken:event.stateKey]; if (thirdPartyInvite) { [self addRoomThirdPartyInviteToParticipants:thirdPartyInvite roomState:liveTimeline.state]; [self finalizeParticipantsList:liveTimeline.state]; [self refreshTableView]; } break; } case MXEventTypeRoomPowerLevels: { [self refreshParticipantsFromRoomMembers]; [self refreshTableView]; break; } default: break; } } }]; }]; } else { // Search bar header is hidden when no room is provided self.searchBarHeader.hidden = YES; } self.bwiUserLabelService = [[UserLabelDefaultService alloc] init]; [self.bwiUserLabelService setRoom:self.mxRoom]; // 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]; } } #pragma mark - Internals - (void)updateInviteButtonForRoom:(MXRoom *)room { AccountRestrictionService *service = [[AccountRestrictionService alloc] initWithMxSession:room.mxSession]; self.showInviteUserFab = !(room.isDirect || BWIBuildSettings.shared.allowInviteOnDirectRooms) && !room.isPersonalNotesRoom && ![service isAddressListRestriction]; if (_showInviteUserFab) { // Add invite members button programmatically [self vc_addFABWithImage:AssetImages.addMemberFloatingAction.image target:self action:@selector(onAddParticipantButtonPressed)]; } } - (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; if (self.showCancelBarButtonItem) { topViewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(onCancel:)]; } else { topViewController.navigationItem.leftBarButtonItem = nil; } } - (void)onAddParticipantButtonPressed { self->invitePresenter = [[RoomParticipantsInviteCoordinatorBridgePresenter alloc] initWithSession:self.mxRoom.mxSession room:self.mxRoom parentSpaceId:self.parentSpaceId currentSearchText:currentSearchText actualParticipants:actualParticipants invitedParticipants:invitedParticipants userParticipant:userParticipant]; self->invitePresenter.delegate = self; [self->invitePresenter presentFrom:self animated:true]; } - (void)refreshParticipantsFromRoomMembers { actualParticipants = [NSMutableArray array]; invitedParticipants = [NSMutableArray array]; adminParticipants = [NSMutableArray array]; modParticipants = [NSMutableArray array]; userParticipant = nil; if (self.mxRoom) { // Retrieve the current members from the room state MXWeakify(self); [self.mxRoom state:^(MXRoomState *roomState) { MXStrongifyAndReturnIfNil(self); NSArray *members = [roomState.members membersWithoutConferenceUser]; NSString *userId = self.mxRoom.mxSession.myUser.userId; NSArray *roomThirdPartyInvites = roomState.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 = [VectorL10n you]; self->userParticipant = [[Contact alloc] initMatrixContactWithDisplayName:displayName andMatrixID:userId]; self->userParticipant.mxMember = [roomState.members memberWithUserId:userId]; } } [self handleRoomMember:mxMember]; } for (MXRoomThirdPartyInvite *roomThirdPartyInvite in roomThirdPartyInvites) { [self addRoomThirdPartyInviteToParticipants:roomThirdPartyInvite roomState:roomState]; } [self finalizeParticipantsList:roomState]; }]; } } - (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; MXRoomState* roomState = self.mxRoom.dangerousSyncState; // Update member power level MXRoomPowerLevels *powerLevels = [roomState powerLevels]; NSInteger powerLevel = [powerLevels powerLevelOfUserWithUserID:mxMember.userId]; RoomPowerLevel roomPowerLevel = [RoomPowerLevelHelper roomPowerLevelFrom:powerLevel]; if (mxMember.membership == MXMembershipInvite) { [invitedParticipants addObject:contact]; } else if (roomPowerLevel == RoomPowerLevelAdmin) { [adminParticipants addObject:contact]; } else if (roomPowerLevel == RoomPowerLevelModerator) { [modParticipants 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 roomState:(MXRoomState*)roomState { // If the homeserver has converted the 3pid invite into a room member, do no show it // If the invite has been revoked (null display name), do not show it too. if (![roomState memberWithThirdPartyInviteToken:roomThirdPartyInvite.token] && roomThirdPartyInvite.displayname) { 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 (adminParticipants.count) { for (index = 0; index < adminParticipants.count; index++) { Contact *contact = adminParticipants[index]; if (contact.mxMember && [contact.mxMember.userId isEqualToString:key]) { [adminParticipants removeObjectAtIndex:index]; return; } } } if (modParticipants.count) { for (index = 0; index < modParticipants.count; index++) { Contact *contact = modParticipants[index]; if (contact.mxMember && [contact.mxMember.userId isEqualToString:key]) { [modParticipants 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:(MXRoomState*)roomState { // 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) { if (BWIBuildSettings.shared.bwiUserLabelParticipantSorting == true) { return [self bwiParticipantsCompareLeft:contactA right: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 = [roomState 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]; [adminParticipants sortUsingComparator:comparator]; [modParticipants 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:^{ self->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.navigationController) { // Hide back button title [self.parentViewController vc_removeBackTitle]; [self.parentViewController.navigationController pushViewController:viewController animated:YES]; } else { // Hide back button title [self vc_removeBackTitle]; [self.navigationController pushViewController:viewController animated:YES]; } } - (void)showDetailFor:(MXRoomMember* _Nonnull)member from:(UIView* _Nullable)sourceView { memberDetailsViewController = [RoomMemberDetailsViewController roomMemberDetailsViewController]; // Set delegate to handle action on member (start chat, mention) memberDetailsViewController.delegate = self; memberDetailsViewController.enableMention = _enableMention; memberDetailsViewController.enableVoipCall = NO; [memberDetailsViewController displayRoomMember:member withMatrixRoom:self.mxRoom]; [self pushViewController:memberDetailsViewController]; } - (void)processInviteError:(NSError*)error withContact:(Contact*)contactId { if( error && error.userInfo ) { NSString *code = [error.userInfo valueForKey:@"errcode"]; if (code && ([code isEqualToString:kMXErrCodeStringForbidden] || [code isEqualToString:@"M_BAD_STATE"])) { NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; if (msg) { NSDictionary *betterUserInfo = @{ NSLocalizedDescriptionKey : [NSString stringWithFormat:[self localizedMessage:msg], contactId.displayName], @"errcode" : code, }; error = [NSError errorWithDomain:error.domain code:error.code userInfo:betterUserInfo]; } } [[AppDelegate theDelegate] showErrorAsAlert:error]; } } - (NSString*) localizedMessage:(NSString*)message { if ([message hasSuffix:@"is already in the room."]) { return NSLocalizedStringFromTable(@"bwi_error_invite_already_in_room", @"Bwi", nil); } else if ([message hasSuffix:@"who was banned"]) { return NSLocalizedStringFromTable(@"bwi_error_invite_banned_in_room", @"Bwi", nil); } else { return NSLocalizedStringFromTable(@"bwi_error_invite_general", @"Bwi", nil); } } #pragma mark - UITableView data source - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { NSInteger count = 0; participantsSection = invitedSection = adminSection = modSection = -1; if (currentSearchText.length) { if (filteredAdminParticipants.count) { adminSection = count++; } if (filteredModParticipants.count) { modSection = count++; } if (filteredActualParticipants.count) { participantsSection = count++; } if (filteredInvitedParticipants.count) { invitedSection = count++; } } else { if (adminParticipants.count) { adminSection = count++; } if (modParticipants.count) { modSection = count++; } if (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; } } else if (section == adminSection) { if (currentSearchText.length) { count = filteredAdminParticipants.count; } else { count = adminParticipants.count; } } else if (section == modSection) { if (currentSearchText.length) { count = filteredModParticipants.count; } else { count = modParticipants.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; ContactTableViewCell* participantCell = [tableView dequeueReusableCellWithIdentifier:@"ParticipantTableViewCellId" forIndexPath:indexPath]; participantCell.selectionStyle = UITableViewCellSelectionStyleNone; participantCell.showCustomAccessoryView = self.showParticipantCustomAccessoryView; participantCell.mxRoom = self.mxRoom; Contact *contact = [self contactAtIndexPath:indexPath]; if (contact) { // BWI: 5208 if (BWIBuildSettings.shared.isFederationEnabled && self.mxRoom.mxSession && self.mxRoom.mxSession.myUserId) { NSArray *myUserIdComponents = [self.mxRoom.mxSession.myUserId componentsSeparatedByString:@":"]; if (myUserIdComponents.count == 2) { [contact checkFederation:[myUserIdComponents objectAtIndex:1]]; } } [participantCell render:contact]; if (contact.mxMember) { MXRoomState *roomState = self.mxRoom.dangerousSyncState; // Update member power level MXRoomPowerLevels *powerLevels = [roomState powerLevels]; NSInteger powerLevel = [powerLevels powerLevelOfUserWithUserID:contact.mxMember.userId]; RoomPowerLevel roomPowerLevel = [RoomPowerLevelHelper roomPowerLevelFrom:powerLevel]; NSString *powerLevelText; switch (roomPowerLevel) { case RoomPowerLevelAdmin: powerLevelText = [VectorL10n roomMemberPowerLevelShortAdmin]; break; case RoomPowerLevelModerator: powerLevelText = [VectorL10n roomMemberPowerLevelShortModerator]; break; default: powerLevelText = nil; break; } participantCell.powerLevelLabel.text = powerLevelText; // Update the contact display name by considering the current room state. if (contact.mxMember.userId) { participantCell.contactDisplayNameLabel.text = [roomState.members memberName:contact.mxMember.userId]; } if (BWIBuildSettings.shared.bwiUserLabelsParticipantsVisible) { contact.bwiUserLabel = [self.bwiUserLabelService getUserLabelWithUser:contact.mxMember.userId]; participantCell.powerLevelLabel.text = contact.bwiUserLabel; } if (BWIBuildSettings.shared.bwiPersonalState && contact.mxMember.userId) { MXUser *user = [self.mxRoom.mxSession userWithUserId:contact.mxMember.userId]; participantCell.contactInformationLabel.text = user.statusMsg; participantCell.contactInformationLabel.hidden = user.statusMsg.length <= 0; } } cell = participantCell; } else { // Return a fake cell to prevent app from crashing. cell = [[UITableViewCell alloc] init]; } return cell; } - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { // bwi: #5671 deactivate swipe gesture - remove participant from room if (BWIBuildSettings.shared.bwiCanEditRoomParticipants) { 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 - (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.0; } - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { UIView* sectionHeader = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 30)]; sectionHeader.backgroundColor = ThemeService.shared.theme.headerBackgroundColor; 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.textColor = ThemeService.shared.theme.textPrimaryColor; headerLabel.font = [UIFont boldSystemFontOfSize:15.0]; headerLabel.backgroundColor = [UIColor clearColor];; if (section == invitedSection) { headerLabel.text = BWIL10n.bwiRoomParticipantsSectionInvite; } else if (section == adminSection) { headerLabel.text = BWIL10n.bwiRoomParticipantsSectionAdmin; } else if (section == modSection) { headerLabel.text = BWIL10n.bwiRoomParticipantsSectionModerator; } else if (section == participantsSection) { headerLabel.text = BWIL10n.bwiRoomParticipantsSectionMember; } [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 = [self contactAtIndexPath:indexPath]; if (contact.mxMember) { UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; [self showDetailFor:contact.mxMember from:selectedCell]; } [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:ThemeService.shared.theme.headerBackgroundColor patternSize:CGSizeMake(74, 74) resourceSize:CGSizeMake(24, 24)]; [actions insertObject:leaveAction atIndex:0]; } return actions; } #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion { [[AppDelegate theDelegate] showNewDirectChat:matrixId withMatrixSession:self.mxRoom.mxSession 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 - Actions - (void)onDeleteAt:(NSIndexPath*)path { NSUInteger section = path.section; NSUInteger row = path.row; if (section == participantsSection || section == invitedSection) { if (currentAlert) { [currentAlert dismissViewControllerAnimated:NO completion:nil]; currentAlert = nil; } if (section == participantsSection && userParticipant && (0 == row) && !currentSearchText.length) { // Leave ? MXWeakify(self); NSString *title, *message; if (self.mxRoom.isDirect) { title = [VectorL10n roomParticipantsLeavePromptTitleForDm]; message = [VectorL10n roomParticipantsLeavePromptMsgForDm]; } else { title = [VectorL10n roomParticipantsLeavePromptTitle]; message = [VectorL10n roomParticipantsLeavePromptMsg]; } currentAlert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n leave] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; [self addPendingActionMask]; MXWeakify(self); [self.mxRoom leave:^{ MXStrongifyAndReturnIfNil(self); [self withdrawViewControllerAnimated:YES completion:nil]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self removePendingActionMask]; MXLogDebug(@"[RoomParticipantsVC] Leave room %@ failed", self.mxRoom.roomId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; }]]; [currentAlert mxk_setAccessibilityIdentifier:@"RoomParticipantsVCLeaveAlert"]; [self presentViewController:currentAlert animated:YES completion:nil]; } else { NSMutableArray *participants; if (section == participantsSection) { if (currentSearchText.length) { participants = filteredActualParticipants; } else { participants = actualParticipants; if (userParticipant) { row --; } } } else { if (currentSearchText.length) { participants = filteredInvitedParticipants; } else { participants = invitedParticipants; } } if (row < participants.count) { Contact *contact = participants[row]; MXWeakify(self); if (contact.mxMember) { NSString *memberUserId = contact.mxMember.userId; // Kick ? NSString *promptMsg = [VectorL10n roomParticipantsRemovePromptMsg:(contact ? contact.displayName : memberUserId)]; currentAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomParticipantsRemovePromptTitle] message:promptMsg preferredStyle:UIAlertControllerStyleAlert]; [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n remove] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; [self addPendingActionMask]; MXWeakify(self); [self.mxRoom kickUser:memberUserId reason:nil success:^{ MXStrongifyAndReturnIfNil(self); [self removePendingActionMask]; [participants removeObjectAtIndex:row]; // Refresh display [self.tableView reloadData]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self removePendingActionMask]; MXLogDebug(@"[RoomParticipantsVC] Kick %@ failed", memberUserId); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; }]]; } else if (contact.mxThirdPartyInvite) { // This is a third-party invite currentAlert = [UIAlertController alertControllerWithTitle:nil message:[VectorL10n roomParticipantsRemoveThirdPartyInvitePromptMsg] preferredStyle:UIAlertControllerStyleAlert]; [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel] style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; }]]; [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n remove] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); self->currentAlert = nil; [self addPendingActionMask]; MXWeakify(self); [self.mxRoom sendStateEventOfType:kMXEventTypeStringRoomThirdPartyInvite content:@{} stateKey:contact.mxThirdPartyInvite.token success:^(NSString *eventId) { MXStrongifyAndReturnIfNil(self); [self removePendingActionMask]; [participants removeObjectAtIndex:row]; // Refresh display [self.tableView reloadData]; } failure:^(NSError *error) { MXStrongifyAndReturnIfNil(self); [self removePendingActionMask]; MXLogDebug(@"[RoomParticipantsVC] Revoke 3pid invite failed"); // Alert user [[AppDelegate theDelegate] showErrorAsAlert:error]; }]; }]]; } [currentAlert mxk_setAccessibilityIdentifier:@"RoomParticipantsVCKickAlert"]; [self presentViewController:currentAlert animated:YES completion:nil]; } } } } - (void)onCancel:(id)sender { [self withdrawViewControllerAnimated:YES completion:nil]; } #pragma mark - UISearchBar delegate - (void)refreshSearchBarItemsColor:(UISearchBar *)searchBar { // bar tint color searchBar.barTintColor = searchBar.tintColor = ThemeService.shared.theme.tintColor; searchBar.tintColor = ThemeService.shared.theme.tintColor; // FIXME: this all seems incredibly fragile and tied to gutwrenching the current UISearchBar internals. // text color UITextField *searchBarTextField = searchBar.vc_searchTextField; searchBarTextField.textColor = ThemeService.shared.theme.textSecondaryColor; // Magnifying glass icon. UIImageView *leftImageView = (UIImageView *)searchBarTextField.leftView; leftImageView.image = [leftImageView.image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; // remove the gray background color UIView *effectBackgroundTop = [searchBarTextField valueForKey:@"_effectBackgroundTop"]; UIView *effectBackgroundBottom = [searchBarTextField valueForKey:@"_effectBackgroundBottom"]; effectBackgroundTop.hidden = YES; effectBackgroundBottom.hidden = YES; // place holder searchBarTextField.textColor = ThemeService.shared.theme.searchPlaceholderColor; } - (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]; filteredModParticipants = [NSMutableArray arrayWithArray:modParticipants]; filteredAdminParticipants = [NSMutableArray arrayWithArray:adminParticipants]; } currentSearchText = searchText; // Filter room participants if (currentSearchText.length) { for (index = 0; index < filteredModParticipants.count;) { contact = filteredModParticipants[index]; if (![contact matchedWithPatterns:@[currentSearchText]]) { [filteredModParticipants removeObjectAtIndex:index]; } else { index++; } } for (index = 0; index < filteredAdminParticipants.count;) { contact = filteredAdminParticipants[index]; if (![contact matchedWithPatterns:@[currentSearchText]]) { [filteredAdminParticipants removeObjectAtIndex:index]; } else { index++; } } 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; filteredModParticipants = nil; filteredAdminParticipants = 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 { if (currentSearchText) { currentSearchText = nil; filteredActualParticipants = nil; filteredInvitedParticipants = nil; [self refreshTableView]; } searchBar.text = nil; // Leave search [searchBar resignFirstResponder]; } #pragma mark - RoomParticipantsInviteCoordinatorBridgePresenterDelegate - (void)roomParticipantsInviteCoordinatorBridgePresenterDidComplete:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter { self->invitePresenter = nil; } - (void)roomParticipantsInviteCoordinatorBridgePresenterDidStartLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter { [self addPendingActionMask]; } - (void)roomParticipantsInviteCoordinatorBridgePresenterDidEndLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter { [self removePendingActionMask]; } #pragma mark - bwi helper for admin and mod sections - (Contact*)contactAtIndexPath:(NSIndexPath*)indexPath { Contact* contact; if (indexPath.section == adminSection) { if (currentSearchText.length && indexPath.row < filteredAdminParticipants.count) { contact = filteredAdminParticipants[indexPath.row]; } else if (indexPath.row < adminParticipants.count) { contact = adminParticipants[indexPath.row]; } } if (indexPath.section == modSection) { if (currentSearchText.length && indexPath.row < filteredModParticipants.count) { contact = filteredModParticipants[indexPath.row]; } else if (indexPath.row < modParticipants.count) { contact = modParticipants[indexPath.row]; } } if (indexPath.section == participantsSection || indexPath.section == invitedSection) { { NSInteger index = indexPath.row; NSArray *participants; if (indexPath.section == participantsSection) { if (currentSearchText.length) { participants = filteredActualParticipants; } else { participants = actualParticipants; } } else { if (currentSearchText.length) { participants = filteredInvitedParticipants; } else { participants = invitedParticipants; } } if (index < participants.count) { contact = participants[index]; } } } return contact; } - (NSComparisonResult)bwiParticipantsCompareLeft:(Contact*)contactA right:(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]; } else if (userA && !userB) { return NSOrderedAscending; } else if (!userA && userB) { return NSOrderedDescending; } else { NSString* userLabelA = [self.bwiUserLabelService getUserLabelWithUser:contactA.mxMember.userId]; NSString* userLabelB = [self.bwiUserLabelService getUserLabelWithUser:contactB.mxMember.userId]; if (userLabelA && !userLabelB) { return NSOrderedAscending; } else if (!userLabelA && userLabelB) { return NSOrderedDescending; } else { return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; } } } @end