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
@@ -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()),