diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index df11d67b0..575c3726a 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -190,16 +190,21 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } case .userProfile(let userID): if isExternalURL { - userSessionFlowCoordinator?.handleAppRoute(route, animated: true) + handleAppRoute(route) } else { - userSessionFlowCoordinator?.handleAppRoute(.roomMemberDetails(userID: userID), animated: true) + handleAppRoute(.roomMemberDetails(userID: userID)) } case .room(let roomID): - // check that the room is joined here, if not use a joinRoom route. if isExternalURL { - userSessionFlowCoordinator?.handleAppRoute(route, animated: true) + handleAppRoute(route) } else { - userSessionFlowCoordinator?.handleAppRoute(.childRoom(roomID: roomID), animated: true) + handleAppRoute(.childRoom(roomID: roomID)) + } + case .roomAlias(let alias): + if isExternalURL { + handleAppRoute(route) + } else { + handleAppRoute(.childRoomAlias(alias)) } default: break @@ -255,7 +260,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg return } - // Handle here the account switching when available handleAppRoute(.room(roomID: roomID)) } diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index 6802400c5..7d3679936 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -24,8 +24,12 @@ enum AppRoute: Equatable { case roomList /// A room, shown as the root of the stack (popping any child rooms). case room(roomID: String) + /// A room, shown as the root of the stack (popping any child rooms). + case roomAlias(String) /// A room, pushed as a child of any existing rooms on the stack. case childRoom(roomID: String) + /// A room, pushed as a child of any existing rooms on the stack. + case childRoomAlias(String) /// The information about a particular room. case roomDetails(roomID: String) /// The profile of a member within the current room. @@ -125,6 +129,8 @@ struct MatrixPermalinkParser: URLParser { switch parseMatrixEntityFrom(uri: url.absoluteString)?.id { case .room(let id): return .room(roomID: id) + case .roomAlias(let alias): + return .roomAlias(alias) case .user(let id): return .userProfile(userID: id) default: diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 08c23e8d6..c71f3f534 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -138,7 +138,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated)) } - case .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: + case .roomAlias, .childRoomAlias, .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: break } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index ab8bf3bb3..bdc58e9f3 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -175,37 +175,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: - FlowCoordinatorProtocol func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { - clearPresentedSheets(animated: animated) { [weak self] in - guard let self else { return } - - switch appRoute { - case .room(let roomID): - stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) - case .childRoom(let roomID): - if let roomFlowCoordinator { - roomFlowCoordinator.handleAppRoute(appRoute, animated: animated) - } else { - stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) - } - case .roomDetails(let roomID): - if stateMachine.state.selectedRoomID == roomID { - roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) - } else { - stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true), userInfo: .init(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): - navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) - case .oidcCallback: - break - case .settings, .chatBackupSettings: - settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated) - } + Task { + await asyncHandleAppRoute(appRoute, animated: animated) } } @@ -215,6 +186,60 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: - Private + func asyncHandleAppRoute(_ appRoute: AppRoute, animated: Bool) async { + showLoadingIndicator(delay: .seconds(0.25)) + defer { + hideLoadingIndicator() + } + + await clearPresentedSheets(animated: animated) + + switch appRoute { + case .room(let roomID): + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) + case .roomAlias(let alias): + guard let roomID = await userSession.clientProxy.resolveRoomAlias(alias) else { + return + } + + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) + case .childRoom(let roomID): + if let roomFlowCoordinator { + roomFlowCoordinator.handleAppRoute(appRoute, animated: animated) + } else { + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) + } + case .childRoomAlias(let alias): + guard let roomID = await userSession.clientProxy.resolveRoomAlias(alias) else { + return + } + + if let roomFlowCoordinator { + roomFlowCoordinator.handleAppRoute(.childRoom(roomID: roomID), animated: animated) + } else { + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated)) + } + case .roomDetails(let roomID): + if stateMachine.state.selectedRoomID == roomID { + roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) + } else { + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true), userInfo: .init(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): + navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) + case .oidcCallback: + break + case .settings, .chatBackupSettings: + settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated) + } + } + func attemptStartingOnboarding() { if onboardingFlowCoordinator.shouldStart { clearRoute(animated: false) @@ -222,18 +247,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } - private func clearPresentedSheets(animated: Bool, completion: @escaping () -> Void) { + private func clearPresentedSheets(animated: Bool) async { if navigationSplitCoordinator.sheetCoordinator == nil { - completion() return } navigationSplitCoordinator.setSheetCoordinator(nil, animated: animated) // Prevents system crashes when presenting a sheet if another one was already shown - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - completion() - } + try? await Task.sleep(for: .seconds(0.25)) } private func setupStateMachine() { @@ -675,4 +697,20 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { self?.stateMachine.processEvent(.dismissedUserProfileScreen) } } + + // MARK: Toasts and loading indicators + + private static let loadingIndicatorIdentifier = "\(UserSessionFlowCoordinator.self)-Loading" + + private func showLoadingIndicator(delay: Duration? = nil) { + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal, + title: L10n.commonLoading, + persistent: true), + delay: delay) + } + + private func hideLoadingIndicator() { + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index a797ef1a6..890932057 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -3361,6 +3361,74 @@ class ClientProxyMock: ClientProxyProtocol { return roomDirectorySearchProxyReturnValue } } + //MARK: - resolveRoomAlias + + var resolveRoomAliasUnderlyingCallsCount = 0 + var resolveRoomAliasCallsCount: Int { + get { + if Thread.isMainThread { + return resolveRoomAliasUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = resolveRoomAliasUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + resolveRoomAliasUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + resolveRoomAliasUnderlyingCallsCount = newValue + } + } + } + } + var resolveRoomAliasCalled: Bool { + return resolveRoomAliasCallsCount > 0 + } + var resolveRoomAliasReceivedAlias: String? + var resolveRoomAliasReceivedInvocations: [String] = [] + + var resolveRoomAliasUnderlyingReturnValue: String? + var resolveRoomAliasReturnValue: String? { + get { + if Thread.isMainThread { + return resolveRoomAliasUnderlyingReturnValue + } else { + var returnValue: String?? = nil + DispatchQueue.main.sync { + returnValue = resolveRoomAliasUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + resolveRoomAliasUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + resolveRoomAliasUnderlyingReturnValue = newValue + } + } + } + } + var resolveRoomAliasClosure: ((String) async -> String?)? + + func resolveRoomAlias(_ alias: String) async -> String? { + resolveRoomAliasCallsCount += 1 + resolveRoomAliasReceivedAlias = alias + resolveRoomAliasReceivedInvocations.append(alias) + if let resolveRoomAliasClosure = resolveRoomAliasClosure { + return await resolveRoomAliasClosure(alias) + } else { + return resolveRoomAliasReturnValue + } + } //MARK: - ignoreUser var ignoreUserUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 379f19a80..9a20c9dc0 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -597,6 +597,15 @@ class ClientProxy: ClientProxyProtocol { RoomDirectorySearchProxy(roomDirectorySearch: client.roomDirectorySearch()) } + func resolveRoomAlias(_ alias: String) async -> String? { + do { + return try await client.resolveRoomAlias(roomAlias: alias) + } catch { + MXLog.error("Failed resolving room alias: \(alias) with error: \(error)") + return nil + } + } + // MARK: Ignored users func ignoreUser(_ userID: String) async -> Result { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 2d9cc725d..3d91a74eb 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -160,6 +160,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func profile(for userID: String) async -> Result func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol + + func resolveRoomAlias(_ alias: String) async -> String? // MARK: - Ignored users diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index b8d09aaa6..defb5c220 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -21,6 +21,7 @@ import Combine @MainActor class UserSessionFlowCoordinatorTests: XCTestCase { + var clientProxy: ClientProxyMock! var userSessionFlowCoordinator: UserSessionFlowCoordinator! var navigationRootCoordinator: NavigationRootCoordinator! var notificationManager: NotificationManagerMock! @@ -33,7 +34,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase { override func setUp() async throws { 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))))) let mediaProvider = MockMediaProvider() let voiceMessageMediaManager = VoiceMessageMediaManagerMock() let userSession = MockUserSession(clientProxy: clientProxy, @@ -84,6 +85,34 @@ class UserSessionFlowCoordinatorTests: XCTestCase { XCTAssertEqual(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations, ["1", "1", "2"]) } + func testRoomAliasPresentation() async throws { + clientProxy.resolveRoomAliasReturnValue = "1" + + try await process(route: .roomAlias("#alias:matrix.org"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + try await process(route: .roomList, expectedState: .roomList(selectedRoomID: nil)) + XCTAssertNil(detailNavigationStack?.rootCoordinator) + XCTAssertNil(detailCoordinator) + + try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + clientProxy.resolveRoomAliasReturnValue = "2" + + try await process(route: .room(roomID: "2"), expectedState: .roomList(selectedRoomID: "2")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + try await process(route: .roomList, expectedState: .roomList(selectedRoomID: nil)) + XCTAssertNil(detailNavigationStack?.rootCoordinator) + XCTAssertNil(detailCoordinator) + + XCTAssertEqual(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations, ["1", "1", "2"]) + } + func testRoomDetailsPresentation() async throws { try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)