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
+4
View File
@@ -364,6 +364,7 @@
5D4643E485C179B2F485C519 /* MentionSuggestionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD0E68C42CA7DDCD4CAD68D /* MentionSuggestionItemView.swift */; };
5D53AE9342A4C06B704247ED /* MediaLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A02406480C351B8C6E0682C /* MediaLoaderProtocol.swift */; };
5D70FAE4D2BF4553AFFFFE41 /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F7FE40EF7490A7E09D7BE6 /* NotificationItemProxy.swift */; };
5DD0EF30070DC0A82C5CCD33 /* RoomMembersListManageMemberSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC853F9B4FBE039D2C16EC6B /* RoomMembersListManageMemberSheet.swift */; };
5DD85A0FE3D85AEC3C7EFE36 /* DeveloperOptionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */; };
5E0F2E612718BB4397A6D40A /* TextRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */; };
5EE1D4E316D66943E97FDCF2 /* BloomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEB970F500BFB248443FA1 /* BloomView.swift */; };
@@ -2008,6 +2009,7 @@
FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = "<group>"; };
FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; };
FC2D505742FDA21FCDC4C18A /* AudioRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineView.swift; sourceTree = "<group>"; };
FC853F9B4FBE039D2C16EC6B /* RoomMembersListManageMemberSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListManageMemberSheet.swift; sourceTree = "<group>"; };
FCE93F0CBF0D96B77111C413 /* AppLockFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockFlowCoordinator.swift; sourceTree = "<group>"; };
FD1275D9CE0FFBA6E8E85426 /* UserIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorController.swift; sourceTree = "<group>"; };
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = "<group>"; };
@@ -3817,6 +3819,7 @@
949B06577E5265373013DDAB /* View */ = {
isa = PBXGroup;
children = (
FC853F9B4FBE039D2C16EC6B /* RoomMembersListManageMemberSheet.swift */,
1B8E176484A89BAC389D4076 /* RoomMembersListScreen.swift */,
CC03209FDE8CE0810617BFFF /* RoomMembersListScreenMemberCell.swift */,
);
@@ -5882,6 +5885,7 @@
6448F8D1D3CA4CD27BB4CADD /* RoomMemberProxy.swift in Sources */,
92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */,
F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */,
5DD0EF30070DC0A82C5CCD33 /* RoomMembersListManageMemberSheet.swift in Sources */,
C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */,
1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */,
A975D60EA49F6AF73308809F /* RoomMembersListScreenMemberCell.swift in Sources */,
@@ -4,7 +4,8 @@
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -29,6 +30,12 @@
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<TestPlans>
<TestPlanReference
default = "YES"
reference = "container:PreviewTests/SupportingFiles/PreviewTests.xctestplan">
</TestPlanReference>
</TestPlans>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -38,6 +45,10 @@
ReferencedContainer = "container:ElementX.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
@@ -47,12 +58,6 @@
ReferencedContainer = "container:ElementX.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<TestPlans>
<TestPlanReference
reference = "container:PreviewTests/SupportingFiles/PreviewTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -73,6 +78,8 @@
ReferencedContainer = "container:ElementX.xcodeproj">
</BuildableReference>
</MacroExpansion>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -80,6 +87,8 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<CommandLineArguments>
</CommandLineArguments>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -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:
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7448e79e299c5ee48583c1e4b93fa9fe337adbf316f835097c06e4240779ccc4
size 96198
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7bb98cc5979ae429fbf7de2858e4dd957780c710574727746407de518c028b7b
size 140127
@@ -19,6 +19,7 @@ import XCTest
@testable import ElementX
@MainActor
final class CompletionSuggestionServiceTests: XCTestCase {
private var cancellables = Set<AnyCancellable>()
@@ -21,6 +21,7 @@ import XCTest
@MainActor
class RoomMembersListScreenViewModelTests: XCTestCase {
var viewModel: RoomMembersListScreenViewModel!
var roomProxy: RoomProxyMock!
var context: RoomMembersListScreenViewModel.Context {
viewModel.context
@@ -125,9 +126,50 @@ class RoomMembersListScreenViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.state.visibleJoinedMembers.count, 0)
}
func testKickMember() async throws {
setup(with: .allMembers)
var deferred = deferFulfillment(context.$viewState) { !$0.visibleJoinedMembers.isEmpty }
try await deferred.fulfill()
context.send(viewAction: .kickMember(viewModel.state.visibleJoinedMembers[0]))
// Calling the mock won't actually change any view state, so sleep instead.
try await Task.sleep(for: .milliseconds(100))
XCTAssert(roomProxy.kickUserCalled)
}
func testBanMember() async throws {
setup(with: .allMembers)
var deferred = deferFulfillment(context.$viewState) { !$0.visibleJoinedMembers.isEmpty }
try await deferred.fulfill()
context.send(viewAction: .banMember(viewModel.state.visibleJoinedMembers[0]))
// Calling the mock won't actually change any view state, so sleep instead.
try await Task.sleep(for: .milliseconds(100))
XCTAssert(roomProxy.banUserCalled)
}
func testUnbanMember() async throws {
setup(with: .allMembers)
var deferred = deferFulfillment(context.$viewState) { !$0.visibleJoinedMembers.isEmpty }
try await deferred.fulfill()
context.send(viewAction: .unbanMember(viewModel.state.visibleJoinedMembers[0]))
// Calling the mock won't actually change any view state, so sleep instead.
try await Task.sleep(for: .milliseconds(100))
XCTAssert(roomProxy.unbanUserCalled)
}
private func setup(with members: [RoomMemberProxyMock]) {
viewModel = .init(roomProxy: RoomProxyMock(with: .init(name: "test", members: members)),
roomProxy = RoomProxyMock(with: .init(name: "test", members: members))
viewModel = .init(roomProxy: roomProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appSettings: ServiceLocator.shared.settings)
}
}
+1
View File
@@ -0,0 +1 @@
Add support for kicking and banning room members.