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.
This commit is contained in:
Doug
2024-03-11 14:19:53 +00:00
committed by GitHub
parent 9ce12e6e16
commit 71721f01b1
37 changed files with 411 additions and 104 deletions
@@ -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";
@@ -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<State, Event>
@@ -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)
+12 -2
View File
@@ -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
@@ -2750,6 +2750,44 @@ class RoomProxyMock: RoomProxyProtocol {
return applyPowerLevelChangesReturnValue
}
}
//MARK: - resetPowerLevels
var resetPowerLevelsCallsCount = 0
var resetPowerLevelsCalled: Bool {
return resetPowerLevelsCallsCount > 0
}
var resetPowerLevelsReturnValue: Result<RoomPowerLevels, RoomProxyError>!
var resetPowerLevelsClosure: (() async -> Result<RoomPowerLevels, RoomProxyError>)?
func resetPowerLevels() async -> Result<RoomPowerLevels, RoomProxyError> {
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<RoomMemberRole, RoomProxyError>!
var suggestedRoleForClosure: ((String) async -> Result<RoomMemberRole, RoomProxyError>)?
func suggestedRole(for userID: String) async -> Result<RoomMemberRole, RoomProxyError> {
suggestedRoleForCallsCount += 1
suggestedRoleForReceivedUserID = userID
suggestedRoleForReceivedInvocations.append(userID)
if let suggestedRoleForClosure = suggestedRoleForClosure {
return await suggestedRoleForClosure(userID)
} else {
return suggestedRoleForReturnValue
}
}
//MARK: - updatePowerLevelsForUsers
var updatePowerLevelsForUsersCallsCount = 0
+13 -2
View File
@@ -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)
@@ -83,12 +83,14 @@ struct RoomChangeRolesScreenViewState: BindableState {
struct RoomChangeRolesScreenViewStateBindings {
var searchQuery = ""
/// Information about the currently displayed alert.
var alertInfo: AlertInfo<RoomChangePermissionsScreenAlertType>?
var alertInfo: AlertInfo<RoomChangeRolesScreenAlertType>?
}
enum RoomChangeRolesScreenAlertType {
/// A warning that a particular promotion can't be undone.
case promotionWarning
/// The generic error message.
case generic
case error
}
enum RoomChangeRolesScreenViewAction {
@@ -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)
}
}
@@ -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)
@@ -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<LoadableAvatarImage, EmptyView, EmptyView, Bool>.Kind<EmptyView, Bool>
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()
}
@@ -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
}
@@ -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()
}
}
@@ -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)
@@ -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)
@@ -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<RoomRolesAndPermissionsScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<RoomRolesAndPermissionsScreenCoordinatorAction, Never> {
private let actionsSubject: PassthroughSubject<RoomRolesAndPermissionsScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<RoomRolesAndPermissionsScreenCoordinatorAction, Never> {
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)
@@ -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<RoomRolesAndPermissionsScreenAlertType>?
}
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 {
@@ -21,14 +21,16 @@ typealias RoomRolesAndPermissionsScreenViewModelType = StateStoreViewModel<RoomR
class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewModelType, RoomRolesAndPermissionsScreenViewModelProtocol {
private let roomProxy: RoomProxyProtocol
private var actionsSubject: PassthroughSubject<RoomRolesAndPermissionsScreenViewModelAction, Never> = .init()
private let userIndicatorController: UserIndicatorControllerProtocol
var actions: AnyPublisher<RoomRolesAndPermissionsScreenViewModelAction, Never> {
private var actionsSubject: PassthroughSubject<RoomRolesAndPermissionsScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<RoomRolesAndPermissionsScreenViewModelAction, Never> {
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"))
}
}
@@ -18,6 +18,6 @@ import Combine
@MainActor
protocol RoomRolesAndPermissionsScreenViewModelProtocol {
var actions: AnyPublisher<RoomRolesAndPermissionsScreenViewModelAction, Never> { get }
var actionsPublisher: AnyPublisher<RoomRolesAndPermissionsScreenViewModelAction, Never> { get }
var context: RoomRolesAndPermissionsScreenViewModelType.Context { get }
}
@@ -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)
@@ -391,6 +391,24 @@ class RoomProxy: RoomProxyProtocol {
}
}
func resetPowerLevels() async -> Result<RoomPowerLevels, RoomProxyError> {
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<RoomMemberRole, RoomProxyError> {
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<Void, RoomProxyError> {
do {
let updates = updates.map { UserPowerLevelUpdate(userId: $0.userID, powerLevel: $0.powerLevel) }
@@ -117,6 +117,8 @@ protocol RoomProxyProtocol {
func powerLevels() async -> Result<RoomPowerLevels, RoomProxyError>
func applyPowerLevelChanges(_ changes: RoomPowerLevelChanges) async -> Result<Void, RoomProxyError>
func resetPowerLevels() async -> Result<RoomPowerLevels, RoomProxyError>
func suggestedRole(for userID: String) async -> Result<RoomMemberRole, RoomProxyError>
func updatePowerLevelsForUsers(_ updates: [(userID: String, powerLevel: Int64)]) async -> Result<Void, RoomProxyError>
func canUser(userID: String, sendStateEvent event: StateEventType) async -> Result<Bool, RoomProxyError>
func canUserInvite(userID: String) async -> Result<Bool, RoomProxyError>
@@ -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()),
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24fccfe79cfc77e77046fe423545001352ce1f4e0c66be73b6dacdd2f17a708f
size 181257
oid sha256:858050a8faf59df5a870e71bb813a621706fd927fb956fa395e4b364054f42fc
size 180368
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a2f1caedbf217a348a766ac55218d3b2d352e30c00115a7780f985a7b7ff0e7
size 184620
oid sha256:d7ad320baf038e985192ec9b2e6062b9b966f9fa46812a0a14ae1fb941e8dd87
size 183492
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e76bed7659ccba488a12c2a921752f91656a125b3878cb72c2e6b2e98d0ea0cd
size 126522
oid sha256:4489b0190f63dd13bc323dcffd192bd690b7a9c4e4f3dd726ff52812bb9d3251
size 124320
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7448e79e299c5ee48583c1e4b93fa9fe337adbf316f835097c06e4240779ccc4
size 96198
oid sha256:e94948fee9956def33e68b42127c82404f461a3fefcee91b607bb05453975a49
size 89868
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7bb98cc5979ae429fbf7de2858e4dd957780c710574727746407de518c028b7b
size 140127
oid sha256:9d51919e17f19f8113f5e1d6e67dc9a98e59d1da646abf7ba3ae94c88ecb6ff5
size 151191
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b64157a3dd3c8fd556a5349037e393a60d4fe0930d971b0f2a33162fc0763289
size 86659
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c308bcee54de0745343888fbba4a3e81191210b9f94bd2321e078e6254f66ea
size 139455
oid sha256:83c75e8d72cca22f3a1db789f84b6dfe0069e6d767bd49a64cea786dfca587ec
size 138099
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e6d8f7ec2e54e6a0e902fb6e8de29bce6f44466bd1c3ca184e628637d60d791
size 141623
oid sha256:b3a7e7c563474f884b73192da1f08388940df8ecf110892defbb8686b7f178df
size 139942
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f089620ba53ff2ca82906f3e0fb48e10d30c1361bf4fccfcd39da2ef94c39779
size 166898
oid sha256:22d594ed0da31669bd94c3c652e75b40d0a5905c426a3d6e8e7ce6e61a19a94e
size 165957
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:145d34a90da16c1832a0969c8ac5b3e5b0b7d4ece6f7ad9458f8130013c5122b
size 170216
oid sha256:75b5efa6846b4bc876a8975282072284aeea3d039f62a96bfd3ff6d6d401c171
size 168814
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab199d1b69f3731c67f43a9b9bc54cbc00a31f41d084383de618e276eb8844e4
size 140934
oid sha256:d85965fbbe40325603bba8c0b92d67ad14b8f7aabd7b7e29015f8e65654c50f0
size 139527
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e1dba096d136d86b03205791fd1a6fa5512d8eb05a4f794360dd7e82ce2f3a8
size 143435
oid sha256:78b6b80698f73bea4d04b61ef875a3a566e125b21c478e502e5a49316ebbeebe
size 141835
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1da36569570dcaa2aaf3fa00d1f9f4bd22e7bdf5dfb253c959b96064d21dd80e
size 169036
oid sha256:6baf9ad75b723e055aefe8bf3bb199c2392ef6358eb405959859ff245e1ae2d9
size 167898
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:623c1782386ae300a2dc7f33d5c1480e84a947875a5c0af1d217b6098efa135c
size 172592
oid sha256:c81449162c9db35b6979852818b25bae3fde5cd3ff8caf0f2fa4a43d0a162d83
size 171202
@@ -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))
}
@@ -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)
}
}