Move the Settings flow from the Chats flow up one level to the UserSession flow. (#4390)

* Add the basis for a state machine to UserSessionFlowCoordinator.

* Move the Settings flow from the Chats flow to UserSession flow.

It now works from the Spaces tab.

* Add a StateMachineFactory and use it to publish the state in the tests.
This commit is contained in:
Doug
2025-08-07 13:13:02 +01:00
committed by GitHub
parent c47ec8a7cc
commit c5cd3ea79e
10 changed files with 388 additions and 164 deletions
+16
View File
@@ -519,6 +519,7 @@
61941DEE5F3834765770BE01 /* InviteUsersScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */; };
61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E93A1BE7D8A2EBCAD51EEB4 /* Array.swift */; };
62684AECDFC5C7DC989CBD9E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 7B6BC3219ADD8AA0311D2B86 /* SnapshotTesting */; };
627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */; };
6298AB0906DDD3525CD78C6B /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 81DB3AB6CE996AB3954F4F03 /* KZFileWatchers */; };
62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; };
62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */; };
@@ -1215,6 +1216,7 @@
E79D79CDAFE8BEBCC3AECA54 /* AppLockScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */; };
E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; };
E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */; };
E8AE3BE5C5D2E6C91751A2A6 /* StateMachineFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73285743189AE4498A51DD /* StateMachineFactory.swift */; };
E8B290CBB7E5FF5E3C1B6124 /* KnockRequestsListEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */; };
E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; };
E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; };
@@ -1849,6 +1851,7 @@
4E7F7A975514E850A834B29F /* PaginationIndicatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineView.swift; sourceTree = "<group>"; };
4F5F0662483ED69791D63B16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = et; path = et.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = "<group>"; };
4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorTests.swift; sourceTree = "<group>"; };
4FAC770857E525B51E277D8C /* SpaceListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceListScreenModels.swift; sourceTree = "<group>"; };
4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = "<group>"; };
4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyProtocol.swift; sourceTree = "<group>"; };
@@ -2300,6 +2303,7 @@
AC43313F21511C853D34544E /* SoftLogoutScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelTests.swift; sourceTree = "<group>"; };
AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = "<group>"; };
AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = "<group>"; };
AC73285743189AE4498A51DD /* StateMachineFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMachineFactory.swift; sourceTree = "<group>"; };
AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = "<group>"; };
ACA11F7F50A4A3887A18CA5A /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
ACD7BD6BEE21264F6677904A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -2947,6 +2951,7 @@
BDCEF7C3BF6D09F5611CFC8B /* SecureBackup */,
82D5AD3EAE3A5C1068A44A88 /* Session */,
5329E48968EB951235E83DAE /* SessionVerification */,
AA94873C78BBB969CE65EE52 /* StateMachine */,
FCDF06BDB123505F0334B4F9 /* Timeline */,
E4E42F93A69AE52E6FAE9412 /* Users */,
CBBF6127C313A5412E438BC6 /* UserSession */,
@@ -4522,6 +4527,7 @@
2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */,
BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */,
71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */,
4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */,
283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */,
AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */,
D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */,
@@ -5368,6 +5374,14 @@
path = DevelopmentAssets;
sourceTree = "<group>";
};
AA94873C78BBB969CE65EE52 /* StateMachine */ = {
isa = PBXGroup;
children = (
AC73285743189AE4498A51DD /* StateMachineFactory.swift */,
);
path = StateMachine;
sourceTree = "<group>";
};
AAFDD509929A0CCF8BCE51EB /* Authentication */ = {
isa = PBXGroup;
children = (
@@ -7187,6 +7201,7 @@
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 */,
44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */,
@@ -8023,6 +8038,7 @@
C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */,
4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */,
B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */,
E8AE3BE5C5D2E6C91751A2A6 /* StateMachineFactory.swift in Sources */,
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */,
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */,
B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */,
@@ -641,6 +641,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
isNewLogin: isNewLogin,
navigationRootCoordinator: navigationRootCoordinator,
appLockService: appLockFlowCoordinator.appLockService,
bugReportService: ServiceLocator.shared.bugReportService,
@@ -651,7 +652,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
appHooks: appHooks,
analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager,
isNewLogin: isNewLogin)
stateMachineFactory: StateMachineFactory())
userSessionFlowCoordinator.actionsPublisher
.sink { [weak self] action in
@@ -12,11 +12,10 @@ import MatrixRustSDK
import SwiftUI
enum ChatsFlowCoordinatorAction {
case logout
case showSettings
case showChatBackupSettings
case sessionVerification(SessionVerificationScreenFlow)
case clearCache
/// Logout and disable App Lock without any confirmation. The user forgot their PIN.
case forceLogout
case logout
}
class ChatsFlowCoordinator: FlowCoordinatorProtocol {
@@ -36,8 +35,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
private var roomFlowCoordinator: RoomFlowCoordinator?
private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let settingsFlowCoordinator: SettingsFlowCoordinator
// periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
@@ -59,10 +56,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.eraseToAnyPublisher()
}
/// For testing purposes.
var statePublisher: AnyPublisher<ChatsFlowCoordinatorStateMachine.State, Never> { stateMachine.statePublisher }
init(userSession: UserSessionProtocol,
isNewLogin: Bool,
navigationSplitCoordinator: NavigationSplitCoordinator,
appLockService: AppLockServiceProtocol,
bugReportService: BugReportServiceProtocol,
@@ -73,8 +68,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
appHooks: AppHooks,
analytics: AnalyticsService,
notificationManager: NotificationManagerProtocol,
isNewLogin: Bool) {
stateMachine = ChatsFlowCoordinatorStateMachine()
stateMachineFactory: StateMachineFactoryProtocol) {
stateMachine = stateMachineFactory.makeChatsFlowStateMachine()
self.userSession = userSession
self.navigationSplitCoordinator = navigationSplitCoordinator
self.bugReportService = bugReportService
@@ -88,22 +83,9 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
settingsFlowCoordinator = SettingsFlowCoordinator(parameters: .init(userSession: userSession,
windowManager: appMediator.windowManager,
appLockService: appLockService,
bugReportService: bugReportService,
notificationSettings: userSession.clientProxy.notificationSettings,
secureBackupController: userSession.clientProxy.secureBackupController,
appSettings: appSettings,
navigationSplitCoordinator: navigationSplitCoordinator,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: analytics))
setupStateMachine()
setupObservers()
}
@@ -189,8 +171,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
Task { await presentCallScreen(roomID: roomID) }
case .genericCallLink(let url):
presentCallScreen(genericCallLink: url)
case .settings, .chatBackupSettings:
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
case .share(let payload):
if let roomID = payload.roomID {
stateMachine.processEvent(.selectRoom(roomID: roomID,
@@ -200,14 +180,14 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated))
}
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .transferOwnership(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership))
}
case .accountProvisioningLink, .settings, .chatBackupSettings:
break // These routes cannot be handled.
}
}
@@ -247,11 +227,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
hideCallScreenOverlay() // Turn any active call into a PiP so that navigation from a notification is visible to the user.
case(.roomList, .deselectRoom, .roomList):
dismissRoomFlow(animated: animated)
case (.roomList, .showSettingsScreen, .settingsScreen):
break
case (.settingsScreen, .dismissedSettingsScreen, .roomList):
break
case (.roomList, .feedbackScreen, .feedbackScreen):
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator),
@@ -333,24 +308,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
}
private func setupObservers() {
settingsFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentedSettings:
stateMachine.processEvent(.showSettingsScreen)
case .dismissedSettings:
stateMachine.processEvent(.dismissedSettingsScreen)
case .runLogoutFlow:
actionsSubject.send(.logout)
case .clearCache:
actionsSubject.send(.clearCache)
case .forceLogout:
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
userSession.clientProxy.actionsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
@@ -439,11 +396,11 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
clearRoute(animated: true)
}
case .presentSettingsScreen:
settingsFlowCoordinator.handleAppRoute(.settings, animated: true)
actionsSubject.send(.showSettings)
case .presentFeedbackScreen:
stateMachine.processEvent(.feedbackScreen)
case .presentSecureBackupSettings:
settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
actionsSubject.send(.showChatBackupSettings)
case .presentRecoveryKeyScreen:
stateMachine.processEvent(.showRecoveryKeyScreen)
case .presentEncryptionResetScreen:
@@ -21,9 +21,6 @@ class ChatsFlowCoordinatorStateMachine {
/// Showing the feedback screen.
case feedbackScreen(roomListSelectedRoomID: String?)
/// Showing the settings screen
case settingsScreen(roomListSelectedRoomID: String?)
/// Showing the recovery key screen.
case recoveryKeyScreen(roomListSelectedRoomID: String?)
@@ -56,7 +53,6 @@ class ChatsFlowCoordinatorStateMachine {
nil
case .roomList(let roomListSelectedRoomID),
.feedbackScreen(let roomListSelectedRoomID),
.settingsScreen(let roomListSelectedRoomID),
.recoveryKeyScreen(let roomListSelectedRoomID),
.encryptionResetFlow(let roomListSelectedRoomID),
.startChatScreen(let roomListSelectedRoomID),
@@ -86,11 +82,6 @@ class ChatsFlowCoordinatorStateMachine {
/// The room screen has been dismissed
case deselectRoom
/// Request presentation of the settings screen
case showSettingsScreen
/// The settings screen has been dismissed
case dismissedSettingsScreen
/// Request presentation of the feedback screen
case feedbackScreen
/// The feedback screen has been dismissed
@@ -137,11 +128,6 @@ class ChatsFlowCoordinatorStateMachine {
stateMachine.state
}
var stateSubject = PassthroughSubject<State, Never>()
var statePublisher: AnyPublisher<State, Never> {
stateSubject.eraseToAnyPublisher()
}
init() {
stateMachine = StateMachine(state: .initial)
configure()
@@ -156,11 +142,6 @@ class ChatsFlowCoordinatorStateMachine {
return .roomList(roomListSelectedRoomID: roomID)
case (.roomList, .deselectRoom):
return .roomList(roomListSelectedRoomID: nil)
case (.roomList(let roomListSelectedRoomID), .showSettingsScreen):
return .settingsScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.settingsScreen(let roomListSelectedRoomID), .dismissedSettingsScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let roomListSelectedRoomID), .feedbackScreen):
return .feedbackScreen(roomListSelectedRoomID: roomListSelectedRoomID)
@@ -219,10 +200,6 @@ class ChatsFlowCoordinatorStateMachine {
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
@@ -9,10 +9,9 @@ import Combine
import SwiftUI
enum SettingsFlowCoordinatorAction {
case presentedSettings
case dismissedSettings
case runLogoutFlow
case dismiss
case clearCache
case runLogoutFlow
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
}
@@ -25,17 +24,14 @@ struct SettingsFlowCoordinatorParameters {
let notificationSettings: NotificationSettingsProxyProtocol
let secureBackupController: SecureBackupControllerProtocol
let appSettings: AppSettings
let navigationSplitCoordinator: NavigationSplitCoordinator
let navigationStackCoordinator: NavigationStackCoordinator
let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService
}
class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private let parameters: SettingsFlowCoordinatorParameters
private var navigationStackCoordinator: NavigationStackCoordinator!
private var cancellables = Set<AnyCancellable>()
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
// periphery:ignore - retaining purpose
private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator?
@@ -44,6 +40,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - retaining purpose
private var encryptionSettingsFlowCoordinator: EncryptionSettingsFlowCoordinator?
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SettingsFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<SettingsFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
@@ -62,15 +60,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
case .settings:
presentSettingsScreen(animated: animated)
case .chatBackupSettings:
if navigationStackCoordinator == nil {
presentSettingsScreen(animated: animated)
}
// The navigation stack doesn't like it if the root and the push happen
// on the same loop run
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
self.startEncryptionSettingsFlow(animated: animated)
}
startEncryptionSettingsFlow(animated: animated)
default:
break
}
@@ -83,8 +73,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Private
private func presentSettingsScreen(animated: Bool) {
navigationStackCoordinator = NavigationStackCoordinator()
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: parameters.userSession,
appSettings: parameters.appSettings,
isBugReportServiceEnabled: parameters.bugReportService.isEnabled))
@@ -95,14 +83,9 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss:
parameters.navigationSplitCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.dismiss)
case .logout:
parameters.navigationSplitCoordinator.setSheetCoordinator(nil)
// The settings sheet needs to be dismissed before the alert can be shown
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.actionsSubject.send(.runLogoutFlow)
}
actionsSubject.send(.runLogoutFlow)
case .secureBackup:
startEncryptionSettingsFlow(animated: true)
case .userDetails:
@@ -136,15 +119,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator, animated: animated)
parameters.navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator) { [weak self] in
guard let self else { return }
navigationStackCoordinator = nil
actionsSubject.send(.dismissedSettings)
}
actionsSubject.send(.presentedSettings)
}
private func startEncryptionSettingsFlow(animated: Bool) {
@@ -174,13 +148,13 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings))
navigationStackCoordinator?.push(coordinator)
navigationStackCoordinator.push(coordinator)
}
private func presentAnalyticsScreen() {
let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: parameters.appSettings,
analytics: parameters.analytics))
navigationStackCoordinator?.push(coordinator)
navigationStackCoordinator.push(coordinator)
}
private func presentAppLockSetupFlow() {
@@ -8,6 +8,7 @@
import Combine
import Compound
import MatrixRustSDK
import SwiftState
import SwiftUI
enum UserSessionFlowCoordinatorAction {
@@ -23,8 +24,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let userSession: UserSessionProtocol
private let navigationRootCoordinator: NavigationRootCoordinator
private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
private let appLockService: AppLockServiceProtocol
private let bugReportService: BugReportServiceProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analytics: AnalyticsService
private let onboardingFlowCoordinator: OnboardingFlowCoordinator
private let onboardingStackCoordinator: NavigationStackCoordinator
@@ -33,14 +37,38 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let spaceExplorerFlowCoordinator: SpaceExplorerFlowCoordinator
private let spacesTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails
// periphery:ignore - retaining purpose
private var settingsFlowCoordinator: SettingsFlowCoordinator?
enum State: StateType {
/// The state machine hasn't started.
case initial
/// The root screen for this flow.
case tabBar
/// Showing the settings screen.
case settingsScreen
}
enum Event: EventType {
/// The flow is being started.
case start
/// Request presentation of the settings screen.
case showSettingsScreen
/// The settings screen has been dismissed.
case dismissedSettingsScreen
}
private let stateMachine: StateMachine<State, Event>
private var cancellables: Set<AnyCancellable> = []
private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserSessionFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var cancellables: Set<AnyCancellable> = []
init(userSession: UserSessionProtocol,
isNewLogin: Bool,
navigationRootCoordinator: NavigationRootCoordinator,
appLockService: AppLockServiceProtocol,
bugReportService: BugReportServiceProtocol,
@@ -51,17 +79,21 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
appHooks: AppHooks,
analytics: AnalyticsService,
notificationManager: NotificationManagerProtocol,
isNewLogin: Bool) {
stateMachineFactory: StateMachineFactoryProtocol) {
self.userSession = userSession
self.navigationRootCoordinator = navigationRootCoordinator
self.appLockService = appLockService
self.bugReportService = bugReportService
self.appMediator = appMediator
self.appSettings = appSettings
self.analytics = analytics
navigationTabCoordinator = NavigationTabCoordinator()
navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator)
let chatsSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator())
chatsFlowCoordinator = ChatsFlowCoordinator(userSession: userSession,
isNewLogin: isNewLogin,
navigationSplitCoordinator: chatsSplitCoordinator,
appLockService: appLockService,
bugReportService: bugReportService,
@@ -72,7 +104,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
appHooks: appHooks,
analytics: analytics,
notificationManager: notificationManager,
isNewLogin: isNewLogin)
stateMachineFactory: stateMachineFactory)
chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid)
chatsTabDetails.barVisibility = .hidden
@@ -98,16 +130,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
.init(coordinator: spacesSplitCoordinator, details: spacesTabDetails)
])
stateMachine = stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial)
configureStateMachine()
setupObservers()
}
func start() {
#warning("This flow still needs a state machine.")
chatsFlowCoordinator.start()
spaceExplorerFlowCoordinator.start()
attemptStartingOnboarding()
stateMachine.tryEvent(.start)
}
func stop() {
@@ -115,13 +145,31 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
// There aren't any routes that directly target this flow yet, so pass them directly to the
// chats flow coordinator.
chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
navigationTabCoordinator.selectedTab = .chats
switch appRoute {
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .settings, .chatBackupSettings:
if stateMachine.state != .settingsScreen {
stateMachine.tryEvent(.showSettingsScreen)
}
settingsFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.call, .genericCallLink, .share, .transferOwnership:
clearRoute(animated: animated) // Make sure the presented route is visible.
chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
navigationTabCoordinator.selectedTab = .chats
}
}
func clearRoute(animated: Bool) {
switch stateMachine.state {
case .initial, .tabBar:
break
case .settingsScreen:
navigationTabCoordinator.setSheetCoordinator(nil, animated: animated)
}
chatsFlowCoordinator.clearRoute(animated: animated)
}
@@ -132,19 +180,40 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Private
private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .tabBar]) { [weak self] _ in
guard let self else { return }
chatsFlowCoordinator.start()
spaceExplorerFlowCoordinator.start()
attemptStartingOnboarding()
}
stateMachine.addRoutes(event: .showSettingsScreen, transitions: [.tabBar => .settingsScreen]) { [weak self] _ in
self?.startSettingsFlow()
}
stateMachine.addRoutes(event: .dismissedSettingsScreen, transitions: [.settingsScreen => .tabBar]) { [weak self] _ in
self?.settingsFlowCoordinator = nil
}
stateMachine.addErrorHandler { context in
fatalError("Unexpected transition: \(context)")
}
}
private func setupObservers() {
chatsFlowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .logout:
Task { await self.runLogoutFlow() }
case .showSettings:
handleAppRoute(.settings, animated: true)
case .showChatBackupSettings:
handleAppRoute(.chatBackupSettings, animated: true)
case .sessionVerification(let flow):
presentSessionVerificationScreen(flow: flow)
case .clearCache:
actionsSubject.send(.clearCache)
case .forceLogout:
actionsSubject.send(.forceLogout)
case .logout:
Task { await self.runLogoutFlow() }
}
}
.store(in: &cancellables)
@@ -154,7 +223,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .showSettings:
break
stateMachine.tryEvent(.showSettingsScreen)
}
}
.store(in: &cancellables)
@@ -198,6 +267,51 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
// MARK: - Settings
private func startSettingsFlow() {
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = SettingsFlowCoordinator(parameters: .init(userSession: userSession,
windowManager: appMediator.windowManager,
appLockService: appLockService,
bugReportService: bugReportService,
notificationSettings: userSession.clientProxy.notificationSettings,
secureBackupController: userSession.clientProxy.secureBackupController,
appSettings: appSettings,
navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: analytics))
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationTabCoordinator.setSheetCoordinator(nil)
case .clearCache:
actionsSubject.send(.clearCache)
case .runLogoutFlow:
Task {
self.navigationTabCoordinator.setSheetCoordinator(nil)
// The sheet needs to be dismissed before the alert can be shown
try await Task.sleep(for: .milliseconds(100))
await self.runLogoutFlow()
}
case .forceLogout:
actionsSubject.send(.forceLogout)
}
}
.store(in: &cancellables)
settingsFlowCoordinator = coordinator
coordinator.handleAppRoute(.settings, animated: false)
navigationTabCoordinator.setSheetCoordinator(navigationStackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedSettingsScreen)
}
}
// MARK: - Session Verification
private func setupSessionVerificationRequestsObserver() {
@@ -0,0 +1,51 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import SwiftState
protocol StateMachineFactoryProtocol {
func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine<UserSessionFlowCoordinator.State, UserSessionFlowCoordinator.Event>
func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine
}
struct StateMachineFactory: StateMachineFactoryProtocol {
func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine<UserSessionFlowCoordinator.State, UserSessionFlowCoordinator.Event> {
.init(state: state)
}
func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine {
.init()
}
}
// MARK: For testing
class PublishedStateMachineFactory: StateMachineFactoryProtocol {
let baseFactory = StateMachineFactory()
// MARK: UserSessionFlowCoordinator
let userSessionFlowStatePublisher = PassthroughSubject<UserSessionFlowCoordinator.State, Never>()
func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine<UserSessionFlowCoordinator.State, UserSessionFlowCoordinator.Event> {
let stateMachine = baseFactory.makeUserSessionFlowStateMachine(state: state)
stateMachine.addAnyHandler(.any => .any) { [weak self] in self?.userSessionFlowStatePublisher.send($0.toState) }
return stateMachine
}
// MARK: ChatsFlowCoordinator
let chatsFlowStatePublisher = PassthroughSubject<ChatsFlowCoordinatorStateMachine.State, Never>()
func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine {
let stateMachine = baseFactory.makeChatsFlowStateMachine()
stateMachine.addTransitionHandler { [weak self] in self?.chatsFlowStatePublisher.send($0.toState) }
return stateMachine
}
}
@@ -589,6 +589,7 @@ class MockScreen: Identifiable {
appMediator.underlyingWindowManager = windowManager
let flowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationRootCoordinator: navigationRootCoordinator,
appLockService: AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings),
@@ -600,7 +601,7 @@ class MockScreen: Identifiable {
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
notificationManager: NotificationManagerMock(),
isNewLogin: false)
stateMachineFactory: StateMachineFactory())
flowCoordinator.start()
@@ -746,6 +747,7 @@ class MockScreen: Identifiable {
appSettings: ServiceLocator.shared.settings)
let flowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationSplitCoordinator: navigationSplitCoordinator,
appLockService: AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings),
@@ -757,7 +759,7 @@ class MockScreen: Identifiable {
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
notificationManager: NotificationManagerMock(),
isNewLogin: false)
stateMachineFactory: StateMachineFactory())
flowCoordinator.start()
@@ -17,6 +17,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
var chatsFlowCoordinator: ChatsFlowCoordinator!
var splitCoordinator: NavigationSplitCoordinator!
var notificationManager: NotificationManagerMock!
let stateMachineFactory = PublishedStateMachineFactory()
var cancellables = Set<AnyCancellable>()
@@ -33,6 +34,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
notificationManager = NotificationManagerMock()
chatsFlowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationSplitCoordinator: splitCoordinator,
appLockService: AppLockServiceMock(),
bugReportService: BugReportServiceMock(.init()),
@@ -43,9 +45,9 @@ class ChatsFlowCoordinatorTests: XCTestCase {
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager,
isNewLogin: false)
stateMachineFactory: stateMachineFactory)
let deferred = deferFulfillment(chatsFlowCoordinator.statePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) }
let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) }
chatsFlowCoordinator.start()
try await deferred.fulfill()
}
@@ -127,7 +129,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
let unexpectedFulfillment = deferFailure(chatsFlowCoordinator.statePublisher, timeout: 1) { _ in true }
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsFlowStatePublisher, timeout: 1) { _ in true }
chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()
@@ -150,7 +152,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
let unexpectedFulfillment = deferFailure(chatsFlowCoordinator.statePublisher, timeout: 1) { _ in true }
let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsFlowStatePublisher, timeout: 1) { _ in true }
chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()
@@ -241,17 +243,6 @@ class ChatsFlowCoordinatorTests: XCTestCase {
"A new timeline should be created for the same room ID, so that the screen isn't stale while loading.")
}
func testShareMediaRouteWithoutRoom() async throws {
try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedState: .shareExtensionRoomList(sharePayload: sharePayload))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
}
func testShareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@@ -264,17 +255,6 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
}
func testShareTextRouteWithoutRoom() async throws {
try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text")
try await process(route: .share(sharePayload),
expectedState: .shareExtensionRoomList(sharePayload: sharePayload))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
}
func testShareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@@ -291,7 +271,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
private func process(route: AppRoute, expectedState: ChatsFlowCoordinatorStateMachine.State) async throws {
// Sometimes the state machine's state changes before the coordinators have updated the stack.
let delayedPublisher = chatsFlowCoordinator.statePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)
let delayedPublisher = stateMachineFactory.chatsFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)
let deferred = deferFulfillment(delayedPublisher) { $0 == expectedState }
chatsFlowCoordinator.handleAppRoute(route, animated: true)
@@ -0,0 +1,152 @@
//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import XCTest
import Combine
@testable import ElementX
@MainActor
class UserSessionFlowCoordinatorTests: XCTestCase {
var clientProxy: ClientProxyMock!
var timelineControllerFactory: TimelineControllerFactoryMock!
var userSessionFlowCoordinator: UserSessionFlowCoordinator!
var rootCoordinator: NavigationRootCoordinator!
var notificationManager: NotificationManagerMock!
let stateMachineFactory = PublishedStateMachineFactory()
var cancellables = Set<AnyCancellable>()
var tabCoordinator: NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>? { rootCoordinator?.rootCoordinator as? NavigationTabCoordinator }
var chatsSplitCoordinator: NavigationSplitCoordinator? { tabCoordinator?.tabCoordinators.first as? NavigationSplitCoordinator }
var detailCoordinator: CoordinatorProtocol? { chatsSplitCoordinator?.detailCoordinator }
var detailNavigationStack: NavigationStackCoordinator? { detailCoordinator as? NavigationStackCoordinator }
override func setUp() async throws {
cancellables.removeAll()
clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
timelineControllerFactory = TimelineControllerFactoryMock(.init())
rootCoordinator = NavigationRootCoordinator()
notificationManager = NotificationManagerMock()
userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationRootCoordinator: rootCoordinator,
appLockService: AppLockServiceMock(),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: timelineControllerFactory,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager,
stateMachineFactory: stateMachineFactory)
userSessionFlowCoordinator.start()
}
func testInitialState() async throws {
XCTAssertNotNil(chatsSplitCoordinator)
XCTAssertNil(detailCoordinator)
}
func testSettingsPresentation() async throws {
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
}
func testRoomPresentation() async throws {
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}
func testShareMediaRouteWithoutRoom() async throws {
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedUserSessionState: .tabBar,
expectedChatsState: .shareExtensionRoomList(sharePayload: sharePayload))
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
}
func testShareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedChatsState: .roomList(roomListSelectedRoomID: "2"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
}
func testShareTextRouteWithoutRoom() async throws {
try await process(route: .settings, expectedUserSessionState: .settingsScreen)
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text")
try await process(route: .share(sharePayload),
expectedUserSessionState: .tabBar,
expectedChatsState: .shareExtensionRoomList(sharePayload: sharePayload))
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator)
}
func testShareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
try await process(route: .share(sharePayload),
expectedChatsState: .roomList(roomListSelectedRoomID: "2"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.")
}
// MARK: - Helpers
private func process(route: AppRoute,
expectedUserSessionState: UserSessionFlowCoordinator.State? = nil,
expectedChatsState: ChatsFlowCoordinatorStateMachine.State? = nil) async throws {
let deferredUserSession: DeferredFulfillment? = if let expectedUserSessionState {
deferFulfillment(stateMachineFactory.userSessionFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)) {
$0 == expectedUserSessionState
}
} else {
nil
}
let deferredChatsState: DeferredFulfillment? = if let expectedChatsState {
deferFulfillment(stateMachineFactory.chatsFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)) {
$0 == expectedChatsState
}
} else {
nil
}
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
try await deferredUserSession?.fulfill()
try await deferredChatsState?.fulfill()
}
}