mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:11:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user