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