From 71721f01b10649a14fe28d4ff233e14624a64e6c Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Mon, 11 Mar 2024 14:19:53 +0000 Subject: [PATCH] Moderation tweaks (#2548) * Only allow admins to see the roles and permissions screen. * Hide the selection checkbox on Admins when changing roles. * Show an empty state for banned users. * Add separate actions for ban and remove. * Implement reset permissions and demote self alerts. * Add tests for resetting permissions and demoting self. * Add a warning when promoting someone to administrator. --- .../en.lproj/Localizable.strings | 9 ++- ...omRolesAndPermissionsFlowCoordinator.swift | 14 +++- ElementX/Sources/Generated/Strings.swift | 14 +++- .../Mocks/Generated/GeneratedMocks.swift | 38 +++++++++ ElementX/Sources/Mocks/RoomProxyMock.swift | 15 +++- .../RoomChangeRolesScreenModels.swift | 6 +- .../RoomChangeRolesScreenViewModel.swift | 18 ++++- .../View/RoomChangeRolesScreen.swift | 8 +- .../View/RoomChangeRolesScreenRow.swift | 22 ++++-- .../RoomDetailsScreenViewModel.swift | 2 +- .../RoomMembersListScreenViewModel.swift | 4 - .../RoomMembersListManageMemberSheet.swift | 46 +++++------ .../View/RoomMembersListScreen.swift | 28 ++++++- ...RolesAndPermissionsScreenCoordinator.swift | 13 ++- .../RoomRolesAndPermissionsScreenModels.swift | 20 +++++ ...omRolesAndPermissionsScreenViewModel.swift | 79 +++++++++++++++++-- ...ndPermissionsScreenViewModelProtocol.swift | 2 +- .../View/RoomRolesAndPermissionsScreen.swift | 4 +- .../Sources/Services/Room/RoomProxy.swift | 18 +++++ .../Services/Room/RoomProxyProtocol.swift | 2 + .../UITests/UITestsAppCoordinator.swift | 5 -- ...t_roomChangeRolesScreen.Administrators.png | 4 +- .../test_roomChangeRolesScreen.Moderators.png | 4 +- .../test_roomChangeRolesScreenRow.1.png | 4 +- ...oomMembersListManageMemberSheet.Banned.png | 4 +- ...oomMembersListManageMemberSheet.Joined.png | 4 +- ...omMembersListScreen.Admin-Empty-Banned.png | 3 + ...neration.roomRolesAndPermissionsFlow-1.png | 4 +- ...neration.roomRolesAndPermissionsFlow-2.png | 4 +- ...Phone-14.roomRolesAndPermissionsFlow-1.png | 4 +- ...Phone-14.roomRolesAndPermissionsFlow-2.png | 4 +- ...neration.roomRolesAndPermissionsFlow-1.png | 4 +- ...neration.roomRolesAndPermissionsFlow-2.png | 4 +- ...Phone-14.roomRolesAndPermissionsFlow-1.png | 4 +- ...Phone-14.roomRolesAndPermissionsFlow-2.png | 4 +- .../RoomChangeRolesScreenViewModelTests.swift | 33 +++++++- ...esAndPermissionsScreenViewModelTests.swift | 60 +++++++++++++- 37 files changed, 411 insertions(+), 104 deletions(-) create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListScreen.Admin-Empty-Banned.png diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 7e03be90c6..fda4118a30 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -75,6 +75,7 @@ "action_reply_in_thread" = "Reply in thread"; "action_report_bug" = "Report bug"; "action_report_content" = "Report content"; +"action_reset" = "Reset"; "action_retry" = "Retry"; "action_retry_decryption" = "Retry decryption"; "action_save" = "Save"; @@ -547,15 +548,17 @@ "screen_room_member_list_ban_member_confirmation_action" = "Ban"; "screen_room_member_list_ban_member_confirmation_description" = "They won’t be able to join this room again if invited."; "screen_room_member_list_ban_member_confirmation_title" = "Are you sure you want to ban this member?"; +"screen_room_member_list_banned_empty" = "There are no banned users in this room."; "screen_room_member_list_banning_user" = "Banning %1$@"; -"screen_room_member_list_manage_member_remove" = "Remove member"; +"screen_room_member_list_manage_member_ban" = "Remove and ban member"; +"screen_room_member_list_manage_member_remove" = "Remove from room"; "screen_room_member_list_manage_member_remove_confirmation_ban" = "Remove and ban member"; "screen_room_member_list_manage_member_remove_confirmation_kick" = "Only remove member"; "screen_room_member_list_manage_member_remove_confirmation_title" = "Remove member and ban from joining in the future?"; "screen_room_member_list_manage_member_unban_action" = "Unban"; "screen_room_member_list_manage_member_unban_message" = "They will be able to join this room again if invited."; "screen_room_member_list_manage_member_unban_title" = "Unban user"; -"screen_room_member_list_manage_member_user_info" = "See user info"; +"screen_room_member_list_manage_member_user_info" = "View profile"; "screen_room_member_list_mode_banned" = "Banned"; "screen_room_member_list_mode_members" = "Members"; "screen_room_member_list_pending_header_title" = "Pending"; @@ -592,6 +595,8 @@ "screen_room_roles_and_permissions_moderators" = "Moderators"; "screen_room_roles_and_permissions_permissions_header" = "Permissions"; "screen_room_roles_and_permissions_reset" = "Reset permissions"; +"screen_room_roles_and_permissions_reset_confirm_description" = "Once you reset permissions, you will lose your current settings."; +"screen_room_roles_and_permissions_reset_confirm_title" = "Reset permissions?"; "screen_room_roles_and_permissions_roles_header" = "Roles"; "screen_room_roles_and_permissions_room_details" = "Room details"; "screen_room_roles_and_permissions_title" = "Roles and permissions"; diff --git a/ElementX/Sources/FlowCoordinators/RoomRolesAndPermissionsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomRolesAndPermissionsFlowCoordinator.swift index d37deaa812..2f61c4740b 100644 --- a/ElementX/Sources/FlowCoordinators/RoomRolesAndPermissionsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomRolesAndPermissionsFlowCoordinator.swift @@ -54,8 +54,10 @@ class RoomRolesAndPermissionsFlowCoordinator: FlowCoordinatorProtocol { case finishedChangingRoles /// The user would like to change room permissions. case changePermissions - /// The user finished changing room permissions + /// The user finished changing room permissions. case finishedChangingPermissions + /// The user has demoted themself. + case demotedOwnUser } private let stateMachine: StateMachine @@ -118,20 +120,26 @@ class RoomRolesAndPermissionsFlowCoordinator: FlowCoordinatorProtocol { } stateMachine.addRoutes(event: .finishedChangingPermissions, transitions: [.changingPermissions => .rolesAndPermissionsScreen]) + stateMachine.addHandler(event: .demotedOwnUser) { [weak self] _ in + self?.actionsSubject.send(.complete) + } + stateMachine.addErrorHandler { context in fatalError("Unexpected transition: \(context)") } } private func presentRolesAndPermissionsScreen() { - let parameters = RoomRolesAndPermissionsScreenCoordinatorParameters(roomProxy: roomProxy) + let parameters = RoomRolesAndPermissionsScreenCoordinatorParameters(roomProxy: roomProxy, userIndicatorController: userIndicatorController) let coordinator = RoomRolesAndPermissionsScreenCoordinator(parameters: parameters) - coordinator.actions.sink { [stateMachine] action in + coordinator.actionsPublisher.sink { [stateMachine] action in switch action { case .editRoles(let role): stateMachine.tryEvent(.changeRoles, userInfo: role) case .editPermissions(let group): stateMachine.tryEvent(.changePermissions, userInfo: (group, RoomPermissions(powerLevels: .mock))) + case .demotedOwnUser: + stateMachine.tryEvent(.demotedOwnUser) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 00de741555..49b7df2519 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -184,6 +184,8 @@ internal enum L10n { internal static var actionReportBug: String { return L10n.tr("Localizable", "action_report_bug") } /// Report content internal static var actionReportContent: String { return L10n.tr("Localizable", "action_report_content") } + /// Reset + internal static var actionReset: String { return L10n.tr("Localizable", "action_reset") } /// Retry internal static var actionRetry: String { return L10n.tr("Localizable", "action_retry") } /// Retry decryption @@ -1341,6 +1343,8 @@ internal enum L10n { internal static var screenRoomMemberListBanMemberConfirmationDescription: String { return L10n.tr("Localizable", "screen_room_member_list_ban_member_confirmation_description") } /// Are you sure you want to ban this member? internal static var screenRoomMemberListBanMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_room_member_list_ban_member_confirmation_title") } + /// There are no banned users in this room. + internal static var screenRoomMemberListBannedEmpty: String { return L10n.tr("Localizable", "screen_room_member_list_banned_empty") } /// Banning %1$@ internal static func screenRoomMemberListBanningUser(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_room_member_list_banning_user", String(describing: p1)) @@ -1349,7 +1353,9 @@ internal enum L10n { internal static func screenRoomMemberListHeaderTitle(_ p1: Int) -> String { return L10n.tr("Localizable", "screen_room_member_list_header_title", p1) } - /// Remove member + /// Remove and ban member + internal static var screenRoomMemberListManageMemberBan: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_ban") } + /// Remove from room internal static var screenRoomMemberListManageMemberRemove: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove") } /// Remove and ban member internal static var screenRoomMemberListManageMemberRemoveConfirmationBan: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove_confirmation_ban") } @@ -1363,7 +1369,7 @@ internal enum L10n { internal static var screenRoomMemberListManageMemberUnbanMessage: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_unban_message") } /// Unban user internal static var screenRoomMemberListManageMemberUnbanTitle: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_unban_title") } - /// See user info + /// View profile internal static var screenRoomMemberListManageMemberUserInfo: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_user_info") } /// Banned internal static var screenRoomMemberListModeBanned: String { return L10n.tr("Localizable", "screen_room_member_list_mode_banned") } @@ -1449,6 +1455,10 @@ internal enum L10n { internal static var screenRoomRolesAndPermissionsPermissionsHeader: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_permissions_header") } /// Reset permissions internal static var screenRoomRolesAndPermissionsReset: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_reset") } + /// Once you reset permissions, you will lose your current settings. + internal static var screenRoomRolesAndPermissionsResetConfirmDescription: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_reset_confirm_description") } + /// Reset permissions? + internal static var screenRoomRolesAndPermissionsResetConfirmTitle: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_reset_confirm_title") } /// Roles internal static var screenRoomRolesAndPermissionsRolesHeader: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_roles_header") } /// Room details diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index c6c854bce4..7e615849d9 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2750,6 +2750,44 @@ class RoomProxyMock: RoomProxyProtocol { return applyPowerLevelChangesReturnValue } } + //MARK: - resetPowerLevels + + var resetPowerLevelsCallsCount = 0 + var resetPowerLevelsCalled: Bool { + return resetPowerLevelsCallsCount > 0 + } + var resetPowerLevelsReturnValue: Result! + var resetPowerLevelsClosure: (() async -> Result)? + + func resetPowerLevels() async -> Result { + resetPowerLevelsCallsCount += 1 + if let resetPowerLevelsClosure = resetPowerLevelsClosure { + return await resetPowerLevelsClosure() + } else { + return resetPowerLevelsReturnValue + } + } + //MARK: - suggestedRole + + var suggestedRoleForCallsCount = 0 + var suggestedRoleForCalled: Bool { + return suggestedRoleForCallsCount > 0 + } + var suggestedRoleForReceivedUserID: String? + var suggestedRoleForReceivedInvocations: [String] = [] + var suggestedRoleForReturnValue: Result! + var suggestedRoleForClosure: ((String) async -> Result)? + + func suggestedRole(for userID: String) async -> Result { + suggestedRoleForCallsCount += 1 + suggestedRoleForReceivedUserID = userID + suggestedRoleForReceivedInvocations.append(userID) + if let suggestedRoleForClosure = suggestedRoleForClosure { + return await suggestedRoleForClosure(userID) + } else { + return suggestedRoleForReturnValue + } + } //MARK: - updatePowerLevelsForUsers var updatePowerLevelsForUsersCallsCount = 0 diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index d4923a5756..b46e112776 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -42,7 +42,6 @@ struct RoomProxyMockConfiguration { }() var members: [RoomMemberProxyMock] = .allMembers - var memberForID: RoomMemberProxyMock = .mockMe var ownUserID = RoomMemberProxyMock.mockMe.userID var canUserInvite = true @@ -81,7 +80,12 @@ extension RoomProxyMock { underlyingActionsPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() setNameClosure = { _ in .success(()) } setTopicClosure = { _ in .success(()) } - getMemberUserIDReturnValue = .success(configuration.memberForID) + getMemberUserIDClosure = { [weak self] userID in + guard let member = self?.membersPublisher.value.first(where: { $0.userID == userID }) else { + return .failure(.failedRetrievingMember) + } + return .success(member) + } flagAsUnreadReturnValue = .success(()) markAsReadReceiptTypeReturnValue = .success(()) @@ -90,6 +94,13 @@ extension RoomProxyMock { powerLevelsReturnValue = .success(.mock) applyPowerLevelChangesReturnValue = .success(()) + resetPowerLevelsReturnValue = .success(.mock) + suggestedRoleForClosure = { [weak self] userID in + guard case .success(let member) = await self?.getMember(userID: userID) else { + return .failure(.failedCheckingPermission) + } + return .success(member.role) + } updatePowerLevelsForUsersReturnValue = .success(()) canUserUserIDSendStateEventClosure = { [weak self] userID, _ in .success(self?.membersPublisher.value.first { $0.userID == userID }?.role ?? .user != .user) diff --git a/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenModels.swift b/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenModels.swift index 14cf758b92..103086787c 100644 --- a/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenModels.swift +++ b/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenModels.swift @@ -83,12 +83,14 @@ struct RoomChangeRolesScreenViewState: BindableState { struct RoomChangeRolesScreenViewStateBindings { var searchQuery = "" /// Information about the currently displayed alert. - var alertInfo: AlertInfo? + var alertInfo: AlertInfo? } enum RoomChangeRolesScreenAlertType { + /// A warning that a particular promotion can't be undone. + case promotionWarning /// The generic error message. - case generic + case error } enum RoomChangeRolesScreenViewAction { diff --git a/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenViewModel.swift b/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenViewModel.swift index 1fec9e44d9..50c37b8834 100644 --- a/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomChangeRolesScreen/RoomChangeRolesScreenViewModel.swift @@ -64,7 +64,11 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh case .demoteMember(let member): demoteMember(member) case .save: - Task { await save() } + if state.mode == .administrator, !state.membersToPromote.isEmpty { + showPromotionWarning() + } else { + Task { await save() } + } } } @@ -99,6 +103,16 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh } } + private func showPromotionWarning() { + context.alertInfo = AlertInfo(id: .promotionWarning, + title: L10n.screenRoomChangeRoleConfirmAddAdminTitle, + message: L10n.screenRoomChangeRoleConfirmAddAdminDescription, + primaryButton: .init(title: L10n.actionContinue) { + Task { await self.save() } + }, + secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) + } + private func save() async { showSavingIndicator() @@ -112,7 +126,7 @@ class RoomChangeRolesScreenViewModel: RoomChangeRolesScreenViewModelType, RoomCh case .success: MXLog.info("Success") case .failure: - context.alertInfo = AlertInfo(id: .generic) + context.alertInfo = AlertInfo(id: .error) } } diff --git a/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreen.swift b/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreen.swift index 3995c980df..5b09e2d044 100644 --- a/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreen.swift +++ b/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreen.swift @@ -77,10 +77,10 @@ struct RoomChangeRolesScreen: View { ForEach(context.viewState.visibleMembers, id: \.id) { member in RoomChangeRolesScreenRow(member: member, imageProvider: context.imageProvider, - kind: .multiSelection(isSelected: context.viewState.isMemberSelected(member)) { - context.send(viewAction: .toggleMember(member)) - }) - .disabled(member.role == .administrator) + isSelected: context.viewState.isMemberSelected(member)) { + context.send(viewAction: .toggleMember(member)) + } + .disabled(member.role == .administrator) } } header: { Text(L10n.screenRoomMemberListRoomMembersHeaderTitle) diff --git a/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenRow.swift b/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenRow.swift index 5c9e5f2b45..3f57443fba 100644 --- a/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenRow.swift +++ b/ElementX/Sources/Screens/RoomChangeRolesScreen/View/RoomChangeRolesScreenRow.swift @@ -19,16 +19,19 @@ import MatrixRustSDK import SwiftUI struct RoomChangeRolesScreenRow: View { + @Environment(\.isEnabled) private var isEnabled + let member: RoomMemberDetails let imageProvider: ImageProviderProtocol? - let kind: ListRow.Kind + let isSelected: Bool + let action: () -> Void var body: some View { ListRow(label: .avatar(title: member.name ?? member.id, description: member.name == nil ? nil : member.id, icon: avatar), - kind: kind) + kind: isEnabled ? .multiSelection(isSelected: isSelected, action: action) : .label) } var avatar: LoadableAvatarImage { @@ -47,25 +50,30 @@ struct RoomChangeRolesScreenRow_Previews: PreviewProvider, TestablePreview { Form { RoomChangeRolesScreenRow(member: .init(withProxy: RoomMemberProxyMock.mockAlice), imageProvider: MockMediaProvider(), - kind: .multiSelection(isSelected: true, action: action)) + isSelected: true, + action: action) RoomChangeRolesScreenRow(member: .init(withProxy: RoomMemberProxyMock.mockBob), imageProvider: MockMediaProvider(), - kind: .multiSelection(isSelected: false, action: action)) + isSelected: false, + action: action) RoomChangeRolesScreenRow(member: .init(withProxy: RoomMemberProxyMock.mockCharlie), imageProvider: MockMediaProvider(), - kind: .multiSelection(isSelected: true, action: action)) + isSelected: true, + action: action) .disabled(true) RoomChangeRolesScreenRow(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@someone:matrix.org", membership: .join))), imageProvider: MockMediaProvider(), - kind: .multiSelection(isSelected: false, action: action)) + isSelected: false, + action: action) .disabled(true) RoomChangeRolesScreenRow(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@someone:matrix.org", membership: .join))), imageProvider: MockMediaProvider(), - kind: .multiSelection(isSelected: false, action: action)) + isSelected: false, + action: action) } .compoundList() } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index 705d027612..84672b57e1 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -178,7 +178,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr state.canEditRoomTopic = await roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomTopic) == .success(true) state.canEditRoomAvatar = await roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomAvatar) == .success(true) if appSettings.roomModerationEnabled { - state.canEditRolesOrPermissions = await roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomPowerLevels) == .success(true) + state.canEditRolesOrPermissions = await roomProxy.suggestedRole(for: roomProxy.ownUserID) == .success(.administrator) } state.canInviteUsers = await canInviteUsers } diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift index fc9d985dd9..b70ab92d2f 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift @@ -114,10 +114,6 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe self.state.canKickUsers = await canKickUsers == .success(true) self.state.canBanUsers = await canBanUsers == .success(true) - if state.bindings.mode == .banned, roomMembersDetails.bannedMembers.isEmpty { - state.bindings.mode = .members - } - hideLoader() } } diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListManageMemberSheet.swift b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListManageMemberSheet.swift index cc39191c6c..aef6a5cdca 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListManageMemberSheet.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListManageMemberSheet.swift @@ -19,9 +19,9 @@ import SwiftUI struct RoomMembersListManageMemberSheet: View { let member: RoomMemberDetails - let context: RoomMembersListScreenViewModel.Context + @ObservedObject var context: RoomMembersListScreenViewModel.Context - @State private var isPresentingRemoveConfirmation = false + @State private var isPresentingBanConfirmation = false var body: some View { Form { @@ -33,25 +33,25 @@ struct RoomMembersListManageMemberSheet: View { Section { ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberUserInfo, - icon: \.info), + icon: \.userProfileSolid), kind: .button { context.send(viewAction: .showMemberDetails(member)) }) - if !member.isBanned { + if context.viewState.canKickUsers, !member.isBanned { ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberRemove, - icon: \.block, - role: .destructive), + icon: \.close), kind: .button { - isPresentingRemoveConfirmation = true + context.send(viewAction: .kickMember(member)) }) - } else { - // Theoretically we shouldn't reach this branch but just in case we do. - ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberUnbanAction, + } + + if context.viewState.canBanUsers, !member.isBanned { + ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberBan, icon: \.block, role: .destructive), kind: .button { - context.send(viewAction: .unbanMember(member)) + isPresentingBanConfirmation = true }) } } @@ -59,20 +59,14 @@ struct RoomMembersListManageMemberSheet: View { .compoundList() .scrollBounceBehavior(.basedOnSize) .presentationDragIndicator(.visible) - .presentationDetents([.large, .fraction(0.5)]) // TODO: Use the ideal height somehow? - .confirmationDialog(L10n.screenRoomMemberListManageMemberRemoveConfirmationTitle, - isPresented: $isPresentingRemoveConfirmation, - titleVisibility: .visible) { - if context.viewState.canKickUsers { - Button(L10n.screenRoomMemberListManageMemberRemoveConfirmationKick) { - context.send(viewAction: .kickMember(member)) - } - } - if context.viewState.canBanUsers { - Button(L10n.screenRoomMemberListManageMemberRemoveConfirmationBan, role: .destructive) { - context.send(viewAction: .banMember(member)) - } + .presentationDetents([.large, .fraction(0.54)]) // TODO: Use the ideal height somehow? + .alert(L10n.screenRoomMemberListBanMemberConfirmationTitle, isPresented: $isPresentingBanConfirmation) { + Button(L10n.actionCancel, role: .cancel) { } + Button(L10n.screenRoomMemberListBanMemberConfirmationAction) { + context.send(viewAction: .banMember(member)) } + } message: { + Text(L10n.screenRoomMemberListBanMemberConfirmationDescription) } } } @@ -84,10 +78,12 @@ struct RoomMembersListManageMemberSheet_Previews: PreviewProvider, TestablePrevi RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockDan), context: viewModel.context) .previewDisplayName("Joined") + .snapshot(delay: 0.2) RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockBanned[3]), context: viewModel.context) .previewDisplayName("Banned") + .snapshot(delay: 0.2) } } @@ -107,7 +103,7 @@ struct RoomMembersListManageMemberSheetLive_Previews: PreviewProvider { private extension RoomMembersListScreenViewModel { static var mock: RoomMembersListScreenViewModel { RoomMembersListScreenViewModel(initialMode: .members, - roomProxy: RoomProxyMock(with: .init()), + roomProxy: RoomProxyMock(with: .init(members: .allMembersAsAdmin)), mediaProvider: MockMediaProvider(), userIndicatorController: ServiceLocator.shared.userIndicatorController, appSettings: ServiceLocator.shared.settings) diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift index 45554c83af..729a6b6adf 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift @@ -22,9 +22,7 @@ struct RoomMembersListScreen: View { var body: some View { ScrollView { - if context.viewState.canBanUsers, - context.viewState.bannedMembersCount > 0 { - // Maybe this should go into the search bar if it can be pinned when not focussed? + if context.viewState.canBanUsers { Picker("", selection: $context.mode) { Text(L10n.screenRoomMemberListModeMembers) .tag(RoomMembersListScreenMode.members) @@ -41,6 +39,16 @@ struct RoomMembersListScreen: View { bannedUsers } } + .overlay { + if context.mode == .banned, context.viewState.bannedMembersCount == 0 { + Text(L10n.screenRoomMemberListBannedEmpty) + .font(.compound.bodyMD) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .background(.compound.bgCanvasDefault) + } + } .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always)) .compoundSearchField() .autocorrectionDisabled() @@ -110,6 +118,7 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { static let invitesViewModel = makeViewModel(withInvites: true) static let adminViewModel = makeViewModel(isAdmin: true, initialMode: .members) static let bannedViewModel = makeViewModel(isAdmin: true, initialMode: .banned) + static let emptyBannedViewModel = makeViewModel(withBanned: false, isAdmin: true, initialMode: .banned) static var previews: some View { NavigationStack { @@ -135,9 +144,16 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { } .snapshot(delay: 1.0) .previewDisplayName("Admin: Banned") + + NavigationStack { + RoomMembersListScreen(context: emptyBannedViewModel.context) + } + .snapshot(delay: 1.0) + .previewDisplayName("Admin: Empty Banned") } static func makeViewModel(withInvites: Bool = false, + withBanned: Bool = true, isAdmin: Bool = false, initialMode: RoomMembersListScreenMode = .members) -> RoomMembersListScreenViewModel { let mockAdmin = RoomMemberProxyMock.mockAdmin @@ -150,7 +166,11 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { .mockCharlie, mockAdmin, .mockModerator - ] + RoomMemberProxyMock.mockBanned + ] + + if withBanned { + members.append(contentsOf: RoomMemberProxyMock.mockBanned) + } if withInvites { members.append(.mockInvited) diff --git a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenCoordinator.swift index d4b338239a..737bab1368 100644 --- a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenCoordinator.swift @@ -21,28 +21,31 @@ import SwiftUI struct RoomRolesAndPermissionsScreenCoordinatorParameters { let roomProxy: RoomProxyProtocol + let userIndicatorController: UserIndicatorControllerProtocol } enum RoomRolesAndPermissionsScreenCoordinatorAction { case editRoles(RoomRolesAndPermissionsScreenRole) case editPermissions(RoomRolesAndPermissionsScreenPermissionsGroup) + case demotedOwnUser } final class RoomRolesAndPermissionsScreenCoordinator: CoordinatorProtocol { private var viewModel: RoomRolesAndPermissionsScreenViewModelProtocol - private let actionsSubject: PassthroughSubject = .init() private var cancellables = Set() - var actions: AnyPublisher { + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } init(parameters: RoomRolesAndPermissionsScreenCoordinatorParameters) { - viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: parameters.roomProxy) + viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: parameters.roomProxy, + userIndicatorController: parameters.userIndicatorController) } func start() { - viewModel.actions.sink { [weak self] action in + viewModel.actionsPublisher.sink { [weak self] action in MXLog.info("Coordinator: received view model action: \(action)") guard let self else { return } @@ -51,6 +54,8 @@ final class RoomRolesAndPermissionsScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.editRoles(role)) case .editPermissions(let permissionsGroup): actionsSubject.send(.editPermissions(permissionsGroup)) + case .demotedOwnUser: + actionsSubject.send(.demotedOwnUser) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenModels.swift b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenModels.swift index 6c24538a7f..fa12f379a1 100644 --- a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenModels.swift @@ -17,13 +17,33 @@ import Foundation enum RoomRolesAndPermissionsScreenViewModelAction { + /// The user would like to edit member roles. case editRoles(RoomRolesAndPermissionsScreenRole) + /// The user would like to edit room permissions. case editPermissions(RoomRolesAndPermissionsScreenPermissionsGroup) + /// The user has demoted themself. + case demotedOwnUser } struct RoomRolesAndPermissionsScreenViewState: BindableState { + /// The number of administrators in the room. var administratorCount: Int? + /// The number of moderators in the room. var moderatorCount: Int? + var bindings = RoomRolesAndPermissionsScreenViewStateBindings() +} + +struct RoomRolesAndPermissionsScreenViewStateBindings { + var alertInfo: AlertInfo? +} + +enum RoomRolesAndPermissionsScreenAlertType { + /// Ask the user which role they would like to demote themself to. + case editOwnRole + /// Confirm that the user would like to reset the room's permissions. + case resetConfirmation + /// An error occurred. + case error } enum RoomRolesAndPermissionsScreenViewAction { diff --git a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModel.swift b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModel.swift index 41da993826..b611b9836d 100644 --- a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModel.swift @@ -21,14 +21,16 @@ typealias RoomRolesAndPermissionsScreenViewModelType = StateStoreViewModel = .init() + private let userIndicatorController: UserIndicatorControllerProtocol - var actions: AnyPublisher { + private var actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(roomProxy: RoomProxyProtocol) { + init(roomProxy: RoomProxyProtocol, userIndicatorController: UserIndicatorControllerProtocol) { self.roomProxy = roomProxy + self.userIndicatorController = userIndicatorController super.init(initialViewState: RoomRolesAndPermissionsScreenViewState()) roomProxy.membersPublisher.sink { [weak self] members in @@ -48,11 +50,28 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM case .editRoles(let role): actionsSubject.send(.editRoles(role)) case .editOwnUserRole: - break + state.bindings.alertInfo = AlertInfo(id: .resetConfirmation, + title: L10n.screenRoomRolesAndPermissionsChangeMyRole, + message: L10n.screenRoomChangeRoleConfirmDemoteSelfDescription, + verticalButtons: [ + .init(title: L10n.screenRoomRolesAndPermissionsChangeRoleDemoteToModerator, role: .destructive) { + Task { await self.updateOwnRole(.moderator) } + }, + .init(title: L10n.screenRoomRolesAndPermissionsChangeRoleDemoteToMember, role: .destructive) { + Task { await self.updateOwnRole(.user) } + }, + .init(title: L10n.actionCancel, role: .cancel) { } + ]) case .editPermissions(let permissionsGroup): actionsSubject.send(.editPermissions(permissionsGroup)) case .reset: - break + state.bindings.alertInfo = AlertInfo(id: .resetConfirmation, + title: L10n.screenRoomRolesAndPermissionsResetConfirmTitle, + message: L10n.screenRoomRolesAndPermissionsResetConfirmDescription, + primaryButton: .init(title: L10n.actionReset, role: .destructive) { + Task { await self.resetPermissions() } + }, + secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { }) } } @@ -62,4 +81,54 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM state.administratorCount = members.filter { $0.role == .administrator }.count state.moderatorCount = members.filter { $0.role == .moderator }.count } + + private func updateOwnRole(_ role: RoomMemberDetails.Role) async { + showSavingIndicator() + + switch await roomProxy.updatePowerLevelsForUsers([(userID: roomProxy.ownUserID, powerLevel: role.rustPowerLevel)]) { + case .success: + showSuccessIndicator() + actionsSubject.send(.demotedOwnUser) + case .failure: + state.bindings.alertInfo = AlertInfo(id: .error) + } + + hideSavingIndicator() + } + + private func resetPermissions() async { + showSavingIndicator() + + switch await roomProxy.resetPowerLevels() { + case .success(let success): + showSuccessIndicator() + case .failure(let failure): + state.bindings.alertInfo = AlertInfo(id: .error) + } + + hideSavingIndicator() + } + + // MARK: Loading indicator + + private static let savingIndicatorID = "RolesAndPermissionsSaving" + private static let successIndicatorID = "RolesAndPermissionsSuccess" + + private func showSavingIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: Self.savingIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.commonSaving, + persistent: true)) + } + + private func hideSavingIndicator() { + userIndicatorController.retractIndicatorWithId(Self.savingIndicatorID) + } + + private func showSuccessIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: Self.successIndicatorID, + type: .toast, + title: L10n.commonSuccess, + iconName: "checkmark")) + } } diff --git a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModelProtocol.swift index 2e9367fcc7..02a60e2f56 100644 --- a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/RoomRolesAndPermissionsScreenViewModelProtocol.swift @@ -18,6 +18,6 @@ import Combine @MainActor protocol RoomRolesAndPermissionsScreenViewModelProtocol { - var actions: AnyPublisher { get } + var actionsPublisher: AnyPublisher { get } var context: RoomRolesAndPermissionsScreenViewModelType.Context { get } } diff --git a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/View/RoomRolesAndPermissionsScreen.swift b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/View/RoomRolesAndPermissionsScreen.swift index ab9adbd070..7f1f6025c5 100644 --- a/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/View/RoomRolesAndPermissionsScreen.swift +++ b/ElementX/Sources/Screens/RoomRolesAndPermissionsScreen/View/RoomRolesAndPermissionsScreen.swift @@ -30,6 +30,7 @@ struct RoomRolesAndPermissionsScreen: View { .compoundList() .navigationTitle(L10n.screenRoomRolesAndPermissionsTitle) .navigationBarTitleDisplayMode(.inline) + .alert(item: $context.alertInfo) } private var rolesSection: some View { @@ -119,7 +120,8 @@ struct RoomRolesAndPermissionsScreen: View { // MARK: - Previews struct RoomRolesAndPermissionsScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: RoomProxyMock(with: .init(members: .allMembersAsAdmin))) + static let viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: RoomProxyMock(with: .init(members: .allMembersAsAdmin)), + userIndicatorController: UserIndicatorControllerMock()) static var previews: some View { NavigationStack { RoomRolesAndPermissionsScreen(context: viewModel.context) diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 2499de6c49..c7e20ac248 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -391,6 +391,24 @@ class RoomProxy: RoomProxyProtocol { } } + func resetPowerLevels() async -> Result { + do { + return try await .success(room.resetPowerLevels()) + } catch { + MXLog.error("Failed resetting the power levels: \(error)") + return .failure(.failedSettingPermission) + } + } + + func suggestedRole(for userID: String) async -> Result { + do { + return try await .success(room.suggestedRoleForUser(userId: userID)) + } catch { + MXLog.error("Failed getting a user's role: \(error)") + return .failure(.failedCheckingPermission) + } + } + func updatePowerLevelsForUsers(_ updates: [(userID: String, powerLevel: Int64)]) async -> Result { do { let updates = updates.map { UserPowerLevelUpdate(userId: $0.userID, powerLevel: $0.powerLevel) } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index aa319db2b1..7e864b3eb9 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -117,6 +117,8 @@ protocol RoomProxyProtocol { func powerLevels() async -> Result func applyPowerLevelChanges(_ changes: RoomPowerLevelChanges) async -> Result + func resetPowerLevels() async -> Result + func suggestedRole(for userID: String) async -> Result func updatePowerLevelsForUsers(_ updates: [(userID: String, powerLevel: Int64)]) async -> Result func canUser(userID: String, sendStateEvent event: StateEventType) async -> Result func canUserInvite(userID: String) async -> Result diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 00d47eb51e..5c0d52f82a 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -586,7 +586,6 @@ class MockScreen: Identifiable { name: "Room", isEncrypted: true, members: members, - memberForID: .mockMe, canUserInvite: false)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(roomProxy: roomProxy, clientProxy: ClientProxyMock(.init()), @@ -609,7 +608,6 @@ class MockScreen: Identifiable { isEncrypted: true, canonicalAlias: "#mock:room.org", members: members, - memberForID: .mockMe, canUserInvite: false)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(roomProxy: roomProxy, clientProxy: ClientProxyMock(.init()), @@ -633,7 +631,6 @@ class MockScreen: Identifiable { isEncrypted: true, canonicalAlias: "#mock:room.org", members: members, - memberForID: .mockMeAdmin, canUserInvite: false)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(roomProxy: roomProxy, clientProxy: ClientProxyMock(.init()), @@ -654,7 +651,6 @@ class MockScreen: Identifiable { name: "Room", isEncrypted: true, members: members, - memberForID: owner, canUserInvite: true)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(roomProxy: roomProxy, clientProxy: ClientProxyMock(.init()), @@ -676,7 +672,6 @@ class MockScreen: Identifiable { isDirect: true, isEncrypted: true, members: members, - memberForID: .mockMe, canUserInvite: false)) let coordinator = RoomDetailsScreenCoordinator(parameters: .init(roomProxy: roomProxy, clientProxy: ClientProxyMock(.init()), diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Administrators.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Administrators.png index b7201d6ec5..8e08f1b638 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Administrators.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Administrators.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24fccfe79cfc77e77046fe423545001352ce1f4e0c66be73b6dacdd2f17a708f -size 181257 +oid sha256:858050a8faf59df5a870e71bb813a621706fd927fb956fa395e4b364054f42fc +size 180368 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Moderators.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Moderators.png index e4f08c18bf..072e74afb4 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Moderators.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreen.Moderators.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a2f1caedbf217a348a766ac55218d3b2d352e30c00115a7780f985a7b7ff0e7 -size 184620 +oid sha256:d7ad320baf038e985192ec9b2e6062b9b966f9fa46812a0a14ae1fb941e8dd87 +size 183492 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreenRow.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreenRow.1.png index 5152b2aa22..f02f2379a6 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreenRow.1.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomChangeRolesScreenRow.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e76bed7659ccba488a12c2a921752f91656a125b3878cb72c2e6b2e98d0ea0cd -size 126522 +oid sha256:4489b0190f63dd13bc323dcffd192bd690b7a9c4e4f3dd726ff52812bb9d3251 +size 124320 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png index b249a0fd9c..70322b7fcd 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7448e79e299c5ee48583c1e4b93fa9fe337adbf316f835097c06e4240779ccc4 -size 96198 +oid sha256:e94948fee9956def33e68b42127c82404f461a3fefcee91b607bb05453975a49 +size 89868 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png index 8422b2a14d..2678be860a 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7bb98cc5979ae429fbf7de2858e4dd957780c710574727746407de518c028b7b -size 140127 +oid sha256:9d51919e17f19f8113f5e1d6e67dc9a98e59d1da646abf7ba3ae94c88ecb6ff5 +size 151191 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListScreen.Admin-Empty-Banned.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListScreen.Admin-Empty-Banned.png new file mode 100644 index 0000000000..8e5ec95054 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListScreen.Admin-Empty-Banned.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b64157a3dd3c8fd556a5349037e393a60d4fe0930d971b0f2a33162fc0763289 +size 86659 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png index fa4fc134a2..7cefc56e62 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c308bcee54de0745343888fbba4a3e81191210b9f94bd2321e078e6254f66ea -size 139455 +oid sha256:83c75e8d72cca22f3a1db789f84b6dfe0069e6d767bd49a64cea786dfca587ec +size 138099 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png index b6c1a4f442..8588f48b11 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e6d8f7ec2e54e6a0e902fb6e8de29bce6f44466bd1c3ca184e628637d60d791 -size 141623 +oid sha256:b3a7e7c563474f884b73192da1f08388940df8ecf110892defbb8686b7f178df +size 139942 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-1.png index eff258fe8e..ea9fb9c56e 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-1.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f089620ba53ff2ca82906f3e0fb48e10d30c1361bf4fccfcd39da2ef94c39779 -size 166898 +oid sha256:22d594ed0da31669bd94c3c652e75b40d0a5905c426a3d6e8e7ce6e61a19a94e +size 165957 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-2.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-2.png index c8a415d730..bda81cca0c 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-2.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomRolesAndPermissionsFlow-2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:145d34a90da16c1832a0969c8ac5b3e5b0b7d4ece6f7ad9458f8130013c5122b -size 170216 +oid sha256:75b5efa6846b4bc876a8975282072284aeea3d039f62a96bfd3ff6d6d401c171 +size 168814 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png index 688686887f..ed7a7c9d17 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab199d1b69f3731c67f43a9b9bc54cbc00a31f41d084383de618e276eb8844e4 -size 140934 +oid sha256:d85965fbbe40325603bba8c0b92d67ad14b8f7aabd7b7e29015f8e65654c50f0 +size 139527 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png index b6efdcfc2c..2fc689ac6f 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomRolesAndPermissionsFlow-2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e1dba096d136d86b03205791fd1a6fa5512d8eb05a4f794360dd7e82ce2f3a8 -size 143435 +oid sha256:78b6b80698f73bea4d04b61ef875a3a566e125b21c478e502e5a49316ebbeebe +size 141835 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-1.png index 1424f11897..c289b5871c 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-1.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1da36569570dcaa2aaf3fa00d1f9f4bd22e7bdf5dfb253c959b96064d21dd80e -size 169036 +oid sha256:6baf9ad75b723e055aefe8bf3bb199c2392ef6358eb405959859ff245e1ae2d9 +size 167898 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-2.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-2.png index 5e5f26c0dc..3720cb8ed3 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-2.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomRolesAndPermissionsFlow-2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:623c1782386ae300a2dc7f33d5c1480e84a947875a5c0af1d217b6098efa135c -size 172592 +oid sha256:c81449162c9db35b6979852818b25bae3fde5cd3ff8caf0f2fa4a43d0a162d83 +size 171202 diff --git a/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift b/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift index 7898f98407..4c79ad2dfc 100644 --- a/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomChangeRolesScreenViewModelTests.swift @@ -148,7 +148,8 @@ class RoomChangeRolesScreenViewModelTests: XCTestCase { XCTAssertTrue(context.viewState.hasChanges) } - func testSaveChanges() async throws { + func testSaveModeratorChanges() async throws { + // Given the change roles view model for moderators. setupRoomProxy() viewModel = RoomChangeRolesScreenViewModel(mode: .moderator, roomProxy: roomProxy, userIndicatorController: UserIndicatorControllerMock()) @@ -158,18 +159,48 @@ class RoomChangeRolesScreenViewModelTests: XCTestCase { return } + // When promoting a regular user and demoting a moderator. context.send(viewAction: .toggleMember(firstUser)) context.send(viewAction: .toggleMember(existingModerator)) context.send(viewAction: .save) try await Task.sleep(for: .milliseconds(100)) + // Then no warning should be shown, and the call to update the users should be made straight away. XCTAssertTrue(roomProxy.updatePowerLevelsForUsersCalled) XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.count, 2) XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains(where: { $0.userID == existingModerator.id && $0.powerLevel == 0 }), true) XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains(where: { $0.userID == firstUser.id && $0.powerLevel == 50 }), true) } + func testSavePromotedAdministrator() async throws { + // Given the change roles view model for administrators. + setupRoomProxy() + viewModel = RoomChangeRolesScreenViewModel(mode: .administrator, roomProxy: roomProxy, userIndicatorController: UserIndicatorControllerMock()) + XCTAssertNil(context.alertInfo) + + guard let firstUser = context.viewState.members.first(where: { !context.viewState.isMemberSelected($0) }) else { + XCTFail("There should be a regular user to begin with.") + return + } + + // When saving changes to promote a user to an administrator. + context.send(viewAction: .toggleMember(firstUser)) + context.send(viewAction: .save) + + // Then an alert should be shown to warn the action cannot be undone. + XCTAssertNotNil(context.alertInfo) + + // When confirming the prompt + context.alertInfo?.primaryButton.action?() + try await Task.sleep(for: .milliseconds(100)) + + // Then the user should be made into an administrator. + XCTAssertTrue(roomProxy.updatePowerLevelsForUsersCalled) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.count, 1) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.contains(where: { $0.userID == firstUser.id && $0.powerLevel == 100 }), true) + } + private func setupRoomProxy() { roomProxy = RoomProxyMock(with: .init(members: .allMembersAsAdmin)) } diff --git a/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift b/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift index 7938f25321..4e26d705a4 100644 --- a/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomRolesAndPermissionsScreenViewModelTests.swift @@ -21,20 +21,74 @@ import XCTest @MainActor class RoomRolesAndPermissionsScreenViewModelTests: XCTestCase { var viewModel: RoomRolesAndPermissionsScreenViewModelProtocol! + var roomProxy: RoomProxyMock! var context: RoomRolesAndPermissionsScreenViewModelType.Context { viewModel.context } func testEmptyCounters() { - viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: RoomProxyMock(with: .init())) + roomProxy = RoomProxyMock(with: .init()) + viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: roomProxy, + userIndicatorController: UserIndicatorControllerMock()) XCTAssertEqual(context.viewState.administratorCount, 0) XCTAssertEqual(context.viewState.moderatorCount, 0) } - func testFilledCounters() async throws { - viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: RoomProxyMock(with: .init(members: .allMembersAsAdmin))) + func testFilledCounters() { + roomProxy = RoomProxyMock(with: .init(members: .allMembersAsAdmin)) + viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: roomProxy, + userIndicatorController: UserIndicatorControllerMock()) XCTAssertEqual(context.viewState.administratorCount, 2) XCTAssertEqual(context.viewState.moderatorCount, 1) } + + func testResetPermissions() async throws { + roomProxy = RoomProxyMock(with: .init(members: .allMembersAsAdmin)) + viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: roomProxy, + userIndicatorController: UserIndicatorControllerMock()) + + context.send(viewAction: .reset) + XCTAssertNotNil(context.alertInfo) + + context.alertInfo?.primaryButton.action?() + + try await Task.sleep(for: .milliseconds(100)) + + XCTAssertTrue(roomProxy.resetPowerLevelsCalled) + } + + func testDemoteToModerator() async throws { + roomProxy = RoomProxyMock(with: .init(members: .allMembersAsAdmin)) + viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: roomProxy, + userIndicatorController: UserIndicatorControllerMock()) + + context.send(viewAction: .editOwnUserRole) + XCTAssertNotNil(context.alertInfo) + + context.alertInfo?.verticalButtons?.first(where: { $0.title.localizedStandardContains("moderator") })?.action?() + + try await Task.sleep(for: .milliseconds(100)) + + XCTAssertTrue(roomProxy.updatePowerLevelsForUsersCalled) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.first?.powerLevel, + RoomMemberDetails.Role.moderator.rustPowerLevel) + } + + func testDemoteToMember() async throws { + roomProxy = RoomProxyMock(with: .init(members: .allMembersAsAdmin)) + viewModel = RoomRolesAndPermissionsScreenViewModel(roomProxy: roomProxy, + userIndicatorController: UserIndicatorControllerMock()) + + context.send(viewAction: .editOwnUserRole) + XCTAssertNotNil(context.alertInfo) + + context.alertInfo?.verticalButtons?.first(where: { $0.title.localizedStandardContains("member") })?.action?() + + try await Task.sleep(for: .milliseconds(100)) + + XCTAssertTrue(roomProxy.updatePowerLevelsForUsersCalled) + XCTAssertEqual(roomProxy.updatePowerLevelsForUsersReceivedUpdates?.first?.powerLevel, + RoomMemberDetails.Role.user.rustPowerLevel) + } }