mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
+3
-1
@@ -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 {
|
||||
|
||||
+112
-8
@@ -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 {
|
||||
|
||||
+115
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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") {
|
||||
|
||||
+1
@@ -54,6 +54,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
|
||||
var markAsUnreadEnabled: Bool { get set }
|
||||
var markAsFavouriteEnabled: Bool { get set }
|
||||
var roomModerationEnabled: Bool { get set }
|
||||
|
||||
var elementCallBaseURL: URL { get set }
|
||||
}
|
||||
|
||||
+4
-1
@@ -32,7 +32,10 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Timeline") {
|
||||
Section("Room") {
|
||||
Toggle(isOn: $context.roomModerationEnabled) {
|
||||
Text("Moderation")
|
||||
}
|
||||
Toggle(isOn: $context.shouldCollapseRoomStateEvents) {
|
||||
Text("Collapse room state events")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user