Introduce a StartChatFlowCoordinator instead of handing a navigation stack to the Screen Coordinator. (#4674)

* Introduce a basic StartChatFlowCoordinator.

* Move the rest of the start chat flow from the screen coordinator into the flow coordinator.

* Add a UI test for the entire start chat flow.

* Refactor CreateRoom… to CreateRoomScreen…
This commit is contained in:
Doug
2025-10-30 16:12:56 +00:00
committed by GitHub
parent 401ff49882
commit 933891614c
68 changed files with 764 additions and 789 deletions
@@ -36,9 +36,10 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
// periphery:ignore - retaining purpose
private var encryptionResetFlowCoordinator: EncryptionResetFlowCoordinator?
// periphery:ignore - retaining purpose
private var startChatFlowCoordinator: StartChatFlowCoordinator?
// periphery:ignore - retaining purpose
private var globalSearchScreenCoordinator: GlobalSearchScreenCoordinator?
@@ -223,12 +224,12 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
case (.roomList, .startEncryptionResetFlow, .encryptionResetFlow):
startEncryptionResetFlow(animated: animated)
case (.encryptionResetFlow, .finishedEncryptionResetFlow, .roomList):
break
encryptionResetFlowCoordinator = nil
case (.roomList, .showStartChatScreen, .startChatScreen):
presentStartChat(animated: animated)
case (.startChatScreen, .dismissedStartChatScreen, .roomList):
break
case (.roomList, .startStartChatFlow, .startChatFlow):
startStartChatFlow(animated: animated)
case (.startChatFlow, .finishedStartChatFlow, .roomList):
startChatFlowCoordinator = nil
case (.roomList, .showRoomDirectorySearchScreen, .roomDirectorySearchScreen):
presentRoomDirectorySearch()
@@ -398,7 +399,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
case .presentEncryptionResetScreen:
stateMachine.processEvent(.startEncryptionResetFlow)
case .presentStartChatScreen:
stateMachine.processEvent(.showStartChatScreen)
stateMachine.processEvent(.startStartChatFlow)
case .presentGlobalSearch:
presentGlobalSearch()
case .logout:
@@ -565,39 +566,34 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
// MARK: Start Chat
private func presentStartChat(animated: Bool) {
let startChatNavigationStackCoordinator = NavigationStackCoordinator()
let userDiscoveryService = UserDiscoveryService(clientProxy: userSession.clientProxy)
let parameters = StartChatScreenCoordinatorParameters(orientationManager: flowParameters.windowManager,
userSession: userSession,
userIndicatorController: flowParameters.userIndicatorController,
navigationStackCoordinator: startChatNavigationStackCoordinator,
userDiscoveryService: userDiscoveryService,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings),
appSettings: flowParameters.appSettings,
analytics: flowParameters.analytics)
private func startStartChatFlow(animated: Bool) {
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = StartChatFlowCoordinator(userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
navigationStackCoordinator: navigationStackCoordinator,
flowParameters: flowParameters)
let coordinator = StartChatScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .close:
navigationSplitCoordinator.setSheetCoordinator(nil)
case .openRoom(let roomID):
navigationSplitCoordinator.setSheetCoordinator(nil)
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .room))
case .openRoomDirectorySearch:
navigationSplitCoordinator.setSheetCoordinator(nil)
stateMachine.processEvent(.showRoomDirectorySearchScreen)
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .finished(let roomID):
navigationSplitCoordinator.setSheetCoordinator(nil)
if let roomID {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .room))
}
case .showRoomDirectory:
navigationSplitCoordinator.setSheetCoordinator(nil)
stateMachine.processEvent(.showRoomDirectorySearchScreen)
}
}
}
.store(in: &cancellables)
startChatNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationSplitCoordinator.setSheetCoordinator(startChatNavigationStackCoordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.dismissedStartChatScreen)
.store(in: &cancellables)
startChatFlowCoordinator = coordinator
coordinator.start()
navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.finishedStartChatFlow)
}
}
@@ -639,10 +635,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .resetComplete:
encryptionResetFlowCoordinator = nil
navigationSplitCoordinator.setSheetCoordinator(nil)
case .cancel:
encryptionResetFlowCoordinator = nil
navigationSplitCoordinator.setSheetCoordinator(nil)
}
}
@@ -33,8 +33,8 @@ class ChatsFlowCoordinatorStateMachine {
/// Showing the encryption reset flow.
case encryptionResetFlow(detailState: DetailState?)
/// Showing the start chat screen
case startChatScreen(detailState: DetailState?)
/// Showing the start chat flow
case startChatFlow(detailState: DetailState?)
/// Showing the logout flows
case logoutConfirmationScreen(detailState: DetailState?)
@@ -61,7 +61,7 @@ class ChatsFlowCoordinatorStateMachine {
.feedbackScreen(let detailState),
.recoveryKeyScreen(let detailState),
.encryptionResetFlow(let detailState),
.startChatScreen(let detailState),
.startChatFlow(let detailState),
.logoutConfirmationScreen(let detailState),
.roomDirectorySearchScreen(let detailState),
.reportRoomScreen(let detailState),
@@ -111,10 +111,10 @@ class ChatsFlowCoordinatorStateMachine {
/// The encryption reset flow is complete and has been dismissed.
case finishedEncryptionResetFlow
/// Request the start of the start chat flow
case showStartChatScreen
/// Start chat has been dismissed
case dismissedStartChatScreen
/// Request the start of the start chat flow.
case startStartChatFlow
/// The Start Chat flow is complete and has been dismissed.
case finishedStartChatFlow
/// Request presentation of the room directory search screen.
case showRoomDirectorySearchScreen
@@ -177,9 +177,9 @@ class ChatsFlowCoordinatorStateMachine {
case (.encryptionResetFlow(let detailState), .finishedEncryptionResetFlow):
return .roomList(detailState: detailState)
case (.roomList(let detailState), .showStartChatScreen):
return .startChatScreen(detailState: detailState)
case (.startChatScreen(let detailState), .dismissedStartChatScreen):
case (.roomList(let detailState), .startStartChatFlow):
return .startChatFlow(detailState: detailState)
case (.startChatFlow(let detailState), .finishedStartChatFlow):
return .roomList(detailState: detailState)
case (.roomList(let detailState), .showRoomDirectorySearchScreen):
@@ -1267,12 +1267,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
private func presentInviteUsersScreen() {
let selectedUsersSubject: CurrentValueSubject<[UserProfileProxy], Never> = .init([])
let stackCoordinator = NavigationStackCoordinator()
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: userSession,
selectedUsers: .init(selectedUsersSubject),
roomType: .room(roomProxy: roomProxy),
roomProxy: roomProxy,
isSkippable: false,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings)
@@ -1286,8 +1284,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .proceed:
fatalError("Not handled in this flow.")
}
}
.store(in: &cancellables)
@@ -255,8 +255,8 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
private func presentInviteUsersScreen() {
let stackCoordinator = NavigationStackCoordinator()
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
selectedUsers: nil,
roomType: .room(roomProxy: roomProxy),
roomProxy: roomProxy,
isSkippable: false,
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings)
@@ -270,8 +270,6 @@ final class RoomMembersFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
case .proceed:
fatalError("Not handled in this flow.")
}
}
.store(in: &cancellables)
@@ -0,0 +1,237 @@
//
// Copyright 2025 Element Creations Ltd.
// 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
enum StartChatFlowCoordinatorAction {
case finished(roomID: String?)
case showRoomDirectory
}
class StartChatFlowCoordinator: FlowCoordinatorProtocol {
private let userDiscoveryService: UserDiscoveryServiceProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
private let flowParameters: CommonFlowParameters
private var createRoomScreenCoordinator: CreateRoomScreenCoordinator?
indirect enum State: StateType {
/// The state machine hasn't started.
case initial
/// Shown when the flow is started with options to create a room/DM, join by alias, use the room directory etc.
case startChat
/// The user is creating a new room.
case createRoom
/// The user is selecting an avatar for the new room.
case roomAvatarPicker
/// The user is inviting users to a newly created room.
case inviteUsers
}
enum Event: EventType {
/// The flow is being started.
case start
/// The user would like to create a room.
case createRoom
/// The user dismissed the create room screen.
case dismissedCreateRoom
/// The user would like to pick an avatar for the room.
case presentRoomAvatarPicker
/// The user finished picking the avatar.
case dismissedRoomAvatarPicker
/// The user's room was created successfully.
case createdRoom
}
private let stateMachine: StateMachine<State, Event>
private var cancellables: Set<AnyCancellable> = []
private let actionsSubject: PassthroughSubject<StartChatFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<StartChatFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(userDiscoveryService: UserDiscoveryServiceProtocol,
navigationStackCoordinator: NavigationStackCoordinator,
flowParameters: CommonFlowParameters) {
self.userDiscoveryService = userDiscoveryService
self.navigationStackCoordinator = navigationStackCoordinator
self.flowParameters = flowParameters
stateMachine = .init(state: .initial)
configureStateMachine()
}
func start(animated: Bool) {
stateMachine.tryEvent(.start)
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
// There aren't any routes to this screen yet, so clear the stacks.
clearRoute(animated: animated)
}
func clearRoute(animated: Bool) {
switch stateMachine.state {
case .initial:
break
case .startChat:
navigationStackCoordinator.setRootCoordinator(nil, animated: animated) // StartChatScreen
case .createRoom:
navigationStackCoordinator.pop(animated: animated) // CreateRoomScreen
navigationStackCoordinator.setRootCoordinator(nil, animated: animated) // StartChatScreen
case .roomAvatarPicker:
navigationStackCoordinator.setSheetCoordinator(nil, animated: animated) // Media Picker
clearRoute(animated: animated) // Re-run with the state machine back in the .createRoom state.
case .inviteUsers:
navigationStackCoordinator.pop(animated: animated) // InviteUsersScreen
navigationStackCoordinator.pop(animated: animated) // CreateRoomScreen
navigationStackCoordinator.setRootCoordinator(nil, animated: animated) // StartChatScreen
}
}
// MARK: - Private
private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .startChat]) { [weak self] _ in
self?.presentStartChatScreen()
}
stateMachine.addRoutes(event: .createRoom, transitions: [.startChat => .createRoom]) { [weak self] _ in
self?.presentCreateRoomScreen()
}
stateMachine.addRoutes(event: .dismissedCreateRoom, transitions: [.createRoom => .startChat]) { [weak self] _ in
self?.createRoomScreenCoordinator = nil
}
stateMachine.addRoutes(event: .presentRoomAvatarPicker, transitions: [.createRoom => .roomAvatarPicker]) { [weak self] context in
guard let mode = context.userInfo as? MediaPickerScreenMode else {
fatalError("A picker mode is required for the room avatar.")
}
self?.presentRoomAvatarPicker(mode)
}
stateMachine.addRoutes(event: .dismissedRoomAvatarPicker, transitions: [.roomAvatarPicker => .createRoom])
stateMachine.addRoutes(event: .createdRoom, transitions: [.createRoom => .inviteUsers]) { [weak self] context in
guard let roomProxy = context.userInfo as? JoinedRoomProxyProtocol else {
fatalError("A room proxy is required to invite users.")
}
self?.presentInviteUsersScreen(roomProxy: roomProxy)
}
stateMachine.addErrorHandler { context in
if context.fromState == context.toState {
MXLog.error("Transition between equal states: \(context.fromState)")
} else {
fatalError("Unexpected transition: \(context)")
}
}
}
private func presentStartChatScreen() {
let parameters = StartChatScreenCoordinatorParameters(userSession: flowParameters.userSession,
userDiscoveryService: userDiscoveryService,
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings,
analytics: flowParameters.analytics)
let coordinator = StartChatScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .close:
actionsSubject.send(.finished(roomID: nil))
case .createRoom:
stateMachine.tryEvent(.createRoom)
case .openRoom(let roomID):
actionsSubject.send(.finished(roomID: roomID))
case .openRoomDirectorySearch:
actionsSubject.send(.showRoomDirectory)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(coordinator)
}
private func presentCreateRoomScreen() {
let createParameters = CreateRoomScreenCoordinatorParameters(userSession: flowParameters.userSession,
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings,
analytics: flowParameters.analytics)
let coordinator = CreateRoomScreenCoordinator(parameters: createParameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .createdRoom(let roomProxy):
stateMachine.tryEvent(.createdRoom, userInfo: roomProxy)
case .displayMediaPickerWithMode(let mode):
stateMachine.tryEvent(.presentRoomAvatarPicker, userInfo: mode)
}
}
.store(in: &cancellables)
createRoomScreenCoordinator = coordinator
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedCreateRoom)
}
}
private func presentRoomAvatarPicker(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
userIndicatorController: flowParameters.userIndicatorController,
orientationManager: flowParameters.windowManager) { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
case .selectedMediaAtURLs(let urls):
guard urls.count == 1 else { fatalError("Received an invalid number of URLs") }
navigationStackCoordinator.setSheetCoordinator(nil)
createRoomScreenCoordinator?.updateAvatar(fileURL: urls[0])
}
}
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedRoomAvatarPicker)
}
}
private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol) {
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
roomProxy: roomProxy,
isSkippable: true,
userDiscoveryService: userDiscoveryService,
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings)
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
actionsSubject.send(.finished(roomID: roomProxy.id))
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator)
}
}