diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 41c6e97fb..f17cefb29 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -636,6 +636,7 @@ 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; }; 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; 7254FB2EFDD43BC8BB7A1213 /* SecurityAndPrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AE42C19EDE64B7CB7BE4D0 /* SecurityAndPrivacyScreen.swift */; }; + 726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */; }; 72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; }; 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; }; 7366E5783D1871D42CF99D34 /* OIDCConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D354D4232DED9649FD0FF4 /* OIDCConfiguration.swift */; }; @@ -1991,6 +1992,7 @@ 5A37E2FACFD041CE466223CD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = ""; }; 5A4EF5724C0F894911AF7811 /* SpaceExplorerFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceExplorerFlowCoordinator.swift; sourceTree = ""; }; + 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersFlowCoordinator.swift; sourceTree = ""; }; 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = ""; }; @@ -4288,6 +4290,7 @@ A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */, 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */, 407C8DD85179D2DB896FC0FA /* RoomFlowCoordinatorStateMachine.swift */, + 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */, 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */, D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */, 5A4EF5724C0F894911AF7811 /* SpaceExplorerFlowCoordinator.swift */, @@ -8155,6 +8158,7 @@ 6448F8D1D3CA4CD27BB4CADD /* RoomMemberProxy.swift in Sources */, 92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */, F118DD449066E594F63C697D /* RoomMemberProxyProtocol.swift in Sources */, + 726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */, C08AAE7563E0722C9383F51C /* RoomMembersListScreen.swift in Sources */, 1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */, A975D60EA49F6AF73308809F /* RoomMembersListScreenMemberCell.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 7622dad42..9fdfcf916 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -495,19 +495,19 @@ "screen_bottom_sheet_create_dm_confirmation_button_title" = "Send invite"; "screen_bottom_sheet_create_dm_message" = "Would you like to start a chat with %1$@?"; "screen_bottom_sheet_create_dm_title" = "Send invite?"; -"screen_bottom_sheet_manage_room_member_ban" = "Ban from room"; +"screen_bottom_sheet_manage_room_member_ban" = "Ban user"; "screen_bottom_sheet_manage_room_member_ban_member_confirmation_action" = "Ban"; -"screen_bottom_sheet_manage_room_member_ban_member_confirmation_description" = "They won’t be able to join this room again if invited."; +"screen_bottom_sheet_manage_room_member_ban_member_confirmation_description" = "They won’t be able to join again if invited."; "screen_bottom_sheet_manage_room_member_ban_member_confirmation_title" = "Are you sure you want to ban this member?"; "screen_bottom_sheet_manage_room_member_banning_user" = "Banning %1$@"; "screen_bottom_sheet_manage_room_member_kick_member_confirmation_action" = "Remove"; "screen_bottom_sheet_manage_room_member_kick_member_confirmation_title" = "Are you sure you want to remove this member?"; "screen_bottom_sheet_manage_room_member_member_user_info" = "View profile"; -"screen_bottom_sheet_manage_room_member_remove" = "Remove from room"; +"screen_bottom_sheet_manage_room_member_remove" = "Remove user"; "screen_bottom_sheet_manage_room_member_remove_confirmation_title" = "Remove member and ban from joining in the future?"; "screen_bottom_sheet_manage_room_member_removing_user" = "Removing %1$@…"; -"screen_bottom_sheet_manage_room_member_unban" = "Unban from room"; -"screen_bottom_sheet_manage_room_member_unban_member_confirmation_description" = "They would be able to join the room again if invited"; +"screen_bottom_sheet_manage_room_member_unban" = "Unban user"; +"screen_bottom_sheet_manage_room_member_unban_member_confirmation_description" = "They would be able to join again if invited"; "screen_bottom_sheet_manage_room_member_unban_member_confirmation_title" = "Are you sure you want to unban this member?"; "screen_bug_report_a11y_screenshot" = "Screenshot"; "screen_create_poll_option_accessibility_label" = "%1$@: %2$@"; @@ -1416,7 +1416,7 @@ "screen_room_details_security_title" = "Security"; "screen_room_details_topic_title" = "Topic"; "screen_room_error_failed_processing_media" = "Failed processing media to upload, please try again."; -"screen_room_member_list_manage_member_remove_confirmation_ban" = "Ban from room"; +"screen_room_member_list_manage_member_remove_confirmation_ban" = "Ban user"; "screen_room_notification_settings_mode_all_messages" = "All messages"; "screen_room_notification_settings_mode_mentions_and_keywords" = "Mentions and Keywords only"; "screen_room_timeline_reactions_show_less" = "Show less"; diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index dcb40d898..6146a7044 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1354,7 +1354,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { selectedUsers: .init(selectedUsersSubject), roomType: .room(roomProxy: roomProxy), userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy), - userIndicatorController: flowParameters.userIndicatorController) + userIndicatorController: flowParameters.userIndicatorController, + appSettings: flowParameters.appSettings) let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters) stackCoordinator.setRootCoordinator(coordinator) @@ -1363,22 +1364,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { guard let self else { return } switch action { - case .cancel: + case .dismiss: navigationStackCoordinator.setSheetCoordinator(nil) case .proceed: - break - case .invite(let users): - self.inviteUsers(users, in: roomProxy) - case .toggleUser(let user): - var selectedUsers = selectedUsersSubject.value - - if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { - selectedUsers.remove(at: index) - } else { - selectedUsers.append(user) - } - - selectedUsersSubject.send(selectedUsers) + fatalError("Not handled in this flow.") } } .store(in: &cancellables) @@ -1388,42 +1377,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func inviteUsers(_ users: [String], in room: JoinedRoomProxyProtocol) { - if flowParameters.appSettings.enableKeyShareOnInvite { - showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing, - message: L10n.screenRoomDetailsInvitePeopleDontClose) - } else { - showLoadingIndicator() - } - - Task { - defer { - navigationStackCoordinator.setSheetCoordinator(nil) - hideLoadingIndicator() - } - - let result: Result = await withTaskGroup(of: Result.self) { group in - for user in users { - group.addTask { - await room.invite(userID: user) - } - } - - return await group.first { inviteResult in - inviteResult.isFailure - } ?? .success(()) - } - - guard case .failure = result else { - return - } - - flowParameters.userIndicatorController.alertInfo = .init(id: .init(), - title: L10n.commonUnableToInviteTitle, - message: L10n.commonUnableToInviteMessage) - } - } - private func presentRolesAndPermissionsScreen() { let parameters = RoomRolesAndPermissionsFlowCoordinatorParameters(roomProxy: roomProxy, mediaProvider: userSession.mediaProvider, diff --git a/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift new file mode 100644 index 000000000..ef7757167 --- /dev/null +++ b/ElementX/Sources/FlowCoordinators/RoomMembersFlowCoordinator.swift @@ -0,0 +1,318 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftState +import SwiftUI + +enum RoomMembersFlowCoordinatorAction { + case finished + case presentCallScreen(roomProxy: JoinedRoomProxyProtocol) + case verifyUser(userID: String) +} + +enum RoomMembersFlowCoordinatorEntryPoint: Equatable { + /// To be used in a room when a member name is tapped + case roomMember(userID: String) + /// To be used in the context of room details, space details etc. + case roomMembersList +} + +final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol { + indirect enum State: StateType { + /// The state machine hasn't started. + case initial + /// The room members list + case roomMembersList + /// The details for a member of the room + case roomMemberDetails(userID: String, previousState: State) + /// In case the details won't load because the user has left the room we load the profile + case userProfile(userID: String, previousState: State) + /// The invite users screen + case inviteUsersScreen + /// A room flow has been started + case roomFlow(roomID: String, previousState: State) + } + + enum Event: EventType { + case start + + case presentRoomMembersList + + case presentRoomMemberDetails(userID: String) + case dismissedRoomMemberDetails + + case presentInviteUsersScreen + case dismissedInviteUsersScreen + + case presentUserProfile(userID: String) + case dismissedUserProfile + + case startRoomFlow(roomID: String) + case stopRoomFlow + } + + private let entryPoint: RoomMembersFlowCoordinatorEntryPoint + private let roomProxy: JoinedRoomProxyProtocol + private let navigationStackCoordinator: NavigationStackCoordinator + private let flowParameters: CommonFlowParameters + + private let stateMachine: StateMachine + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private var roomFlowCoordinator: RoomFlowCoordinator? + + init(entryPoint: RoomMembersFlowCoordinatorEntryPoint, + roomProxy: JoinedRoomProxyProtocol, + navigationStackCoordinator: NavigationStackCoordinator, + flowParameters: CommonFlowParameters) { + self.entryPoint = entryPoint + self.roomProxy = roomProxy + self.flowParameters = flowParameters + self.navigationStackCoordinator = navigationStackCoordinator + + stateMachine = .init(state: .initial) + configureStateMachine() + } + + func start() { + switch entryPoint { + case .roomMember(let userID): + stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID)) + case .roomMembersList: + stateMachine.tryEvent(.presentRoomMembersList) + } + } + + func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { + fatalError("Unavailable") + } + + func clearRoute(animated: Bool) { + if stateMachine.state == .inviteUsersScreen { + navigationStackCoordinator.setSheetCoordinator(nil, animated: animated) + } else if let roomFlowCoordinator { + roomFlowCoordinator.clearRoute(animated: animated) + } + // We don't support dismissing a sub flow by itself, only the entire chain. + // The presenter flow will take care of dismissing it + actionsSubject.send(.finished) + } + + private func configureStateMachine() { + stateMachine.addRouteMapping { event, fromState, _ in + switch (fromState, event) { + case (.initial, .presentRoomMembersList): + return .roomMembersList + case (.initial, .presentRoomMemberDetails(let userID)): + // previous state doesn't matter in this csase + return .roomMemberDetails(userID: userID, previousState: fromState) + + case (.roomMembersList, .presentRoomMemberDetails(let userID)): + return .roomMemberDetails(userID: userID, previousState: fromState) + case (.roomMemberDetails, .dismissedRoomMemberDetails): + return .roomMembersList + + case (.roomMembersList, .presentInviteUsersScreen): + return .inviteUsersScreen + case (.inviteUsersScreen, .dismissedInviteUsersScreen): + return .roomMembersList + + case (.roomMemberDetails(_, let previousState), .presentUserProfile(let userID)): + return .userProfile(userID: userID, previousState: previousState) + case (.userProfile(_, let previousState), .dismissedUserProfile): + return previousState + + case (_, .startRoomFlow(let roomID)): + return .roomFlow(roomID: roomID, previousState: fromState) + case (.roomFlow(_, let previousState), .stopRoomFlow): + return previousState + + default: + return nil + } + } + + stateMachine.addAnyHandler(.any => .any) { [weak self] context in + guard let self else { return } + switch (context.fromState, context.event, context.toState) { + case (.initial, .presentRoomMembersList, .roomMembersList): + presentRoomMembersList() + case (.initial, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)): + presentRoomMemberDetails(userID: userID) + + case (.roomMembersList, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)): + presentRoomMemberDetails(userID: userID) + case (.roomMemberDetails, .dismissedRoomMemberDetails, .roomMembersList): + break + + case (.roomMembersList, .presentInviteUsersScreen, .inviteUsersScreen): + presentInviteUsersScreen() + case (.inviteUsersScreen, .dismissedInviteUsersScreen, .roomMembersList): + break + + case (.roomMemberDetails, .presentUserProfile, .userProfile(let userID, _)): + replaceRoomMemberDetailsWithUserProfile(userID: userID) + case (.userProfile, .dismissedUserProfile, _): + break + + case (_, .startRoomFlow(let roomID), .roomFlow): + startRoomFlow(roomID: roomID) + case (.roomFlow, .stopRoomFlow, _): + roomFlowCoordinator = nil + + default: + fatalError("Unhandled transition") + } + } + } + + private func presentRoomMembersList() { + let coordinator = RoomMembersListScreenCoordinator(parameters: .init(userSession: flowParameters.userSession, + roomProxy: roomProxy, + userIndicatorController: flowParameters.userIndicatorController, + analytics: flowParameters.analytics)) + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .invite: + stateMachine.tryEvent(.presentInviteUsersScreen) + case .selectedMember(let member): + stateMachine.tryEvent(.presentRoomMemberDetails(userID: member.userID)) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.actionsSubject.send(.finished) + } + } + + private func presentRoomMemberDetails(userID: String) { + let params = RoomMemberDetailsScreenCoordinatorParameters(userID: userID, + roomProxy: roomProxy, + userSession: flowParameters.userSession, + userIndicatorController: flowParameters.userIndicatorController, + analytics: flowParameters.analytics) + let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .openUserProfile: + stateMachine.tryEvent(.presentUserProfile(userID: userID)) + case .openDirectChat(let roomID): + stateMachine.tryEvent(.startRoomFlow(roomID: roomID)) + case .startCall(let roomProxy): + actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) + case .verifyUser(let userID): + actionsSubject.send(.verifyUser(userID: userID)) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.push(coordinator) { [weak self] in + guard let self else { return } + if entryPoint == .roomMember(userID: userID) { + actionsSubject.send(.finished) + } else { + stateMachine.tryEvent(.dismissedRoomMemberDetails) + } + } + } + + private func presentInviteUsersScreen() { + let stackCoordinator = NavigationStackCoordinator() + let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession, + selectedUsers: nil, + roomType: .room(roomProxy: roomProxy), + userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy), + userIndicatorController: flowParameters.userIndicatorController, + appSettings: flowParameters.appSettings) + + let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters) + stackCoordinator.setRootCoordinator(coordinator) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + + switch action { + case .dismiss: + navigationStackCoordinator.setSheetCoordinator(nil) + case .proceed: + fatalError("Not handled in this flow.") + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissedInviteUsersScreen) + } + } + + private func replaceRoomMemberDetailsWithUserProfile(userID: String) { + let parameters = UserProfileScreenCoordinatorParameters(userID: userID, + isPresentedModally: false, + userSession: flowParameters.userSession, + userIndicatorController: flowParameters.userIndicatorController, + analytics: flowParameters.analytics) + let coordinator = UserProfileScreenCoordinator(parameters: parameters) + coordinator.actionsPublisher.sink { [weak self] action in + guard let self else { return } + + switch action { + case .openDirectChat(let roomID): + stateMachine.tryEvent(.startRoomFlow(roomID: roomID)) + case .startCall(let roomProxy): + actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) + case .dismiss: + break // Not supported when pushed. + } + } + .store(in: &cancellables) + + // Replace the RoomMemberDetailsScreen without any animation. + // If this pop and push happens before the previous navigation is completed it might break screen presentation logic + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + self.navigationStackCoordinator.pop(animated: false) + self.navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in + self?.stateMachine.tryEvent(.dismissedUserProfile) + } + } + } + + private func startRoomFlow(roomID: String) { + let coordinator = RoomFlowCoordinator(roomID: roomID, + isChildFlow: true, + navigationStackCoordinator: navigationStackCoordinator, + flowParameters: flowParameters) + coordinator.actions + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .presentCallScreen(let roomProxy): + actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) + case .verifyUser(let userID): + actionsSubject.send(.verifyUser(userID: userID)) + case .continueWithSpaceFlow: + fatalError("Will never trigger because only direct chats can be displayed in this flow") + case .finished: + stateMachine.tryEvent(.stopRoomFlow) + } + } + .store(in: &cancellables) + + roomFlowCoordinator = coordinator + coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true) + } +} diff --git a/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift index f01ff4028..b6aa7df70 100644 --- a/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SpaceFlowCoordinator.swift @@ -41,6 +41,7 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { private var childSpaceFlowCoordinator: SpaceFlowCoordinator? private var roomFlowCoordinator: RoomFlowCoordinator? + private var membersFlowCoordinator: RoomMembersFlowCoordinator? indirect enum State: StateType { /// The state machine hasn't started. @@ -53,6 +54,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { case presentingChild(childSpaceID: String, previousState: State) /// A room flow is in progress case roomFlow(previousState: State) + /// A members flow is in progress + case membersFlow case leftSpace } @@ -77,6 +80,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { case startRoomFlow(roomID: String) case stopRoomFlow + + case startMembersFlow + case stopMembersFlow } private let stateMachine: StateMachine @@ -133,6 +139,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { case .roomFlow: roomFlowCoordinator?.clearRoute(animated: animated) clearRoute(animated: animated) // Re-run with the state machine back in the .space state. + case .membersFlow: + membersFlowCoordinator?.clearRoute(animated: animated) + clearRoute(animated: animated) // Re-run with the state machine back in the .space state. } } @@ -193,6 +202,26 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { selectedSpaceRoomSubject.send(nil) } + stateMachine.addRouteMapping { event, fromState, _ in + guard case .startMembersFlow = event, case .space = fromState else { + return nil + } + return .membersFlow + } handler: { [weak self] context in + guard let self, let roomProxy = context.userInfo as? JoinedRoomProxyProtocol else { + fatalError("The room proxy must always be provided") + } + Task { await self.startMembersFlow(roomProxy: roomProxy) } + } + + stateMachine.addRouteMapping { event, fromState, _ in + guard event == .stopMembersFlow, case .membersFlow = fromState else { return nil } + return .space + } handler: { [weak self] _ in + guard let self else { return } + membersFlowCoordinator = nil + } + stateMachine.addErrorHandler { context in fatalError("Unexpected transition: \(context)") } @@ -219,6 +248,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.startRoomFlow(roomID: roomID)) case .leftSpace: stateMachine.tryEvent(.leftSpace) + case .displayMembers(let roomProxy): + stateMachine.tryEvent(.startMembersFlow, userInfo: roomProxy) } } .store(in: &cancellables) @@ -339,4 +370,26 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol { coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true) selectedSpaceRoomSubject.send(roomID) } + + private func startMembersFlow(roomProxy: JoinedRoomProxyProtocol) async { + let flowCoordinator = RoomMembersFlowCoordinator(entryPoint: .roomMembersList, + roomProxy: roomProxy, + navigationStackCoordinator: navigationStackCoordinator, + flowParameters: flowParameters) + + flowCoordinator.actions.sink { [weak self] actions in + guard let self else { return } + switch actions { + case .finished: + stateMachine.tryEvent(.stopMembersFlow) + case .presentCallScreen(let roomProxy): + actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) + case .verifyUser(let userID): + actionsSubject.send(.verifyUser(userID: userID)) + } + } + .store(in: &cancellables) + membersFlowCoordinator = flowCoordinator + flowCoordinator.start() + } } diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 134a90e6f..9a698d91b 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -1312,11 +1312,11 @@ internal enum L10n { } /// Send invite? internal static var screenBottomSheetCreateDmTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_create_dm_title") } - /// Ban from room + /// Ban user internal static var screenBottomSheetManageRoomMemberBan: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban") } /// Ban internal static var screenBottomSheetManageRoomMemberBanMemberConfirmationAction: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban_member_confirmation_action") } - /// They won’t be able to join this room again if invited. + /// They won’t be able to join again if invited. internal static var screenBottomSheetManageRoomMemberBanMemberConfirmationDescription: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban_member_confirmation_description") } /// Are you sure you want to ban this member? internal static var screenBottomSheetManageRoomMemberBanMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_ban_member_confirmation_title") } @@ -1332,7 +1332,7 @@ internal enum L10n { internal static var screenBottomSheetManageRoomMemberKickMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_kick_member_confirmation_title") } /// View profile internal static var screenBottomSheetManageRoomMemberMemberUserInfo: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_member_user_info") } - /// Remove from room + /// Remove user internal static var screenBottomSheetManageRoomMemberRemove: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_remove") } /// Remove member and ban from joining in the future? internal static var screenBottomSheetManageRoomMemberRemoveConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_remove_confirmation_title") } @@ -1340,11 +1340,11 @@ internal enum L10n { internal static func screenBottomSheetManageRoomMemberRemovingUser(_ p1: Any) -> String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_removing_user", String(describing: p1)) } - /// Unban from room + /// Unban user internal static var screenBottomSheetManageRoomMemberUnban: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban") } /// Unban internal static var screenBottomSheetManageRoomMemberUnbanMemberConfirmationAction: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban_member_confirmation_action") } - /// They would be able to join the room again if invited + /// They would be able to join again if invited internal static var screenBottomSheetManageRoomMemberUnbanMemberConfirmationDescription: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban_member_confirmation_description") } /// Are you sure you want to unban this member? internal static var screenBottomSheetManageRoomMemberUnbanMemberConfirmationTitle: String { return L10n.tr("Localizable", "screen_bottom_sheet_manage_room_member_unban_member_confirmation_title") } @@ -2468,7 +2468,7 @@ internal enum L10n { internal static func screenRoomMemberListHeaderTitle(_ p1: Int) -> String { return L10n.tr("Localizable", "screen_room_member_list_header_title", p1) } - /// Ban from room + /// Ban user internal static var screenRoomMemberListManageMemberRemoveConfirmationBan: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove_confirmation_ban") } /// Only remove member internal static var screenRoomMemberListManageMemberRemoveConfirmationKick: String { return L10n.tr("Localizable", "screen_room_member_list_manage_member_remove_confirmation_kick") } diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift index 7d9b71626..192323af3 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift @@ -13,14 +13,14 @@ struct CreateRoomCoordinatorParameters { let userSession: UserSessionProtocol let userIndicatorController: UserIndicatorControllerProtocol let createRoomParameters: CurrentValuePublisher - let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never> + let selectedUsers: [UserProfileProxy] let appSettings: AppSettings let analytics: AnalyticsService } enum CreateRoomCoordinatorAction { case openRoom(withIdentifier: String) - case deselectUser(UserProfileProxy) + case updateSelectedUsers([UserProfileProxy]) case updateDetails(CreateRoomFlowParameters) case displayMediaPickerWithMode(MediaPickerScreenMode) case removeImage @@ -48,8 +48,8 @@ final class CreateRoomCoordinator: CoordinatorProtocol { viewModel.actions.sink { [weak self] action in guard let self else { return } switch action { - case .deselectUser(let user): - actionsSubject.send(.deselectUser(user)) + case .updateSelectedUsers(let users): + actionsSubject.send(.updateSelectedUsers(users)) case .openRoom(let identifier): actionsSubject.send(.openRoom(withIdentifier: identifier)) case .updateDetails(let details): diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift index 10f523761..6ba679725 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift @@ -18,7 +18,7 @@ enum CreateRoomScreenErrorType: Error { enum CreateRoomViewModelAction { case openRoom(withIdentifier: String) - case deselectUser(UserProfileProxy) + case updateSelectedUsers([UserProfileProxy]) case updateDetails(CreateRoomFlowParameters) case displayMediaPicker case displayCameraPicker diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift index eb87f4397..69ad6afe7 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift @@ -28,7 +28,7 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol init(userSession: UserSessionProtocol, createRoomParameters: CurrentValuePublisher, - selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>, + selectedUsers: [UserProfileProxy], analytics: AnalyticsService, userIndicatorController: UserIndicatorControllerProtocol, appSettings: AppSettings) { @@ -46,7 +46,7 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol super.init(initialViewState: CreateRoomViewState(roomName: parameters.name, serverName: userSession.clientProxy.userIDServerName ?? "", isKnockingFeatureEnabled: appSettings.knockingEnabled, - selectedUsers: selectedUsers.value, + selectedUsers: selectedUsers, aliasLocalPart: parameters.aliasLocalPart ?? roomAliasNameFromRoomDisplayName(roomName: parameters.name), bindings: bindings), mediaProvider: userSession.mediaProvider) @@ -67,12 +67,6 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol } .store(in: &cancellables) - selectedUsers - .sink { [weak self] users in - self?.state.selectedUsers = users - } - .store(in: &cancellables) - setupBindings() } @@ -85,7 +79,8 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol await createRoom() } case .deselectUser(let user): - actionsSubject.send(.deselectUser(user)) + state.selectedUsers.removeAll { $0.userID == user.userID } + actionsSubject.send(.updateSelectedUsers(state.selectedUsers)) case .displayCameraPicker: actionsSubject.send(.displayCameraPicker) case .displayMediaPicker: diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift index 6e74ea454..63031f823 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenCoordinator.swift @@ -11,17 +11,16 @@ import SwiftUI struct InviteUsersScreenCoordinatorParameters { let userSession: UserSessionProtocol - let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never> + let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>? let roomType: InviteUsersScreenRoomType let userDiscoveryService: UserDiscoveryServiceProtocol let userIndicatorController: UserIndicatorControllerProtocol + let appSettings: AppSettings } enum InviteUsersScreenCoordinatorAction { - case cancel - case proceed - case invite(users: [String]) - case toggleUser(UserProfileProxy) + case dismiss + case proceed(selectedUsers: [UserProfileProxy]) } final class InviteUsersScreenCoordinator: CoordinatorProtocol { @@ -38,21 +37,18 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol { selectedUsers: parameters.selectedUsers, roomType: parameters.roomType, userDiscoveryService: parameters.userDiscoveryService, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + appSettings: parameters.appSettings) } func start() { viewModel.actions.sink { [weak self] action in guard let self else { return } switch action { - case .cancel: - actionsSubject.send(.cancel) - case .proceed: - actionsSubject.send(.proceed) - case .invite(let users): - actionsSubject.send(.invite(users: users)) - case .toggleUser(let user): - actionsSubject.send(.toggleUser(user)) + case .dismiss: + actionsSubject.send(.dismiss) + case .proceed(let selectedUsers): + actionsSubject.send(.proceed(selectedUsers: selectedUsers)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift index 6f7fc7f60..5e3c66efc 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenModels.swift @@ -15,10 +15,8 @@ enum InviteUsersScreenErrorType: Error { } enum InviteUsersScreenViewModelAction { - case cancel - case proceed - case invite(users: [String]) - case toggleUser(UserProfileProxy) + case dismiss + case proceed(selectedUsers: [UserProfileProxy]) } enum InviteUsersScreenRoomType { diff --git a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift index 201d2c11a..7f00bab5d 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/InviteUsersScreenViewModel.swift @@ -16,6 +16,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr private let roomType: InviteUsersScreenRoomType private let userDiscoveryService: UserDiscoveryServiceProtocol private let userIndicatorController: UserIndicatorControllerProtocol + private let appSettings: AppSettings private var suggestedUsers = [UserProfileProxy]() @@ -24,20 +25,25 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr actionsSubject.eraseToAnyPublisher() } + private let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>? + init(userSession: UserSessionProtocol, - selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>, + selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?, roomType: InviteUsersScreenRoomType, userDiscoveryService: UserDiscoveryServiceProtocol, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + appSettings: AppSettings) { self.roomType = roomType self.userDiscoveryService = userDiscoveryService self.userIndicatorController = userIndicatorController + self.appSettings = appSettings + self.selectedUsers = selectedUsers - super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers.value, + super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers?.value ?? [], isCreatingRoom: roomType.isCreatingRoom), mediaProvider: userSession.mediaProvider) - setupSubscriptions(selectedUsers: selectedUsers) + setupSubscriptions() fetchMembersIfNeeded() Task { @@ -54,23 +60,67 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr override func process(viewAction: InviteUsersScreenViewAction) { switch viewAction { case .cancel: - actionsSubject.send(.cancel) + actionsSubject.send(.dismiss) case .proceed: switch roomType { case .draft: - actionsSubject.send(.proceed) - case .room: - actionsSubject.send(.invite(users: state.selectedUsers.map(\.userID))) + actionsSubject.send(.proceed(selectedUsers: state.selectedUsers)) + case .room(let roomProxy): + inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy) } case .toggleUser(let user): - let willSelectUser = !state.selectedUsers.contains(user) - state.scrollToLastID = willSelectUser ? user.userID : nil - actionsSubject.send(.toggleUser(user)) + toggleUser(user) } } // MARK: - Private + private func toggleUser(_ user: UserProfileProxy) { + if state.selectedUsers.contains(user) { + state.scrollToLastID = nil + state.selectedUsers.removeAll(where: { $0.userID == user.userID }) + } else { + state.scrollToLastID = user.userID + state.selectedUsers.append(user) + } + } + + private func inviteUsers(_ users: [String], roomProxy: JoinedRoomProxyProtocol) { + if appSettings.enableKeyShareOnInvite { + showLoader(title: L10n.screenRoomDetailsInvitePeoplePreparing, + message: L10n.screenRoomDetailsInvitePeopleDontClose) + } else { + showLoader() + } + + Task { + defer { + hideLoader() + actionsSubject.send(.dismiss) + } + + let result: Result = await withTaskGroup(of: Result.self) { group in + for user in users { + group.addTask { + await roomProxy.invite(userID: user) + } + } + + return await group.first { inviteResult in + inviteResult.isFailure + } ?? .success(()) + } + + guard case .failure = result else { + return + } + + userIndicatorController.alertInfo = .init(id: .init(), + title: L10n.commonUnableToInviteTitle, + message: L10n.commonUnableToInviteMessage) + } + } + private func buildMembershipStateIfNeeded(members: [RoomMemberProxyProtocol]) { showLoader() @@ -92,7 +142,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr @CancellableTask private var fetchUsersTask: Task? - private func setupSubscriptions(selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>) { + private func setupSubscriptions() { context.$viewState .map(\.bindings.searchQuery) .debounceTextQueriesAndRemoveDuplicates() @@ -101,11 +151,13 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr } .store(in: &cancellables) - selectedUsers - .sink { [weak self] users in - self?.state.selectedUsers = users - } - .store(in: &cancellables) + if let selectedUsers { + selectedUsers + .sink { [weak self] users in + self?.state.selectedUsers = users + } + .store(in: &cancellables) + } } private func fetchMembersIfNeeded() { @@ -159,8 +211,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr private let userIndicatorID = UUID().uuidString - private func showLoader() { - userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal, title: L10n.commonLoading, persistent: true), delay: .milliseconds(200)) + private func showLoader(title: String = L10n.commonLoading, + message: String? = nil) { + userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, + type: .modal, + title: title, + message: message, + persistent: true), + delay: .milliseconds(200)) } private func hideLoader() { diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift index 09063d5ad..2d1928f85 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift @@ -158,7 +158,8 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview { selectedUsers: .init([]), roomType: .draft, userDiscoveryService: userDiscoveryService, - userIndicatorController: UserIndicatorControllerMock()) + userIndicatorController: UserIndicatorControllerMock(), + appSettings: ServiceLocator.shared.settings) }() static var previews: some View { diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift index 17eccac8c..8c6077a50 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift @@ -48,6 +48,7 @@ struct RoomMembersListScreen: View { .autocorrectionDisabled() .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) .navigationTitle(L10n.commonPeople) + .navigationBarTitleDisplayMode(.inline) .sheet(item: $context.manageMemeberViewModel) { ManageRoomMemberSheetView(context: $0.context) } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift index bece62e8c..faec25c81 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenCoordinator.swift @@ -24,6 +24,7 @@ enum SpaceScreenCoordinatorAction { case selectUnjoinedSpace(SpaceRoomProxyProtocol) case selectRoom(roomID: String) case leftSpace + case displayMembers(roomProxy: JoinedRoomProxyProtocol) } final class SpaceScreenCoordinator: CoordinatorProtocol { @@ -61,6 +62,8 @@ final class SpaceScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.selectRoom(roomID: roomID)) case .leftSpace: actionsSubject.send(.leftSpace) + case .displayMembers(let roomProxy): + actionsSubject.send(.displayMembers(roomProxy: roomProxy)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift index 8d27efc47..5a4ab2e73 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenModels.swift @@ -13,12 +13,14 @@ enum SpaceScreenViewModelAction { case selectUnjoinedSpace(SpaceRoomProxyProtocol) case selectRoom(roomID: String) case leftSpace + case displayMembers(roomProxy: JoinedRoomProxyProtocol) } struct SpaceScreenViewState: BindableState { var space: SpaceRoomProxyProtocol var permalink: URL? + var roomProxy: JoinedRoomProxyProtocol? var isPaginating = false var rooms: [SpaceRoomProxyProtocol] @@ -42,4 +44,5 @@ enum SpaceScreenViewAction { case toggleLeaveSpaceRoomDetails(id: String) case confirmLeaveSpace case spaceSettings + case displayMembers(roomProxy: JoinedRoomProxyProtocol) } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift index 50a6fd46c..4e879c7cc 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift @@ -68,9 +68,13 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc .store(in: &cancellables) Task { - if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.id), - case let .success(permalinkURL) = await roomProxy.matrixToPermalink() { - state.permalink = permalinkURL + if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.id) { + // Required to listen for membership updates in the members flow + await roomProxy.subscribeForUpdates() + state.roomProxy = roomProxy + if case let .success(permalinkURL) = await roomProxy.matrixToPermalink() { + state.permalink = permalinkURL + } } } } @@ -115,6 +119,8 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc } case .confirmLeaveSpace: Task { await confirmLeaveSpace() } + case .displayMembers(let roomProxy): + actionsSubject.send(.displayMembers(roomProxy: roomProxy)) case .spaceSettings: break // Not implemented. } diff --git a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift index 62b4c1039..0d74f01c6 100644 --- a/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift +++ b/ElementX/Sources/Screens/Spaces/SpaceScreen/View/SpaceScreen.swift @@ -62,8 +62,13 @@ struct SpaceScreen: View { // controller attempts to anchor itself to the button that is no longer visible. ToolbarItem(placement: .primaryAction) { Menu { - if let permalink = context.viewState.permalink { - Section { + Section { + if let roomProxy = context.viewState.roomProxy { + Button { context.send(viewAction: .displayMembers(roomProxy: roomProxy)) } label: { + Label(L10n.screenSpaceMenuActionMembers, icon: \.user) + } + } + if let permalink = context.viewState.permalink { ShareLink(item: permalink) { Label(L10n.actionShare, icon: \.shareIos) } @@ -106,10 +111,16 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview { let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceRoomProxy: spaceRoomProxy, initialSpaceRooms: .mockSpaceList)) + let clientProxy = ClientProxyMock(.init()) + clientProxy.roomForIdentifierClosure = { _ in + .joined(JoinedRoomProxyMock(.init())) + } + let userSession = UserSessionMock(.init(clientProxy: clientProxy)) + let viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy, spaceServiceProxy: SpaceServiceProxyMock(.init()), selectedSpaceRoomPublisher: .init(nil), - userSession: UserSessionMock(.init()), + userSession: userSession, userIndicatorController: UserIndicatorControllerMock()) return viewModel } diff --git a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift index fa8591e30..c48f7db52 100644 --- a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift +++ b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift @@ -87,20 +87,17 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { selectedUsers: selectedUsersPublisher, roomType: .draft, userDiscoveryService: parameters.userDiscoveryService, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + appSettings: parameters.appSettings) let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters) coordinator.actions.sink { [weak self] action in guard let self else { return } - switch action { - case .cancel: - break // Not shown in this flow. - case .proceed: + case .dismiss: + fatalError("Not shown in this flow.") + case .proceed(let selectedUsers): + self.selectedUsers.send(selectedUsers) openCreateRoomScreen() - case .invite: - break - case .toggleUser(let user): - toggleUser(user) } } .store(in: &cancellables) @@ -115,15 +112,15 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { let createParameters = CreateRoomCoordinatorParameters(userSession: parameters.userSession, userIndicatorController: parameters.userIndicatorController, createRoomParameters: createRoomParametersPublisher, - selectedUsers: selectedUsersPublisher, + selectedUsers: selectedUsers.value, appSettings: parameters.appSettings, analytics: parameters.analytics) let coordinator = CreateRoomCoordinator(parameters: createParameters) coordinator.actions.sink { [weak self] action in guard let self else { return } switch action { - case .deselectUser(let user): - self.toggleUser(user) + case .updateSelectedUsers(let users): + self.selectedUsers.send(users) case .updateDetails(let details): self.createRoomParameters.send(details) case .openRoom(let identifier): @@ -190,16 +187,6 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { } } - private func toggleUser(_ user: UserProfileProxy) { - var selectedUsers = selectedUsers.value - if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { - selectedUsers.remove(at: index) - } else { - selectedUsers.append(user) - } - self.selectedUsers.send(selectedUsers) - } - // MARK: Loading indicator private static let loadingIndicatorIdentifier = "\(StartChatScreenCoordinator.self)-Loading" diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPad-en-GB.png index d05ca6abb..f9487ffae 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d2b28b530b24af558f8fae27de0d170e3b1e0578120e33e83970d9d49723e34 -size 196380 +oid sha256:09ed4bd158ac78c3bc2c79b002123c8acd01fa2c916117c4b2f25c546c2877dd +size 196319 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPhone-16-en-GB.png index 722d244f5..fabacd23c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPhone-16-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-Disabled-iPhone-16-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d6f04d43984ace12233b6b3362af3bb77d4942ea1297e0050b8b5955c25130ef -size 137585 +oid sha256:c93361807cc6a52d22c29e9795a0d2b9ca39b6538f82e99d47d5ec205ff604c9 +size 136562 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPad-en-GB.png index 1561084fd..82fad0987 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a95d9aade23b43dc3941fd270aa56b40b02b9e9ab08f6944ebed09870f13b120 -size 196824 +oid sha256:65ed07a3fede8980ff18ad8948b54a9d45f2cae7ea3889f070e0bcb5212aff6c +size 196864 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPhone-16-en-GB.png index 2a116497c..79c5f6f5d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPhone-16-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.All-Actions-iPhone-16-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68236a129b6528f9a9a688ce70205cf15afbcfe5c923b604b7ec1678c4a846a5 -size 138159 +oid sha256:63763276f73d6f8a0e632853f0a746b65e0bca5438d62d34da5bcee8f22de111 +size 137171 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPad-en-GB.png index b6b4d3fcb..f68db1ff5 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9d357a82eea7676227d54dc28e6b9ec84845c07c69ce9e0e24810e8a8a46335 -size 190488 +oid sha256:8691d45d5629c189e6a97421aad1ff73f89bbe5373271cae18f2c0321a4fd3cc +size 190379 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPhone-16-en-GB.png index 3bacaeb1d..94fdd56cc 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPhone-16-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Ban-Only-iPhone-16-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f67d0bc3d1407fe230c418d4d3fe492af4e8a4a7057d07124f8d259801328ae -size 131411 +oid sha256:3398bc4647612b235ce3bfeb18941044fd985cefc239379b51f3003a57bc6ba4 +size 130984 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPad-en-GB.png index 77c2f3f29..203abe21c 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:552d0fddd4944914a7aeb2678cdb991d5f55599f8f09938ca10cc23f37de8967 -size 189773 +oid sha256:0661718686f07bd5a4c36f7211064030f7d0f76193ef489f9927dd83fd9703d1 +size 189924 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPhone-16-en-GB.png index 1d0ad2e9a..e19ac0d0a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPhone-16-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Kick-Only-iPhone-16-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:684526b282a46532d45dc048c806b1c5b81ed06fa3ec12496d5f0c2036c53192 -size 131331 +oid sha256:86e26201a4c716cb1b2b229b3f89805297828f52feb29fa8cff22a9c29c8eda8 +size 130816 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPad-en-GB.png index 12c9b1e72..e29862b1a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bba02f0b1a660e3a7b813fb36bf66afe00d3adee4c287651531ac5cf95863b9a -size 105278 +oid sha256:072af060db2d6ab8464cd15e064dc792bd781d45b08b7ef6cc70115e5f2ecfa0 +size 104992 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPhone-16-en-GB.png index 25ad567f9..9265cc013 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPhone-16-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/manageRoomMemberSheetView.Unban-Only-iPhone-16-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7bd4668b039105fc66b51759be8f12581dbfba3d8012dc595e6a342dbc366b60 -size 56240 +oid sha256:a88d8981907ce176f1af65d81804b27366b04feec2c9ea185d335aa6f5c5858f +size 55886 diff --git a/UnitTests/Sources/CreateRoomViewModelTests.swift b/UnitTests/Sources/CreateRoomViewModelTests.swift index 4867c41c0..efc32002f 100644 --- a/UnitTests/Sources/CreateRoomViewModelTests.swift +++ b/UnitTests/Sources/CreateRoomViewModelTests.swift @@ -18,41 +18,23 @@ class CreateRoomScreenViewModelTests: XCTestCase { var userSession: UserSessionMock! private let usersSubject = CurrentValueSubject<[UserProfileProxy], Never>([]) - private var cancellables = Set() var context: CreateRoomViewModel.Context { viewModel.context } override func setUpWithError() throws { - cancellables.removeAll() clientProxy = ClientProxyMock(.init(userIDServerName: "matrix.org", userID: "@a:b.com")) userSession = UserSessionMock(.init(clientProxy: clientProxy)) let parameters = CreateRoomFlowParameters() - usersSubject.send([.mockAlice, .mockBob, .mockCharlie]) ServiceLocator.shared.settings.knockingEnabled = true let viewModel = CreateRoomViewModel(userSession: userSession, createRoomParameters: .init(parameters), - selectedUsers: usersSubject.asCurrentValuePublisher(), + selectedUsers: [.mockAlice, .mockBob, .mockCharlie], analytics: ServiceLocator.shared.analytics, userIndicatorController: UserIndicatorControllerMock(), appSettings: ServiceLocator.shared.settings) self.viewModel = viewModel - - viewModel.actions.sink { [weak self] action in - guard let self else { return } - switch action { - case .deselectUser(let user): - var selectedUsers = usersSubject.value - if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { - selectedUsers.remove(at: index) - } - usersSubject.send(selectedUsers) - default: - break - } - } - .store(in: &cancellables) } func testDeselectUser() { diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift index a4cafd552..206a57008 100644 --- a/UnitTests/Sources/InviteUsersViewModelTests.swift +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -15,17 +15,11 @@ import XCTest class InviteUsersScreenViewModelTests: XCTestCase { var viewModel: InviteUsersScreenViewModelProtocol! var userDiscoveryService: UserDiscoveryServiceMock! - - private var cancellables = Set() - + var context: InviteUsersScreenViewModel.Context { viewModel.context } - override func setUp() { - cancellables.removeAll() - } - func testSelectUser() { setupWithRoomType(roomType: .draft) XCTAssertTrue(context.viewState.selectedUsers.isEmpty) @@ -56,7 +50,9 @@ class InviteUsersScreenViewModelTests: XCTestCase { func testInviteButton() async throws { let mockedMembers: [RoomMemberProxyMock] = [.mockAlice, .mockBob] - setupWithRoomType(roomType: .room(roomProxy: JoinedRoomProxyMock(.init(name: "test", members: mockedMembers)))) + let roomProxy = JoinedRoomProxyMock(.init(name: "test", members: mockedMembers)) + roomProxy.inviteUserIDReturnValue = .success(()) + setupWithRoomType(roomType: .room(roomProxy: roomProxy)) let deferredState = deferFulfillment(viewModel.context.$viewState) { state in state.isUserSelected(.mockAlice) @@ -68,7 +64,7 @@ class InviteUsersScreenViewModelTests: XCTestCase { let deferredAction = deferFulfillment(viewModel.actions) { action in switch action { - case .invite: + case .dismiss: return true default: return false @@ -77,41 +73,20 @@ class InviteUsersScreenViewModelTests: XCTestCase { context.send(viewAction: .proceed) - guard case let .invite(members) = try await deferredAction.fulfill() else { - XCTFail("Sent action should be 'invite'") - return - } - - XCTAssertEqual(members, [RoomMemberProxyMock.mockAlice.userID]) + try await deferredAction.fulfill() + XCTAssertEqual(roomProxy.inviteUserIDReceivedInvocations, [RoomMemberProxyMock.mockAlice.userID]) } private func setupWithRoomType(roomType: InviteUsersScreenRoomType) { - let usersSubject = CurrentValueSubject<[UserProfileProxy], Never>([]) userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.searchProfilesWithReturnValue = .success([]) - usersSubject.send([]) let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init()), - selectedUsers: usersSubject.asCurrentValuePublisher(), + selectedUsers: nil, roomType: roomType, userDiscoveryService: userDiscoveryService, - userIndicatorController: UserIndicatorControllerMock()) + userIndicatorController: UserIndicatorControllerMock(), + appSettings: ServiceLocator.shared.settings) viewModel.state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) self.viewModel = viewModel - - viewModel.actions.sink { action in - switch action { - case .toggleUser(let user): - var selectedUsers = usersSubject.value - if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { - selectedUsers.remove(at: index) - } else { - selectedUsers.append(user) - } - usersSubject.send(selectedUsers) - default: - break - } - } - .store(in: &cancellables) } }