diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index cbe55ccb37..ea14dc354d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -596,6 +596,7 @@ 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */; }; 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; + 912B88362BADD6EA00CD00F6 /* UserSessionFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912B88352BADD6EA00CD00F6 /* UserSessionFlowCoordinatorTests.swift */; }; 915B4CDAF220D9AEB4047D45 /* PollInteractionHandlerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259E5B05BDE6E20C26CF11B4 /* PollInteractionHandlerProtocol.swift */; }; 91ABC91758A6E4A5FAA2E9C4 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */; }; 91C6AC0E9D2B9C0C76CC6AD4 /* RoomDirectorySearchScreenScreenModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3984C93B8E9B10C92DADF9EE /* RoomDirectorySearchScreenScreenModelProtocol.swift */; }; @@ -1656,6 +1657,7 @@ 90A55430639712CFACA34F43 /* TextRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItem.swift; sourceTree = ""; }; 90DFF217B3D9D0941283278C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenViewModelProtocol.swift; sourceTree = ""; }; 90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = ""; }; + 912B88352BADD6EA00CD00F6 /* UserSessionFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorTests.swift; sourceTree = ""; }; 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillTextAttachmentData.swift; sourceTree = ""; }; 91868EB98818044E6FEBE532 /* NotificationPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenCoordinator.swift; sourceTree = ""; }; 91CF6F7D08228D16BA69B63B /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = ""; }; @@ -3535,6 +3537,7 @@ 7583EAC171059A86B767209F /* MediaProvider */, 7DBC911559934065993A5FF4 /* NotificationManager */, 1C62F5382CC9D9F7DCEC344A /* UserDiscoveryService */, + 912B88352BADD6EA00CD00F6 /* UserSessionFlowCoordinatorTests.swift */, ); path = Sources; sourceTree = ""; @@ -5624,6 +5627,7 @@ 50381244BA280451771BE3ED /* PINTextFieldTests.swift in Sources */, 27E9263DA75E266690A37EB1 /* PermalinkBuilderTests.swift in Sources */, 3982E60F9C126437D5E488A3 /* PillContextTests.swift in Sources */, + 912B88362BADD6EA00CD00F6 /* UserSessionFlowCoordinatorTests.swift in Sources */, FF7E8ECC8E7E1D1851517536 /* PollFormScreenViewModelTests.swift in Sources */, D415764645491F10344FC6AC /* Publisher.swift in Sources */, D53B80EF02C1062E68659EDD /* ReportContentViewModelTests.swift in Sources */, diff --git a/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift b/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift index 79f848c0ab..8a1c9452bf 100644 --- a/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift +++ b/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift @@ -54,3 +54,6 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol { func hideGlobalSearch() } + +// sourcery: AutoMockable +extension WindowManagerProtocol { } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 4e84847e7d..793df92990 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -20,35 +20,35 @@ import SwiftState import UserNotifications enum RoomFlowCoordinatorAction: Equatable { - case presentedRoom(String) - case dismissedRoom + case presentRoom(roomID: String) case presentCallScreen(roomProxy: RoomProxyProtocol) + case finished static func == (lhs: RoomFlowCoordinatorAction, rhs: RoomFlowCoordinatorAction) -> Bool { switch (lhs, rhs) { - case (.presentedRoom(let lhsRoomID), .presentedRoom(let rhsRoomID)): - return lhsRoomID == rhsRoomID - case (.dismissedRoom, .dismissedRoom): - return true + case (.presentRoom(let lhsRoomID), .presentRoom(let rhsRoomID)): + lhsRoomID == rhsRoomID case (.presentCallScreen(let lhsRoomProxy), .presentCallScreen(let rhsRoomProxy)): - return lhsRoomProxy.id == rhsRoomProxy.id + lhsRoomProxy.id == rhsRoomProxy.id + case (.finished, .finished): + true default: - return false + false } } } // swiftlint:disable file_length class RoomFlowCoordinator: FlowCoordinatorProtocol { - private let orientationManager: OrientationManagerProtocol + private let roomProxy: RoomProxyProtocol private let userSession: UserSessionProtocol private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol private let navigationStackCoordinator: NavigationStackCoordinator - private let navigationSplitCoordinator: NavigationSplitCoordinator private let emojiProvider: EmojiProviderProtocol private let appSettings: AppSettings private let analytics: AnalyticsService private let userIndicatorController: UserIndicatorControllerProtocol + private let orientationManager: OrientationManagerProtocol // periphery:ignore - used to avoid deallocation private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator? @@ -62,34 +62,35 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { actionsSubject.eraseToAnyPublisher() } - private var roomProxy: RoomProxyProtocol? { - didSet { - oldValue?.unsubscribeFromUpdates() - } - } - private var timelineController: RoomTimelineControllerProtocol? - init(userSession: UserSessionProtocol, + init(roomProxy: RoomProxyProtocol, + userSession: UserSessionProtocol, roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, navigationStackCoordinator: NavigationStackCoordinator, - navigationSplitCoordinator: NavigationSplitCoordinator, emojiProvider: EmojiProviderProtocol, appSettings: AppSettings, analytics: AnalyticsService, userIndicatorController: UserIndicatorControllerProtocol, - orientationManager: OrientationManagerProtocol) { + orientationManager: OrientationManagerProtocol) async { + self.roomProxy = roomProxy self.userSession = userSession self.roomTimelineControllerFactory = roomTimelineControllerFactory self.navigationStackCoordinator = navigationStackCoordinator - self.navigationSplitCoordinator = navigationSplitCoordinator self.emojiProvider = emojiProvider self.appSettings = appSettings self.analytics = analytics 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 } // MARK: - FlowCoordinatorProtocol @@ -101,18 +102,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { switch appRoute { case .room(let roomID): - if case .room(let identifier) = stateMachine.state, - roomID == identifier { - return - } - - stateMachine.tryEvent(.presentRoom(roomID: roomID), userInfo: EventUserInfo(animated: animated)) + guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") } + stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated)) case .roomDetails(let roomID): - stateMachine.tryEvent(.presentRoomDetails(roomID: roomID), userInfo: EventUserInfo(animated: animated)) + 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)) case .roomMemberDetails(let userID): - stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID)) + stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated)) case .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: break } @@ -131,107 +129,105 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private func setupStateMachine() { stateMachine.addRouteMapping { event, fromState, _ in switch (fromState, event) { - case (_, .presentRoom(let roomID)): - return .room(roomID: roomID) + case (_, .presentRoom): + return .room case (.room, .dismissRoom): - return .initial + return .complete - case (.initial, .presentRoomDetails(let roomID)): - return .roomDetails(roomID: roomID, isRoot: true) - case (.room(let currentRoomID), .presentRoomDetails(let roomID)): - return .roomDetails(roomID: roomID, isRoot: roomID != currentRoomID) - case (.roomDetails(let currentRoomID, _), .presentRoomDetails(let roomID)): - return .roomDetails(roomID: roomID, isRoot: roomID != currentRoomID) - case (.roomDetails(let roomID, _), .dismissRoomDetails): - return .room(roomID: roomID) + case (.initial, .presentRoomDetails): + return .roomDetails(isRoot: true) + case (.room, .presentRoomDetails): + return .roomDetails(isRoot: false) + case (.roomDetails, .dismissRoomDetails): + return .room case (.roomDetails, .dismissRoom): - return .initial + return .complete - case (.roomDetails(let roomID, _), .presentRoomDetailsEditScreen): - return .roomDetailsEditScreen(roomID: roomID) - case (.roomDetailsEditScreen(let roomID), .dismissRoomDetailsEditScreen): - return .roomDetails(roomID: roomID, isRoot: false) + case (.roomDetails, .presentRoomDetailsEditScreen): + return .roomDetailsEditScreen + case (.roomDetailsEditScreen, .dismissRoomDetailsEditScreen): + return .roomDetails(isRoot: false) - case (.roomDetails(let roomID, _), .presentNotificationSettingsScreen): - return .notificationSettings(roomID: roomID) - case (.notificationSettings(let roomID), .dismissNotificationSettingsScreen): - return .roomDetails(roomID: roomID, isRoot: false) + case (.roomDetails, .presentNotificationSettingsScreen): + return .notificationSettings + case (.notificationSettings, .dismissNotificationSettingsScreen): + return .roomDetails(isRoot: false) - case (.notificationSettings(let roomID), .presentGlobalNotificationSettingsScreen): - return .globalNotificationSettings(roomID: roomID) - case (.globalNotificationSettings(let roomID), .dismissGlobalNotificationSettingsScreen): - return .notificationSettings(roomID: roomID) + case (.notificationSettings, .presentGlobalNotificationSettingsScreen): + return .globalNotificationSettings + case (.globalNotificationSettings, .dismissGlobalNotificationSettingsScreen): + return .notificationSettings - case (.roomDetails(let roomID, _), .presentRoomMembersList): - return .roomMembersList(roomID: roomID) - case (.roomMembersList(let roomID), .dismissRoomMembersList): - return .roomDetails(roomID: roomID, isRoot: false) + case (.roomDetails, .presentRoomMembersList): + return .roomMembersList + case (.roomMembersList, .dismissRoomMembersList): + return .roomDetails(isRoot: false) - case (.room(let roomID), .presentRoomMemberDetails(userID: let userID)): - return .roomMemberDetails(roomID: roomID, userID: userID, fromRoomMembersList: false) - case (.roomMembersList(let roomID), .presentRoomMemberDetails(userID: let userID)): - return .roomMemberDetails(roomID: roomID, userID: userID, fromRoomMembersList: true) - case (.roomMemberDetails(let roomID, _, let fromRoomMembersList), .dismissRoomMemberDetails): - return fromRoomMembersList ? .roomMembersList(roomID: roomID) : .room(roomID: roomID) + case (.room, .presentRoomMemberDetails(userID: let userID)): + return .roomMemberDetails(userID: userID, fromRoomMembersList: false) + case (.roomMembersList, .presentRoomMemberDetails(userID: let userID)): + return .roomMemberDetails(userID: userID, fromRoomMembersList: true) + case (.roomMemberDetails(_, let fromRoomMembersList), .dismissRoomMemberDetails): + return fromRoomMembersList ? .roomMembersList : .room - case (.roomDetails(let roomID, _), .presentInviteUsersScreen): - return .inviteUsersScreen(roomID: roomID, fromRoomMembersList: false) - case (.roomMembersList(let roomID), .presentInviteUsersScreen): - return .inviteUsersScreen(roomID: roomID, fromRoomMembersList: true) - case (.inviteUsersScreen(let roomID, let fromRoomMembersList), .dismissInviteUsersScreen): - return fromRoomMembersList ? .roomMembersList(roomID: roomID) : .roomDetails(roomID: roomID, isRoot: false) + case (.roomDetails, .presentInviteUsersScreen): + return .inviteUsersScreen(fromRoomMembersList: false) + case (.roomMembersList, .presentInviteUsersScreen): + return .inviteUsersScreen(fromRoomMembersList: true) + case (.inviteUsersScreen(let fromRoomMembersList), .dismissInviteUsersScreen): + return fromRoomMembersList ? .roomMembersList : .roomDetails(isRoot: false) - case (.room(let roomID), .presentReportContent(let itemID, let senderID)): - return .reportContent(roomID: roomID, itemID: itemID, senderID: senderID) - case (.reportContent(let roomID, _, _), .dismissReportContent): - return .room(roomID: roomID) + case (.room, .presentReportContent(let itemID, let senderID)): + return .reportContent(itemID: itemID, senderID: senderID) + case (.reportContent, .dismissReportContent): + return .room - case (.room(let roomID), .presentMediaUploadPicker(let source)): - return .mediaUploadPicker(roomID: roomID, source: source) - case (.mediaUploadPicker(let roomID, _), .dismissMediaUploadPicker): - return .room(roomID: roomID) + case (.room, .presentMediaUploadPicker(let source)): + return .mediaUploadPicker(source: source) + case (.mediaUploadPicker, .dismissMediaUploadPicker): + return .room - case (.mediaUploadPicker(let roomID, _), .presentMediaUploadPreview(let fileURL)): - return .mediaUploadPreview(roomID: roomID, fileURL: fileURL) - case (.room(let roomID), .presentMediaUploadPreview(let fileURL)): - return .mediaUploadPreview(roomID: roomID, fileURL: fileURL) - case (.mediaUploadPreview(let roomID, _), .dismissMediaUploadPreview): - return .room(roomID: roomID) + case (.mediaUploadPicker, .presentMediaUploadPreview(let fileURL)): + return .mediaUploadPreview(fileURL: fileURL) + case (.room, .presentMediaUploadPreview(let fileURL)): + return .mediaUploadPreview(fileURL: fileURL) + case (.mediaUploadPreview, .dismissMediaUploadPreview): + return .room - case (.room(let roomID), .presentEmojiPicker(let itemID, let selectedEmoji)): - return .emojiPicker(roomID: roomID, itemID: itemID, selectedEmojis: selectedEmoji) - case (.emojiPicker(let roomID, _, _), .dismissEmojiPicker): - return .room(roomID: roomID) + case (.room, .presentEmojiPicker(let itemID, let selectedEmoji)): + return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji) + case (.emojiPicker, .dismissEmojiPicker): + return .room - case (.room(let roomID), .presentMessageForwarding(let itemID)): - return .messageForwarding(roomID: roomID, itemID: itemID) - case (.messageForwarding(let roomID, _), .dismissMessageForwarding): - return .room(roomID: roomID) + case (.room, .presentMessageForwarding(let itemID)): + return .messageForwarding(itemID: itemID) + case (.messageForwarding, .dismissMessageForwarding): + return .room - case (.room(let roomID), .presentMapNavigator): - return .mapNavigator(roomID: roomID) - case (.mapNavigator(let roomID), .dismissMapNavigator): - return .room(roomID: roomID) + case (.room, .presentMapNavigator): + return .mapNavigator + case (.mapNavigator, .dismissMapNavigator): + return .room - case (.room(let roomID), .presentPollForm): - return .pollForm(roomID: roomID) - case (.pollForm(let roomID), .dismissPollForm): - return .room(roomID: roomID) + case (.room, .presentPollForm): + return .pollForm + case (.pollForm, .dismissPollForm): + return .room - case (.roomDetails(let roomID, _), .presentPollsHistory): - return .pollsHistory(roomID: roomID) - case (.pollsHistory(let roomID), .dismissPollsHistory): - return .roomDetails(roomID: roomID, isRoot: false) + case (.roomDetails, .presentPollsHistory): + return .pollsHistory + case (.pollsHistory, .dismissPollsHistory): + return .roomDetails(isRoot: false) - case (.pollsHistory(let roomID), .presentPollForm): - return .pollsHistoryForm(roomID: roomID) - case (.pollsHistoryForm(let roomID), .dismissPollForm): - return .pollsHistory(roomID: roomID) + case (.pollsHistory, .presentPollForm): + return .pollsHistoryForm + case (.pollsHistoryForm, .dismissPollForm): + return .pollsHistory - case (.roomDetails(let roomID, _), .presentRolesAndPermissionsScreen): - return .rolesAndPermissions(roomID: roomID) - case (.rolesAndPermissions(let roomID), .dismissRolesAndPermissionsScreen): - return .roomDetails(roomID: roomID, isRoot: false) + case (.roomDetails, .presentRolesAndPermissionsScreen): + return .rolesAndPermissions + case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen): + return .roomDetails(isRoot: false) default: return nil @@ -244,25 +240,19 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let animated = (context.userInfo as? EventUserInfo)?.animated ?? true switch (context.fromState, context.event, context.toState) { - case (.roomDetails(roomID: let currentRoomID, true), .presentRoom(let roomID), .room) where currentRoomID == roomID: - dismissRoom(animated: animated) - presentRoom(roomID, animated: animated) - case (_, .presentRoom(let roomID), .room): - let destinationRoomProxy = (context.userInfo as? EventUserInfo)?.destinationRoomProxy - presentRoom(roomID, animated: animated, destinationRoomProxy: destinationRoomProxy) - case (.room, .dismissRoom, .initial): - dismissRoom(animated: animated) + case (_, .presentRoom, .room): + Task { await self.presentRoom(animated: animated) } + case (.room, .dismissRoom, .complete): + dismissFlow(animated: animated) - case (.roomDetails(let currentRoomID, _), .presentRoomDetails, .roomDetails(let roomID, _)) where currentRoomID == roomID: - break - case (.initial, .presentRoomDetails, .roomDetails(let roomID, let isRoot)), - (.room, .presentRoomDetails, .roomDetails(let roomID, let isRoot)), - (.roomDetails, .presentRoomDetails, .roomDetails(let roomID, let isRoot)): - self.presentRoomDetails(roomID: roomID, isRoot: isRoot, animated: animated) + case (.initial, .presentRoomDetails, .roomDetails(let isRoot)), + (.room, .presentRoomDetails, .roomDetails(let isRoot)), + (.roomDetails, .presentRoomDetails, .roomDetails(let isRoot)): + Task { await self.presentRoomDetails(isRoot: isRoot, animated: animated) } case (.roomDetails, .dismissRoomDetails, .room): break - case (.roomDetails, .dismissRoom, .initial): - dismissRoom(animated: animated) + case (.roomDetails, .dismissRoom, .complete): + dismissFlow(animated: animated) case (.roomDetails, .presentRoomDetailsEditScreen, .roomDetailsEditScreen): presentRoomDetailsEditScreen() @@ -284,12 +274,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (.roomMembersList, .dismissRoomMembersList, .roomDetails): break - case (.room, .presentRoomMemberDetails, .roomMemberDetails(_, let userID, _)): + case (.room, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)): presentRoomMemberDetails(userID: userID) case (.roomMemberDetails, .dismissRoomMemberDetails, .room): break - case (.roomMembersList, .presentRoomMemberDetails, .roomMemberDetails(_, let userID, _)): + case (.roomMembersList, .presentRoomMemberDetails, .roomMemberDetails(let userID, _)): presentRoomMemberDetails(userID: userID) case (.roomMemberDetails, .dismissRoomMemberDetails, .roomMembersList): break @@ -304,24 +294,24 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (.inviteUsersScreen, .dismissInviteUsersScreen, .roomMembersList): break - case (.room, .presentReportContent, .reportContent(_, let itemID, let senderID)): + case (.room, .presentReportContent, .reportContent(let itemID, let senderID)): presentReportContent(for: itemID, from: senderID) case (.reportContent, .dismissReportContent, .room): break - case (.room, .presentMediaUploadPicker, .mediaUploadPicker(_, let source)): + case (.room, .presentMediaUploadPicker, .mediaUploadPicker(let source)): presentMediaUploadPickerWithSource(source) case (.mediaUploadPicker, .dismissMediaUploadPicker, .room): break - case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)): + case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL)): presentMediaUploadPreviewScreen(for: fileURL) - case (.room, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)): + case (.room, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL)): presentMediaUploadPreviewScreen(for: fileURL) case (.mediaUploadPreview, .dismissMediaUploadPreview, .room): break - case (.room, .presentEmojiPicker, .emojiPicker(_, let itemID, let selectedEmoji)): + case (.room, .presentEmojiPicker, .emojiPicker(let itemID, let selectedEmoji)): presentEmojiPicker(for: itemID, selectedEmoji: selectedEmoji) case (.emojiPicker, .dismissEmojiPicker, .room): break @@ -385,42 +375,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { /// - destinationRoomProxy: an optional already build roomProxy for the target room. It is currently used when /// forwarding messages so that we can take advantage of the local echo /// and have the message already there when presenting the room - private func presentRoom(_ roomID: String, animated: Bool, destinationRoomProxy: RoomProxyProtocol? = nil) { - Task { - await asyncPresentRoom(roomID, animated: animated, destinationRoomProxy: destinationRoomProxy) - } - } - - private func asyncPresentRoom(_ roomID: String, animated: Bool, destinationRoomProxy: RoomProxyProtocol? = nil) async { + private func presentRoom(animated: Bool) async { // If any sheets are presented dismiss them, rely on their dismissal callbacks to transition the state machine // through the correct states before presenting the room navigationStackCoordinator.setSheetCoordinator(nil) - if let roomProxy, roomProxy.id == roomID { - navigationStackCoordinator.popToRoot() - return - } - - let roomProxy: RoomProxyProtocol - - if let destinationRoomProxy { - roomProxy = destinationRoomProxy - } else { - guard let proxy = await userSession.clientProxy.roomForIdentifier(roomID) else { - MXLog.error("Invalid room identifier: \(roomID)") - stateMachine.tryEvent(.dismissRoom) - return - } - - roomProxy = proxy - } - - await roomProxy.subscribeForUpdates() - - actionsSubject.send(.presentedRoom(roomID)) - - self.roomProxy = roomProxy - Task { // Flag the room as read on entering, the timeline will take care of the read receipts await roomProxy.flagAsUnread(false) @@ -456,7 +415,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .presentRoomDetails: - stateMachine.tryEvent(.presentRoomDetails(roomID: roomID)) + stateMachine.tryEvent(.presentRoomDetails) case .presentReportContent(let itemID, let senderID): stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID)) case .presentMediaUploadPicker(let source): @@ -476,10 +435,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .presentMessageForwarding(let itemID): stateMachine.tryEvent(.presentMessageForwarding(itemID: itemID)) case .presentCallScreen: - guard let roomProxy = self.roomProxy else { - fatalError() - } - actionsSubject.send(.presentCallScreen(roomProxy: roomProxy)) } } @@ -489,49 +444,22 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // Move the state machine to no room selected if the room currently being dismissed // is the same as the one selected in the state machine. // This generally happens when popping the room screen while in a compact layout - switch self?.stateMachine.state { - case let .room(selectedRoomID) where selectedRoomID == roomID: - self?.stateMachine.tryEvent(.dismissRoom) - default: - break - } - } - - if navigationSplitCoordinator.detailCoordinator == nil { - navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated) + self?.stateMachine.tryEvent(.dismissRoom) } } - private func dismissRoom(animated: Bool) { - // DON'T CHANGE THE ORDER IN WHICH POP AND SET ARE DONE, IT CAN CAUSE A CRASH + private func dismissFlow(animated: Bool) { navigationStackCoordinator.popToRoot(animated: false) - navigationSplitCoordinator.setDetailCoordinator(nil, animated: animated) - roomProxy = nil + navigationStackCoordinator.setRootCoordinator(nil, animated: false) + + roomProxy.unsubscribeFromUpdates() timelineController = nil - actionsSubject.send(.dismissedRoom) + actionsSubject.send(.finished) + analytics.signpost.endRoomFlow() } - private func presentRoomDetails(roomID: String, isRoot: Bool, animated: Bool) { - Task { - await asyncPresentRoomDetails(roomID: roomID, isRoot: isRoot, animated: animated) - } - } - - private func asyncPresentRoomDetails(roomID: String, isRoot: Bool, animated: Bool) async { - if isRoot { - roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) - await roomProxy?.subscribeForUpdates() - } else { - await asyncPresentRoom(roomID, animated: animated) - } - - guard let roomProxy else { - MXLog.error("Invalid room identifier: \(roomID)") - stateMachine.tryEvent(.dismissRoom) - return - } - + private func presentRoomDetails(isRoot: Bool, animated: Bool) async { let params = RoomDetailsScreenCoordinatorParameters(roomProxy: roomProxy, clientProxy: userSession.clientProxy, mediaProvider: userSession.mediaProvider, @@ -547,7 +475,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .leftRoom: - dismissRoom(animated: animated) + stateMachine.tryEvent(.dismissRoom) case .presentRoomMembersList: stateMachine.tryEvent(.presentRoomMembersList) case .presentRoomDetailsEditScreen: @@ -566,17 +494,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { if isRoot { navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in - guard let self else { return } - if case .roomDetails(let detailsRoomID, _) = stateMachine.state, detailsRoomID == roomID { - stateMachine.tryEvent(.dismissRoom) - } - } - - if navigationSplitCoordinator.detailCoordinator == nil { - navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated) + self?.stateMachine.tryEvent(.dismissRoom) // This seems wrong but it isn't, Maybe the event should be called dismissFlow? } - - actionsSubject.send(.presentedRoom(roomID)) } else { navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in guard let self else { return } @@ -588,10 +507,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentRoomMembersList() { - guard let roomProxy else { - fatalError() - } - let parameters = RoomMembersListScreenCoordinatorParameters(mediaProvider: userSession.mediaProvider, roomProxy: roomProxy, userIndicatorController: userIndicatorController, @@ -618,10 +533,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentRoomDetailsEditScreen() { - guard let roomProxy else { - fatalError() - } - let stackCoordinator = NavigationStackCoordinator() let roomDetailsEditParameters = RoomDetailsEditScreenCoordinatorParameters(roomProxy: roomProxy, @@ -647,7 +558,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentReportContent(for itemID: TimelineItemIdentifier, from senderID: String) { - guard let roomProxy, let eventID = itemID.eventID else { + guard let eventID = itemID.eventID else { fatalError() } @@ -707,10 +618,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentMediaUploadPreviewScreen(for url: URL) { - guard let roomProxy else { - fatalError() - } - let stackCoordinator = NavigationStackCoordinator() let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController, @@ -776,12 +683,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .selectedLocation(let geoURI, let isUserLocation): Task { - _ = await self.roomProxy?.timeline.sendLocation(body: geoURI.bodyMessage, - geoURI: geoURI, - description: nil, - zoomLevel: 15, - assetType: isUserLocation ? .sender : .pin) - self.navigationSplitCoordinator.setSheetCoordinator(nil) + _ = await self.roomProxy.timeline.sendLocation(body: geoURI.bodyMessage, + geoURI: geoURI, + description: nil, + zoomLevel: 15, + assetType: isUserLocation ? .sender : .pin) + self.navigationStackCoordinator.setSheetCoordinator(nil) } self.analytics.trackComposer(inThread: false, @@ -790,7 +697,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { messageType: isUserLocation ? .LocationUser : .LocationPin, startsThread: nil) case .close: - self.navigationSplitCoordinator.setSheetCoordinator(nil) + self.navigationStackCoordinator.setSheetCoordinator(nil) } } .store(in: &cancellables) @@ -813,7 +720,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return } - self.navigationSplitCoordinator.setSheetCoordinator(nil) + self.navigationStackCoordinator.setSheetCoordinator(nil) switch action { case .cancel: @@ -831,18 +738,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - navigationSplitCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in self?.stateMachine.tryEvent(.dismissPollForm) } } private func createPoll(question: String, options: [String], pollKind: Poll.Kind) { Task { - guard let roomProxy = self.roomProxy else { - self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - return - } - let result = await roomProxy.timeline.createPoll(question: question, answers: options, pollKind: pollKind) self.analytics.trackComposer(inThread: false, @@ -864,11 +766,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private func editPoll(pollStartID: String, question: String, options: [String], pollKind: Poll.Kind) { Task { - guard let roomProxy = self.roomProxy else { - self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - return - } - let result = await roomProxy.timeline.editPoll(original: pollStartID, question: question, answers: options, pollKind: pollKind) switch result { @@ -882,7 +779,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private func deletePoll(mode: PollFormMode) { Task { - guard case .edit(let pollStartID, _) = mode, let roomProxy = self.roomProxy else { + guard case .edit(let pollStartID, _) = mode else { self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) return } @@ -905,10 +802,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func asyncPresentRoomPollsHistory() async { - guard let roomProxy else { - fatalError() - } - let userID = userSession.clientProxy.userID let timelineItemFactory = RoomTimelineItemFactory(userID: userID, @@ -940,10 +833,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentRoomMemberDetails(userID: String) { - guard let roomProxy else { - fatalError() - } - let params = RoomMemberDetailsScreenCoordinatorParameters(userID: userID, roomProxy: roomProxy, clientProxy: userSession.clientProxy, @@ -968,12 +857,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let currentDirectRoom = await userSession.clientProxy.directRoomForUserID(userID) switch currentDirectRoom { case .success(.some(let roomID)): - stateMachine.tryEvent(.presentRoom(roomID: roomID)) + actionsSubject.send(.presentRoom(roomID: roomID)) case .success(nil): switch await userSession.clientProxy.createDirectRoom(with: userID, expectedRoomName: displayName) { case .success(let roomID): analytics.trackCreatedRoom(isDM: true) - stateMachine.tryEvent(.presentRoom(roomID: roomID)) + actionsSubject.send(.presentRoom(roomID: roomID)) case .failure: userIndicatorController.alertInfo = .init(id: UUID()) } @@ -993,7 +882,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentMessageForwarding(for itemID: TimelineItemIdentifier) { - guard let roomProxy, let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider, let eventID = itemID.eventID else { + guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider, let eventID = itemID.eventID else { fatalError() } @@ -1028,12 +917,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func forward(eventID: String, toRoomID roomID: String) async { - guard let roomProxy else { - MXLog.error("Failed retrieving current room with id: \(roomID)") - userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - return - } - guard let messageEventContent = roomProxy.timeline.messageEventContent(for: eventID) else { MXLog.error("Failed retrieving forwarded message event content for eventID: \(eventID)") userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) @@ -1052,14 +935,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return } - stateMachine.tryEvent(.presentRoom(roomID: roomID), userInfo: EventUserInfo(animated: true, destinationRoomProxy: targetRoomProxy)) + // This will become a child flow and we can pass the proxy down afterwards. + + actionsSubject.send(.presentRoom(roomID: roomID)) } private func presentNotificationSettingsScreen() { - guard let roomProxy else { - fatalError() - } - let parameters = RoomNotificationSettingsScreenCoordinatorParameters(notificationSettingsProxy: userSession.clientProxy.notificationSettings, roomProxy: roomProxy, displayAsUserDefinedRoomSettings: false) @@ -1101,10 +982,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentInviteUsersScreen() { - guard let roomProxy else { - fatalError() - } - let selectedUsersSubject: CurrentValueSubject<[UserProfileProxy], Never> = .init([]) let stackCoordinator = NavigationStackCoordinator() @@ -1173,8 +1050,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } private func presentRolesAndPermissionsScreen() { - guard let roomProxy else { fatalError() } - let parameters = RoomRolesAndPermissionsFlowCoordinatorParameters(roomProxy: roomProxy, navigationStackCoordinator: navigationStackCoordinator, userIndicatorController: userIndicatorController, @@ -1208,39 +1083,41 @@ private extension RoomFlowCoordinator { enum State: StateType { case initial - case room(roomID: String) - case roomDetails(roomID: String, isRoot: Bool) - case roomDetailsEditScreen(roomID: String) - case notificationSettings(roomID: String) - case globalNotificationSettings(roomID: String) - case roomMembersList(roomID: String) - case roomMemberDetails(roomID: String, userID: String, fromRoomMembersList: Bool) - case inviteUsersScreen(roomID: String, fromRoomMembersList: Bool) - case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource) - case mediaUploadPreview(roomID: String, fileURL: URL) - case emojiPicker(roomID: String, itemID: TimelineItemIdentifier, selectedEmojis: Set) - case mapNavigator(roomID: String) - case messageForwarding(roomID: String, itemID: TimelineItemIdentifier) - case reportContent(roomID: String, itemID: TimelineItemIdentifier, senderID: String) - case pollForm(roomID: String) - case pollsHistory(roomID: String) - case pollsHistoryForm(roomID: String) - case rolesAndPermissions(roomID: String) + case room + case roomDetails(isRoot: Bool) + case roomDetailsEditScreen + case notificationSettings + case globalNotificationSettings + case roomMembersList + case roomMemberDetails(userID: String, fromRoomMembersList: Bool) + case inviteUsersScreen(fromRoomMembersList: Bool) + case mediaUploadPicker(source: MediaPickerScreenSource) + case mediaUploadPreview(fileURL: URL) + case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case mapNavigator + case messageForwarding(itemID: TimelineItemIdentifier) + case reportContent(itemID: TimelineItemIdentifier, senderID: String) + case pollForm + case pollsHistory + case pollsHistoryForm + case rolesAndPermissions + + /// The flow is complete and is handing control of the stack back to its parent. + case complete } struct EventUserInfo { let animated: Bool - var destinationRoomProxy: RoomProxyProtocol? } enum Event: EventType { - case presentRoom(roomID: String) + case presentRoom case dismissRoom case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) case dismissReportContent - case presentRoomDetails(roomID: String) + case presentRoomDetails case dismissRoomDetails case presentRoomDetailsEditScreen diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 871c156b95..3db860b94b 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -31,10 +31,13 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let windowManager: WindowManagerProtocol private let bugReportService: BugReportServiceProtocol private let appSettings: AppSettings + private let analytics: AnalyticsService private let stateMachine: UserSessionFlowCoordinatorStateMachine - private let roomFlowCoordinator: RoomFlowCoordinator + // periphery:ignore - retaining purpose + private var roomFlowCoordinator: RoomFlowCoordinator? + private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol private let settingsFlowCoordinator: SettingsFlowCoordinator @@ -58,6 +61,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { actionsSubject.eraseToAnyPublisher() } + /// For testing purposes. + var statePublisher: AnyPublisher { stateMachine.statePublisher } + init(userSession: UserSessionProtocol, navigationRootCoordinator: NavigationRootCoordinator, windowManager: WindowManagerProtocol, @@ -73,7 +79,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { self.navigationRootCoordinator = navigationRootCoordinator self.windowManager = windowManager self.bugReportService = bugReportService + self.roomTimelineControllerFactory = roomTimelineControllerFactory self.appSettings = appSettings + self.analytics = analytics navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator()) @@ -81,16 +89,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) - - roomFlowCoordinator = RoomFlowCoordinator(userSession: userSession, - roomTimelineControllerFactory: roomTimelineControllerFactory, - navigationStackCoordinator: detailNavigationStackCoordinator, - navigationSplitCoordinator: navigationSplitCoordinator, - emojiProvider: EmojiProvider(), - appSettings: appSettings, - analytics: analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController, - orientationManager: windowManager) settingsFlowCoordinator = SettingsFlowCoordinator(parameters: .init(userSession: userSession, windowManager: windowManager, @@ -125,28 +123,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - roomFlowCoordinator.actions.sink { [weak self] action in - guard let self else { return } - - switch action { - case .presentedRoom(let roomID): - analytics.signpost.beginRoomFlow(roomID) - - let availableInvitesCount = userSession.clientProxy.inviteSummaryProvider?.roomListPublisher.value.count ?? 0 - if case .invitesScreen = stateMachine.state, availableInvitesCount == 1 { - dismissInvitesList(animated: true) - } - - stateMachine.processEvent(.selectRoom(roomID: roomID)) - case .dismissedRoom: - stateMachine.processEvent(.deselectRoom) - analytics.signpost.endRoomFlow() - case .presentCallScreen(let roomProxy): - presentCallScreen(roomProxy: roomProxy) - } - } - .store(in: &cancellables) - settingsFlowCoordinator.actions.sink { [weak self] action in guard let self else { return } @@ -202,11 +178,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch appRoute { case .room(let roomID): - Task { - await self.handleRoomRoute(roomID: roomID, animated: animated) + Task { await self.handleRoomRoute(roomID: roomID, 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 .roomDetails, .roomList, .roomMemberDetails: - self.roomFlowCoordinator.handleAppRoute(appRoute, animated: animated) + case .roomList, .roomMemberDetails: + self.roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) case .genericCallLink(let url): self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) case .oidcCallback: @@ -221,11 +201,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch await userSession.clientProxy.roomForIdentifier(roomID)?.membership { case .invited: if UIDevice.current.isPhone { - roomFlowCoordinator.clearRoute(animated: animated) + roomFlowCoordinator?.clearRoute(animated: animated) } stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated)) case .joined: - roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: animated) + 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 @@ -233,7 +213,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } func clearRoute(animated: Bool) { - roomFlowCoordinator.clearRoute(animated: animated) + roomFlowCoordinator?.handleAppRoute(.roomList, animated: animated) } // MARK: - Private @@ -268,15 +248,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentHomeScreen() attemptStartingOnboarding() - case(.roomList, .selectRoom, .roomList): - break + case(.roomList(let currentRoomID), .selectRoom(let roomID, let showingRoomDetails), .roomList): + Task { await self.presentRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) } case(.roomList, .deselectRoom, .roomList): - break + tearDownRoomFlow(animated: animated) case (.invitesScreen, .selectRoom, .invitesScreen): break case (.invitesScreen, .deselectRoom, .invitesScreen): - break + tearDownRoomFlow(animated: animated) case (.roomList, .showSettingsScreen, .settingsScreen): break @@ -353,13 +333,13 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch action { case .presentRoom(let roomID): - roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true) + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false)) case .presentRoomDetails(let roomID): - roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true) + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true)) case .roomLeft(let roomID): if case .roomList(selectedRoomID: let selectedRoomID) = stateMachine.state, selectedRoomID == roomID { - roomFlowCoordinator.handleAppRoute(.roomList, animated: true) + clearRoute(animated: true) } case .presentSettingsScreen: settingsFlowCoordinator.handleAppRoute(.settings, animated: true) @@ -431,6 +411,62 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentSecureBackupLogoutConfirmationScreen() } + // 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, + userSession: userSession, + roomTimelineControllerFactory: roomTimelineControllerFactory, + navigationStackCoordinator: detailNavigationStackCoordinator, + emojiProvider: EmojiProvider(), + appSettings: appSettings, + analytics: analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + orientationManager: windowManager) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + + switch action { + case .finished: + stateMachine.processEvent(.deselectRoom) + case .presentRoom(let roomID): + stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false)) + case .presentCallScreen(let roomProxy): + presentCallScreen(roomProxy: roomProxy) + } + } + .store(in: &cancellables) + + roomFlowCoordinator = coordinator + + if navigationSplitCoordinator.detailCoordinator !== detailNavigationStackCoordinator { + navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated) + } + + if showingRoomDetails { + coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: animated) + } else { + coordinator.handleAppRoute(.room(roomID: roomID), animated: animated) + } + + let availableInvitesCount = userSession.clientProxy.inviteSummaryProvider?.roomListPublisher.value.count ?? 0 + if case .invitesScreen = stateMachine.state, availableInvitesCount == 1 { + dismissInvitesList(animated: true) + } + } + + private func tearDownRoomFlow(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 + } + // MARK: Start Chat private func presentStartChat(animated: Bool) { @@ -451,7 +487,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { self.navigationSplitCoordinator.setSheetCoordinator(nil) case .openRoom(let roomID): self.navigationSplitCoordinator.setSheetCoordinator(nil) - self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true) + self.stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false)) } } .store(in: &cancellables) @@ -473,7 +509,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { .sink { [weak self] action in switch action { case .openRoom(let roomID): - self?.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true) + self?.stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift index ff950300da..c1c320ad4f 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import Foundation import SwiftState @@ -38,11 +39,27 @@ class UserSessionFlowCoordinatorStateMachine { /// Showing invites list screen case invitesScreen(selectedRoomID: String?) - // Showing the logout flows + /// Showing the logout flows case logoutConfirmationScreen(selectedRoomID: String?) - // Showing Room Directory Search screen + /// Showing Room Directory Search screen case roomDirectorySearchScreen(selectedRoomID: String?) + + /// The selected room ID from the state if available. + var selectedRoomID: String? { + switch self { + case .initial: + nil + case .roomList(let selectedRoomID), + .feedbackScreen(let selectedRoomID), + .settingsScreen(let selectedRoomID), + .startChatScreen(let selectedRoomID), + .invitesScreen(let selectedRoomID), + .logoutConfirmationScreen(let selectedRoomID), + .roomDirectorySearchScreen(let selectedRoomID): + selectedRoomID + } + } } struct EventUserInfo { @@ -56,7 +73,7 @@ class UserSessionFlowCoordinatorStateMachine { /// Request presentation for a particular room /// - Parameter roomID:the room identifier - case selectRoom(roomID: String) + case selectRoom(roomID: String, showingRoomDetails: Bool) /// The room screen has been dismissed case deselectRoom @@ -96,6 +113,11 @@ class UserSessionFlowCoordinatorStateMachine { stateMachine.state } + var stateSubject = PassthroughSubject() + var statePublisher: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } + init() { stateMachine = StateMachine(state: .initial) configure() @@ -106,9 +128,9 @@ class UserSessionFlowCoordinatorStateMachine { stateMachine.addRouteMapping { event, fromState, _ in switch (fromState, event) { - case (.roomList, .selectRoom(let roomID)): + case (.roomList, .selectRoom(let roomID, _)): return .roomList(selectedRoomID: roomID) - case (.invitesScreen, .selectRoom(let roomID)): + case (.invitesScreen, .selectRoom(let roomID, _)): return .invitesScreen(selectedRoomID: roomID) case (.roomList, .deselectRoom): return .roomList(selectedRoomID: nil) @@ -160,6 +182,10 @@ class UserSessionFlowCoordinatorStateMachine { MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`") } } + + addTransitionHandler { [weak self] context in + self?.stateSubject.send(context.toState) + } } /// Attempt to move the state machine to another state through an event diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index a35f62e1d3..a61295d32f 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -84,12 +84,14 @@ extension ClientProxyMock { guard let room = self?.roomSummaryProvider?.roomListPublisher.value.first(where: { $0.id == identifier }) else { return nil } + + let roomID = room.id ?? UUID().uuidString switch room { case .empty: return await RoomProxyMock(with: .init(name: "Empty room")) case .filled(let details), .invalidated(let details): - return await RoomProxyMock(with: .init(name: details.name)) + return await RoomProxyMock(with: .init(id: roomID, name: details.name)) } } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 80a75f317d..115dfb3628 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4351,4 +4351,109 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol { } } } +class WindowManagerMock: WindowManagerProtocol { + weak var delegate: WindowManagerDelegate? + var mainWindow: UIWindow! + var overlayWindow: UIWindow! + var globalSearchWindow: UIWindow! + var alternateWindow: UIWindow! + var windows: [UIWindow] = [] + + //MARK: - configure + + var configureWithCallsCount = 0 + var configureWithCalled: Bool { + return configureWithCallsCount > 0 + } + var configureWithReceivedWindowScene: UIWindowScene? + var configureWithReceivedInvocations: [UIWindowScene] = [] + var configureWithClosure: ((UIWindowScene) -> Void)? + + func configure(with windowScene: UIWindowScene) { + configureWithCallsCount += 1 + configureWithReceivedWindowScene = windowScene + configureWithReceivedInvocations.append(windowScene) + configureWithClosure?(windowScene) + } + //MARK: - switchToMain + + var switchToMainCallsCount = 0 + var switchToMainCalled: Bool { + return switchToMainCallsCount > 0 + } + var switchToMainClosure: (() -> Void)? + + func switchToMain() { + switchToMainCallsCount += 1 + switchToMainClosure?() + } + //MARK: - switchToAlternate + + var switchToAlternateCallsCount = 0 + var switchToAlternateCalled: Bool { + return switchToAlternateCallsCount > 0 + } + var switchToAlternateClosure: (() -> Void)? + + func switchToAlternate() { + switchToAlternateCallsCount += 1 + switchToAlternateClosure?() + } + //MARK: - showGlobalSearch + + var showGlobalSearchCallsCount = 0 + var showGlobalSearchCalled: Bool { + return showGlobalSearchCallsCount > 0 + } + var showGlobalSearchClosure: (() -> Void)? + + func showGlobalSearch() { + showGlobalSearchCallsCount += 1 + showGlobalSearchClosure?() + } + //MARK: - hideGlobalSearch + + var hideGlobalSearchCallsCount = 0 + var hideGlobalSearchCalled: Bool { + return hideGlobalSearchCallsCount > 0 + } + var hideGlobalSearchClosure: (() -> Void)? + + func hideGlobalSearch() { + hideGlobalSearchCallsCount += 1 + hideGlobalSearchClosure?() + } + //MARK: - setOrientation + + var setOrientationCallsCount = 0 + var setOrientationCalled: Bool { + return setOrientationCallsCount > 0 + } + var setOrientationReceivedOrientation: UIInterfaceOrientationMask? + var setOrientationReceivedInvocations: [UIInterfaceOrientationMask] = [] + var setOrientationClosure: ((UIInterfaceOrientationMask) -> Void)? + + func setOrientation(_ orientation: UIInterfaceOrientationMask) { + setOrientationCallsCount += 1 + setOrientationReceivedOrientation = orientation + setOrientationReceivedInvocations.append(orientation) + setOrientationClosure?(orientation) + } + //MARK: - lockOrientation + + var lockOrientationCallsCount = 0 + var lockOrientationCalled: Bool { + return lockOrientationCallsCount > 0 + } + var lockOrientationReceivedOrientation: UIInterfaceOrientationMask? + var lockOrientationReceivedInvocations: [UIInterfaceOrientationMask] = [] + var lockOrientationClosure: ((UIInterfaceOrientationMask) -> Void)? + + func lockOrientation(_ orientation: UIInterfaceOrientationMask) { + lockOrientationCallsCount += 1 + lockOrientationReceivedOrientation = orientation + lockOrientationReceivedInvocations.append(orientation) + lockOrientationClosure?(orientation) + } +} // swiftlint:enable all diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index b46e112776..9ae37196b4 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -68,6 +68,7 @@ extension RoomProxyMock { timeline = configuration.timeline ownUserID = configuration.ownUserID + membership = .joined membersPublisher = CurrentValueSubject(configuration.members).asCurrentValuePublisher() typingMembersPublisher = CurrentValueSubject([]).asCurrentValuePublisher() diff --git a/UnitTests/Sources/Extensions/XCTestCase.swift b/UnitTests/Sources/Extensions/XCTestCase.swift index 6a4b988872..17802520ca 100644 --- a/UnitTests/Sources/Extensions/XCTestCase.swift +++ b/UnitTests/Sources/Extensions/XCTestCase.swift @@ -83,6 +83,34 @@ extension XCTestCase { return deferred } + /// XCTest utility that assists in subscribing to a publisher and deferring the failure for a particular value until some other actions have been performed. + /// - Parameters: + /// - publisher: The publisher to wait on. + /// - timeout: A timeout after which we give up. + /// - message: An optional custom expectation message + /// - until: callback that evaluates outputs until some condition is reached + /// - Returns: The deferred fulfilment to be executed after some actions. The publisher's result is not returned from this fulfilment. + func deferFailure(_ publisher: P, + timeout: TimeInterval, + message: String? = nil, + until condition: @escaping (P.Output) -> Bool) -> DeferredFulfillment where P.Failure == Never { + let expectation = expectation(description: message ?? "Awaiting publisher") + expectation.isInverted = true + var hasFulfilled = false + let cancellable = publisher + .sink { value in + if condition(value), !hasFulfilled { + expectation.fulfill() + hasFulfilled = true + } + } + + return DeferredFulfillment { + await self.fulfillment(of: [expectation], timeout: timeout) + cancellable.cancel() + } + } + struct DeferredFulfillment { let closure: () async throws -> T @discardableResult func fulfill() async throws -> T { diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 81dcb70f1a..a3f27770ed 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -38,86 +38,63 @@ class RoomFlowCoordinatorTests: XCTestCase { navigationStackCoordinator = NavigationStackCoordinator() navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator) - roomFlowCoordinator = RoomFlowCoordinator(userSession: userSession, - roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), - navigationStackCoordinator: navigationStackCoordinator, - navigationSplitCoordinator: navigationSplitCoordinator, - emojiProvider: EmojiProvider(), - appSettings: ServiceLocator.shared.settings, - analytics: ServiceLocator.shared.analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController, - orientationManager: OrientationManagerMock()) + roomFlowCoordinator = await RoomFlowCoordinator(roomProxy: RoomProxyMock(with: .init(id: "1")), + userSession: userSession, + roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), + navigationStackCoordinator: navigationStackCoordinator, + emojiProvider: EmojiProvider(), + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + orientationManager: OrientationManagerMock()) } func testRoomPresentation() async throws { - try await process(route: .room(roomID: "1"), expectedAction: .presentedRoom("1")) + try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - try await process(route: .roomList, expectedAction: .dismissedRoom) - XCTAssertNil(navigationStackCoordinator.rootCoordinator) - - try await process(route: .room(roomID: "1"), expectedAction: .presentedRoom("1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - - try await process(route: .room(roomID: "2"), expectedAction: .presentedRoom("2")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - - try await process(route: .roomList, expectedAction: .dismissedRoom) + try await process(route: .roomList, expectedAction: .finished) XCTAssertNil(navigationStackCoordinator.rootCoordinator) } func testRoomDetailsPresentation() async throws { - try await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) + try await process(route: .roomDetails(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - try await process(route: .roomList, expectedAction: .dismissedRoom) + try await process(route: .roomList, expectedAction: .finished) XCTAssertNil(navigationStackCoordinator.rootCoordinator) } - func testStackUnwinding() async throws { - try await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - - try await process(route: .room(roomID: "2"), expectedAction: .presentedRoom("2")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - } - func testNoOp() async throws { - try await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) + try await process(route: .roomDetails(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) + let detailsCoordinator = navigationStackCoordinator.rootCoordinator + roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) await Task.yield() - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - } - - func testSwitchToDifferentDetails() async throws { - try await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - try await process(route: .roomDetails(roomID: "2"), expectedAction: .presentedRoom("2")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssert(navigationStackCoordinator.rootCoordinator === detailsCoordinator) } func testPushDetails() async throws { - try await process(route: .room(roomID: "1"), expectedAction: .presentedRoom("1")) + try await process(route: .room(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) - try await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) + try await process(route: .roomDetails(roomID: "1")) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator) } - func testReplaceDetailsWithTimeline() async throws { - try await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator) - - try await process(route: .room(roomID: "1"), expectedActions: [.dismissedRoom, .presentedRoom("1")]) - XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) - } - // MARK: - Private + private func process(route: AppRoute) async throws { + roomFlowCoordinator.handleAppRoute(route, animated: true) + await Task.yield() + } + private func process(route: AppRoute, expectedAction: RoomFlowCoordinatorAction) async throws { try await process(route: route, expectedActions: [expectedAction]) } diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift new file mode 100644 index 0000000000..82e384f424 --- /dev/null +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -0,0 +1,164 @@ +// +// Copyright 2023 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 + +import Combine +@testable import ElementX + +@MainActor +class UserSessionFlowCoordinatorTests: XCTestCase { + var userSessionFlowCoordinator: UserSessionFlowCoordinator! + var navigationRootCoordinator: NavigationRootCoordinator! + var cancellables = Set() + + var detailCoordinator: CoordinatorProtocol? { + let navigationSplitCoordinator = navigationRootCoordinator.rootCoordinator as? NavigationSplitCoordinator + return navigationSplitCoordinator?.detailCoordinator + } + + var detailNavigationStack: NavigationStackCoordinator? { + detailCoordinator as? NavigationStackCoordinator + } + + override func setUp() async throws { + cancellables.removeAll() + let clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) + let mediaProvider = MockMediaProvider() + let voiceMessageMediaManager = VoiceMessageMediaManagerMock() + let userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: mediaProvider, + voiceMessageMediaManager: voiceMessageMediaManager) + + navigationRootCoordinator = NavigationRootCoordinator() + + userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, + navigationRootCoordinator: navigationRootCoordinator, + windowManager: WindowManagerMock(), + appLockService: AppLockServiceMock(), + bugReportService: BugReportServiceMock(), + roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + notificationManager: NotificationManagerMock(), + isNewLogin: false) + + let deferred = deferFulfillment(userSessionFlowCoordinator.statePublisher) { $0 == .roomList(selectedRoomID: nil) } + userSessionFlowCoordinator.start() + try await deferred.fulfill() + } + + func testRoomPresentation() async throws { + try await process(route: .room(roomID: "1"), 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) + + 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) + } + + func testRoomDetailsPresentation() async throws { + try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + try await process(route: .roomList, expectedState: .roomList(selectedRoomID: nil)) + XCTAssertNil(detailNavigationStack?.rootCoordinator) + XCTAssertNil(detailCoordinator) + } + + func testStackUnwinding() async throws { + try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + try await process(route: .room(roomID: "2"), expectedState: .roomList(selectedRoomID: "2")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + } + + func testNoOp() async throws { + try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + let unexpectedFulfillment = deferFailure(userSessionFlowCoordinator.statePublisher, timeout: 1) { _ in true } + userSessionFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) + try await unexpectedFulfillment.fulfill() + + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + } + + func testSwitchToDifferentDetails() async throws { + try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + try await process(route: .roomDetails(roomID: "2"), expectedState: .roomList(selectedRoomID: "2")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + } + + func testPushDetails() async throws { + try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + let unexpectedFulfillment = deferFailure(userSessionFlowCoordinator.statePublisher, timeout: 1) { _ in true } + userSessionFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) + try await unexpectedFulfillment.fulfill() + + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1) + XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + } + + func testReplaceDetailsWithTimeline() async throws { + try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + + try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + } + + // MARK: - Private + + private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws { + // Sometimes the state machine's state changes before the coordinators have updated the stack. + let delayedPublisher = userSessionFlowCoordinator.statePublisher.delay(for: .milliseconds(10), scheduler: DispatchQueue.main) + + let deferred = deferFulfillment(delayedPublisher) { $0 == expectedState } + userSessionFlowCoordinator.handleAppRoute(route, animated: true) + try await deferred.fulfill() + } +}