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
@@ -523,11 +523,14 @@
"screen_room_member_details_unblock_alert_action" = "Unblock";
"screen_room_member_details_unblock_alert_description" = "You'll be able to see all messages from them again.";
"screen_room_member_details_unblock_user" = "Unblock user";
"screen_room_member_list_banning_user" = "Banning %1$@";
"screen_room_member_list_manage_member_remove" = "Remove member";
"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" = "Unban";
"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_mode_banned" = "Banned";
"screen_room_member_list_mode_members" = "Members";
@@ -536,6 +539,7 @@
"screen_room_member_list_role_administrator" = "Admin";
"screen_room_member_list_role_moderator" = "Moderator";
"screen_room_member_list_room_members_header_title" = "Room members";
"screen_room_member_list_unbanning_user" = "Unbanning %1$@";
"screen_room_message_copied" = "Message copied";
"screen_room_no_permission_to_post" = "You do not have permission to post to this room";
"screen_room_notification_settings_allow_custom" = "Allow custom setting";
@@ -555,6 +559,15 @@
"screen_room_reactions_show_more" = "Show more";
"screen_room_retry_send_menu_send_again_action" = "Send again";
"screen_room_retry_send_menu_title" = "Your message failed to send";
"screen_room_roles_and_permissions_admins" = "Admins";
"screen_room_roles_and_permissions_member_moderation" = "Member moderation";
"screen_room_roles_and_permissions_messages_and_content" = "Messages and content";
"screen_room_roles_and_permissions_moderators" = "Moderators";
"screen_room_roles_and_permissions_permissions_header" = "Permissions";
"screen_room_roles_and_permissions_reset" = "Reset roles and 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";
"screen_room_timeline_add_reaction" = "Add emoji";
"screen_room_timeline_less_reactions" = "Show less";
"screen_room_typing_many_members_first_component_ios" = "%1$@, %2$@ and ";
@@ -726,6 +739,7 @@
"screen_room_details_invite_people_title" = "Invite people";
"screen_room_details_leave_conversation_title" = "Leave conversation";
"screen_room_details_leave_room_title" = "Leave room";
"screen_room_details_roles_and_permissions" = "Roles and permissions";
"screen_room_details_room_name_label" = "Room name";
"screen_room_details_security_title" = "Security";
"screen_room_details_topic_title" = "Topic";
@@ -50,6 +50,7 @@ final class AppSettings {
case roomListFiltersEnabled
case markAsUnreadEnabled
case markAsFavouriteEnabled
case roomModerationEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -289,6 +290,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.markAsFavouriteEnabled, defaultValue: false, storageType: .userDefaults(store))
var markAsFavouriteEnabled
@UserPreference(key: UserDefaultsKeys.roomModerationEnabled, defaultValue: false, storageType: .userDefaults(store))
var roomModerationEnabled
#endif
// MARK: - Shared
@@ -578,7 +578,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
let params = RoomMembersListScreenCoordinatorParameters(mediaProvider: userSession.mediaProvider,
roomProxy: roomProxy)
roomProxy: roomProxy,
appSettings: appSettings)
let coordinator = RoomMembersListScreenCoordinator(parameters: params)
coordinator.actions
+33 -1
View File
@@ -1249,6 +1249,8 @@ internal enum L10n {
internal static var screenRoomDetailsNotificationModeDefault: String { return L10n.tr("Localizable", "screen_room_details_notification_mode_default") }
/// Notifications
internal static var screenRoomDetailsNotificationTitle: String { return L10n.tr("Localizable", "screen_room_details_notification_title") }
/// Roles and permissions
internal static var screenRoomDetailsRolesAndPermissions: String { return L10n.tr("Localizable", "screen_room_details_roles_and_permissions") }
/// Room name
internal static var screenRoomDetailsRoomNameLabel: String { return L10n.tr("Localizable", "screen_room_details_room_name_label") }
/// Security
@@ -1283,6 +1285,10 @@ internal enum L10n {
internal static var screenRoomMemberDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_room_member_details_unblock_alert_description") }
/// Unblock user
internal static var screenRoomMemberDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_room_member_details_unblock_user") }
/// Banning %1$@
internal static func screenRoomMemberListBanningUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_room_member_list_banning_user", String(describing: p1))
}
/// Plural format key: "%#@COUNT@"
internal static func screenRoomMemberListHeaderTitle(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_room_member_list_header_title", p1)
@@ -1296,7 +1302,11 @@ internal enum L10n {
/// Remove member and ban from joining in the future?
internal static var screenRoomMemberListManageMemberRemoveConfirmationTitle: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove_confirmation_title") }
/// Unban
internal static var screenRoomMemberListManageMemberUnban: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_unban") }
internal static var screenRoomMemberListManageMemberUnbanAction: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_unban_action") }
/// They will be able to join this room again if invited.
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
internal static var screenRoomMemberListManageMemberUserInfo: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_user_info") }
/// Banned
@@ -1315,6 +1325,10 @@ internal enum L10n {
internal static var screenRoomMemberListRoleModerator: String { return L10n.tr("Localizable", "screen_room_member_list_role_moderator") }
/// Room members
internal static var screenRoomMemberListRoomMembersHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_room_members_header_title") }
/// Unbanning %1$@
internal static func screenRoomMemberListUnbanningUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_room_member_list_unbanning_user", String(describing: p1))
}
/// Notify the whole room
internal static var screenRoomMentionsAtRoomSubtitle: String { return L10n.tr("Localizable", "screen_room_mentions_at_room_subtitle") }
/// Everyone
@@ -1361,6 +1375,24 @@ internal enum L10n {
internal static var screenRoomRetrySendMenuSendAgainAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_send_again_action") }
/// Your message failed to send
internal static var screenRoomRetrySendMenuTitle: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_title") }
/// Admins
internal static var screenRoomRolesAndPermissionsAdmins: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_admins") }
/// Member moderation
internal static var screenRoomRolesAndPermissionsMemberModeration: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_member_moderation") }
/// Messages and content
internal static var screenRoomRolesAndPermissionsMessagesAndContent: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_messages_and_content") }
/// Moderators
internal static var screenRoomRolesAndPermissionsModerators: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_moderators") }
/// Permissions
internal static var screenRoomRolesAndPermissionsPermissionsHeader: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_permissions_header") }
/// Reset roles and permissions
internal static var screenRoomRolesAndPermissionsReset: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_reset") }
/// Roles
internal static var screenRoomRolesAndPermissionsRolesHeader: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_roles_header") }
/// Room details
internal static var screenRoomRolesAndPermissionsRoomDetails: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_room_details") }
/// Roles and permissions
internal static var screenRoomRolesAndPermissionsTitle: String { return L10n.tr("Localizable", "screen_room_roles_and_permissions_title") }
/// Add emoji
internal static var screenRoomTimelineAddReaction: String { return L10n.tr("Localizable", "screen_room_timeline_add_reaction") }
/// Show less
@@ -1750,6 +1750,11 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol {
set(value) { underlyingCanInviteUsers = value }
}
var underlyingCanInviteUsers: Bool!
var canKickUsers: Bool {
get { return underlyingCanKickUsers }
set(value) { underlyingCanKickUsers = value }
}
var underlyingCanKickUsers: Bool!
var canBanUsers: Bool {
get { return underlyingCanBanUsers }
set(value) { underlyingCanBanUsers = value }
@@ -2338,6 +2343,69 @@ class RoomProxyMock: RoomProxyProtocol {
return flagAsFavouriteReturnValue
}
}
//MARK: - kickUser
var kickUserCallsCount = 0
var kickUserCalled: Bool {
return kickUserCallsCount > 0
}
var kickUserReceivedUserID: String?
var kickUserReceivedInvocations: [String] = []
var kickUserReturnValue: Result<Void, RoomProxyError>!
var kickUserClosure: ((String) async -> Result<Void, RoomProxyError>)?
func kickUser(_ userID: String) async -> Result<Void, RoomProxyError> {
kickUserCallsCount += 1
kickUserReceivedUserID = userID
kickUserReceivedInvocations.append(userID)
if let kickUserClosure = kickUserClosure {
return await kickUserClosure(userID)
} else {
return kickUserReturnValue
}
}
//MARK: - banUser
var banUserCallsCount = 0
var banUserCalled: Bool {
return banUserCallsCount > 0
}
var banUserReceivedUserID: String?
var banUserReceivedInvocations: [String] = []
var banUserReturnValue: Result<Void, RoomProxyError>!
var banUserClosure: ((String) async -> Result<Void, RoomProxyError>)?
func banUser(_ userID: String) async -> Result<Void, RoomProxyError> {
banUserCallsCount += 1
banUserReceivedUserID = userID
banUserReceivedInvocations.append(userID)
if let banUserClosure = banUserClosure {
return await banUserClosure(userID)
} else {
return banUserReturnValue
}
}
//MARK: - unbanUser
var unbanUserCallsCount = 0
var unbanUserCalled: Bool {
return unbanUserCallsCount > 0
}
var unbanUserReceivedUserID: String?
var unbanUserReceivedInvocations: [String] = []
var unbanUserReturnValue: Result<Void, RoomProxyError>!
var unbanUserClosure: ((String) async -> Result<Void, RoomProxyError>)?
func unbanUser(_ userID: String) async -> Result<Void, RoomProxyError> {
unbanUserCallsCount += 1
unbanUserReceivedUserID = userID
unbanUserReceivedInvocations.append(userID)
if let unbanUserClosure = unbanUserClosure {
return await unbanUserClosure(userID)
} else {
return unbanUserReturnValue
}
}
//MARK: - canUserJoinCall
var canUserJoinCallUserIDCallsCount = 0
@@ -2389,6 +2457,11 @@ class RoomTimelineProviderMock: RoomTimelineProviderProtocol {
set(value) { underlyingBackPaginationState = value }
}
var underlyingBackPaginationState: BackPaginationStatus!
var membershipChangePublisher: AnyPublisher<Void, Never> {
get { return underlyingMembershipChangePublisher }
set(value) { underlyingMembershipChangePublisher = value }
}
var underlyingMembershipChangePublisher: AnyPublisher<Void, Never>!
}
class SecureBackupControllerMock: SecureBackupControllerProtocol {
@@ -27,6 +27,7 @@ struct RoomMemberProxyMockConfiguration {
var powerLevel = 0
var role = RoomMemberRole.user
var canInviteUsers = false
var canKickUsers = false
var canBanUsers = false
var canSendStateEvent: (StateEventType) -> Bool = { _ in true }
}
@@ -43,6 +44,7 @@ extension RoomMemberProxyMock {
powerLevel = configuration.powerLevel
role = configuration.role
canInviteUsers = configuration.canInviteUsers
canKickUsers = configuration.canKickUsers
canBanUsers = configuration.canBanUsers
canSendStateEventTypeClosure = configuration.canSendStateEvent
}
@@ -17,6 +17,7 @@
import Combine
import Foundation
@MainActor
struct RoomProxyMockConfiguration {
var id = UUID().uuidString
var name: String?
@@ -33,6 +34,10 @@ struct RoomProxyMockConfiguration {
let mock = TimelineProxyMock()
mock.underlyingActions = Empty(completeImmediately: false).eraseToAnyPublisher()
mock.timelineStartReached = false
let timelineProvider = RoomTimelineProviderMock()
timelineProvider.underlyingMembershipChangePublisher = PassthroughSubject().eraseToAnyPublisher()
mock.underlyingTimelineProvider = timelineProvider
return mock
}()
@@ -45,6 +50,7 @@ struct RoomProxyMockConfiguration {
}
extension RoomProxyMock {
@MainActor
convenience init(with configuration: RoomProxyMockConfiguration) {
self.init()
@@ -84,6 +90,10 @@ extension RoomProxyMock {
underlyingIsFavourite = false
flagAsFavouriteReturnValue = .success(())
kickUserReturnValue = .success(())
banUserReturnValue = .success(())
unbanUserReturnValue = .success(())
let widgetDriver = ElementCallWidgetDriverMock()
widgetDriver.underlyingMessagePublisher = .init()
widgetDriver.underlyingActions = PassthroughSubject().eraseToAnyPublisher()
@@ -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") {
@@ -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 }
}
@@ -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")
}
@@ -17,7 +17,7 @@
import Foundation
import MatrixRustSDK
struct RoomMemberDetails: Identifiable, Equatable {
struct RoomMemberDetails: Identifiable, Hashable {
let id: String
let name: String?
let avatarURL: URL?
@@ -46,6 +46,7 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
lazy var powerLevel = Int(member.powerLevel())
lazy var role = member.suggestedRoleForPowerLevel()
lazy var canInviteUsers = member.canInvite()
lazy var canKickUsers = member.canKick()
lazy var canBanUsers = member.canBan()
func canSendStateEvent(type: StateEventType) -> Bool {
@@ -34,6 +34,7 @@ protocol RoomMemberProxyProtocol: AnyObject {
var powerLevel: Int { get }
var role: RoomMemberRole { get }
var canInviteUsers: Bool { get }
var canKickUsers: Bool { get }
var canBanUsers: Bool { get }
func ignoreUser() async -> Result<Void, RoomMemberProxyError>
@@ -416,6 +416,38 @@ class RoomProxy: RoomProxyProtocol {
}
}
// MARK: - Moderation
func kickUser(_ userID: String) async -> Result<Void, RoomProxyError> {
do {
try await room.kickUser(userId: userID, reason: nil)
return .success(())
} catch {
MXLog.error("Failed kicking \(userID) with error: \(error)")
return .failure(.failedModeration)
}
}
func banUser(_ userID: String) async -> Result<Void, RoomProxyError> {
do {
try await room.banUser(userId: userID, reason: nil)
return .success(())
} catch {
MXLog.error("Failed banning \(userID) with error: \(error)")
return .failure(.failedModeration)
}
}
func unbanUser(_ userID: String) async -> Result<Void, RoomProxyError> {
do {
try await room.unbanUser(userId: userID, reason: nil)
return .success(())
} catch {
MXLog.error("Failed unbanning \(userID) with error: \(error)")
return .failure(.failedModeration)
}
}
// MARK: - Element Call
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError> {
@@ -36,6 +36,7 @@ enum RoomProxyError: Error, Equatable {
case failedMarkingAsRead
case failedSendingTypingNotice
case failedFlaggingAsFavourite
case failedModeration
}
enum RoomProxyAction {
@@ -120,6 +121,12 @@ protocol RoomProxyProtocol {
func flagAsFavourite(_ isFavourite: Bool) async -> Result<Void, RoomProxyError>
// MARK: - Moderation
func kickUser(_ userID: String) async -> Result<Void, RoomProxyError>
func banUser(_ userID: String) async -> Result<Void, RoomProxyError>
func unbanUser(_ userID: String) async -> Result<Void, RoomProxyError>
// MARK: - Element Call
func canUserJoinCall(userID: String) async -> Result<Bool, RoomProxyError>
@@ -38,6 +38,12 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
.map { _, _ in () }
.eraseToAnyPublisher()
}
private let membershipChangeSubject = PassthroughSubject<Void, Never>()
var membershipChangePublisher: AnyPublisher<Void, Never> {
membershipChangeSubject
.eraseToAnyPublisher()
}
init(currentItems: [TimelineItem],
updatePublisher: AnyPublisher<[TimelineDiff], Never>,
@@ -99,7 +105,13 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
MXLog.verbose("Append \(items.map(\.debugIdentifier))")
for (index, item) in items.enumerated() {
changes.append(.insert(offset: Int(itemProxies.count) + index, element: TimelineItemProxy(item: item), associatedWith: nil))
let itemProxy = TimelineItemProxy(item: item)
if itemProxy.isMembershipChange {
membershipChangeSubject.send(())
}
changes.append(.insert(offset: Int(itemProxies.count) + index, element: itemProxy, associatedWith: nil))
}
case .clear:
MXLog.verbose("Clear all items")
@@ -129,6 +141,11 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
MXLog.verbose("Push Back \(item.debugIdentifier)")
let itemProxy = TimelineItemProxy(item: item)
if itemProxy.isMembershipChange {
membershipChangeSubject.send(())
}
changes.append(.insert(offset: Int(itemProxies.count), element: itemProxy, associatedWith: nil))
case .pushFront:
guard let item = diff.pushFront() else { fatalError() }
@@ -197,6 +214,20 @@ private extension TimelineItemProxy {
return .unknown(timelineID: String(item.uniqueId()))
}
}
var isMembershipChange: Bool {
switch self {
case .event(let eventTimelineItemProxy):
switch eventTimelineItemProxy.content.kind() {
case .roomMembership:
true
default:
false
}
case .virtual, .unknown:
false
}
}
}
private extension VirtualTimelineItem {
@@ -22,7 +22,14 @@ import MatrixRustSDK
@MainActor
// sourcery: AutoMockable
protocol RoomTimelineProviderProtocol {
/// A publisher that signals when ``itemProxies`` or ``backPaginationState`` are changed.
var updatePublisher: AnyPublisher<Void, Never> { get }
/// The current set of items in the timeline.
var itemProxies: [TimelineItemProxy] { get }
/// Whether the timeline is back paginating or not (or has reached the start of the room).
var backPaginationState: BackPaginationStatus { get }
/// A publisher that signals when changes to the room's membership have occurred through `/sync`.
///
/// This is temporary and will be replace by a subscription on the room itself.
var membershipChangePublisher: AnyPublisher<Void, Never> { get }
}
@@ -699,14 +699,16 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockAlice, .mockBob, .mockCharlie]
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(mediaProvider: MockMediaProvider(),
roomProxy: RoomProxyMock(with: .init(name: "test", members: members))))
roomProxy: RoomProxyMock(with: .init(name: "test", members: members)),
appSettings: ServiceLocator.shared.settings))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomMembersListScreenPendingInvites:
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockInvitedAlice, .mockBob, .mockCharlie]
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(mediaProvider: MockMediaProvider(),
roomProxy: RoomProxyMock(with: .init(name: "test", members: members))))
roomProxy: RoomProxyMock(with: .init(name: "test", members: members)),
appSettings: ServiceLocator.shared.settings))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomNotificationSettingsDefaultSetting: