From fdbaef5aee60113db5f790d6d53e2cc802c92dbf Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Tue, 27 Feb 2024 13:02:35 +0000 Subject: [PATCH] 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. --- ElementX.xcodeproj/project.pbxproj | 4 + .../xcschemes/PreviewTests.xcscheme | 23 +++- .../en.lproj/Localizable.strings | 16 ++- .../Sources/Application/AppSettings.swift | 4 + .../RoomFlowCoordinator.swift | 3 +- ElementX/Sources/Generated/Strings.swift | 34 ++++- .../Mocks/Generated/GeneratedMocks.swift | 73 +++++++++++ .../Sources/Mocks/RoomMemberProxyMock.swift | 2 + ElementX/Sources/Mocks/RoomProxyMock.swift | 10 ++ .../RoomMembersListScreenCoordinator.swift | 4 +- .../RoomMembersListScreenModels.swift | 15 ++- .../RoomMembersListScreenViewModel.swift | 120 ++++++++++++++++-- .../RoomMembersListManageMemberSheet.swift | 115 +++++++++++++++++ .../View/RoomMembersListScreen.swift | 27 ++-- .../RoomMembersListScreenMemberCell.swift | 5 +- .../DeveloperOptionsScreenModels.swift | 1 + .../View/DeveloperOptionsScreen.swift | 5 +- .../Room/RoomMember/RoomMemberDetails.swift | 2 +- .../Room/RoomMember/RoomMemberProxy.swift | 1 + .../RoomMember/RoomMemberProxyProtocol.swift | 1 + .../Sources/Services/Room/RoomProxy.swift | 32 +++++ .../Services/Room/RoomProxyProtocol.swift | 7 + .../Timeline/RoomTimelineProvider.swift | 33 ++++- .../RoomTimelineProviderProtocol.swift | 7 + .../UITests/UITestsAppCoordinator.swift | 6 +- ...oomMembersListManageMemberSheet.Banned.png | 3 + ...oomMembersListManageMemberSheet.Joined.png | 3 + .../CompletionSuggestionServiceTests.swift | 1 + .../RoomMembersListViewModelTests.swift | 46 ++++++- changelog.d/2357.wip | 1 + 30 files changed, 561 insertions(+), 43 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListManageMemberSheet.swift create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png create mode 100644 changelog.d/2357.wip diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 73edf8f00c..7b8dc82999 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = ""; }; FC2D505742FDA21FCDC4C18A /* AudioRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineView.swift; sourceTree = ""; }; + FC853F9B4FBE039D2C16EC6B /* RoomMembersListManageMemberSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListManageMemberSheet.swift; sourceTree = ""; }; FCE93F0CBF0D96B77111C413 /* AppLockFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockFlowCoordinator.swift; sourceTree = ""; }; FD1275D9CE0FFBA6E8E85426 /* UserIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorController.swift; sourceTree = ""; }; FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainControllerTests.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX.xcodeproj/xcshareddata/xcschemes/PreviewTests.xcscheme b/ElementX.xcodeproj/xcshareddata/xcschemes/PreviewTests.xcscheme index 86b8247e9a..3d2a819676 100644 --- a/ElementX.xcodeproj/xcshareddata/xcschemes/PreviewTests.xcscheme +++ b/ElementX.xcodeproj/xcshareddata/xcschemes/PreviewTests.xcscheme @@ -4,7 +4,8 @@ version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + + + + + + + + - - - - + + + + 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 diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 34a6de0112..1aa5df3589 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -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! + var kickUserClosure: ((String) async -> Result)? + + func kickUser(_ userID: String) async -> Result { + 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! + var banUserClosure: ((String) async -> Result)? + + func banUser(_ userID: String) async -> Result { + 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! + var unbanUserClosure: ((String) async -> Result)? + + func unbanUser(_ userID: String) async -> Result { + 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 { + get { return underlyingMembershipChangePublisher } + set(value) { underlyingMembershipChangePublisher = value } + } + var underlyingMembershipChangePublisher: AnyPublisher! } class SecureBackupControllerMock: SecureBackupControllerProtocol { diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index 736af3232c..5dcbbd4917 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -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 } diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index be5873b230..0cd3fe0c6c 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -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() diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift index 0d00b024eb..1b7968ea8c 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenCoordinator.swift @@ -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() { diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift index ed09bfb697..1dd56bf773 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift @@ -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? + var alertInfo: AlertInfo? } 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 { diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift index 907c122fc0..de5461f03b 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift @@ -22,6 +22,7 @@ typealias RoomMembersListScreenViewModelType = StateStoreViewModel Bool { diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index c957128f52..e31fcd5959 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -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 diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 4aced6f331..c95a650402 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -416,6 +416,38 @@ class RoomProxy: RoomProxyProtocol { } } + // MARK: - Moderation + + func kickUser(_ userID: String) async -> Result { + 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 { + 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 { + 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 { diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 51d4d68e73..840026696b 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -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 + // MARK: - Moderation + + func kickUser(_ userID: String) async -> Result + func banUser(_ userID: String) async -> Result + func unbanUser(_ userID: String) async -> Result + // MARK: - Element Call func canUserJoinCall(userID: String) async -> Result diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index e3d3bc19c5..455495279b 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -38,6 +38,12 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { .map { _, _ in () } .eraseToAnyPublisher() } + + private let membershipChangeSubject = PassthroughSubject() + var membershipChangePublisher: AnyPublisher { + 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 { diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift index 83c20d6e31..ff7036c7e5 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift @@ -22,7 +22,14 @@ import MatrixRustSDK @MainActor // sourcery: AutoMockable protocol RoomTimelineProviderProtocol { + /// A publisher that signals when ``itemProxies`` or ``backPaginationState`` are changed. var updatePublisher: AnyPublisher { 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 { get } } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 4c2a742c84..fc21e8915e 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -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: diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png new file mode 100644 index 0000000000..b249a0fd9c --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Banned.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7448e79e299c5ee48583c1e4b93fa9fe337adbf316f835097c06e4240779ccc4 +size 96198 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png new file mode 100644 index 0000000000..8422b2a14d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomMembersListManageMemberSheet.Joined.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bb98cc5979ae429fbf7de2858e4dd957780c710574727746407de518c028b7b +size 140127 diff --git a/UnitTests/Sources/CompletionSuggestionServiceTests.swift b/UnitTests/Sources/CompletionSuggestionServiceTests.swift index 16dc27a171..528ced2e94 100644 --- a/UnitTests/Sources/CompletionSuggestionServiceTests.swift +++ b/UnitTests/Sources/CompletionSuggestionServiceTests.swift @@ -19,6 +19,7 @@ import XCTest @testable import ElementX +@MainActor final class CompletionSuggestionServiceTests: XCTestCase { private var cancellables = Set() diff --git a/UnitTests/Sources/RoomMembersListViewModelTests.swift b/UnitTests/Sources/RoomMembersListViewModelTests.swift index 718d45fd8c..3681a7c69f 100644 --- a/UnitTests/Sources/RoomMembersListViewModelTests.swift +++ b/UnitTests/Sources/RoomMembersListViewModelTests.swift @@ -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) } } diff --git a/changelog.d/2357.wip b/changelog.d/2357.wip new file mode 100644 index 0000000000..5a79fc3097 --- /dev/null +++ b/changelog.d/2357.wip @@ -0,0 +1 @@ +Add support for kicking and banning room members. \ No newline at end of file