From effe3b6a9b26295bf6aa0348fd8378fbda9dbde9 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 19 Apr 2024 17:04:18 +0300 Subject: [PATCH] Membership aware room routes (#2709) * Remove the invites button when room list invites are enabled * element-hq/element-x-ios/issues/2632 - Handle room permalinks based on their membership state and show the according room joining screens: - modify the room flow coordinator to only take a roomID and be able to decide its own room flows (especially for unjoined rooms) - add support for the new room preview API - have RoomDirectorySearch go through the same paths --- .../RoomFlowCoordinator.swift | 205 ++++++++++++++---- .../UserSessionFlowCoordinator.swift | 52 ++--- .../Mocks/Generated/GeneratedMocks.swift | 68 ++++++ .../HomeScreen/View/HomeScreenContent.swift | 2 +- .../View/HomeScreenInviteCell.swift | 6 + .../JoinRoomScreenCoordinator.swift | 16 +- .../JoinRoomScreen/JoinRoomScreenModels.swift | 56 ++--- .../JoinRoomScreenViewModel.swift | 106 +++++++-- .../JoinRoomScreenViewModelProtocol.swift | 2 + .../JoinRoomScreen/View/JoinRoomScreen.swift | 112 +++++++--- ...RoomDirectorySearchScreenCoordinator.swift | 6 +- .../RoomDirectorySearchScreenModels.swift | 4 +- .../RoomDirectorySearchScreenViewModel.swift | 35 +-- .../View/RoomDirectorySearchScreen.swift | 2 +- .../Sources/Services/Client/ClientProxy.swift | 26 +++ .../Services/Client/ClientProxyProtocol.swift | 16 ++ .../test_joinRoomScreen-iPad-en-GB.Invite.png | 4 +- .../test_joinRoomScreen-iPad-en-GB.Join.png | 4 +- .../test_joinRoomScreen-iPad-en-GB.Knock.png | 4 +- ...test_joinRoomScreen-iPad-en-GB.Unknown.png | 3 + ...test_joinRoomScreen-iPad-pseudo.Invite.png | 4 +- .../test_joinRoomScreen-iPad-pseudo.Join.png | 4 +- .../test_joinRoomScreen-iPad-pseudo.Knock.png | 4 +- ...est_joinRoomScreen-iPad-pseudo.Unknown.png | 3 + ..._joinRoomScreen-iPhone-15-en-GB.Invite.png | 4 +- ...st_joinRoomScreen-iPhone-15-en-GB.Join.png | 4 +- ...t_joinRoomScreen-iPhone-15-en-GB.Knock.png | 4 +- ...joinRoomScreen-iPhone-15-en-GB.Unknown.png | 3 + ...joinRoomScreen-iPhone-15-pseudo.Invite.png | 4 +- ...t_joinRoomScreen-iPhone-15-pseudo.Join.png | 4 +- ..._joinRoomScreen-iPhone-15-pseudo.Knock.png | 4 +- ...oinRoomScreen-iPhone-15-pseudo.Unknown.png | 3 + .../JoinRoomScreenViewModelTests.swift | 39 +++- .../Sources/RoomFlowCoordinatorTests.swift | 130 +++++++++-- .../Sources/RoomScreenViewModelTests.swift | 2 +- 35 files changed, 698 insertions(+), 247 deletions(-) create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Unknown.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Unknown.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Unknown.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Unknown.png diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index b04cce75f..74c1cdeec 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -40,7 +40,7 @@ enum RoomFlowCoordinatorAction: Equatable { // swiftlint:disable file_length class RoomFlowCoordinator: FlowCoordinatorProtocol { - private let roomProxy: RoomProxyProtocol + private let roomID: String private let userSession: UserSessionProtocol private let isChildFlow: Bool private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol @@ -52,6 +52,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private let userIndicatorController: UserIndicatorControllerProtocol private let orientationManager: OrientationManagerProtocol + private var roomProxy: RoomProxyProtocol! + + private weak var joinRoomScreenCoordinator: JoinRoomScreenCoordinator? + // periphery:ignore - used to avoid deallocation private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator? // periphery:ignore - used to avoid deallocation @@ -68,7 +72,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private var timelineController: RoomTimelineControllerProtocol? - init(roomProxy: RoomProxyProtocol, + init(roomID: String, userSession: UserSessionProtocol, isChildFlow: Bool, roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, @@ -79,7 +83,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { analytics: AnalyticsService, userIndicatorController: UserIndicatorControllerProtocol, orientationManager: OrientationManagerProtocol) async { - self.roomProxy = roomProxy + self.roomID = roomID self.userSession = userSession self.isChildFlow = isChildFlow self.roomTimelineControllerFactory = roomTimelineControllerFactory @@ -91,14 +95,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { self.userIndicatorController = userIndicatorController self.orientationManager = orientationManager - // The SDK needs to handle multiple subscription calls before we can start adding child room flows. - let subscriptionTask = Task { await roomProxy.subscribeForUpdates() } - setupStateMachine() - analytics.signpost.beginRoomFlow(roomProxy.id) - - _ = await subscriptionTask.result + analytics.signpost.beginRoomFlow(roomID) } // MARK: - FlowCoordinatorProtocol @@ -110,21 +109,31 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { switch appRoute { case .room(let roomID): - guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") } - stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated)) + Task { + await handleRoomRoute(roomID: roomID, animated: animated) + } case .childRoom(let roomID): if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator { childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated) } else if roomID != roomProxy.id { - stateMachine.tryEvent(.presentChildRoom(roomID: roomID), userInfo: EventUserInfo(animated: animated)) + stateMachine.tryEvent(.startChildFlow(roomID: roomID), userInfo: EventUserInfo(animated: animated)) } else { MXLog.info("Ignoring presentation of the same room as a child of this one.") } case .roomDetails(let roomID): - guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") } - stateMachine.tryEvent(.presentRoomDetails, userInfo: EventUserInfo(animated: animated)) - case .roomList: - stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated)) + guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") } + + Task { + if roomProxy == nil { + guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { + return + } + + await storeAndSubscribeToRoomProxy(roomProxy) + } + + stateMachine.tryEvent(.presentRoomDetails, userInfo: EventUserInfo(animated: animated)) + } case .roomMemberDetails(let userID): // Always assume this will be presented on the child, external permalinks to a user aren't for a room member. if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator { @@ -132,27 +141,68 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated)) } - case .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: + case .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: break } } + + private func handleRoomRoute(roomID: String, animated: Bool) async { + guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") } + + guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { + stateMachine.tryEvent(.presentJoinRoomScreen, userInfo: EventUserInfo(animated: animated)) + return + } + + switch roomProxy.membership { + case .joined: + await storeAndSubscribeToRoomProxy(roomProxy) + stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated)) + default: + stateMachine.tryEvent(.presentJoinRoomScreen, userInfo: EventUserInfo(animated: animated)) + } + } func clearRoute(animated: Bool) { guard stateMachine.state != .initial else { return } - stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated)) + + stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated)) } // MARK: - Private + private func storeAndSubscribeToRoomProxy(_ roomProxy: RoomProxyProtocol) async { + if let oldRoomProxy = self.roomProxy { + if oldRoomProxy.id != roomProxy.id { + fatalError("Trying to create different room proxies for the same flow coordinator") + } + + return + } + + guard roomProxy.membership == .joined else { + fatalError("Requesting room details for an unjoined room") + } + + self.roomProxy = roomProxy + + await roomProxy.subscribeForUpdates() + } + // swiftlint:disable:next function_body_length private func setupStateMachine() { stateMachine.addRouteMapping { event, fromState, _ in switch (fromState, event) { + case (_, .presentJoinRoomScreen): + return .joinRoomScreen + case (_, .dismissJoinRoomScreen): + return .complete + case (_, .presentRoom): return .room - case (_, .dismissRoom): + case (_, .dismissFlow): return .complete case (.initial, .presentRoomDetails): @@ -255,9 +305,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // Child flow - case (_, .presentChildRoom(let roomID)): + case (_, .startChildFlow(let roomID)): return .presentingChild(childRoomID: roomID, previousState: fromState) - case (.presentingChild(_, let previousState), .dismissChildRoom): + case (.presentingChild(_, let previousState), .dismissChildFlow): return previousState default: @@ -271,9 +321,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let animated = (context.userInfo as? EventUserInfo)?.animated ?? true switch (context.fromState, context.event, context.toState) { + case (_, .presentJoinRoomScreen, .joinRoomScreen): + presentJoinRoomScreen(animated: true) + case (_, .dismissJoinRoomScreen, .complete): + dismissFlow(animated: animated) + case (_, .presentRoom, .room): Task { await self.presentRoom(animated: animated) } - case (_, .dismissRoom, .complete): + case (_, .dismissFlow, .complete): dismissFlow(animated: animated) case (.initial, .presentRoomDetails, .roomDetails(let isRoot)), @@ -381,9 +436,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { rolesAndPermissionsFlowCoordinator = nil // Child flow - case (_, .presentChildRoom(let roomID), .presentingChild): - Task { await self.presentChildFlow(for: roomID) } - case (.presentingChild, .dismissChildRoom, _): + case (_, .startChildFlow(let roomID), .presentingChild): + Task { await self.startChildFlow(for: roomID) } + case (.presentingChild, .dismissChildFlow, _): childRoomFlowCoordinator = nil default: @@ -479,27 +534,84 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { if !isChildFlow { navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in - self?.stateMachine.tryEvent(.dismissRoom) + self?.stateMachine.tryEvent(.dismissFlow) + } + } else { + if joinRoomScreenCoordinator != nil { + navigationStackCoordinator.pop() + } + + navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in + self?.stateMachine.tryEvent(.dismissFlow) + } + } + } + + private func presentJoinRoomScreen(animated: Bool) { + let coordinator = JoinRoomScreenCoordinator(parameters: .init(roomID: roomID, + clientProxy: userSession.clientProxy, + mediaProvider: userSession.mediaProvider, + userIndicatorController: userIndicatorController)) + + joinRoomScreenCoordinator = coordinator + + coordinator.actionsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .joined: + Task { [weak self] in + guard let self else { return } + + if let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) { + await storeAndSubscribeToRoomProxy(roomProxy) + stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated)) + + analytics.trackJoinedRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace, activeMemberCount: UInt(roomProxy.activeMembersCount)) + } else { + stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated)) + } + } + case .cancelled: + stateMachine.tryEvent(.dismissJoinRoomScreen) + } + } + .store(in: &cancellables) + + if !isChildFlow { + navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in + if self?.stateMachine.state == .joinRoomScreen { + self?.stateMachine.tryEvent(.dismissJoinRoomScreen) + } } } else { navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in - self?.stateMachine.tryEvent(.dismissRoom) + if self?.stateMachine.state == .joinRoomScreen { + self?.stateMachine.tryEvent(.dismissJoinRoomScreen) + } } } } private func dismissFlow(animated: Bool) { - childRoomFlowCoordinator?.handleAppRoute(.roomList, animated: animated) + childRoomFlowCoordinator?.clearRoute(animated: animated) if isChildFlow { // We don't support dismissing a child flow by itself, only the entire chain. - MXLog.info("Leaving navigation clean-up to the parent flow.") + MXLog.info("Leaving the rest of the navigation clean-up to the parent flow.") + + if joinRoomScreenCoordinator != nil { + navigationStackCoordinator.pop() + } } else { navigationStackCoordinator.popToRoot(animated: false) navigationStackCoordinator.setRootCoordinator(nil, animated: false) } - roomProxy.unsubscribeFromUpdates() + roomProxy?.unsubscribeFromUpdates() + timelineController = nil actionsSubject.send(.finished) @@ -520,7 +632,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .leftRoom: - stateMachine.tryEvent(.dismissRoom) + stateMachine.tryEvent(.dismissFlow) case .presentRoomMembersList: stateMachine.tryEvent(.presentRoomMembersList) case .presentRoomDetailsEditScreen: @@ -539,7 +651,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { if isRoot { navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in - self?.stateMachine.tryEvent(.dismissRoom) // This seems wrong but it isn't, Maybe the event should be called dismissFlow? + self?.stateMachine.tryEvent(.dismissFlow) } } else { navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in @@ -890,7 +1002,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .openUserProfile: stateMachine.tryEvent(.presentUserProfile(userID: userID)) case .openDirectChat(let roomID): - stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) + stateMachine.tryEvent(.startChildFlow(roomID: roomID)) } } .store(in: &cancellables) @@ -913,7 +1025,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .openDirectChat(let roomID): - stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) + stateMachine.tryEvent(.startChildFlow(roomID: roomID)) case .dismiss: break // Not supported when pushed. } @@ -983,7 +1095,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // We don't need to worry about passing in the room proxy as timelines are // cached. The local echo will be visible when fetching the room by its ID. - stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) + stateMachine.tryEvent(.startChildFlow(roomID: roomID)) } private func presentNotificationSettingsScreen() { @@ -1117,15 +1229,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // MARK: - Child Flow - private func presentChildFlow(for roomID: String) async { - guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { - MXLog.error("Child flow requested for missing room.") - userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - stateMachine.tryEvent(.dismissChildRoom) - return - } - - let coordinator = await RoomFlowCoordinator(roomProxy: roomProxy, + private func startChildFlow(for roomID: String) async { + let coordinator = await RoomFlowCoordinator(roomID: roomID, userSession: userSession, isChildFlow: true, roomTimelineControllerFactory: roomTimelineControllerFactory, @@ -1145,7 +1250,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .presentCallScreen(let roomProxy): actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) case .finished: - stateMachine.tryEvent(.dismissChildRoom) + stateMachine.tryEvent(.dismissChildFlow) } } .store(in: &cancellables) @@ -1170,6 +1275,7 @@ private extension RoomFlowCoordinator { indirect enum State: StateType { case initial + case joinRoomScreen case room case roomDetails(isRoot: Bool) case roomDetailsEditScreen @@ -1201,8 +1307,11 @@ private extension RoomFlowCoordinator { } enum Event: EventType { + case presentJoinRoomScreen + case dismissJoinRoomScreen + case presentRoom - case dismissRoom + case dismissFlow case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) case dismissReportContent @@ -1256,8 +1365,8 @@ private extension RoomFlowCoordinator { case dismissRolesAndPermissionsScreen // Child room flow events - case presentChildRoom(roomID: String) - case dismissChildRoom + case startChildFlow(roomID: String) + case dismissChildFlow } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 5acdc34d2..a73b61c99 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -183,12 +183,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch appRoute { case .room(let roomID): - Task { await self.handleRoomRoute(roomID: roomID, animated: animated) } + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) case .childRoom(let roomID): if let roomFlowCoordinator { roomFlowCoordinator.handleAppRoute(appRoute, animated: animated) } else { - Task { await self.handleRoomRoute(roomID: roomID, animated: animated) } + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) } case .roomDetails(let roomID): if stateMachine.state.selectedRoomID == roomID { @@ -196,12 +196,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true), userInfo: .init(animated: animated)) } - case .roomList, .roomMemberDetails: - self.roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) + case .roomList: + roomFlowCoordinator?.clearRoute(animated: animated) + case .roomMemberDetails: + roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) case .userProfile(let userID): stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated)) case .genericCallLink(let url): - self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) + navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) case .oidcCallback: break case .settings, .chatBackupSettings: @@ -210,23 +212,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } - private func handleRoomRoute(roomID: String, animated: Bool) async { - switch await userSession.clientProxy.roomForIdentifier(roomID)?.membership { - case .invited: - if UIDevice.current.isPhone { - roomFlowCoordinator?.clearRoute(animated: animated) - } - stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated)) - case .joined: - stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) - case .left, .none: - // Do nothing but maybe we should ask design to have some kind of error state - break - } - } - func clearRoute(animated: Bool) { - roomFlowCoordinator?.handleAppRoute(.roomList, animated: animated) + roomFlowCoordinator?.clearRoute(animated: animated) } // MARK: - Private @@ -262,14 +249,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { attemptStartingOnboarding() case(.roomList, .selectRoom(let roomID, let showingRoomDetails), .roomList): - Task { await self.presentRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) } + Task { await self.startRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) } case(.roomList, .deselectRoom, .roomList): - tearDownRoomFlow(animated: animated) + dismissRoomFlow(animated: animated) case (.invitesScreen, .selectRoom, .invitesScreen): break case (.invitesScreen, .deselectRoom, .invitesScreen): - tearDownRoomFlow(animated: animated) + dismissRoomFlow(animated: animated) case (.roomList, .showSettingsScreen, .settingsScreen): break @@ -350,9 +337,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch action { case .presentRoom(let roomID): - stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false)) + handleAppRoute(.room(roomID: roomID), animated: true) case .presentRoomDetails(let roomID): - stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true)) + handleAppRoute(.roomDetails(roomID: roomID), animated: true) case .roomLeft(let roomID): if case .roomList(selectedRoomID: let selectedRoomID) = stateMachine.state, selectedRoomID == roomID { @@ -430,13 +417,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: Room Flow - private func presentRoomFlow(roomID: String, showingRoomDetails: Bool, animated: Bool) async { - guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { - MXLog.error("Invalid room ID: \(roomID)") - return - } - - let coordinator = await RoomFlowCoordinator(roomProxy: roomProxy, + private func startRoomFlow(roomID: String, showingRoomDetails: Bool, animated: Bool) async { + let coordinator = await RoomFlowCoordinator(roomID: roomID, userSession: userSession, isChildFlow: false, roomTimelineControllerFactory: roomTimelineControllerFactory, @@ -486,7 +468,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } - private func tearDownRoomFlow(animated: Bool) { + private func dismissRoomFlow(animated: Bool) { // THIS MUST BE CALLED *AFTER* THE FLOW HAS TIDIED UP THE STACK OR IT CAN CAUSE A CRASH. navigationSplitCoordinator.setDetailCoordinator(nil, animated: animated) roomFlowCoordinator = nil @@ -650,7 +632,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { guard let self else { return } switch action { - case .joined(let roomID): + case .select(let roomID): stateMachine.processEvent(.dismissedRoomDirectorySearchScreen) handleAppRoute(.room(roomID: roomID), animated: true) case .dismiss: diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 5107df23a..5b40afb16 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2553,6 +2553,74 @@ class ClientProxyMock: ClientProxyProtocol { return roomForIdentifierReturnValue } } + //MARK: - roomPreviewForIdentifier + + var roomPreviewForIdentifierUnderlyingCallsCount = 0 + var roomPreviewForIdentifierCallsCount: Int { + get { + if Thread.isMainThread { + return roomPreviewForIdentifierUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = roomPreviewForIdentifierUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + roomPreviewForIdentifierUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + roomPreviewForIdentifierUnderlyingCallsCount = newValue + } + } + } + } + var roomPreviewForIdentifierCalled: Bool { + return roomPreviewForIdentifierCallsCount > 0 + } + var roomPreviewForIdentifierReceivedIdentifier: String? + var roomPreviewForIdentifierReceivedInvocations: [String] = [] + + var roomPreviewForIdentifierUnderlyingReturnValue: Result! + var roomPreviewForIdentifierReturnValue: Result! { + get { + if Thread.isMainThread { + return roomPreviewForIdentifierUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = roomPreviewForIdentifierUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + roomPreviewForIdentifierUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + roomPreviewForIdentifierUnderlyingReturnValue = newValue + } + } + } + } + var roomPreviewForIdentifierClosure: ((String) async -> Result)? + + func roomPreviewForIdentifier(_ identifier: String) async -> Result { + roomPreviewForIdentifierCallsCount += 1 + roomPreviewForIdentifierReceivedIdentifier = identifier + roomPreviewForIdentifierReceivedInvocations.append(identifier) + if let roomPreviewForIdentifierClosure = roomPreviewForIdentifierClosure { + return await roomPreviewForIdentifierClosure(identifier) + } else { + return roomPreviewForIdentifierReturnValue + } + } //MARK: - loadUserDisplayName var loadUserDisplayNameUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift index f44529404..268c7a834 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift @@ -113,7 +113,7 @@ struct HomeScreenContent: View { HomeScreenRecoveryKeyConfirmationBanner(context: context) } - if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused { + if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused, !ServiceLocator.shared.settings.roomListInvitesEnabled { HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) { context.send(viewAction: .selectInvites) } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift index 4cbaaf085..ef50c6232 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift @@ -40,12 +40,18 @@ struct HomeScreenInviteCell: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 16) .padding(.trailing, 16) + .multilineTextAlignment(.leading) .overlay(alignment: .bottom) { separator } } .padding(.top, 12) .padding(.leading, 16) + .onTapGesture { + if let roomId = room.roomId { + context.send(viewAction: .selectRoom(roomIdentifier: roomId)) + } + } } // MARK: - Private diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenCoordinator.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenCoordinator.swift index 216b68674..b2b91b86c 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenCoordinator.swift @@ -19,10 +19,9 @@ import SwiftUI struct JoinRoomScreenCoordinatorParameters { let roomID: String - let roomName: String - let avatarURL: URL? - let interaction: JoinRoomScreenInteraction let clientProxy: ClientProxyProtocol + let mediaProvider: MediaProviderProtocol + let userIndicatorController: UserIndicatorControllerProtocol } enum JoinRoomScreenCoordinatorAction { @@ -45,10 +44,9 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol { self.parameters = parameters viewModel = JoinRoomScreenViewModel(roomID: parameters.roomID, - roomName: parameters.roomName, - avatarURL: parameters.avatarURL, - interaction: parameters.interaction, - clientProxy: parameters.clientProxy) + clientProxy: parameters.clientProxy, + mediaProvider: parameters.mediaProvider, + userIndicatorController: parameters.userIndicatorController) } func start() { @@ -65,6 +63,10 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol { } .store(in: &cancellables) } + + func stop() { + viewModel.stop() + } func toPresentable() -> AnyView { AnyView(JoinRoomScreen(context: viewModel.context)) diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift index 9c2845c0c..fd88b0889 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift @@ -21,34 +21,44 @@ enum JoinRoomScreenViewModelAction { case cancelled } +enum JoinRoomScreenInteractionMode { + case unknown + case invited + case join + case knock +} + struct JoinRoomScreenViewState: BindableState { // Maybe use room summary details or similar here?? let roomID: String - let roomName: String - let avatarURL: URL? - let interaction: JoinRoomScreenInteraction + var roomDetails: RoomPreviewDetails? - var isJoining = false + var mode: JoinRoomScreenInteractionMode { + guard let roomDetails else { + return .unknown + } + + if roomDetails.isInvited { + return .invited + } + + if roomDetails.isPublic { + return .join + } + + // Knocking is not supported yet, treat it as .unknown + // if roomDetails.canKnock { + // return .knock + // } + + return .unknown + } var bindings = JoinRoomScreenViewStateBindings() var title: String { - switch interaction { - case .knock: - L10n.screenJoinRoomTitleKnock - case .join, .invited: - L10n.screenJoinRoomTitleNoPreview - } - } - - var subtitle: String { - switch interaction { - case .knock: - L10n.screenJoinRoomSubtitleKnock - case .join, .invited: - L10n.screenJoinRoomSubtitleNoPreview - } + roomDetails?.name ?? L10n.screenJoinRoomTitleNoPreview } } @@ -57,13 +67,7 @@ struct JoinRoomScreenViewStateBindings { } enum JoinRoomScreenAlertType { - case joinFailed -} - -enum JoinRoomScreenInteraction { - case knock - case join - case invited + case declineInvite } enum JoinRoomScreenViewAction { diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift index 9048730fc..12b4b488c 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift @@ -20,20 +20,28 @@ import SwiftUI typealias JoinRoomScreenViewModelType = StateStoreViewModel class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewModelProtocol { + private let roomID: String private let clientProxy: ClientProxyProtocol + private let userIndicatorController: UserIndicatorControllerProtocol private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(roomID: String, roomName: String, avatarURL: URL?, interaction: JoinRoomScreenInteraction, clientProxy: ClientProxyProtocol) { + init(roomID: String, + clientProxy: ClientProxyProtocol, + mediaProvider: MediaProviderProtocol, + userIndicatorController: UserIndicatorControllerProtocol) { + self.roomID = roomID self.clientProxy = clientProxy + self.userIndicatorController = userIndicatorController - super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID, - roomName: roomName, - avatarURL: avatarURL, - interaction: interaction)) + super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID), imageProvider: mediaProvider) + + Task { + await loadRoomDetails() + } } // MARK: - Public @@ -47,21 +55,93 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo case .join: Task { await joinRoom() } case .acceptInvite: - break + Task { await joinRoom() } case .declineInvite: - break + showDeclineInviteConfirmationAlert() } } + func stop() { + hideLoadingIndicator() + } + // MARK: - Private - private func joinRoom() async { - state.isJoining = true - switch await clientProxy.joinRoom(state.roomID) { - case .success: - actionsSubject.send(.joined) + private func loadRoomDetails() async { + showLoadingIndicator() + + defer { + hideLoadingIndicator() + } + + switch await clientProxy.roomPreviewForIdentifier(roomID) { + case .success(let roomDetails): + state.roomDetails = roomDetails case .failure: - state.bindings.alertInfo = .init(id: .joinFailed) + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) } } + + private func joinRoom() async { + showLoadingIndicator() + + defer { + hideLoadingIndicator() + } + + switch await clientProxy.joinRoom(roomID) { + case .success: + actionsSubject.send(.joined) + case .failure(let error): + MXLog.error("Failed joining room with error: \(error)") + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + } + } + + private func showDeclineInviteConfirmationAlert() { + guard let roomDetails = state.roomDetails else { + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + return + } + + let roomName = roomDetails.name ?? roomID + state.bindings.alertInfo = .init(id: .declineInvite, + title: L10n.screenInvitesDeclineChatTitle, + message: L10n.screenInvitesDeclineChatMessage(roomName), + primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), + secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite() } })) + } + + private func declineInvite() async { + defer { + userIndicatorController.retractIndicatorWithId(roomID) + } + + userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true)) + + guard let roomProxy = await clientProxy.roomForIdentifier(roomID) else { + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + return + } + + let result = await roomProxy.rejectInvitation() + + if case .failure = result { + userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) + } + } + + private static let loadingIndicatorIdentifier = "\(JoinRoomScreenViewModel.self)-Loading" + + private func showLoadingIndicator() { + userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true), + title: L10n.commonLoading, + persistent: true), + delay: .seconds(0.25)) + } + + private func hideLoadingIndicator() { + userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } } diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModelProtocol.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModelProtocol.swift index f9e36914d..11ccd7a25 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModelProtocol.swift @@ -20,4 +20,6 @@ import Combine protocol JoinRoomScreenViewModelProtocol { var actionsPublisher: AnyPublisher { get } var context: JoinRoomScreenViewModelType.Context { get } + + func stop() } diff --git a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift index 6ac5806d0..1ae37a41f 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift @@ -28,16 +28,16 @@ struct JoinRoomScreen: View { } bottomContent: { buttons } + .alert(item: $context.alertInfo) .background() .backgroundStyle(.compound.bgCanvasDefault) .navigationBarTitleDisplayMode(.inline) - .toolbar { toolbar } } var mainContent: some View { VStack(spacing: 16) { - LoadableAvatarImage(url: context.viewState.avatarURL, - name: context.viewState.roomName, + LoadableAvatarImage(url: context.viewState.roomDetails?.avatarURL, + name: context.viewState.title, contentID: context.viewState.roomID, avatarSize: .room(on: .joinRoom), imageProvider: context.imageProvider) @@ -49,10 +49,28 @@ struct JoinRoomScreen: View { .foregroundStyle(.compound.textPrimary) .multilineTextAlignment(.center) - Text(context.viewState.subtitle) - .font(.compound.bodyMD) - .foregroundStyle(.compound.textSecondary) - .multilineTextAlignment(.center) + if let alias = context.viewState.roomDetails?.canonicalAlias { + Text(alias) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + } + + if let memberCount = context.viewState.roomDetails?.memberCount { + Label("\(memberCount)", icon: \.userProfile, iconSize: .small, relativeTo: .compound.bodySMSemibold) + .font(.compound.bodySMSemibold) + .foregroundStyle(.compound.textSecondary) + .padding(4) + .background(Capsule().fill(.compound.bgSubtleSecondary)) + .labelStyle(.custom(spacing: 4)) + } + + if let topic = context.viewState.roomDetails?.topic { + Text(topic) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .multilineTextAlignment(.center) + .lineLimit(3) + } } Image(asset: Asset.Images.joinRoomGraphic) @@ -62,7 +80,9 @@ struct JoinRoomScreen: View { @ViewBuilder var buttons: some View { - switch context.viewState.interaction { + switch context.viewState.mode { + case .unknown: + EmptyView() case .knock: Button(L10n.screenJoinRoomKnockAction) { context.send(viewAction: .knock) } .buttonStyle(.compound(.primary)) @@ -79,52 +99,82 @@ struct JoinRoomScreen: View { @ViewBuilder var inviteButtons: some View { - Button(L10n.actionDecline) { context.send(viewAction: .acceptInvite) } + Button(L10n.actionDecline) { context.send(viewAction: .declineInvite) } .buttonStyle(.compound(.secondary)) - Button(L10n.actionAccept) { context.send(viewAction: .declineInvite) } + Button(L10n.actionAccept) { context.send(viewAction: .acceptInvite) } .buttonStyle(.compound(.primary)) } - - @ToolbarContentBuilder - var toolbar: some ToolbarContent { - ToolbarItem(placement: .principal) { - RoomHeaderView(roomID: context.viewState.roomID, - roomName: context.viewState.roomName, - avatarURL: context.viewState.avatarURL, - imageProvider: context.imageProvider) - } - } } // MARK: - Previews struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview { - static let knockViewModel = makeViewModel(interaction: .knock) - static let joinViewModel = makeViewModel(interaction: .join) - static let inviteViewModel = makeViewModel(interaction: .invited) + static let unknownViewModel = makeViewModel(mode: .unknown) + static let knockViewModel = makeViewModel(mode: .knock) + static let joinViewModel = makeViewModel(mode: .join) + static let inviteViewModel = makeViewModel(mode: .invited) static var previews: some View { NavigationStack { - JoinRoomScreen(context: knockViewModel.context) + JoinRoomScreen(context: unknownViewModel.context) } - .previewDisplayName("Knock") + .previewDisplayName("Unknown") + .snapshot(delay: 0.25) + +// NavigationStack { +// JoinRoomScreen(context: knockViewModel.context) +// } +// .previewDisplayName("Knock") +// .snapshot(delay: 0.25) NavigationStack { JoinRoomScreen(context: joinViewModel.context) } .previewDisplayName("Join") + .snapshot(delay: 0.25) NavigationStack { JoinRoomScreen(context: inviteViewModel.context) } .previewDisplayName("Invite") + .snapshot(delay: 0.25) } - static func makeViewModel(interaction: JoinRoomScreenInteraction) -> JoinRoomScreenViewModel { - JoinRoomScreenViewModel(roomID: "6", - roomName: "Room Name", - avatarURL: nil, - interaction: interaction, - clientProxy: ClientProxyMock(.init())) + static func makeViewModel(mode: JoinRoomScreenInteractionMode) -> JoinRoomScreenViewModel { + let clientProxy = ClientProxyMock(.init()) + + // swiftlint:disable:next large_tuple + let membership: (isJoined: Bool, isInvited: Bool, isPublic: Bool, canKnock: Bool) = switch mode { + case .unknown: + (false, false, false, false) + case .invited: + (false, true, false, false) + case .join: + (false, false, true, false) + case .knock: + (false, false, false, true) + } + + if mode == .unknown { + clientProxy.roomPreviewForIdentifierReturnValue = .failure(.sdkError(ClientProxyMockError.generic)) + } else { + clientProxy.roomPreviewForIdentifierReturnValue = .success(.init(roomID: "1", + name: "The Three-Body Problem - 三体", + canonicalAlias: "#3🌞problem:matrix.org", + // swiftlint:disable:next line_length + topic: "“Science and technology were the only keys to opening the door to the future, and people approached science with the faith and sincerity of elementary school students.”", + avatarURL: URL.homeDirectory, + memberCount: UInt(100), + isHistoryWorldReadable: false, + isJoined: membership.isJoined, + isInvited: membership.isInvited, + isPublic: membership.isPublic, + canKnock: membership.canKnock)) + } + + return JoinRoomScreenViewModel(roomID: "1", + clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController) } } diff --git a/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenCoordinator.swift index eac60bf5a..f03c654d0 100644 --- a/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenCoordinator.swift @@ -24,7 +24,7 @@ struct RoomDirectorySearchScreenCoordinatorParameters { } enum RoomDirectorySearchScreenCoordinatorAction { - case joined(roomID: String) + case select(roomID: String) case dismiss } @@ -48,8 +48,8 @@ final class RoomDirectorySearchScreenCoordinator: CoordinatorProtocol { viewModel.actionsPublisher.sink { [weak self] action in guard let self else { return } switch action { - case .joined(let roomID): - actionsSubject.send(.joined(roomID: roomID)) + case .select(let roomID): + actionsSubject.send(.select(roomID: roomID)) case .dismiss: actionsSubject.send(.dismiss) } diff --git a/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenModels.swift b/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenModels.swift index b3385bb51..d8452a4f0 100644 --- a/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenModels.swift +++ b/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenModels.swift @@ -17,7 +17,7 @@ import Foundation enum RoomDirectorySearchScreenViewModelAction { - case joined(roomID: String) + case select(roomID: String) case dismiss } @@ -35,6 +35,6 @@ struct RoomDirectorySearchScreenViewStateBindings { enum RoomDirectorySearchScreenViewAction { case dismiss - case join(roomID: String) + case select(roomID: String) case reachedBottom } diff --git a/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenViewModel.swift b/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenViewModel.swift index 5dd597f5f..ef0774b01 100644 --- a/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDirectorySearchScreen/RoomDirectorySearchScreenViewModel.swift @@ -65,8 +65,8 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType switch viewAction { case .dismiss: actionsSubject.send(.dismiss) - case .join(roomID: let roomID): - joinRoom(roomID: roomID) + case .select(roomID: let roomID): + actionsSubject.send(.select(roomID: roomID)) case .reachedBottom: loadNextPage() } @@ -74,24 +74,6 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType // MARK: - Private - private func joinRoom(roomID: String) { - showLoadingIndicator() - - Task { - defer { - hideLoadingIndicator() - } - - switch await clientProxy.joinRoom(roomID) { - case .success: - actionsSubject.send(.joined(roomID: roomID)) - case .failure(let error): - MXLog.error("Failed joining room with error: \(error)") - userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown)) - } - } - } - private static let errorID = "roomDirectorySearchViewModelLoadingError" private func search(query: String?) { @@ -122,17 +104,4 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType state.isLoading = false } } - - private static let loadingIndicatorIdentifier = "\(RoomDirectorySearchScreenViewModel.self)-Loading" - - private func showLoadingIndicator() { - userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier, - type: .modal, - title: L10n.commonLoading, - persistent: true)) - } - - private func hideLoadingIndicator() { - userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) - } } diff --git a/ElementX/Sources/Screens/RoomDirectorySearchScreen/View/RoomDirectorySearchScreen.swift b/ElementX/Sources/Screens/RoomDirectorySearchScreen/View/RoomDirectorySearchScreen.swift index a881ccfe6..90a1f0480 100644 --- a/ElementX/Sources/Screens/RoomDirectorySearchScreen/View/RoomDirectorySearchScreen.swift +++ b/ElementX/Sources/Screens/RoomDirectorySearchScreen/View/RoomDirectorySearchScreen.swift @@ -26,7 +26,7 @@ struct RoomDirectorySearchScreen: View { Section { ForEach(context.viewState.rooms) { room in RoomDirectorySearchCell(result: room, imageProvider: context.imageProvider) { - context.send(viewAction: .join(roomID: room.id)) + context.send(viewAction: .select(roomID: room.id)) } } } footer: { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index f4654c532..10bdccf70 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -456,6 +456,16 @@ class ClientProxy: ClientProxyProtocol { room: room, backgroundTaskService: backgroundTaskService) } + + func roomPreviewForIdentifier(_ identifier: String) async -> Result { + do { + let roomPreview = try await client.getRoomPreview(roomIdOrAlias: identifier) + return .success(.init(roomPreview)) + } catch { + MXLog.error("Failed retrieving preview for room: \(identifier) with error: \(error)") + return .failure(.sdkError(error)) + } + } func loadUserDisplayName() async -> Result { await Task.dispatch(on: clientQueue) { @@ -930,3 +940,19 @@ private class IgnoredUsersListenerProxy: IgnoredUsersListener { onUpdateClosure(ignoredUserIds) } } + +private extension RoomPreviewDetails { + init(_ roomPreview: RoomPreview) { + self = RoomPreviewDetails(roomID: roomPreview.roomId, + name: roomPreview.name, + canonicalAlias: roomPreview.canonicalAlias, + topic: roomPreview.topic, + avatarURL: roomPreview.avatarUrl.flatMap(URL.init(string:)), + memberCount: UInt(roomPreview.numJoinedMembers), + isHistoryWorldReadable: roomPreview.isHistoryWorldReadable, + isJoined: roomPreview.isJoined, + isInvited: roomPreview.isInvited, + isPublic: roomPreview.isPublic, + canKnock: roomPreview.canKnock) + } +} diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 7137fb0ca..2d9cc725d 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -67,6 +67,20 @@ enum SessionVerificationState { case unverified } +struct RoomPreviewDetails { + let roomID: String + let name: String? + let canonicalAlias: String? + let topic: String? + let avatarURL: URL? + let memberCount: UInt + let isHistoryWorldReadable: Bool + let isJoined: Bool + let isInvited: Bool + let isPublic: Bool + let canKnock: Bool +} + // sourcery: AutoMockable protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { var actionsPublisher: AnyPublisher { get } @@ -123,6 +137,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? + func roomPreviewForIdentifier(_ identifier: String) async -> Result + @discardableResult func loadUserDisplayName() async -> Result func setUserDisplayName(_ name: String) async -> Result diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Invite.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Invite.png index 29c2bdeea..982e3a32f 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Invite.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Invite.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47d269397c823468762d9c7fd7075a6f998b4180f4ef5c126d13623348ba02b2 -size 2089420 +oid sha256:95b4eb933dfcab0a5da9c608a0defc26a62206ab665e3a9af0dd6ac4a29bba72 +size 2099891 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Join.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Join.png index 53f541425..2b37b0a32 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Join.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Join.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1df7a389ffbf315811e1605cb542a01ae560e977872c49e15092dc0f7432b1eb -size 2053936 +oid sha256:0b1b5884816d8998bc519d62fb538d961d878e79c62c386c152aa7d6910f3acf +size 2064569 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Knock.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Knock.png index 4293e58eb..84fb9b1e2 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Knock.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Knock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf9c852e2d1618056391d08faacfbe402be84ced71793364e25e4e1fd0a84481 -size 2057420 +oid sha256:1e227ebf40207f27f1e9ad19881d274a6e6b87bdc2ea40051b6e7fa1e59c84c4 +size 2054048 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Unknown.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Unknown.png new file mode 100644 index 000000000..f26a2de25 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-en-GB.Unknown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:991acd0c4f868421686daa929e1c52bc7c1d47afcc39e23da19d17f0f19c8a0a +size 2048512 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Invite.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Invite.png index 0ba98e47e..f64d26093 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Invite.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Invite.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5df7c700584320710a80633c4b06b1045886a88a32ba112a692560b58ce5eaf0 -size 2123755 +oid sha256:da004ba89cda86c27b44ee6acf78c69d845d887ede2776e90e4194f2819a4e1a +size 2104970 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Join.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Join.png index 5a0f98fa8..cf5dc6eb5 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Join.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Join.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8945225fb83489edf46bb1454770b0c55d2265b8b330ecd16079534c1fc2ef76 -size 2087732 +oid sha256:3a663cf0f68bc9ed2ac0df7e88a9b144a4cc25205fc71d9a3f0e25f892c86788 +size 2069238 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Knock.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Knock.png index 5eb1b37a3..c9832d42c 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Knock.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Knock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e60ad9a4485f37882412ec46821127ea0f605f9abe435598f43d8bc2d5279c83 -size 2104307 +oid sha256:09dca383db2dfc978c9f0d78bda8331783aca72a1650c60cba1262fb73637af3 +size 2059391 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Unknown.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Unknown.png new file mode 100644 index 000000000..15d58b1a3 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPad-pseudo.Unknown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac085ce2465a37d69978c118fba9b35b301449f1a8c5acd97fb5632cb0a8fe47 +size 2062324 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Invite.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Invite.png index b31f8ad02..004d21439 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Invite.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Invite.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e17277315e799bbac0caf145de342a624bfc80357aa63f297334fe8d6c81367 -size 826589 +oid sha256:2806fc99ef156ed9b0d9e97bc7329a00905eabe5bac5200a17e59124de5b94c1 +size 857415 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Join.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Join.png index dc38a150b..2a3ba7ba8 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Join.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Join.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e45734de2938d98904d8b7c19a44b997fb1f6447839f60909e79c1e620096fd -size 806983 +oid sha256:5691851137a7bd9c6fd1e6b18597ed4f5b6aa6d89cba714224f54e620e24fb96 +size 837040 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Knock.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Knock.png index 29370035f..5751bfea2 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Knock.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Knock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0f4de3f95f6242680a8b8b52939fd2a3a79a299d8dea6d5609244038c95bc1a -size 811524 +oid sha256:c28a125af66dac1dbdfe12cb8dc588b51a1c4611693275113caf522ffe1c68c8 +size 829240 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Unknown.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Unknown.png new file mode 100644 index 000000000..3ac0151b4 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-en-GB.Unknown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32985fbf4c2f829638965222ef31392e490fd260ff0d07bc777571eed90d25de +size 810769 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Invite.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Invite.png index 4a20fc664..094c2dfd0 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Invite.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Invite.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0bca263cdc71f987ef6b0f4f3d70f4779ea3865a603f752fc5e3ae773c9f394 -size 857464 +oid sha256:52d999afeabc4fd6dded6ad2ece35968c59c955b90c53961ee1fd42179aeb706 +size 861376 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Join.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Join.png index 527147447..d92764daf 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Join.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Join.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:171a0753cc99267b93c83f3bc2caf9546d2d72c685251f9fe70796556db5fc3e -size 837228 +oid sha256:0bae4ecdbf0b1a8d06e4115af0add88ea5dadd6dc43f61e6a6c0a4aa55dbd34e +size 840927 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Knock.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Knock.png index 446e24deb..87315383f 100644 --- a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Knock.png +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Knock.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a88bb18fd0311c7c380f76a7c1364a1eb844f708835425b8a22abcc12997c25c -size 851763 +oid sha256:32d350137393e9ca3ab38f481c57fa7eacf9c0a89be63de6cdacc2274d0f26cb +size 834432 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Unknown.png b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Unknown.png new file mode 100644 index 000000000..a241c1101 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_joinRoomScreen-iPhone-15-pseudo.Unknown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1a65eab7cb10c6918f1a4efa9ae210b5b55959d90128a3a21536d5235948daf +size 825025 diff --git a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift index d0ac82d46..36c63e8bd 100644 --- a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift +++ b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift @@ -34,13 +34,44 @@ class JoinRoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() } + func testAcceptInviteInteraction() async throws { + setupViewModel() + + let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined } + context.send(viewAction: .acceptInvite) + try await deferred.fulfill() + } + + func testDeclineInviteInteraction() async throws { + setupViewModel() + + try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill() + + context.send(viewAction: .declineInvite) + + XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInvite) + } + private func setupViewModel(throwing: Bool = false) { let clientProxy = ClientProxyMock(.init()) + clientProxy.joinRoomReturnValue = throwing ? .failure(.sdkError(ClientProxyMockError.generic)) : .success(()) + + clientProxy.roomPreviewForIdentifierReturnValue = .success(.init(roomID: "", + name: nil, + canonicalAlias: nil, + topic: nil, + avatarURL: nil, + memberCount: 0, + isHistoryWorldReadable: false, + isJoined: false, + isInvited: false, + isPublic: false, + canKnock: false)) + viewModel = JoinRoomScreenViewModel(roomID: "1", - roomName: "Room Name", - avatarURL: nil, - interaction: .join, - clientProxy: clientProxy) + clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController) } } diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 0c4d7ed20..4d5da5f26 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -21,32 +21,33 @@ import Combine @MainActor class RoomFlowCoordinatorTests: XCTestCase { + var clientProxy: ClientProxyMock! var roomFlowCoordinator: RoomFlowCoordinator! var navigationStackCoordinator: NavigationStackCoordinator! var cancellables = Set() func testRoomPresentation() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - try await process(route: .roomList, expectedAction: .finished) + try await clearRoute(expectedActions: [.finished]) XCTAssertNil(navigationStackCoordinator.rootCoordinator) } func testRoomDetailsPresentation() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .roomDetails(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - try await process(route: .roomList, expectedAction: .finished) + try await clearRoute(expectedActions: [.finished]) XCTAssertNil(navigationStackCoordinator.rootCoordinator) } func testNoOp() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .roomDetails(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) @@ -60,7 +61,7 @@ class RoomFlowCoordinatorTests: XCTestCase { } func testPushDetails() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) @@ -73,7 +74,7 @@ class RoomFlowCoordinatorTests: XCTestCase { } func testChildRoomFlow() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) @@ -88,14 +89,14 @@ class RoomFlowCoordinatorTests: XCTestCase { XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) - try await process(route: .roomList, expectedAction: .finished) + try await clearRoute(expectedActions: [.finished]) XCTAssertNil(navigationStackCoordinator.rootCoordinator) XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) } /// Tests the child flow teardown in isolation of it's parent. func testChildFlowTearDown() async throws { - await setupViewModel(asChildFlow: true) + await setupRoomFlowCoordinator(asChildFlow: true) navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator()) try await process(route: .room(roomID: "1")) @@ -105,7 +106,7 @@ class RoomFlowCoordinatorTests: XCTestCase { XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator) - try await process(route: .roomList, expectedAction: .finished) + try await clearRoute(expectedActions: [.finished]) XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator) XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2, "A child room flow should leave its parent to clean up the stack.") XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator, "A child room flow should leave its parent to clean up the stack.") @@ -113,7 +114,7 @@ class RoomFlowCoordinatorTests: XCTestCase { } func testChildRoomMemberDetails() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) @@ -130,7 +131,7 @@ class RoomFlowCoordinatorTests: XCTestCase { } func testChildRoomIgnoresDirectDuplicate() async throws { - await setupViewModel() + await setupRoomFlowCoordinator() try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) @@ -151,6 +152,60 @@ class RoomFlowCoordinatorTests: XCTestCase { XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) } + func testRoomMembershipInvite() async throws { + await setupRoomFlowCoordinator(roomType: .invited(roomID: "InvitedRoomID")) + + try await process(route: .room(roomID: "InvitedRoomID")) + XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + + try await clearRoute(expectedActions: [.finished]) + XCTAssertNil(navigationStackCoordinator.rootCoordinator) + + try await process(route: .room(roomID: "InvitedRoomID")) + XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + + // "Join" the room + clientProxy.roomForIdentifierClosure = { _ in + RoomProxyMock(with: .init()) + } + + try await process(route: .room(roomID: "InvitedRoomID")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + } + + func testChildRoomMembershipInvite() async throws { + await setupRoomFlowCoordinator(asChildFlow: true, roomType: .invited(roomID: "InvitedRoomID")) + navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator()) + + try await process(route: .room(roomID: "InvitedRoomID")) + XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator) + + try await clearRoute(expectedActions: [.finished]) + XCTAssertNil(navigationStackCoordinator.stackCoordinators.last, "A child room flow should remove the join room scren on dismissal") + + navigationStackCoordinator.popToRoot() + + try await process(route: .room(roomID: "InvitedRoomID")) + XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator) + + // "Join" the room + clientProxy.roomForIdentifierClosure = { _ in + RoomProxyMock(with: .init()) + } + + try await process(route: .room(roomID: "InvitedRoomID")) + XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.") + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator) + } + // MARK: - Private private func process(route: AppRoute) async throws { @@ -159,11 +214,15 @@ class RoomFlowCoordinatorTests: XCTestCase { try await Task.sleep(for: .milliseconds(100)) } - private func process(route: AppRoute, expectedAction: RoomFlowCoordinatorAction) async throws { - try await process(route: route, expectedActions: [expectedAction]) + private func clearRoute(expectedActions: [RoomFlowCoordinatorAction]) async throws { + try await processRouteOrClear(route: nil, expectedActions: expectedActions) } private func process(route: AppRoute, expectedActions: [RoomFlowCoordinatorAction]) async throws { + try await processRouteOrClear(route: route, expectedActions: expectedActions) + } + + private func processRouteOrClear(route: AppRoute?, expectedActions: [RoomFlowCoordinatorAction]) async throws { guard !expectedActions.isEmpty else { return } @@ -176,16 +235,40 @@ class RoomFlowCoordinatorTests: XCTestCase { }) } - roomFlowCoordinator.handleAppRoute(route, animated: true) + if let route { + roomFlowCoordinator.handleAppRoute(route, animated: true) + } else { + roomFlowCoordinator.clearRoute(animated: true) + } for fulfillment in fulfillments { try await fulfillment.fulfill() } } - private func setupViewModel(asChildFlow: Bool = false) async { + private func setupRoomFlowCoordinator(asChildFlow: Bool = false, roomType: RoomType? = nil) async { cancellables.removeAll() - let clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) + clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) + + clientProxy.roomPreviewForIdentifierClosure = { [roomType] roomID in + switch roomType { + case .invited: + return .success(.init(roomID: roomID, + name: "Invite", + canonicalAlias: nil, + topic: nil, + avatarURL: nil, + memberCount: 0, + isHistoryWorldReadable: false, + isJoined: false, + isInvited: true, + isPublic: false, + canKnock: false)) + default: + fatalError("Something isn't set up right") + } + } + let mediaProvider = MockMediaProvider() let voiceMessageMediaManager = VoiceMessageMediaManagerMock() let userSession = MockUserSession(clientProxy: clientProxy, @@ -196,7 +279,14 @@ class RoomFlowCoordinatorTests: XCTestCase { navigationStackCoordinator = NavigationStackCoordinator() navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator) - roomFlowCoordinator = await RoomFlowCoordinator(roomProxy: RoomProxyMock(with: .init(id: "1")), + let roomID = switch roomType { + case .invited(let roomID): + roomID + default: + "1" + } + + roomFlowCoordinator = await RoomFlowCoordinator(roomID: roomID, userSession: userSession, isChildFlow: asChildFlow, roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), @@ -209,3 +299,7 @@ class RoomFlowCoordinatorTests: XCTestCase { orientationManager: OrientationManagerMock()) } } + +private enum RoomType { + case invited(roomID: String) +} diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 037d137cf..3f90e3420 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -325,7 +325,7 @@ class RoomScreenViewModelTests: XCTestCase { let items = [TextRoomTimelineItem(eventID: "t1"), TextRoomTimelineItem(eventID: "t2"), TextRoomTimelineItem(eventID: "t3")] - let (viewModel, roomProxy, timelineProxy, _, notificationCenter) = readReceiptsConfiguration(with: items) + let (viewModel, _, timelineProxy, _, _) = readReceiptsConfiguration(with: items) // When sending a read receipt for the last item. viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))