Fixes #918, #919 - Introduce a RoomTimelineFlowCoordinator and related FlowCoordinator protocol

Squashed commits:
Add unit tests and move the state machine into the FlowCoordinator
[bb686861] Replace the RoomFlowCoordinator's public interface with just `handleAppRoute`
[0d9a4f8d] Remove the navigationStackCoordinator dependency from the roomScreenCoordinator
[4b5fbdf2] Allow rooms to be selected from any other state
[41dbd127] Move all missing coordinators to the RoomFlowCoordinator and state machines
[f32431b7] The UserSessionFlowCoordinator does not need to conform to the CoordinatorProtocol
[0f07e87d] Fix leaving a room dismissing the currently selected one when different
[138385a2] Rewind the navigation stack when re-selecting the same room (iPad)
[0727eb93] Fix presenting different room details from the side menu on iPads
[faf4cc60] Fix selecting the same room multiple times
[fb3391da] Move room details presentation responsibility to the RoomFlowCoordinator. Fixed invitation flows.
[fa2a68d9] Rename RoomTimelineFlowCoordinator -> RoomFlowCoordinator
[0c9c06b5] Start moving things away from the RoomScreenCoordinator and into the RoomTimelineFlowCoordinator
[86cbbdcc] Introduce a RoomTimelineFlowCoordinator to deal with timeline related operations
[9b2381be] Introduce the FlowCoordinatorProtocol
This commit is contained in:
Stefan Ceriu
2023-05-18 12:45:34 +03:00
committed by Stefan Ceriu
parent f2b7faa183
commit 0082aba9d4
12 changed files with 715 additions and 334 deletions
@@ -0,0 +1,22 @@
//
// 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 Foundation
@MainActor
protocol FlowCoordinatorProtocol {
func handleAppRoute(_ appRoute: AppRoute, animated: Bool)
}
@@ -19,7 +19,9 @@ import Foundation
import URLRouting
enum AppRoute {
case roomList
case room(roomID: String)
case roomDetails(roomID: String)
}
struct AppRouterManager {
@@ -0,0 +1,480 @@
//
// 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 Combine
import Foundation
import SwiftState
enum RoomFlowCoordinatorAction: Equatable {
case presentedRoom(String)
case dismissedRoom
}
class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let userSession: UserSessionProtocol
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
private let navigationSplitCoordinator: NavigationSplitCoordinator
private let emojiProvider: EmojiProviderProtocol
private let stateMachine: StateMachine<State, Event> = .init(state: .initial)
private var cancellables: Set<AnyCancellable> = .init()
private let actionsSubject: PassthroughSubject<RoomFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<RoomFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var roomProxy: RoomProxyProtocol?
private var timelineController: RoomTimelineControllerProtocol?
init(userSession: UserSessionProtocol,
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
navigationStackCoordinator: NavigationStackCoordinator,
navigationSplitCoordinator: NavigationSplitCoordinator,
emojiProvider: EmojiProviderProtocol) {
self.userSession = userSession
self.roomTimelineControllerFactory = roomTimelineControllerFactory
self.navigationStackCoordinator = navigationStackCoordinator
self.navigationSplitCoordinator = navigationSplitCoordinator
self.emojiProvider = emojiProvider
setupStateMachine()
}
// MARK: - 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))
case .roomDetails(let roomID):
stateMachine.tryEvent(.presentRoomDetails(roomID: roomID), userInfo: EventUserInfo(animated: animated))
case .roomList:
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
}
}
// MARK: - Private
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func setupStateMachine() {
stateMachine.addRouteMapping { event, fromState, _ in
switch (event, fromState) {
case (.presentRoom(let roomID), _):
return .room(roomID: roomID)
case (.dismissRoom, .room):
return .initial
case (.presentRoomDetails(let roomID), .initial):
return .roomDetails(roomID: roomID)
case (.presentRoomDetails(let roomID), .room):
return .roomDetails(roomID: roomID)
case (.presentRoomDetails(let roomID), .roomDetails):
return .roomDetails(roomID: roomID)
case (.dismissRoomDetails, .roomDetails(let roomID)):
return .room(roomID: roomID)
case (.dismissRoom, .roomDetails):
return .initial
case (.presentMediaViewer(let file, let title), .room(let roomID)):
return .mediaViewer(roomID: roomID, file: file, title: title)
case (.dismissMediaViewer, .mediaViewer(let roomID, _, _)):
return .room(roomID: roomID)
case (.presentReportContent(let itemID, let senderID), .room(let roomID)):
return .reportContent(roomID: roomID, itemID: itemID, senderID: senderID)
case (.dismissReportContent, .reportContent(let roomID, _, _)):
return .room(roomID: roomID)
case (.presentMediaUploadPicker(let source), .room(let roomID)):
return .mediaUploadPicker(roomID: roomID, source: source)
case (.dismissMediaUploadPicker, .mediaUploadPicker(let roomID, _)):
return .room(roomID: roomID)
case (.presentMediaUploadPreview(let fileURL), .mediaUploadPicker(let roomID, _)):
return .mediaUploadPreview(roomID: roomID, fileURL: fileURL)
case (.presentMediaUploadPreview(let fileURL), .room(let roomID)):
return .mediaUploadPreview(roomID: roomID, fileURL: fileURL)
case (.dismissMediaUploadPreview, .mediaUploadPreview(let roomID, _)):
return .room(roomID: roomID)
case (.presentEmojiPicker(let itemID), .room(let roomID)):
return .emojiPicker(roomID: roomID, itemID: itemID)
case (.dismissEmojiPicker, .emojiPicker(let roomID, _)):
return .room(roomID: roomID)
default:
return nil
}
}
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
guard let self else { return }
let animated = (context.userInfo as? EventUserInfo)?.animated ?? true
switch (context.fromState, context.event, context.toState) {
case (_, .presentRoom(let roomID), .room):
presentRoom(roomID, animated: animated)
case (.room, .dismissRoom, .initial):
dismissRoom(animated: animated)
case (.initial, .presentRoomDetails, .roomDetails(let roomID)),
(.room, .presentRoomDetails, .roomDetails(let roomID)),
(.roomDetails, .presentRoomDetails, .roomDetails(let roomID)):
Task {
await self.presentRoomDetails(roomID: roomID, animated: animated)
}
case (.roomDetails, .dismissRoomDetails, .room):
break
case (.roomDetails, .dismissRoom, .initial):
dismissRoom(animated: animated)
case (.room, .presentMediaViewer, .mediaViewer(_, let file, let title)):
presentMediaViewer(file, title: title)
case (.mediaViewer, .dismissMediaViewer, .room):
break
case (.room, .presentReportContent, .reportContent(_, let itemID, let senderID)):
presentReportContent(for: itemID, from: senderID)
case (.reportContent, .dismissReportContent, .room):
break
case (.room, .presentMediaUploadPicker, .mediaUploadPicker(_, let source)):
presentMediaUploadPickerWithSource(source)
case (.mediaUploadPicker, .dismissMediaUploadPicker, .room):
break
case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)):
presentMediaUploadPreviewScreen(for: fileURL)
case (.room, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)):
presentMediaUploadPreviewScreen(for: fileURL)
case (.mediaUploadPreview, .dismissMediaUploadPreview, .room):
break
case (.room, .presentEmojiPicker, .emojiPicker(_, let itemID)):
presentEmojiPicker(for: itemID)
case (.emojiPicker, .dismissEmojiPicker, .room):
break
default:
fatalError("Unknown transition: \(context)")
}
}
stateMachine.addAnyHandler(.any => .any) { context in
if let event = context.event {
MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(event)`")
} else {
MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`")
}
}
stateMachine.addErrorHandler { context in
fatalError("Failed transition with context: \(context)")
}
}
private func presentRoom(_ roomID: String, animated: Bool) {
Task {
await asyncPresentRoom(roomID, animated: animated)
}
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func asyncPresentRoom(_ roomID: String, animated: Bool) async {
if let roomProxy, roomProxy.id == roomID {
navigationStackCoordinator.popToRoot()
return
}
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Invalid room identifier: \(roomID)")
stateMachine.tryEvent(.dismissRoom)
return
}
actionsSubject.send(.presentedRoom(roomID))
self.roomProxy = roomProxy
let userId = userSession.clientProxy.userID
let timelineItemFactory = RoomTimelineItemFactory(userID: userId,
mediaProvider: userSession.mediaProvider,
attributedStringBuilder: AttributedStringBuilder(),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userId))
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(userId: userId,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider)
self.timelineController = timelineController
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
emojiProvider: emojiProvider)
let coordinator = RoomScreenCoordinator(parameters: parameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentRoomDetails:
stateMachine.tryEvent(.presentRoomDetails(roomID: roomID))
case .presentMediaViewer(let file, let title):
stateMachine.tryEvent(.presentMediaViewer(file: file, title: title))
case .presentReportContent(let itemID, let senderID):
stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID))
case .presentMediaUploadPicker(let source):
stateMachine.tryEvent(.presentMediaUploadPicker(source: source))
case .presentMediaUploadPreviewScreen(let url):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url))
case .presentEmojiPicker(let itemID):
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID))
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
// 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)
}
}
private func dismissRoom(animated: Bool) {
navigationStackCoordinator.popToRoot(animated: true)
navigationSplitCoordinator.setDetailCoordinator(nil)
roomProxy = nil
actionsSubject.send(.dismissedRoom)
}
private func presentRoomDetails(roomID: String, animated: Bool) async {
if roomProxy?.id != roomID {
await asyncPresentRoom(roomID, animated: true)
}
guard let roomProxy else {
fatalError()
}
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: roomProxy,
mediaProvider: userSession.mediaProvider,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy))
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.pop()
case .leftRoom:
self?.dismissRoom(animated: animated)
}
}
navigationStackCoordinator.push(coordinator) { [weak self] in
guard let self else { return }
if case .roomDetails = stateMachine.state {
stateMachine.tryEvent(.dismissRoomDetails)
}
}
}
private func presentMediaViewer(_ file: MediaFileHandleProxy, title: String?) {
let params = FilePreviewScreenCoordinatorParameters(mediaFile: file, title: title)
let coordinator = FilePreviewScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.pop()
}
}
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissMediaViewer)
}
}
private func presentReportContent(for itemID: String, from senderID: String) {
guard let roomProxy else {
fatalError()
}
let navigationCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationCoordinator)
let parameters = ReportContentScreenCoordinatorParameters(itemID: itemID,
senderID: senderID,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController)
let coordinator = ReportContentScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] completion in
self?.navigationStackCoordinator.setSheetCoordinator(nil)
switch completion {
case .cancel:
break
case .finish:
self?.showSuccess(label: L10n.commonReportSubmitted)
}
}
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
self?.stateMachine.tryEvent(.dismissReportContent)
}
}
private func presentMediaUploadPickerWithSource(_ source: MediaPickerScreenSource) {
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
self?.stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url))
}
}
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
if case .mediaUploadPicker = self?.stateMachine.state {
self?.stateMachine.tryEvent(.dismissMediaUploadPicker)
}
}
}
private func presentMediaUploadPreviewScreen(for url: URL) {
guard let roomProxy else {
fatalError()
}
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController,
roomProxy: roomProxy,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(),
title: url.lastPathComponent,
url: url)
let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) { [weak self] action in
switch action {
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
self?.stateMachine.tryEvent(.dismissMediaUploadPreview)
}
}
private func presentEmojiPicker(for itemId: String) {
let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator()
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider,
itemId: itemId)
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case let .emojiSelected(emoji: emoji, itemId: itemId):
MXLog.debug("Selected \(emoji) for \(itemId)")
self?.navigationStackCoordinator.setSheetCoordinator(nil)
Task {
await self?.timelineController?.sendReaction(emoji, to: itemId)
}
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
emojiPickerNavigationStackCoordinator.setRootCoordinator(coordinator)
emojiPickerNavigationStackCoordinator.presentationDetents = [.medium, .large]
navigationStackCoordinator.setSheetCoordinator(emojiPickerNavigationStackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissEmojiPicker)
}
}
private func showSuccess(label: String) {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark"))
}
}
private extension RoomFlowCoordinator {
enum State: StateType {
case initial
case room(roomID: String)
case mediaViewer(roomID: String, file: MediaFileHandleProxy, title: String?)
case reportContent(roomID: String, itemID: String, senderID: String)
case roomDetails(roomID: String)
case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource)
case mediaUploadPreview(roomID: String, fileURL: URL)
case emojiPicker(roomID: String, itemID: String)
}
struct EventUserInfo {
let animated: Bool
}
enum Event: EventType {
case presentRoom(roomID: String)
case dismissRoom
case presentMediaViewer(file: MediaFileHandleProxy, title: String?)
case dismissMediaViewer
case presentReportContent(itemID: String, senderID: String)
case dismissReportContent
case presentRoomDetails(roomID: String)
case dismissRoomDetails
case presentMediaUploadPicker(source: MediaPickerScreenSource)
case dismissMediaUploadPicker
case presentMediaUploadPreview(fileURL: URL)
case dismissMediaUploadPreview
case presentEmojiPicker(itemID: String)
case dismissEmojiPicker
}
}
@@ -22,15 +22,16 @@ enum UserSessionFlowCoordinatorAction {
case clearCache
}
class UserSessionFlowCoordinator: CoordinatorProtocol {
private let stateMachine: UserSessionFlowCoordinatorStateMachine
private var cancellables: Set<AnyCancellable> = .init()
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let userSession: UserSessionProtocol
private let navigationSplitCoordinator: NavigationSplitCoordinator
private let bugReportService: BugReportServiceProtocol
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
private let emojiProvider: EmojiProviderProtocol = EmojiProvider()
private let stateMachine: UserSessionFlowCoordinatorStateMachine
private let roomFlowCoordinator: RoomFlowCoordinator
private var cancellables: Set<AnyCancellable> = .init()
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
private let detailNavigationStackCoordinator: NavigationStackCoordinator
@@ -52,7 +53,23 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
roomFlowCoordinator = RoomFlowCoordinator(userSession: userSession,
roomTimelineControllerFactory: roomTimelineControllerFactory,
navigationStackCoordinator: detailNavigationStackCoordinator,
navigationSplitCoordinator: navigationSplitCoordinator,
emojiProvider: EmojiProvider())
setupStateMachine()
roomFlowCoordinator.actions.sink { action in
switch action {
case .presentedRoom(let roomID):
self.stateMachine.processEvent(.selectRoom(roomId: roomID))
case .dismissedRoom:
self.stateMachine.processEvent(.deselectRoom)
}
}
.store(in: &cancellables)
}
func start() {
@@ -64,6 +81,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
stateMachine.isDisplayingRoomScreen(withRoomId: roomId)
}
// MARK: - FlowCoordinatorProtocol
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch stateMachine.state {
@@ -72,9 +91,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
case .roomList, .initial:
break
}
switch appRoute {
case .room(let roomID):
stateMachine.processEvent(.selectRoom(roomId: roomID), userInfo: .init(animated: animated))
case .room, .roomDetails, .roomList:
roomFlowCoordinator.handleAppRoute(appRoute, animated: true)
}
}
@@ -84,20 +104,20 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in
guard let self else { return }
let animated = (context.userInfo as? EventUserInfo)?.animated ?? true
let animated = (context.userInfo as? UserSessionFlowCoordinatorStateMachine.EventUserInfo)?.animated ?? true
switch (context.fromState, context.event, context.toState) {
case (.initial, .start, .roomList):
self.presentHomeScreen()
case(.roomList(let currentRoomId), .selectRoom, .roomList(let selectedRoomId)):
guard let selectedRoomId,
selectedRoomId != currentRoomId else {
return
}
self.presentRoomWithIdentifier(selectedRoomId, animated: animated)
case(.roomList, .selectRoom, .roomList):
break
case(.roomList, .deselectRoom, .roomList):
break
case (.invitesScreen, .selectRoom, .invitesScreen):
break
case (.invitesScreen, .deselectRoom, .invitesScreen):
break
case (.roomList, .showSessionVerificationScreen, .sessionVerificationScreen):
self.presentSessionVerification(animated: animated)
@@ -123,16 +143,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
self.presentInvitesList(animated: animated)
case (.invitesScreen, .closedInvitesScreen, .roomList):
break
case (.invitesScreen, .selectRoom(let roomId), .invitesScreen(let selectedRoomId)) where roomId == selectedRoomId:
self.presentRoomWithIdentifier(roomId, animated: animated)
case (.invitesScreen, .deselectRoom, .invitesScreen):
break
case (.roomList(let currentRoomId), .selectRoomDetails(let roomId), .roomList) where currentRoomId == roomId:
break
case (.roomList, .selectRoomDetails(let roomId), .roomList(let selectedRoomId)) where roomId == selectedRoomId:
self.presentRoomDetails(roomIdentifier: roomId, animated: animated)
default:
fatalError("Unknown transition: \(context)")
}
@@ -143,6 +154,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
}
}
// swiftlint:disable:next cyclomatic_complexity
private func presentHomeScreen() {
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(),
@@ -154,12 +166,15 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .presentRoom(let roomIdentifier):
self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier))
case .presentRoomDetails(let roomIdentifier):
self.stateMachine.processEvent(.selectRoomDetails(roomId: roomIdentifier))
case .roomLeft(let roomIdentifier):
self.deselectRoomIfNeeded(roomIdentifier: roomIdentifier)
case .presentRoom(let roomID):
self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true)
case .presentRoomDetails(let roomID):
self.roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .roomLeft(let roomID):
if case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state,
selectedRoomId == roomID {
self.roomFlowCoordinator.handleAppRoute(.roomList, animated: true)
}
case .presentSettingsScreen:
self.stateMachine.processEvent(.showSettingsScreen)
case .presentFeedbackScreen:
@@ -178,114 +193,6 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
sidebarNavigationStackCoordinator.setRootCoordinator(coordinator)
}
// MARK: Rooms
private func presentRoomWithIdentifier(_ roomIdentifier: String, animated: Bool = true) {
Task { @MainActor in
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
stateMachine.processEvent(.deselectRoom)
return
}
let userId = userSession.clientProxy.userID
let timelineItemFactory = RoomTimelineItemFactory(userID: userId,
mediaProvider: userSession.mediaProvider,
attributedStringBuilder: AttributedStringBuilder(),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userId))
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(userId: userId,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider)
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
emojiProvider: emojiProvider,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy))
let coordinator = RoomScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
switch action {
case .leftRoom:
self?.dismissRoom()
}
}
detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in
guard let self else { return }
// 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 .roomList(selectedRoomId) where selectedRoomId == roomIdentifier,
let .invitesScreen(selectedRoomId) where selectedRoomId == roomIdentifier:
self.stateMachine.processEvent(.deselectRoom)
self.detailNavigationStackCoordinator.setRootCoordinator(nil)
default:
break
}
}
if navigationSplitCoordinator.detailCoordinator == nil {
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated)
}
}
}
private func deselectRoomIfNeeded(roomIdentifier: String) {
guard
case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state,
selectedRoomId == roomIdentifier
else {
return
}
stateMachine.processEvent(.deselectRoom)
navigationSplitCoordinator.setDetailCoordinator(nil)
}
private func dismissRoom() {
detailNavigationStackCoordinator.popToRoot(animated: true)
navigationSplitCoordinator.setDetailCoordinator(nil)
}
private func presentRoomDetails(roomIdentifier: String, animated: Bool = true) {
Task {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
return
}
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
roomProxy: roomProxy,
mediaProvider: userSession.mediaProvider,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy))
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case .cancel, .leftRoom:
self?.stateMachine.processEvent(.deselectRoom)
self?.detailNavigationStackCoordinator.setRootCoordinator(nil)
}
}
detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in
self?.deselectRoomIfNeeded(roomIdentifier: roomIdentifier)
}
if navigationSplitCoordinator.detailCoordinator == nil {
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated)
}
}
}
// MARK: Settings
private func presentSettingsScreen(animated: Bool) {
@@ -352,9 +259,9 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
switch action {
case .close:
self.navigationSplitCoordinator.setSheetCoordinator(nil)
case .openRoom(let identifier):
case .openRoom(let roomID):
self.navigationSplitCoordinator.setSheetCoordinator(nil)
self.stateMachine.processEvent(.selectRoom(roomId: identifier))
self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true)
}
}
.store(in: &cancellables)
@@ -400,8 +307,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
coordinator.actions
.sink { [weak self] action in
switch action {
case .openRoom(let roomId):
self?.stateMachine.processEvent(.selectRoom(roomId: roomId))
case .openRoom(let roomID):
self?.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true)
}
}
.store(in: &cancellables)
@@ -41,6 +41,10 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing invites list screen
case invitesScreen(selectedRoomId: String?)
}
struct EventUserInfo {
let animated: Bool
}
/// Events that can be triggered on the AppCoordinator state machine
enum Event: EventType {
@@ -77,9 +81,6 @@ class UserSessionFlowCoordinatorStateMachine {
case showInvitesScreen
/// The invites screen has been dismissed
case closedInvitesScreen
/// Request presentation of the settings of a specific room
case selectRoomDetails(roomId: String)
}
private let stateMachine: StateMachine<State, Event>
@@ -101,8 +102,12 @@ class UserSessionFlowCoordinatorStateMachine {
switch (event, fromState) {
case (.selectRoom(let roomId), .roomList):
return .roomList(selectedRoomId: roomId)
case (.selectRoom(let roomId), .invitesScreen):
return .invitesScreen(selectedRoomId: roomId)
case (.deselectRoom, .roomList):
return .roomList(selectedRoomId: nil)
case (.deselectRoom, .invitesScreen):
return .invitesScreen(selectedRoomId: nil)
case (.showSettingsScreen, .roomList(let selectedRoomId)):
return .settingsScreen(selectedRoomId: selectedRoomId)
@@ -128,14 +133,7 @@ class UserSessionFlowCoordinatorStateMachine {
return .invitesScreen(selectedRoomId: selectedRoomId)
case (.closedInvitesScreen, .invitesScreen(let selectedRoomId)):
return .roomList(selectedRoomId: selectedRoomId)
case (.selectRoom(let roomId), .invitesScreen):
return .invitesScreen(selectedRoomId: roomId)
case (.deselectRoom, .invitesScreen):
return .invitesScreen(selectedRoomId: nil)
case (.selectRoomDetails(let roomId), .roomList):
return .roomList(selectedRoomId: roomId)
default:
return nil
}
@@ -176,7 +174,3 @@ class UserSessionFlowCoordinatorStateMachine {
}
}
}
struct EventUserInfo {
let animated: Bool
}
@@ -14,30 +14,34 @@
// limitations under the License.
//
import Combine
import SwiftUI
struct RoomScreenCoordinatorParameters {
let navigationStackCoordinator: NavigationStackCoordinator
let roomProxy: RoomProxyProtocol
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let emojiProvider: EmojiProviderProtocol
let userDiscoveryService: UserDiscoveryServiceProtocol
}
enum RoomScreenCoordinatorAction {
case leftRoom
case presentMediaViewer(file: MediaFileHandleProxy, title: String?)
case presentReportContent(itemID: String, senderID: String)
case presentMediaUploadPicker(MediaPickerScreenSource)
case presentMediaUploadPreviewScreen(URL)
case presentRoomDetails
case presentEmojiPicker(itemID: String)
}
final class RoomScreenCoordinator: CoordinatorProtocol {
private var parameters: RoomScreenCoordinatorParameters
private var viewModel: RoomScreenViewModelProtocol
private var navigationStackCoordinator: NavigationStackCoordinator {
parameters.navigationStackCoordinator
}
var callback: ((RoomScreenCoordinatorAction) -> Void)?
private let actionsSubject: PassthroughSubject<RoomScreenCoordinatorAction, Never> = .init()
var actions: AnyPublisher<RoomScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: RoomScreenCoordinatorParameters) {
self.parameters = parameters
@@ -55,21 +59,21 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
switch action {
case .displayRoomDetails:
self.displayRoomDetails()
case .displayMediaFile(let file, let title):
self.displayFilePreview(for: file, with: title)
actionsSubject.send(.presentRoomDetails)
case .displayMediaViewer(let file, let title):
actionsSubject.send(.presentMediaViewer(file: file, title: title))
case .displayEmojiPicker(let itemID):
self.displayEmojiPickerScreen(for: itemID)
actionsSubject.send(.presentEmojiPicker(itemID: itemID))
case .displayReportContent(let itemID, let senderID):
self.displayReportContent(for: itemID, from: senderID)
actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID))
case .displayCameraPicker:
self.displayMediaPickerWithSource(.camera)
actionsSubject.send(.presentMediaUploadPicker(.camera))
case .displayMediaPicker:
self.displayMediaPickerWithSource(.photoLibrary)
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary))
case .displayDocumentPicker:
self.displayMediaPickerWithSource(.documents)
actionsSubject.send(.presentMediaUploadPicker(.documents))
case .displayMediaUploadPreviewScreen(let url):
self.displayMediaUploadPreviewScreenForFile(at: url)
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
}
}
}
@@ -81,123 +85,4 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(RoomScreen(context: viewModel.context))
}
// MARK: - Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) {
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
self?.displayMediaUploadPreviewScreenForFile(at: url)
}
}
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func displayMediaUploadPreviewScreenForFile(at url: URL) {
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController,
roomProxy: parameters.roomProxy,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(),
title: url.lastPathComponent,
url: url)
let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) { [weak self] action in
switch action {
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func displayFilePreview(for file: MediaFileHandleProxy, with title: String?) {
let params = FilePreviewScreenCoordinatorParameters(mediaFile: file, title: title)
let coordinator = FilePreviewScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] _ in
self?.navigationStackCoordinator.pop()
}
navigationStackCoordinator.push(coordinator)
}
private func displayEmojiPickerScreen(for itemId: String) {
let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator()
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: parameters.emojiProvider,
itemId: itemId)
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case let .emojiSelected(emoji: emoji, itemId: itemId):
MXLog.debug("Selected \(emoji) for \(itemId)")
self?.navigationStackCoordinator.setSheetCoordinator(nil)
Task {
await self?.parameters.timelineController.sendReaction(emoji, to: itemId)
}
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
emojiPickerNavigationStackCoordinator.setRootCoordinator(coordinator)
emojiPickerNavigationStackCoordinator.presentationDetents = [.medium, .large]
navigationStackCoordinator.setSheetCoordinator(emojiPickerNavigationStackCoordinator)
}
private func displayRoomDetails() {
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider,
userDiscoveryService: parameters.userDiscoveryService)
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.pop()
case .leftRoom:
self?.callback?(.leftRoom)
}
}
navigationStackCoordinator.push(coordinator)
}
private func displayReportContent(for itemID: String, from senderID: String) {
let navigationCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationCoordinator)
let parameters = ReportContentScreenCoordinatorParameters(itemID: itemID,
senderID: senderID,
roomProxy: parameters.roomProxy,
userIndicatorController: userIndicatorController)
let coordinator = ReportContentScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] completion in
self?.navigationStackCoordinator.setSheetCoordinator(nil)
switch completion {
case .cancel: break
case .finish:
self?.showSuccess(label: L10n.commonReportSubmitted)
}
}
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func showSuccess(label: String) {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark"))
}
}
@@ -20,7 +20,7 @@ import UIKit
enum RoomScreenViewModelAction {
case displayRoomDetails
case displayMediaFile(file: MediaFileHandleProxy, title: String?)
case displayMediaViewer(file: MediaFileHandleProxy, title: String?)
case displayEmojiPicker(itemID: String)
case displayReportContent(itemID: String, senderID: String)
case displayCameraPicker
@@ -154,7 +154,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch action {
case .displayMediaFile(let file, let title):
callback?(.displayMediaFile(file: file, title: title))
callback?(.displayMediaViewer(file: file, title: title))
case .none:
break
}
@@ -159,23 +159,19 @@ class MockScreen: Identifiable {
return OnboardingCoordinator()
case .roomPlainNoAvatar:
let navigationStackCoordinator = NavigationStackCoordinator()
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomEncryptedWithAvatar:
let navigationStackCoordinator = NavigationStackCoordinator()
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
@@ -183,12 +179,10 @@ class MockScreen: Identifiable {
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
@@ -199,12 +193,10 @@ class MockScreen: Identifiable {
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk]
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -215,12 +207,10 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -231,12 +221,10 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -248,12 +236,10 @@ class MockScreen: Identifiable {
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -264,12 +250,10 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
emojiProvider: EmojiProvider(),
userDiscoveryService: UserDiscoveryServiceMock())
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)