Kick/ban room members (#2501)

* Add a temporary membershipChangePublisher on the timeline.

* Kick/Ban users from a room.

* Unit tests.

* Disable autocorrection on the members list search field.
This commit is contained in:
Doug
2024-02-27 13:02:35 +00:00
committed by GitHub
parent 1afde10ce2
commit fdbaef5aee
30 changed files with 561 additions and 43 deletions
@@ -20,6 +20,7 @@ import SwiftUI
struct RoomMembersListScreenCoordinatorParameters {
let mediaProvider: MediaProviderProtocol
let roomProxy: RoomProxyProtocol
let appSettings: AppSettings
}
enum RoomMembersListScreenCoordinatorAction {
@@ -40,7 +41,8 @@ final class RoomMembersListScreenCoordinator: CoordinatorProtocol {
init(parameters: RoomMembersListScreenCoordinatorParameters) {
viewModel = RoomMembersListScreenViewModel(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appSettings: parameters.appSettings)
}
func start() {
@@ -38,6 +38,7 @@ struct RoomMembersListScreenViewState: BindableState {
var bannedMembersCount: Int { bannedMembers.count }
var canInviteUsers = false
var canKickUsers = false
var canBanUsers = false
var bindings: RoomMembersListScreenViewStateBindings
@@ -74,16 +75,26 @@ struct RoomMembersListScreenViewStateBindings {
var searchQuery = ""
/// The current mode the screen is in.
var mode: RoomMembersListScreenMode = .members
/// A selected member to kick, ban, promote etc.
var memberToManage: RoomMemberDetails?
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomDetailsScreenErrorType>?
var alertInfo: AlertInfo<RoomMembersListScreenAlertType>?
}
enum RoomMembersListScreenViewAction {
case selectMember(id: String)
case selectMember(RoomMemberDetails)
case showMemberDetails(RoomMemberDetails)
case kickMember(RoomMemberDetails)
case banMember(RoomMemberDetails)
case unbanMember(RoomMemberDetails)
case invite
}
enum RoomMembersListScreenAlertType: Hashable {
case unbanConfirmation(RoomMemberDetails)
}
private extension RoomMemberDetails {
func matches(searchQuery: String) -> Bool {
guard !searchQuery.isEmpty else {
@@ -22,6 +22,7 @@ typealias RoomMembersListScreenViewModelType = StateStoreViewModel<RoomMembersLi
class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMembersListScreenViewModelProtocol {
private let roomProxy: RoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appSettings: AppSettings
private var members: [RoomMemberProxyProtocol] = []
@@ -34,9 +35,11 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
init(initialMode: RoomMembersListScreenMode = .members,
roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
appSettings: AppSettings) {
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
super.init(initialViewState: .init(joinedMembersCount: roomProxy.joinedMembersCount,
bindings: .init(mode: initialMode)),
@@ -49,12 +52,16 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
override func process(viewAction: RoomMembersListScreenViewAction) {
switch viewAction {
case .selectMember(let id):
guard let member = members.first(where: { $0.userID == id }) else {
MXLog.error("Selected member \(id) not found")
return
}
actionsSubject.send(.selectMember(member))
case .selectMember(let member):
selectMember(member)
case .showMemberDetails(let member):
showMemberDetails(member)
case .kickMember(let member):
Task { await kickMember(member) }
case .banMember(let member):
Task { await banMember(member) }
case .unbanMember(let member):
Task { await unbanMember(member) }
case .invite:
actionsSubject.send(.invite)
}
@@ -64,7 +71,7 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
hideLoader()
}
// MARK: - Private
// MARK: - Members
private func setupMembers() {
Task {
@@ -80,11 +87,17 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
self?.updateState(members: members)
}
.store(in: &cancellables)
roomProxy.timeline.timelineProvider.membershipChangePublisher.sink { [roomProxy] _ in
Task { await roomProxy.updateMembers() }
}
.store(in: &cancellables)
}
private func updateState(members: [RoomMemberProxyProtocol]) {
Task {
showLoader()
let members = members.sorted()
let roomMembersDetails = await buildMembersDetails(members: members)
self.members = members
@@ -95,8 +108,13 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
bindings: state.bindings)
if let accountOwner = roomMembersDetails.accountOwner {
self.state.canInviteUsers = accountOwner.canInviteUsers
self.state.canKickUsers = accountOwner.canKickUsers
self.state.canBanUsers = accountOwner.canBanUsers
}
if state.bindings.mode == .banned, roomMembersDetails.bannedMembers.isEmpty {
state.bindings.mode = .members
}
hideLoader()
}
}
@@ -134,6 +152,76 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
.value
}
private func selectMember(_ member: RoomMemberDetails) {
if appSettings.roomModerationEnabled, state.canKickUsers || state.canBanUsers {
if member.isBanned {
state.bindings.alertInfo = AlertInfo(id: .unbanConfirmation(member),
title: L10n.screenRoomMemberListManageMemberUnbanTitle,
message: L10n.screenRoomMemberListManageMemberUnbanMessage,
primaryButton: .init(title: L10n.screenRoomMemberListManageMemberUnbanAction) { [weak self] in
self?.context.send(viewAction: .unbanMember(member))
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { })
} else {
state.bindings.memberToManage = member
}
} else {
showMemberDetails(member)
}
}
private func showMemberDetails(_ member: RoomMemberDetails) {
guard let member = members.first(where: { $0.userID == member.id }) else {
MXLog.error("Selected member \(member.id) not found")
return
}
actionsSubject.send(.selectMember(member))
state.bindings.memberToManage = nil
}
// MARK: - Member Management
private func kickMember(_ member: RoomMemberDetails) async {
let indicatorTitle = L10n.screenRoomMemberListRemovingUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.kickUser(member.id) {
case .success:
state.bindings.memberToManage = nil
hideManageMemberIndicator(title: indicatorTitle)
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
private func banMember(_ member: RoomMemberDetails) async {
let indicatorTitle = L10n.screenRoomMemberListBanningUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.banUser(member.id) {
case .success:
state.bindings.memberToManage = nil
hideManageMemberIndicator(title: indicatorTitle)
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
private func unbanMember(_ member: RoomMemberDetails) async {
let indicatorTitle = L10n.screenRoomMemberListUnbanningUser(member.name ?? member.id)
showManageMemberIndicator(title: indicatorTitle)
switch await roomProxy.unbanUser(member.id) {
case .success:
state.bindings.memberToManage = nil
hideManageMemberIndicator(title: indicatorTitle)
case .failure:
showManageMemberFailure(title: indicatorTitle)
}
}
// MARK: - Indicators
private let userIndicatorID = UUID().uuidString
private func showLoader() {
@@ -147,6 +235,22 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
private func hideLoader() {
userIndicatorController.retractIndicatorWithId(userIndicatorID)
}
private func showManageMemberIndicator(title: String) {
userIndicatorController.submitIndicator(UserIndicator(id: title,
type: .toast(progress: .indeterminate),
title: title,
persistent: true))
}
private func hideManageMemberIndicator(title: String) {
userIndicatorController.retractIndicatorWithId(title)
}
private func showManageMemberFailure(title: String) {
userIndicatorController.retractIndicatorWithId(title)
userIndicatorController.submitIndicator(UserIndicator(title: L10n.commonFailed, iconName: "xmark"))
}
}
private struct RoomMembersDetails {
@@ -0,0 +1,115 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
struct RoomMembersListManageMemberSheet: View {
let member: RoomMemberDetails
let context: RoomMembersListScreenViewModel.Context
@State private var isPresentingRemoveConfirmation = false
var body: some View {
Form {
AvatarHeaderView(member: member,
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider) {
EmptyView()
}
Section {
ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberUserInfo,
icon: \.info),
kind: .button {
context.send(viewAction: .showMemberDetails(member))
})
if !member.isBanned {
ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberRemove,
icon: \.block,
role: .destructive),
kind: .button {
isPresentingRemoveConfirmation = true
})
} else {
// Theoretically we shouldn't reach this branch but just in case we do.
ListRow(label: .default(title: L10n.screenRoomMemberListManageMemberUnbanAction,
icon: \.block,
role: .destructive),
kind: .button {
context.send(viewAction: .unbanMember(member))
})
}
}
}
.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))
}
}
}
}
}
struct RoomMembersListManageMemberSheet_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomMembersListScreenViewModel.mock
static var previews: some View {
RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockDan),
context: viewModel.context)
.previewDisplayName("Joined")
RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockBanned[3]),
context: viewModel.context)
.previewDisplayName("Banned")
}
}
struct RoomMembersListManageMemberSheetLive_Previews: PreviewProvider {
static let viewModel = RoomMembersListScreenViewModel.mock
static var previews: some View {
Color.clear
.sheet(isPresented: .constant(true)) {
RoomMembersListManageMemberSheet(member: .init(withProxy: RoomMemberProxyMock.mockDan),
context: viewModel.context)
}
.previewDisplayName("Sheet")
}
}
private extension RoomMembersListScreenViewModel {
static var mock: RoomMembersListScreenViewModel {
RoomMembersListScreenViewModel(initialMode: .members,
roomProxy: RoomProxyMock(with: .init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appSettings: ServiceLocator.shared.settings)
}
}
@@ -43,14 +43,14 @@ struct RoomMembersListScreen: View {
}
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
.compoundSearchField()
.autocorrectionDisabled()
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.navigationTitle(L10n.commonPeople)
.alert(item: $context.alertInfo)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
inviteButton
}
.sheet(item: $context.memberToManage) {
RoomMembersListManageMemberSheet(member: $0, context: context)
}
.alert(item: $context.alertInfo)
.toolbar { toolbar }
.track(screen: .RoomMembers)
}
@@ -91,13 +91,13 @@ struct RoomMembersListScreen: View {
}
}
@ViewBuilder
private var inviteButton: some View {
if context.viewState.canInviteUsers {
Button {
context.send(viewAction: .invite)
} label: {
Text(L10n.actionInvite)
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
if context.viewState.canInviteUsers {
Button(L10n.actionInvite) {
context.send(viewAction: .invite)
}
}
}
}
@@ -162,6 +162,7 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview {
return RoomMembersListScreenViewModel(initialMode: initialMode,
roomProxy: RoomProxyMock(with: .init(name: "Some room", members: members)),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appSettings: ServiceLocator.shared.settings)
}
}
@@ -22,7 +22,7 @@ struct RoomMembersListScreenMemberCell: View {
var body: some View {
Button {
context.send(viewAction: .selectMember(id: member.id))
context.send(viewAction: .selectMember(member))
} label: {
HStack(spacing: 8) {
LoadableAvatarImage(url: avatarURL,
@@ -109,7 +109,8 @@ struct RoomMembersListMemberCell_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomMembersListScreenViewModel(roomProxy: RoomProxyMock(with: .init(name: "Some room",
members: members)),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appSettings: ServiceLocator.shared.settings)
static var previews: some View {
VStack(spacing: 12) {
Section("Invited/Joined") {