diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d27a9dc73e..a3a7dd473c 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -481,6 +481,7 @@ 7354D094A4C59B555F407FA1 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 7361B011A79BF723D8C9782B /* EmojiCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */; }; 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; + 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; }; 74604ACFDBE7F54260E7B617 /* ApplicationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */; }; @@ -1576,6 +1577,7 @@ 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreen.swift; sourceTree = ""; }; 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenModels.swift; sourceTree = ""; }; 71D52BAA5BADB06E5E8C295D /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; + 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileScreenViewModelTests.swift; sourceTree = ""; }; 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = ""; }; 7310D8DFE01AF45F0689C3AA /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportFlowCoordinator.swift; sourceTree = ""; }; @@ -3620,6 +3622,7 @@ EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */, 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */, BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, + 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */, 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */, 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */, AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */, @@ -5819,6 +5822,7 @@ E313BDD2B8813144139B2E00 /* UserDiscoveryServiceTest.swift in Sources */, A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */, 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */, + 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */, 627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */, 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */, 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 9bd3c8966e..b00747338d 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -193,8 +193,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } else { navigationRootCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url))) } - case .roomMemberDetails: - userSessionFlowCoordinator?.handleAppRoute(route, animated: true) + case .userProfile(let userID): + if isExternalURL { + userSessionFlowCoordinator?.handleAppRoute(route, animated: true) + } else { + userSessionFlowCoordinator?.handleAppRoute(.roomMemberDetails(userID: userID), animated: true) + } case .room(let roomID): // check that the room is joined here, if not use a joinRoom route. if isExternalURL { diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 47d2c9351a..9a5a75ddb9 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -129,6 +129,8 @@ final class AppSettings { let encryptionURL: URL = "https://element.io/help#encryption" /// A URL where users can go read more about the chat backup. let chatBackupDetailsURL: URL = "https://element.io/help#encryption5" + /// Any domains that Element web may be hosted on - used for handling links. + let elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"] @UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store)) var appAppearance: AppAppearance diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index 67c55b932e..6802400c5e 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -29,8 +29,9 @@ enum AppRoute: Equatable { /// The information about a particular room. case roomDetails(roomID: String) /// The profile of a member within the current room. - /// (This can be specialised into 2 routes when we support user permalinks). case roomMemberDetails(userID: String) + /// The profile of a matrix user (outside of a room). + case userProfile(userID: String) /// An Element Call link generated outside of a chat room. case genericCallLink(url: URL) /// The settings screen. @@ -45,6 +46,7 @@ struct AppRouteURLParser { init(appSettings: AppSettings) { urlParsers = [ MatrixPermalinkParser(), + ElementWebURLParser(domains: appSettings.elementWebHosts), OIDCCallbackURLParser(appSettings: appSettings), ElementCallURLParser() ] @@ -64,9 +66,6 @@ struct AppRouteURLParser { /// Represents a type that can parse a `URL` into an `AppRoute`. /// /// The following Universal Links are missing parsers. -/// - app.element.io -/// - staging.element.io -/// - develop.element.io /// - mobile.element.io protocol URLParser { func route(from url: URL) -> AppRoute? @@ -123,17 +122,43 @@ struct ElementCallURLParser: URLParser { struct MatrixPermalinkParser: URLParser { func route(from url: URL) -> AppRoute? { - guard let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) else { + switch parseMatrixEntityFrom(uri: url.absoluteString)?.id { + case .room(let id): + return .room(roomID: id) + case .user(let id): + return .userProfile(userID: id) + default: return nil } + } +} + +struct ElementWebURLParser: URLParser { + let domains: [String] + let paths = ["room", "user"] + + private let permalinkParser = MatrixPermalinkParser() + + func route(from url: URL) -> AppRoute? { + guard let matrixToURL = buildMatrixToURL(from: url) else { return nil } + return permalinkParser.route(from: matrixToURL) + } + + private func buildMatrixToURL(from url: URL) -> URL? { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } - switch matrixEntity.id { - case .user(let userID): - return .roomMemberDetails(userID: userID) - case .room(let roomID): - return .room(roomID: roomID) - default: - return nil + for domain in domains where domain == url.host { + components.host = "matrix.to" + for path in paths { + components.fragment?.replace("/\(path)", with: "") + } + + guard let matrixToURL = components.url else { continue } + return matrixToURL } + + return url } } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 23b1614e69..f12673fb11 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -129,7 +129,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated)) } - case .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: + case .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: break } } @@ -875,7 +875,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { roomProxy: roomProxy, clientProxy: userSession.clientProxy, mediaProvider: userSession.mediaProvider, - userIndicatorController: userIndicatorController) + userIndicatorController: userIndicatorController, + analytics: analytics) let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params) coordinator.actions.sink { [weak self] action in @@ -883,8 +884,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .openUserProfile: stateMachine.tryEvent(.presentUserProfile(userID: userID)) - case .openDirectChat(let displayName): - openDirectChat(with: userID, displayName: displayName) + case .openDirectChat(let roomID): + stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) } } .store(in: &cancellables) @@ -896,16 +897,20 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private func replaceRoomMemberDetailsWithUserProfile(userID: String) { let parameters = UserProfileScreenCoordinatorParameters(userID: userID, + isPresentedModally: false, clientProxy: userSession.clientProxy, mediaProvider: userSession.mediaProvider, - userIndicatorController: userIndicatorController) + userIndicatorController: userIndicatorController, + analytics: analytics) let coordinator = UserProfileScreenCoordinator(parameters: parameters) coordinator.actionsPublisher.sink { [weak self] action in guard let self else { return } switch action { - case .openDirectChat(let displayName): - openDirectChat(with: userID, displayName: displayName) + case .openDirectChat(let roomID): + stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) + case .dismiss: + break // Not supported when pushed. } } .store(in: &cancellables) @@ -917,37 +922,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func openDirectChat(with userID: String, displayName: String?) { - let loadingIndicatorIdentifier = "OpenDirectChatLoadingIndicator" - - userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, - type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), - title: L10n.commonLoading, - persistent: true)) - - Task { [weak self] in - guard let self else { return } - - let currentDirectRoom = await userSession.clientProxy.directRoomForUserID(userID) - switch currentDirectRoom { - case .success(.some(let roomID)): - stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) - case .success(nil): - switch await userSession.clientProxy.createDirectRoom(with: userID, expectedRoomName: displayName) { - case .success(let roomID): - analytics.trackCreatedRoom(isDM: true) - stateMachine.tryEvent(.presentChildRoom(roomID: roomID)) - case .failure: - userIndicatorController.alertInfo = .init(id: UUID()) - } - case .failure: - userIndicatorController.alertInfo = .init(id: UUID()) - } - - userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) - } - } - private func presentMessageForwarding(for itemID: TimelineItemIdentifier) { guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider, let eventID = itemID.eventID else { fatalError() diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 0031b4003b..33608e421f 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -195,6 +195,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } case .roomList, .roomMemberDetails: self.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) case .oidcCallback: @@ -302,6 +304,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentRoomDirectorySearch() case (.roomDirectorySearchScreen, .dismissedRoomDirectorySearchScreen, .roomList): dismissRoomDirectorySearch() + + case (_, .showUserProfileScreen(let userID), .userProfileScreen): + presentUserProfileScreen(userID: userID, animated: animated) + case (.userProfileScreen, .dismissedUserProfileScreen, .roomList): + break default: fatalError("Unknown transition: \(context)") @@ -654,4 +661,36 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private func dismissRoomDirectorySearch() { navigationSplitCoordinator.setFullScreenCoverCoordinator(nil) } + + // MARK: User Profile + + private func presentUserProfileScreen(userID: String, animated: Bool) { + clearRoute(animated: animated) + + let navigationStackCoordinator = NavigationStackCoordinator() + let parameters = UserProfileScreenCoordinatorParameters(userID: userID, + isPresentedModally: true, + clientProxy: userSession.clientProxy, + mediaProvider: userSession.mediaProvider, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: analytics) + let coordinator = UserProfileScreenCoordinator(parameters: parameters) + coordinator.actionsPublisher.sink { [weak self] action in + guard let self else { return } + + switch action { + case .openDirectChat(let roomID): + navigationSplitCoordinator.setSheetCoordinator(nil) + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false)) + case .dismiss: + navigationSplitCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setRootCoordinator(coordinator, animated: false) + navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator, animated: animated) { [weak self] in + self?.stateMachine.processEvent(.dismissedUserProfileScreen) + } + } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift index c1c320ad4f..57551f4717 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift @@ -27,7 +27,7 @@ class UserSessionFlowCoordinatorStateMachine { /// Showing the home screen. The `selectedRoomID` represents the timeline shown on the detail panel (if any) case roomList(selectedRoomID: String?) - /// Showing the session verification flows + /// Showing the feedback screen. case feedbackScreen(selectedRoomID: String?) /// Showing the settings screen @@ -45,10 +45,13 @@ class UserSessionFlowCoordinatorStateMachine { /// Showing Room Directory Search screen case roomDirectorySearchScreen(selectedRoomID: String?) + /// Showing the user profile screen. This screen clears the navigation. + case userProfileScreen + /// The selected room ID from the state if available. var selectedRoomID: String? { switch self { - case .initial: + case .initial, .userProfileScreen: nil case .roomList(let selectedRoomID), .feedbackScreen(let selectedRoomID), @@ -102,9 +105,15 @@ class UserSessionFlowCoordinatorStateMachine { /// Logout has been cancelled case dismissedLogoutConfirmationScreen + /// Request presentation of the room directory search screen. case showRoomDirectorySearchScreen - + /// The room directory search screen has been dismissed. case dismissedRoomDirectorySearchScreen + + /// Request presentation of the user profile screen. + case showUserProfileScreen(userID: String) + /// The user profile screen has been dismissed. + case dismissedUserProfileScreen } private let stateMachine: StateMachine @@ -169,6 +178,11 @@ class UserSessionFlowCoordinatorStateMachine { return .roomDirectorySearchScreen(selectedRoomID: selectedRoomID) case (.roomDirectorySearchScreen(let selectedRoomID), .dismissedRoomDirectorySearchScreen): return .roomList(selectedRoomID: selectedRoomID) + + case (_, .showUserProfileScreen): + return .userProfileScreen + case (.userProfileScreen, .dismissedUserProfileScreen): + return .roomList(selectedRoomID: nil) default: return nil diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index cb2ca9887d..dc6a5358ca 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2077,6 +2077,74 @@ class ClientProxyMock: ClientProxyProtocol { return accountURLActionReturnValue } } + //MARK: - createDirectRoomIfNeeded + + var createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount = 0 + var createDirectRoomIfNeededWithExpectedRoomNameCallsCount: Int { + get { + if Thread.isMainThread { + return createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount = newValue + } + } + } + } + var createDirectRoomIfNeededWithExpectedRoomNameCalled: Bool { + return createDirectRoomIfNeededWithExpectedRoomNameCallsCount > 0 + } + var createDirectRoomIfNeededWithExpectedRoomNameReceivedArguments: (userID: String, expectedRoomName: String?)? + var createDirectRoomIfNeededWithExpectedRoomNameReceivedInvocations: [(userID: String, expectedRoomName: String?)] = [] + + var createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue: Result<(roomID: String, isNewRoom: Bool), ClientProxyError>! + var createDirectRoomIfNeededWithExpectedRoomNameReturnValue: Result<(roomID: String, isNewRoom: Bool), ClientProxyError>! { + get { + if Thread.isMainThread { + return createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue + } else { + var returnValue: Result<(roomID: String, isNewRoom: Bool), ClientProxyError>? = nil + DispatchQueue.main.sync { + returnValue = createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue = newValue + } + } + } + } + var createDirectRoomIfNeededWithExpectedRoomNameClosure: ((String, String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError>)? + + func createDirectRoomIfNeeded(with userID: String, expectedRoomName: String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError> { + createDirectRoomIfNeededWithExpectedRoomNameCallsCount += 1 + createDirectRoomIfNeededWithExpectedRoomNameReceivedArguments = (userID: userID, expectedRoomName: expectedRoomName) + createDirectRoomIfNeededWithExpectedRoomNameReceivedInvocations.append((userID: userID, expectedRoomName: expectedRoomName)) + if let createDirectRoomIfNeededWithExpectedRoomNameClosure = createDirectRoomIfNeededWithExpectedRoomNameClosure { + return await createDirectRoomIfNeededWithExpectedRoomNameClosure(userID, expectedRoomName) + } else { + return createDirectRoomIfNeededWithExpectedRoomNameReturnValue + } + } //MARK: - directRoomForUserID var directRoomForUserIDUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift index a8b7497483..6032997313 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenCoordinator.swift @@ -23,11 +23,12 @@ struct RoomMemberDetailsScreenCoordinatorParameters { let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol let userIndicatorController: UserIndicatorControllerProtocol + let analytics: AnalyticsService } enum RoomMemberDetailsScreenCoordinatorAction { case openUserProfile - case openDirectChat(displayName: String?) + case openDirectChat(roomID: String) } final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol { @@ -45,7 +46,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol { roomProxy: parameters.roomProxy, clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + analytics: parameters.analytics) } func start() { @@ -55,8 +57,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol { switch action { case .openUserProfile: actionsSubject.send(.openUserProfile) - case .openDirectChat(let displayName): - actionsSubject.send(.openDirectChat(displayName: displayName)) + case .openDirectChat(let roomID): + actionsSubject.send(.openDirectChat(roomID: roomID)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift index 7c312a819b..51eafd0ecb 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenModels.swift @@ -18,7 +18,7 @@ import Foundation enum RoomMemberDetailsScreenViewModelAction { case openUserProfile - case openDirectChat(displayName: String?) + case openDirectChat(roomID: String) } struct RoomMemberDetailsScreenViewState: BindableState { @@ -86,5 +86,6 @@ enum RoomMemberDetailsScreenViewAction { } enum RoomMemberDetailsScreenError: Hashable { + case failedOpeningDirectChat case unknown } diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift index 9a6b5593b0..5bad4efa22 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift @@ -24,6 +24,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro private let clientProxy: ClientProxyProtocol private let mediaProvider: MediaProviderProtocol private let userIndicatorController: UserIndicatorControllerProtocol + private let analytics: AnalyticsService private var actionsSubject: PassthroughSubject = .init() @@ -37,11 +38,13 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro roomProxy: RoomProxyProtocol, clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + analytics: AnalyticsService) { self.roomProxy = roomProxy self.clientProxy = clientProxy self.mediaProvider = mediaProvider self.userIndicatorController = userIndicatorController + self.analytics = analytics let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, bindings: .init()) @@ -85,13 +88,9 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro case .unignoreConfirmed: Task { await unignoreUser() } case .displayAvatar: - displayFullScreenAvatar() + Task { await displayFullScreenAvatar() } case .openDirectChat: - guard let roomMemberProxy else { - fatalError() - } - - actionsSubject.send(.openDirectChat(displayName: roomMemberProxy.displayName)) + Task { await openDirectChat() } } } @@ -145,7 +144,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro } } - private func displayFullScreenAvatar() { + private func displayFullScreenAvatar() async { guard let roomMemberProxy else { fatalError() } @@ -156,16 +155,32 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro let loadingIndicatorIdentifier = "roomMemberAvatarLoadingIndicator" userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) - - Task { - defer { - userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) - } + defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } - // We don't actually know the mime type here, assume it's an image. - if case let .success(file) = await mediaProvider.loadFileFromSource(.init(url: avatarURL, mimeType: "image/jpeg")) { - state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: roomMemberProxy.displayName) + // We don't actually know the mime type here, assume it's an image. + if case let .success(file) = await mediaProvider.loadFileFromSource(.init(url: avatarURL, mimeType: "image/jpeg")) { + state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: roomMemberProxy.displayName) + } + } + + private func openDirectChat() async { + guard let roomMemberProxy else { fatalError() } + + let loadingIndicatorIdentifier = "openDirectChatLoadingIndicator" + userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.commonLoading, + persistent: true)) + defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } + + switch await clientProxy.createDirectRoomIfNeeded(with: roomMemberProxy.userID, expectedRoomName: roomMemberProxy.displayName) { + case .success((let roomID, let isNewRoom)): + if isNewRoom { + analytics.trackCreatedRoom(isDM: true) } + actionsSubject.send(.openDirectChat(roomID: roomID)) + case .failure: + state.bindings.alertInfo = .init(id: .failedOpeningDirectChat) } } diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift index 87f18625a5..4ce3d043bd 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/View/RoomMemberDetailsScreen.swift @@ -114,41 +114,9 @@ struct RoomMemberDetailsScreen: View { // MARK: - Previews struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview { - static let otherUserViewModel = { - let member = RoomMemberProxyMock.mockDan - let roomProxyMock = RoomProxyMock(with: .init(name: "")) - roomProxyMock.getMemberUserIDReturnValue = .success(member) - - return RoomMemberDetailsScreenViewModel(userID: member.userID, - roomProxy: roomProxyMock, - clientProxy: ClientProxyMock(.init()), - mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) - }() - - static let accountOwnerViewModel = { - let member = RoomMemberProxyMock.mockMe - let roomProxyMock = RoomProxyMock(with: .init(name: "")) - roomProxyMock.getMemberUserIDReturnValue = .success(member) - - return RoomMemberDetailsScreenViewModel(userID: member.userID, - roomProxy: roomProxyMock, - clientProxy: ClientProxyMock(.init()), - mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) - }() - - static let ignoredUserViewModel = { - let member = RoomMemberProxyMock.mockIgnored - let roomProxyMock = RoomProxyMock(with: .init(name: "")) - roomProxyMock.getMemberUserIDReturnValue = .success(member) - - return RoomMemberDetailsScreenViewModel(userID: member.userID, - roomProxy: roomProxyMock, - clientProxy: ClientProxyMock(.init()), - mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) - }() + static let otherUserViewModel = makeViewModel(member: .mockDan) + static let accountOwnerViewModel = makeViewModel(member: .mockMe) + static let ignoredUserViewModel = makeViewModel(member: .mockIgnored) static var previews: some View { RoomMemberDetailsScreen(context: otherUserViewModel.context) @@ -161,4 +129,16 @@ struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview { .previewDisplayName("Ignored User") .snapshot(delay: 0.25) } + + static func makeViewModel(member: RoomMemberProxyMock) -> RoomMemberDetailsScreenViewModel { + let roomProxyMock = RoomProxyMock(with: .init(name: "")) + roomProxyMock.getMemberUserIDReturnValue = .success(member) + + return RoomMemberDetailsScreenViewModel(userID: member.userID, + roomProxy: roomProxyMock, + clientProxy: ClientProxyMock(.init()), + mediaProvider: MockMediaProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) + } } diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift index d435bcced9..0ee3b0fbc8 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenCoordinator.swift @@ -19,13 +19,16 @@ import SwiftUI struct UserProfileScreenCoordinatorParameters { let userID: String + let isPresentedModally: Bool let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol let userIndicatorController: UserIndicatorControllerProtocol + let analytics: AnalyticsService } enum UserProfileScreenCoordinatorAction { - case openDirectChat(displayName: String?) + case openDirectChat(roomID: String) + case dismiss } final class UserProfileScreenCoordinator: CoordinatorProtocol { @@ -40,9 +43,11 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol { init(parameters: UserProfileScreenCoordinatorParameters) { viewModel = UserProfileScreenViewModel(userID: parameters.userID, + isPresentedModally: parameters.isPresentedModally, clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + analytics: parameters.analytics) } func start() { @@ -50,8 +55,10 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .openDirectChat(let displayName): - actionsSubject.send(.openDirectChat(displayName: displayName)) + case .openDirectChat(let roomID): + actionsSubject.send(.openDirectChat(roomID: roomID)) + case .dismiss: + actionsSubject.send(.dismiss) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift index 9f911d41bd..3f3300ea99 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenModels.swift @@ -17,12 +17,14 @@ import Foundation enum UserProfileScreenViewModelAction { - case openDirectChat(displayName: String?) + case openDirectChat(roomID: String) + case dismiss } struct UserProfileScreenViewState: BindableState { let userID: String let isOwnUser: Bool + let isPresentedModally: Bool var userProfile: UserProfileProxy? var permalink: URL? @@ -40,8 +42,10 @@ struct UserProfileScreenViewStateBindings { enum UserProfileScreenViewAction { case displayAvatar case openDirectChat + case dismiss } enum UserProfileScreenError: Hashable { + case failedOpeningDirectChat case unknown } diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift index 817037ee9a..38511b8727 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift @@ -24,6 +24,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr private let clientProxy: ClientProxyProtocol private let mediaProvider: MediaProviderProtocol private let userIndicatorController: UserIndicatorControllerProtocol + private let analytics: AnalyticsService private var actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { @@ -31,23 +32,27 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr } init(userID: String, + isPresentedModally: Bool, clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + analytics: AnalyticsService) { self.clientProxy = clientProxy self.mediaProvider = mediaProvider self.userIndicatorController = userIndicatorController + self.analytics = analytics let initialViewState = UserProfileScreenViewState(userID: userID, isOwnUser: userID == clientProxy.userID, + isPresentedModally: isPresentedModally, bindings: .init()) super.init(initialViewState: initialViewState, imageProvider: mediaProvider) - showMemberLoadingIndicator() + showLoadingIndicator(allowsInteraction: true) Task { defer { - hideMemberLoadingIndicator() + hideLoadingIndicator() } switch await clientProxy.profile(for: userID) { @@ -67,37 +72,49 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. state.bindings.mediaPreviewItem = nil - hideMemberLoadingIndicator() + hideLoadingIndicator() } override func process(viewAction: UserProfileScreenViewAction) { switch viewAction { case .displayAvatar: - displayFullScreenAvatar() + Task { await displayFullScreenAvatar() } case .openDirectChat: - guard let userProfile = state.userProfile else { fatalError() } - actionsSubject.send(.openDirectChat(displayName: userProfile.displayName)) + Task { await openDirectChat() } + case .dismiss: + actionsSubject.send(.dismiss) } } // MARK: - Private - private func displayFullScreenAvatar() { + private func displayFullScreenAvatar() async { guard let userProfile = state.userProfile else { fatalError() } guard let avatarURL = userProfile.avatarURL else { return } - let loadingIndicatorIdentifier = "roomMemberAvatarLoadingIndicator" - userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) + showLoadingIndicator(allowsInteraction: false) + defer { hideLoadingIndicator() } - Task { - defer { - userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) - } + // We don't actually know the mime type here, assume it's an image. + if case let .success(file) = await mediaProvider.loadFileFromSource(.init(url: avatarURL, mimeType: "image/jpeg")) { + state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: userProfile.displayName) + } + } + + private func openDirectChat() async { + guard let userProfile = state.userProfile else { fatalError() } + + showLoadingIndicator(allowsInteraction: false) + defer { hideLoadingIndicator() } - // We don't actually know the mime type here, assume it's an image. - if case let .success(file) = await mediaProvider.loadFileFromSource(.init(url: avatarURL, mimeType: "image/jpeg")) { - state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: userProfile.displayName) + switch await clientProxy.createDirectRoomIfNeeded(with: userProfile.userID, expectedRoomName: userProfile.displayName) { + case .success((let roomID, let isNewRoom)): + if isNewRoom { + analytics.trackCreatedRoom(isDM: true) } + actionsSubject.send(.openDirectChat(roomID: roomID)) + case .failure: + state.bindings.alertInfo = .init(id: .failedOpeningDirectChat) } } @@ -105,15 +122,15 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr private static let loadingIndicatorIdentifier = "\(UserProfileScreenViewModel.self)-Loading" - private func showMemberLoadingIndicator() { + private func showLoadingIndicator(allowsInteraction: Bool) { userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, - type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true), + type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: allowsInteraction), title: L10n.commonLoading, persistent: true), delay: .milliseconds(100)) } - private func hideMemberLoadingIndicator() { + private func hideLoadingIndicator() { userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) } } diff --git a/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift b/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift index 1f33afe7ee..750cbc45cb 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/View/UserProfileScreen.swift @@ -30,6 +30,8 @@ struct UserProfileScreen: View { } .compoundList() .navigationTitle(L10n.screenRoomMemberDetailsTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } .alert(item: $context.alertInfo) .track(screen: .User) .interactiveQuickLook(item: $context.mediaPreviewItem, shouldHideControls: true) @@ -73,6 +75,17 @@ struct UserProfileScreen: View { .accessibilityIdentifier(A11yIdentifiers.roomMemberDetailsScreen.directChat) } } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + if context.viewState.isPresentedModally { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.actionDone) { + context.send(viewAction: .dismiss) + } + } + } + } } // MARK: - Previews @@ -92,8 +105,10 @@ struct UserProfileScreen_Previews: PreviewProvider, TestablePreview { static func makeViewModel(userID: String) -> UserProfileScreenViewModel { UserProfileScreenViewModel(userID: userID, + isPresentedModally: false, clientProxy: ClientProxyMock(.init()), mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) } } diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 395420cd83..f4654c5326 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -289,6 +289,23 @@ class ClientProxy: ClientProxyProtocol { try? client.accountUrl(action: action).flatMap(URL.init(string:)) } + func createDirectRoomIfNeeded(with userID: String, expectedRoomName: String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError> { + let currentDirectRoom = await directRoomForUserID(userID) + switch currentDirectRoom { + case .success(.some(let roomID)): + return .success((roomID: roomID, isNewRoom: false)) + case .success(.none): + switch await createDirectRoom(with: userID, expectedRoomName: expectedRoomName) { + case .success(let roomID): + return .success((roomID: roomID, isNewRoom: true)) + case .failure(let error): + return .failure(.sdkError(error)) + } + case .failure(let error): + return .failure(.sdkError(error)) + } + } + func directRoomForUserID(_ userID: String) async -> Result { await Task.dispatch(on: clientQueue) { do { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index e73a1d4211..7137fb0cae 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -109,6 +109,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func accountURL(action: AccountManagementAction) -> URL? + func createDirectRoomIfNeeded(with userID: String, expectedRoomName: String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError> + func directRoomForUserID(_ userID: String) async -> Result func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result diff --git a/UnitTests/Sources/AppRouteURLParserTests.swift b/UnitTests/Sources/AppRouteURLParserTests.swift index 54a0f99f09..0efeebe097 100644 --- a/UnitTests/Sources/AppRouteURLParserTests.swift +++ b/UnitTests/Sources/AppRouteURLParserTests.swift @@ -118,7 +118,7 @@ class AppRouteURLParserTests: XCTestCase { let route = appRouteURLParser.route(from: url) - XCTAssertEqual(route, .roomMemberDetails(userID: userID)) + XCTAssertEqual(route, .userProfile(userID: userID)) } func testMatrixRoomIdentifierURL() { @@ -132,4 +132,28 @@ class AppRouteURLParserTests: XCTestCase { XCTAssertEqual(route, .room(roomID: id)) } + + func testWebRoomIDURL() { + let id = "!abcdefghijklmnopqrstuvwxyz1234567890:matrix.org" + guard let url = URL(string: "https://app.element.io/#/room/\(id)") else { + XCTFail("URL invalid") + return + } + + let route = appRouteURLParser.route(from: url) + + XCTAssertEqual(route, .room(roomID: id)) + } + + func testWebUserIDURL() { + let id = "@alice:matrix.org" + guard let url = URL(string: "https://develop.element.io/#/user/\(id)") else { + XCTFail("URL invalid") + return + } + + let route = appRouteURLParser.route(from: url) + + XCTAssertEqual(route, .userProfile(userID: id)) + } } diff --git a/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift b/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift index b7055297f9..ad70d8f95e 100644 --- a/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift @@ -39,7 +39,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: ClientProxyMock(.init()), mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() @@ -55,7 +56,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: ClientProxyMock(.init()), mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() @@ -92,7 +94,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: clientProxy, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() @@ -128,7 +131,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: ClientProxyMock(.init()), mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() @@ -163,7 +167,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: clientProxy, mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() @@ -198,7 +203,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: ClientProxyMock(.init()), mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() @@ -214,7 +220,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase { roomProxy: roomProxyMock, clientProxy: ClientProxyMock(.init()), mediaProvider: MockMediaProvider(), - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil } try await waitForMemberToLoad.fulfill() diff --git a/UnitTests/Sources/UserProfileScreenViewModelTests.swift b/UnitTests/Sources/UserProfileScreenViewModelTests.swift new file mode 100644 index 0000000000..7003ee8143 --- /dev/null +++ b/UnitTests/Sources/UserProfileScreenViewModelTests.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +@MainActor +class UserProfileScreenViewModelTests: XCTestCase { + var viewModel: UserProfileScreenViewModel! + var context: UserProfileScreenViewModelType.Context { viewModel.context } + + func testInitialState() async throws { + let profile = UserProfileProxy(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: .picturesDirectory) + let clientProxy = ClientProxyMock(.init()) + clientProxy.profileForReturnValue = .success(profile) + + viewModel = UserProfileScreenViewModel(userID: profile.userID, + isPresentedModally: false, + clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) + + let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.userProfile != nil } + try await waitForMemberToLoad.fulfill() + + XCTAssertFalse(context.viewState.isOwnUser) + XCTAssertEqual(context.viewState.userProfile, profile) + XCTAssertNotNil(context.viewState.permalink) + } + + func testInitialStateAccountOwner() async throws { + let profile = UserProfileProxy(userID: RoomMemberProxyMock.mockMe.userID, displayName: "Me", avatarURL: .picturesDirectory) + let clientProxy = ClientProxyMock(.init()) + clientProxy.profileForReturnValue = .success(profile) + + viewModel = UserProfileScreenViewModel(userID: profile.userID, + isPresentedModally: false, + clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: ServiceLocator.shared.analytics) + + let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.userProfile != nil } + try await waitForMemberToLoad.fulfill() + + XCTAssertTrue(context.viewState.isOwnUser) + XCTAssertEqual(context.viewState.userProfile, profile) + XCTAssertNotNil(context.viewState.permalink) + } +} diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index 7a2700f93d..e2d24cb67f 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -27,14 +27,9 @@ class UserSessionFlowCoordinatorTests: XCTestCase { var cancellables = Set() - var detailCoordinator: CoordinatorProtocol? { - let navigationSplitCoordinator = navigationRootCoordinator.rootCoordinator as? NavigationSplitCoordinator - return navigationSplitCoordinator?.detailCoordinator - } - - var detailNavigationStack: NavigationStackCoordinator? { - detailCoordinator as? NavigationStackCoordinator - } + var splitCoordinator: NavigationSplitCoordinator? { navigationRootCoordinator.rootCoordinator as? NavigationSplitCoordinator } + var detailCoordinator: CoordinatorProtocol? { splitCoordinator?.detailCoordinator } + var detailNavigationStack: NavigationStackCoordinator? { detailCoordinator as? NavigationStackCoordinator } override func setUp() async throws { cancellables.removeAll() @@ -157,6 +152,21 @@ class UserSessionFlowCoordinatorTests: XCTestCase { XCTAssertNotNil(detailCoordinator) } + func testUserProfileClearsStack() async throws { + try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + XCTAssertNil(splitCoordinator?.sheetCoordinator) + + try await process(route: .userProfile(userID: "alice"), expectedState: .userProfileScreen) + XCTAssertNil(detailNavigationStack?.rootCoordinator) + guard let sheetStackCoordinator = splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator else { + XCTFail("There should be a navigation stack presented as a sheet.") + return + } + XCTAssertTrue(sheetStackCoordinator.rootCoordinator is UserProfileScreenCoordinator) + } + // MARK: - Private private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws {