element-x-ios/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift

544 lines
24 KiB
Swift

//
// 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, isRoot: true)
case (.presentRoomDetails(let roomID), .room(let currentRoomID)):
return .roomDetails(roomID: roomID, isRoot: roomID != currentRoomID)
case (.presentRoomDetails(let roomID), .roomDetails(let currentRoomID, _)):
return .roomDetails(roomID: roomID, isRoot: roomID != currentRoomID)
case (.dismissRoomDetails, .roomDetails(let roomID, _)):
return .room(roomID: roomID)
case (.dismissRoom, .roomDetails):
return .initial
case (.presentRoomMemberDetails(let member), .room(let roomID)):
return .roomMemberDetails(roomID: roomID, member: member)
case (.dismissRoomMemberDetails, .roomMemberDetails(let roomID, _)):
return .room(roomID: roomID)
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 (.roomDetails(roomID: let currentRoomID, true), .presentRoom(let roomID), .room) where currentRoomID == roomID:
dismissRoom(animated: animated)
presentRoom(roomID, animated: animated)
case (_, .presentRoom(let roomID), .room):
presentRoom(roomID, animated: animated)
case (.room, .dismissRoom, .initial):
dismissRoom(animated: animated)
case (.roomDetails(let currentRoomID, _), .presentRoomDetails, .roomDetails(let roomID, _)) where currentRoomID == roomID:
break
case (.initial, .presentRoomDetails, .roomDetails(let roomID, let isRoot)),
(.room, .presentRoomDetails, .roomDetails(let roomID, let isRoot)),
(.roomDetails, .presentRoomDetails, .roomDetails(let roomID, let isRoot)):
self.presentRoomDetails(roomID: roomID, isRoot: isRoot, animated: animated)
case (.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
case (.room, .presentRoomMemberDetails, .roomMemberDetails(_, let member)):
presentRoomMemberDetails(member: member.value)
case (.roomMemberDetails, .dismissRoomMemberDetails, .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))
case .presentRoomMemberDetails(member: let member):
stateMachine.tryEvent(.presentRoomMemberDetails(member: .init(value: member)))
}
}
.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: animated)
navigationSplitCoordinator.setDetailCoordinator(nil, animated: animated)
roomProxy = nil
timelineController = nil
actionsSubject.send(.dismissedRoom)
}
private func presentRoomDetails(roomID: String, isRoot: Bool, animated: Bool) {
Task {
await asyncPresentRoomDetails(roomID: roomID, isRoot: isRoot, animated: animated)
}
}
private func asyncPresentRoomDetails(roomID: String, isRoot: Bool, animated: Bool) async {
if isRoot {
roomProxy = await userSession.clientProxy.roomForIdentifier(roomID)
} else {
await asyncPresentRoom(roomID, animated: animated)
}
guard let roomProxy else {
MXLog.error("Invalid room identifier: \(roomID)")
stateMachine.tryEvent(.dismissRoom)
return
}
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 .leftRoom:
self?.dismissRoom(animated: animated)
}
}
if isRoot {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
guard let self else { return }
if case .roomDetails(let detailsRoomID, _) = stateMachine.state, detailsRoomID == roomID {
stateMachine.tryEvent(.dismissRoom)
}
}
if navigationSplitCoordinator.detailCoordinator == nil {
navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated)
}
actionsSubject.send(.presentedRoom(roomID))
} else {
navigationStackCoordinator.push(coordinator, animated: animated) { [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 presentRoomMemberDetails(member: RoomMemberProxyProtocol) {
let params = RoomMemberDetailsScreenCoordinatorParameters(roomMemberProxy: member, mediaProvider: userSession.mediaProvider)
let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoomMemberDetails)
}
}
private func showSuccess(label: String) {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark"))
}
}
private extension RoomFlowCoordinator {
struct HashableRoomMemberWrapper: Hashable {
let value: RoomMemberProxyProtocol
static func == (lhs: HashableRoomMemberWrapper, rhs: HashableRoomMemberWrapper) -> Bool {
lhs.value.userID == rhs.value.userID
}
func hash(into hasher: inout Hasher) {
hasher.combine(value.userID)
}
}
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, isRoot: Bool)
case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource)
case mediaUploadPreview(roomID: String, fileURL: URL)
case emojiPicker(roomID: String, itemID: String)
case roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper)
}
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
case presentRoomMemberDetails(member: HashableRoomMemberWrapper)
case dismissRoomMemberDetails
}
}