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)
|
||||
|
||||
237
ElementX/Sources/FlowCoordinators/StartChatFlowCoordinator.swift
Normal file
237
ElementX/Sources/FlowCoordinators/StartChatFlowCoordinator.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ extension JoinedRoomProxyMock {
|
||||
|
||||
powerLevelsReturnValue = .success(powerLevelsProxyMock)
|
||||
|
||||
inviteUserIDReturnValue = .success(())
|
||||
kickUserReasonReturnValue = .success(())
|
||||
banUserReasonReturnValue = .success(())
|
||||
unbanUserReturnValue = .success(())
|
||||
|
||||
@@ -270,8 +270,11 @@ enum A11yIdentifiers {
|
||||
}
|
||||
|
||||
struct CreateRoomScreen {
|
||||
let create = "create_room-create"
|
||||
let roomAvatar = "create_room-room_avatar"
|
||||
let roomName = "create_room-room_name"
|
||||
let roomTopic = "create_room-room_topic"
|
||||
let mediaPicker = "create_room-media_picker"
|
||||
}
|
||||
|
||||
struct PollFormScreen {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// Copyright 2022-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 SwiftUI
|
||||
|
||||
struct CreateRoomCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let createRoomParameters: CurrentValuePublisher<CreateRoomFlowParameters, Never>
|
||||
let selectedUsers: [UserProfileProxy]
|
||||
let appSettings: AppSettings
|
||||
let analytics: AnalyticsService
|
||||
}
|
||||
|
||||
enum CreateRoomCoordinatorAction {
|
||||
case openRoom(withIdentifier: String)
|
||||
case updateSelectedUsers([UserProfileProxy])
|
||||
case updateDetails(CreateRoomFlowParameters)
|
||||
case displayMediaPickerWithMode(MediaPickerScreenMode)
|
||||
case removeImage
|
||||
}
|
||||
|
||||
final class CreateRoomCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: CreateRoomViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<CreateRoomCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<CreateRoomCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: CreateRoomCoordinatorParameters) {
|
||||
viewModel = CreateRoomViewModel(userSession: parameters.userSession,
|
||||
createRoomParameters: parameters.createRoomParameters,
|
||||
selectedUsers: parameters.selectedUsers,
|
||||
analytics: parameters.analytics,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
appSettings: parameters.appSettings)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .updateSelectedUsers(let users):
|
||||
actionsSubject.send(.updateSelectedUsers(users))
|
||||
case .openRoom(let identifier):
|
||||
actionsSubject.send(.openRoom(withIdentifier: identifier))
|
||||
case .updateDetails(let details):
|
||||
actionsSubject.send(.updateDetails(details))
|
||||
case .displayCameraPicker:
|
||||
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single)))
|
||||
case .displayMediaPicker:
|
||||
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single)))
|
||||
case .removeImage:
|
||||
actionsSubject.send(.removeImage)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(CreateRoomScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// Copyright 2022-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 SwiftUI
|
||||
|
||||
struct CreateRoomScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let appSettings: AppSettings
|
||||
let analytics: AnalyticsService
|
||||
}
|
||||
|
||||
enum CreateRoomScreenCoordinatorAction {
|
||||
case createdRoom(JoinedRoomProxyProtocol)
|
||||
case displayMediaPickerWithMode(MediaPickerScreenMode)
|
||||
}
|
||||
|
||||
final class CreateRoomScreenCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: CreateRoomScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<CreateRoomScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<CreateRoomScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: CreateRoomScreenCoordinatorParameters) {
|
||||
viewModel = CreateRoomScreenViewModel(userSession: parameters.userSession,
|
||||
analytics: parameters.analytics,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
appSettings: parameters.appSettings)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .createdRoom(let roomProxy):
|
||||
actionsSubject.send(.createdRoom(roomProxy))
|
||||
case .displayCameraPicker:
|
||||
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single)))
|
||||
case .displayMediaPicker:
|
||||
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single)))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(CreateRoomScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
func updateAvatar(fileURL: URL) {
|
||||
viewModel.updateAvatar(fileURL: fileURL)
|
||||
}
|
||||
}
|
||||
@@ -16,28 +16,24 @@ enum CreateRoomScreenErrorType: Error {
|
||||
case unknown
|
||||
}
|
||||
|
||||
enum CreateRoomViewModelAction {
|
||||
case openRoom(withIdentifier: String)
|
||||
case updateSelectedUsers([UserProfileProxy])
|
||||
case updateDetails(CreateRoomFlowParameters)
|
||||
enum CreateRoomScreenViewModelAction {
|
||||
case createdRoom(JoinedRoomProxyProtocol)
|
||||
case displayMediaPicker
|
||||
case displayCameraPicker
|
||||
case removeImage
|
||||
}
|
||||
|
||||
struct CreateRoomViewState: BindableState {
|
||||
struct CreateRoomScreenViewState: BindableState {
|
||||
var roomName: String
|
||||
let serverName: String
|
||||
let isKnockingFeatureEnabled: Bool
|
||||
var selectedUsers: [UserProfileProxy]
|
||||
var aliasLocalPart: String
|
||||
var bindings: CreateRoomViewStateBindings
|
||||
var bindings: CreateRoomScreenViewStateBindings
|
||||
var avatarURL: URL?
|
||||
var canCreateRoom: Bool {
|
||||
!roomName.isEmpty && aliasErrors.isEmpty
|
||||
}
|
||||
|
||||
var aliasErrors: Set<CreateRoomAliasErrorState> = []
|
||||
var aliasErrors: Set<CreateRoomScreenAliasErrorState> = []
|
||||
var aliasErrorDescription: String? {
|
||||
if aliasErrors.contains(.alreadyExists) {
|
||||
L10n.errorRoomAddressAlreadyExists
|
||||
@@ -49,7 +45,7 @@ struct CreateRoomViewState: BindableState {
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateRoomViewStateBindings {
|
||||
struct CreateRoomScreenViewStateBindings {
|
||||
var roomTopic: String
|
||||
var isRoomPrivate: Bool
|
||||
var isKnockingOnly: Bool
|
||||
@@ -59,9 +55,8 @@ struct CreateRoomViewStateBindings {
|
||||
var alertInfo: AlertInfo<CreateRoomScreenErrorType>?
|
||||
}
|
||||
|
||||
enum CreateRoomViewAction {
|
||||
enum CreateRoomScreenViewAction {
|
||||
case createRoom
|
||||
case deselectUser(UserProfileProxy)
|
||||
case displayCameraPicker
|
||||
case displayMediaPicker
|
||||
case removeImage
|
||||
@@ -69,12 +64,12 @@ enum CreateRoomViewAction {
|
||||
case updateAliasLocalPart(String)
|
||||
}
|
||||
|
||||
enum CreateRoomAliasErrorState {
|
||||
enum CreateRoomScreenAliasErrorState {
|
||||
case alreadyExists
|
||||
case invalidSymbols
|
||||
}
|
||||
|
||||
extension Set<CreateRoomAliasErrorState> {
|
||||
extension Set<CreateRoomScreenAliasErrorState> {
|
||||
var errorDescription: String? {
|
||||
if contains(.alreadyExists) {
|
||||
return L10n.errorRoomAddressAlreadyExists
|
||||
@@ -10,83 +10,70 @@ import Combine
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
typealias CreateRoomViewModelType = StateStoreViewModel<CreateRoomViewState, CreateRoomViewAction>
|
||||
typealias CreateRoomScreenViewModelType = StateStoreViewModel<CreateRoomScreenViewState, CreateRoomScreenViewAction>
|
||||
|
||||
class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol {
|
||||
class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreenViewModelProtocol {
|
||||
struct Parameters {
|
||||
var name = ""
|
||||
var topic = ""
|
||||
var isRoomPrivate = true
|
||||
var isKnockingOnly = false
|
||||
var avatarImageMedia: MediaInfo?
|
||||
var aliasLocalPart: String?
|
||||
}
|
||||
|
||||
private let userSession: UserSessionProtocol
|
||||
private var createRoomParameters: CreateRoomFlowParameters
|
||||
private var parameters: Parameters
|
||||
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor
|
||||
private let analytics: AnalyticsService
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private var syncNameAndAlias = true
|
||||
@CancellableTask private var checkAliasAvailabilityTask: Task<Void, Never>?
|
||||
|
||||
private var actionsSubject: PassthroughSubject<CreateRoomViewModelAction, Never> = .init()
|
||||
private var actionsSubject: PassthroughSubject<CreateRoomScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<CreateRoomViewModelAction, Never> {
|
||||
var actions: AnyPublisher<CreateRoomScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
createRoomParameters: CurrentValuePublisher<CreateRoomFlowParameters, Never>,
|
||||
selectedUsers: [UserProfileProxy],
|
||||
initialParameters: Parameters = .init(),
|
||||
analytics: AnalyticsService,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appSettings: AppSettings) {
|
||||
let parameters = createRoomParameters.value
|
||||
|
||||
self.userSession = userSession
|
||||
self.createRoomParameters = parameters
|
||||
parameters = initialParameters
|
||||
mediaUploadingPreprocessor = MediaUploadingPreprocessor(appSettings: appSettings)
|
||||
self.analytics = analytics
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
let bindings = CreateRoomViewStateBindings(roomTopic: parameters.topic,
|
||||
isRoomPrivate: parameters.isRoomPrivate,
|
||||
isKnockingOnly: appSettings.knockingEnabled ? parameters.isKnockingOnly : false)
|
||||
let bindings = CreateRoomScreenViewStateBindings(roomTopic: parameters.topic,
|
||||
isRoomPrivate: parameters.isRoomPrivate,
|
||||
isKnockingOnly: appSettings.knockingEnabled ? parameters.isKnockingOnly : false)
|
||||
|
||||
super.init(initialViewState: CreateRoomViewState(roomName: parameters.name,
|
||||
serverName: userSession.clientProxy.userIDServerName ?? "",
|
||||
isKnockingFeatureEnabled: appSettings.knockingEnabled,
|
||||
selectedUsers: selectedUsers,
|
||||
aliasLocalPart: parameters.aliasLocalPart ?? roomAliasNameFromRoomDisplayName(roomName: parameters.name),
|
||||
bindings: bindings),
|
||||
super.init(initialViewState: CreateRoomScreenViewState(roomName: parameters.name,
|
||||
serverName: userSession.clientProxy.userIDServerName ?? "",
|
||||
isKnockingFeatureEnabled: appSettings.knockingEnabled,
|
||||
aliasLocalPart: parameters.aliasLocalPart ?? roomAliasNameFromRoomDisplayName(roomName: parameters.name),
|
||||
bindings: bindings),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
createRoomParameters
|
||||
.map(\.avatarImageMedia)
|
||||
.removeDuplicates { $0?.url == $1?.url }
|
||||
.sink { [weak self] mediaInfo in
|
||||
self?.createRoomParameters.avatarImageMedia = mediaInfo
|
||||
switch mediaInfo {
|
||||
case .image(_, let thumbnailURL, _):
|
||||
self?.state.avatarURL = thumbnailURL
|
||||
case nil:
|
||||
self?.state.avatarURL = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: CreateRoomViewAction) {
|
||||
override func process(viewAction: CreateRoomScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .createRoom:
|
||||
Task {
|
||||
await createRoom()
|
||||
}
|
||||
case .deselectUser(let user):
|
||||
state.selectedUsers.removeAll { $0.userID == user.userID }
|
||||
actionsSubject.send(.updateSelectedUsers(state.selectedUsers))
|
||||
Task { await createRoom() }
|
||||
case .displayCameraPicker:
|
||||
actionsSubject.send(.displayCameraPicker)
|
||||
case .displayMediaPicker:
|
||||
actionsSubject.send(.displayMediaPicker)
|
||||
case .removeImage:
|
||||
actionsSubject.send(.removeImage)
|
||||
parameters.avatarImageMedia = nil
|
||||
state.avatarURL = nil
|
||||
case .updateAliasLocalPart(let aliasLocalPart):
|
||||
state.aliasLocalPart = aliasLocalPart.lowercased()
|
||||
// If this has been called this means that the user wants a custom address not necessarily reflecting the name
|
||||
@@ -104,6 +91,32 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
}
|
||||
}
|
||||
|
||||
func updateAvatar(fileURL: URL) {
|
||||
showLoadingIndicator()
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
guard case let .success(maxUploadSize) = await userSession.clientProxy.maxMediaUploadSize else {
|
||||
MXLog.error("Failed to get max upload size")
|
||||
userIndicatorController.alertInfo = AlertInfo(id: .init())
|
||||
return
|
||||
}
|
||||
let mediaInfo = try await mediaUploadingPreprocessor.processMedia(at: fileURL, maxUploadSize: maxUploadSize).get()
|
||||
|
||||
switch mediaInfo {
|
||||
case .image(_, let thumbnailURL, _):
|
||||
parameters.avatarImageMedia = mediaInfo
|
||||
state.avatarURL = thumbnailURL
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
userIndicatorController.alertInfo = AlertInfo(id: .init())
|
||||
}
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupBindings() {
|
||||
@@ -135,7 +148,6 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
.sink { [weak self] state in
|
||||
guard let self else { return }
|
||||
updateParameters(state: state)
|
||||
actionsSubject.send(.updateDetails(createRoomParameters))
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -183,15 +195,15 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateParameters(state: CreateRoomViewState) {
|
||||
createRoomParameters.name = state.roomName
|
||||
createRoomParameters.topic = state.bindings.roomTopic
|
||||
createRoomParameters.isRoomPrivate = state.bindings.isRoomPrivate
|
||||
createRoomParameters.isKnockingOnly = state.bindings.isKnockingOnly
|
||||
private func updateParameters(state: CreateRoomScreenViewState) {
|
||||
parameters.name = state.roomName
|
||||
parameters.topic = state.bindings.roomTopic
|
||||
parameters.isRoomPrivate = state.bindings.isRoomPrivate
|
||||
parameters.isKnockingOnly = state.bindings.isKnockingOnly
|
||||
if state.isKnockingFeatureEnabled, !state.aliasLocalPart.isEmpty {
|
||||
createRoomParameters.aliasLocalPart = state.aliasLocalPart
|
||||
parameters.aliasLocalPart = state.aliasLocalPart
|
||||
} else {
|
||||
createRoomParameters.aliasLocalPart = nil
|
||||
parameters.aliasLocalPart = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +217,8 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
updateParameters(state: state)
|
||||
|
||||
// Better to double check the errors also when trying to create the room
|
||||
if state.isKnockingFeatureEnabled, !createRoomParameters.isRoomPrivate {
|
||||
guard let canonicalAlias = String.makeCanonicalAlias(aliasLocalPart: createRoomParameters.aliasLocalPart,
|
||||
if state.isKnockingFeatureEnabled, !parameters.isRoomPrivate {
|
||||
guard let canonicalAlias = String.makeCanonicalAlias(aliasLocalPart: parameters.aliasLocalPart,
|
||||
serverName: state.serverName),
|
||||
isRoomAliasFormatValid(alias: canonicalAlias) else {
|
||||
state.aliasErrors = [.invalidSymbols]
|
||||
@@ -226,7 +238,7 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
}
|
||||
|
||||
let avatarURL: URL?
|
||||
if let media = createRoomParameters.avatarImageMedia {
|
||||
if let media = parameters.avatarImageMedia {
|
||||
switch await userSession.clientProxy.uploadMedia(media) {
|
||||
case .success(let url):
|
||||
avatarURL = URL(string: url)
|
||||
@@ -251,17 +263,23 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
avatarURL = nil
|
||||
}
|
||||
|
||||
switch await userSession.clientProxy.createRoom(name: createRoomParameters.name,
|
||||
topic: createRoomParameters.topic.isBlank ? nil : createRoomParameters.topic,
|
||||
isRoomPrivate: createRoomParameters.isRoomPrivate,
|
||||
switch await userSession.clientProxy.createRoom(name: parameters.name,
|
||||
topic: parameters.topic.isBlank ? nil : parameters.topic,
|
||||
isRoomPrivate: parameters.isRoomPrivate,
|
||||
// As of right now we don't want to make private rooms with the knock rule
|
||||
isKnockingOnly: createRoomParameters.isRoomPrivate ? false : createRoomParameters.isKnockingOnly,
|
||||
userIDs: state.selectedUsers.map(\.userID),
|
||||
isKnockingOnly: parameters.isRoomPrivate ? false : parameters.isKnockingOnly,
|
||||
userIDs: [], // The invite users screen is shown next so we don't need to invite anyone right now.
|
||||
avatarURL: avatarURL,
|
||||
aliasLocalPart: createRoomParameters.isRoomPrivate ? nil : createRoomParameters.aliasLocalPart) {
|
||||
case .success(let roomId):
|
||||
aliasLocalPart: parameters.isRoomPrivate ? nil : parameters.aliasLocalPart) {
|
||||
case .success(let roomID):
|
||||
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
|
||||
state.bindings.alertInfo = AlertInfo(id: .failedCreatingRoom,
|
||||
title: L10n.commonError,
|
||||
message: L10n.screenStartChatErrorStartingChat)
|
||||
return
|
||||
}
|
||||
analytics.trackCreatedRoom(isDM: false)
|
||||
actionsSubject.send(.openRoom(withIdentifier: roomId))
|
||||
actionsSubject.send(.createdRoom(roomProxy))
|
||||
case .failure:
|
||||
state.bindings.alertInfo = AlertInfo(id: .failedCreatingRoom,
|
||||
title: L10n.commonError,
|
||||
@@ -271,7 +289,7 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
|
||||
|
||||
// MARK: Loading indicator
|
||||
|
||||
private static let loadingIndicatorIdentifier = "\(CreateRoomViewModel.self)-Loading"
|
||||
private static let loadingIndicatorIdentifier = "\(CreateRoomScreenViewModel.self)-Loading"
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
|
||||
@@ -7,9 +7,12 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol CreateRoomViewModelProtocol {
|
||||
var actions: AnyPublisher<CreateRoomViewModelAction, Never> { get }
|
||||
var context: CreateRoomViewModelType.Context { get }
|
||||
protocol CreateRoomScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<CreateRoomScreenViewModelAction, Never> { get }
|
||||
var context: CreateRoomScreenViewModelType.Context { get }
|
||||
|
||||
func updateAvatar(fileURL: URL)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct CreateRoomScreen: View {
|
||||
@ObservedObject var context: CreateRoomViewModel.Context
|
||||
@ObservedObject var context: CreateRoomScreenViewModel.Context
|
||||
@FocusState private var focus: Focus?
|
||||
|
||||
private enum Focus {
|
||||
@@ -52,7 +52,6 @@ struct CreateRoomScreen: View {
|
||||
.navigationTitle(L10n.screenCreateRoomTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.readFrame($frame)
|
||||
.alert(item: $context.alertInfo)
|
||||
.shouldScrollOnKeyboardDidShow(focus == .alias, to: Focus.alias)
|
||||
}
|
||||
@@ -109,6 +108,7 @@ struct CreateRoomScreen: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomAvatar)
|
||||
.confirmationDialog("", isPresented: $context.showAttachmentConfirmationDialog) {
|
||||
Button(L10n.actionTakePhoto) {
|
||||
context.send(viewAction: .displayCameraPicker)
|
||||
@@ -116,6 +116,8 @@ struct CreateRoomScreen: View {
|
||||
Button(L10n.actionChoosePhoto) {
|
||||
context.send(viewAction: .displayMediaPicker)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.mediaPicker)
|
||||
|
||||
if context.viewState.avatarURL != nil {
|
||||
Button(L10n.actionRemove, role: .destructive) {
|
||||
context.send(viewAction: .removeImage)
|
||||
@@ -134,32 +136,9 @@ struct CreateRoomScreen: View {
|
||||
} header: {
|
||||
Text(L10n.screenCreateRoomTopicLabel)
|
||||
.compoundListSectionHeader()
|
||||
} footer: {
|
||||
if !context.viewState.selectedUsers.isEmpty {
|
||||
selectedUsersSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var frame: CGRect = .zero
|
||||
@ScaledMetric private var invitedUserCellWidth: CGFloat = 72
|
||||
|
||||
private var selectedUsersSection: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 16) {
|
||||
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .deselectUser(user))
|
||||
}
|
||||
.frame(width: invitedUserCellWidth)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, ListRowPadding.horizontal)
|
||||
.padding(.vertical, 22)
|
||||
}
|
||||
.frame(width: frame.width)
|
||||
}
|
||||
|
||||
private var securitySection: some View {
|
||||
Section {
|
||||
ListRow(label: .default(title: L10n.screenCreateRoomPrivateOptionTitle,
|
||||
@@ -223,6 +202,7 @@ struct CreateRoomScreen: View {
|
||||
context.send(viewAction: .createRoom)
|
||||
}
|
||||
.disabled(!context.viewState.canCreateRoom)
|
||||
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.create)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,65 +212,43 @@ struct CreateRoomScreen: View {
|
||||
struct CreateRoom_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = {
|
||||
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
|
||||
let parameters = CreateRoomFlowParameters()
|
||||
let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie]
|
||||
|
||||
return CreateRoomViewModel(userSession: userSession,
|
||||
createRoomParameters: .init(parameters),
|
||||
selectedUsers: .init(selectedUsers),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}()
|
||||
|
||||
static let emtpyViewModel = {
|
||||
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
|
||||
let parameters = CreateRoomFlowParameters()
|
||||
return CreateRoomViewModel(userSession: userSession,
|
||||
createRoomParameters: .init(parameters),
|
||||
selectedUsers: .init([]),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
return CreateRoomScreenViewModel(userSession: userSession,
|
||||
initialParameters: .init(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}()
|
||||
|
||||
static let publicRoomViewModel = {
|
||||
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
|
||||
let parameters = CreateRoomFlowParameters(isRoomPrivate: false)
|
||||
let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie]
|
||||
ServiceLocator.shared.settings.knockingEnabled = true
|
||||
return CreateRoomViewModel(userSession: userSession,
|
||||
createRoomParameters: .init(parameters),
|
||||
selectedUsers: .init([]),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
return CreateRoomScreenViewModel(userSession: userSession,
|
||||
initialParameters: .init(isRoomPrivate: false),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}()
|
||||
|
||||
static let publicRoomInvalidAliasViewModel = {
|
||||
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
|
||||
let parameters = CreateRoomFlowParameters(isRoomPrivate: false, aliasLocalPart: "#:")
|
||||
ServiceLocator.shared.settings.knockingEnabled = true
|
||||
return CreateRoomViewModel(userSession: userSession,
|
||||
createRoomParameters: .init(parameters),
|
||||
selectedUsers: .init([]),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
return CreateRoomScreenViewModel(userSession: userSession,
|
||||
initialParameters: .init(isRoomPrivate: false, aliasLocalPart: "#:"),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}()
|
||||
|
||||
static let publicRoomExistingAliasViewModel = {
|
||||
let clientProxy = ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))
|
||||
clientProxy.isAliasAvailableReturnValue = .success(false)
|
||||
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
|
||||
let parameters = CreateRoomFlowParameters(isRoomPrivate: false, aliasLocalPart: "existing")
|
||||
ServiceLocator.shared.settings.knockingEnabled = true
|
||||
return CreateRoomViewModel(userSession: userSession,
|
||||
createRoomParameters: .init(parameters),
|
||||
selectedUsers: .init([]),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
return CreateRoomScreenViewModel(userSession: userSession,
|
||||
initialParameters: .init(isRoomPrivate: false, aliasLocalPart: "existing"),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
@@ -298,27 +256,22 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
|
||||
CreateRoomScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("Create Room")
|
||||
NavigationStack {
|
||||
CreateRoomScreen(context: emtpyViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Create Room without users")
|
||||
|
||||
NavigationStack {
|
||||
CreateRoomScreen(context: publicRoomViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Create Public Room")
|
||||
|
||||
NavigationStack {
|
||||
CreateRoomScreen(context: publicRoomInvalidAliasViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: publicRoomExistingAliasViewModel.context.$viewState.map { state in
|
||||
!state.aliasErrors.isEmpty
|
||||
})
|
||||
.snapshotPreferences(expect: publicRoomInvalidAliasViewModel.context.$viewState.map { !$0.aliasErrors.isEmpty })
|
||||
.previewDisplayName("Create Public Room, invalid alias")
|
||||
|
||||
NavigationStack {
|
||||
CreateRoomScreen(context: publicRoomExistingAliasViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: publicRoomExistingAliasViewModel.context.$viewState.map { state in
|
||||
!state.aliasErrors.isEmpty
|
||||
})
|
||||
.snapshotPreferences(expect: publicRoomExistingAliasViewModel.context.$viewState.map { !$0.aliasErrors.isEmpty })
|
||||
.previewDisplayName("Create Public Room, existing alias")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import SwiftUI
|
||||
|
||||
struct InviteUsersScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?
|
||||
let roomType: InviteUsersScreenRoomType
|
||||
let roomProxy: JoinedRoomProxyProtocol
|
||||
let isSkippable: Bool
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let appSettings: AppSettings
|
||||
@@ -20,7 +20,6 @@ struct InviteUsersScreenCoordinatorParameters {
|
||||
|
||||
enum InviteUsersScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case proceed(selectedUsers: [UserProfileProxy])
|
||||
}
|
||||
|
||||
final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -34,8 +33,8 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: InviteUsersScreenCoordinatorParameters) {
|
||||
viewModel = InviteUsersScreenViewModel(userSession: parameters.userSession,
|
||||
selectedUsers: parameters.selectedUsers,
|
||||
roomType: parameters.roomType,
|
||||
roomProxy: parameters.roomProxy,
|
||||
isSkippable: parameters.isSkippable,
|
||||
userDiscoveryService: parameters.userDiscoveryService,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
appSettings: parameters.appSettings)
|
||||
@@ -47,8 +46,6 @@ final class InviteUsersScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .proceed(let selectedUsers):
|
||||
actionsSubject.send(.proceed(selectedUsers: selectedUsers))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -16,7 +16,6 @@ enum InviteUsersScreenErrorType: Error {
|
||||
|
||||
enum InviteUsersScreenViewModelAction {
|
||||
case dismiss
|
||||
case proceed(selectedUsers: [UserProfileProxy])
|
||||
}
|
||||
|
||||
enum InviteUsersScreenRoomType {
|
||||
@@ -53,18 +52,18 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
membershipState[user.userID]
|
||||
}
|
||||
|
||||
let isCreatingRoom: Bool
|
||||
let isSkippable: Bool
|
||||
|
||||
var actionText: String {
|
||||
if isCreatingRoom {
|
||||
return selectedUsers.isEmpty ? L10n.actionSkip : L10n.actionNext
|
||||
if isSkippable, selectedUsers.isEmpty {
|
||||
L10n.actionSkip
|
||||
} else {
|
||||
return L10n.actionInvite
|
||||
L10n.actionInvite
|
||||
}
|
||||
}
|
||||
|
||||
var isActionDisabled: Bool {
|
||||
isCreatingRoom ? false : selectedUsers.isEmpty
|
||||
isSkippable ? false : selectedUsers.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import SwiftUI
|
||||
typealias InviteUsersScreenViewModelType = StateStoreViewModel<InviteUsersScreenViewState, InviteUsersScreenViewAction>
|
||||
|
||||
class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScreenViewModelProtocol {
|
||||
private let roomType: InviteUsersScreenRoomType
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
@@ -25,22 +25,19 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
selectedUsers: CurrentValuePublisher<[UserProfileProxy], Never>?,
|
||||
roomType: InviteUsersScreenRoomType,
|
||||
roomProxy: JoinedRoomProxyProtocol,
|
||||
isSkippable: Bool,
|
||||
userDiscoveryService: UserDiscoveryServiceProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appSettings: AppSettings) {
|
||||
self.roomType = roomType
|
||||
self.roomProxy = roomProxy
|
||||
self.userDiscoveryService = userDiscoveryService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appSettings = appSettings
|
||||
self.selectedUsers = selectedUsers
|
||||
|
||||
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: selectedUsers?.value ?? [],
|
||||
isCreatingRoom: roomType.isCreatingRoom),
|
||||
super.init(initialViewState: InviteUsersScreenViewState(selectedUsers: [],
|
||||
isSkippable: isSkippable),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
setupSubscriptions()
|
||||
@@ -62,12 +59,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .proceed:
|
||||
switch roomType {
|
||||
case .draft:
|
||||
actionsSubject.send(.proceed(selectedUsers: state.selectedUsers))
|
||||
case .room(let roomProxy):
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
}
|
||||
inviteUsers(state.selectedUsers.map(\.userID), roomProxy: roomProxy)
|
||||
case .toggleUser(let user):
|
||||
toggleUser(user)
|
||||
}
|
||||
@@ -78,7 +70,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
private func toggleUser(_ user: UserProfileProxy) {
|
||||
if state.selectedUsers.contains(user) {
|
||||
state.scrollToLastID = nil
|
||||
state.selectedUsers.removeAll(where: { $0.userID == user.userID })
|
||||
state.selectedUsers.removeAll { $0.userID == user.userID }
|
||||
} else {
|
||||
state.scrollToLastID = user.userID
|
||||
state.selectedUsers.append(user)
|
||||
@@ -87,15 +79,14 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
|
||||
private func inviteUsers(_ users: [String], roomProxy: JoinedRoomProxyProtocol) {
|
||||
if appSettings.enableKeyShareOnInvite {
|
||||
showLoader(title: L10n.screenRoomDetailsInvitePeoplePreparing,
|
||||
message: L10n.screenRoomDetailsInvitePeopleDontClose)
|
||||
showLoadingIndicator(title: L10n.screenRoomDetailsInvitePeoplePreparing, message: L10n.screenRoomDetailsInvitePeopleDontClose)
|
||||
} else {
|
||||
showLoader()
|
||||
showLoadingIndicator()
|
||||
}
|
||||
|
||||
Task {
|
||||
defer {
|
||||
hideLoader()
|
||||
hideLoadingIndicator()
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
|
||||
@@ -122,7 +113,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
}
|
||||
|
||||
private func buildMembershipStateIfNeeded(members: [RoomMemberProxyProtocol]) {
|
||||
showLoader()
|
||||
showLoadingIndicator()
|
||||
|
||||
Task.detached { [members] in
|
||||
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
|
||||
@@ -133,7 +124,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
|
||||
Task { @MainActor in
|
||||
self.state.membershipState = membershipState
|
||||
self.hideLoader()
|
||||
self.hideLoadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,25 +141,13 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
self?.fetchUsers()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
if let selectedUsers {
|
||||
selectedUsers
|
||||
.sink { [weak self] users in
|
||||
self?.state.selectedUsers = users
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchMembersIfNeeded() {
|
||||
guard case let .room(roomProxy) = roomType else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
showLoader()
|
||||
showLoadingIndicator()
|
||||
await roomProxy.updateMembers()
|
||||
hideLoader()
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
roomProxy.membersPublisher
|
||||
@@ -211,8 +190,8 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
|
||||
private let userIndicatorID = UUID().uuidString
|
||||
|
||||
private func showLoader(title: String = L10n.commonLoading,
|
||||
message: String? = nil) {
|
||||
private func showLoadingIndicator(title: String = L10n.commonLoading,
|
||||
message: String? = nil) {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID,
|
||||
type: .modal,
|
||||
title: title,
|
||||
@@ -221,18 +200,7 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
delay: .milliseconds(200))
|
||||
}
|
||||
|
||||
private func hideLoader() {
|
||||
private func hideLoadingIndicator() {
|
||||
userIndicatorController.retractIndicatorWithId(userIndicatorID)
|
||||
}
|
||||
}
|
||||
|
||||
private extension InviteUsersScreenRoomType {
|
||||
var isCreatingRoom: Bool {
|
||||
switch self {
|
||||
case .draft:
|
||||
return true
|
||||
case .room:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ struct InviteUsersScreen: View {
|
||||
accessibilityFocusOnStart: true)
|
||||
.compoundSearchField()
|
||||
.alert(item: $context.alertInfo)
|
||||
.navigationBarBackButtonHidden(context.viewState.isSkippable)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
@@ -126,7 +127,7 @@ struct InviteUsersScreen: View {
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
if !context.viewState.isCreatingRoom {
|
||||
if !context.viewState.isSkippable {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
@@ -155,8 +156,8 @@ struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
||||
return InviteUsersScreenViewModel(userSession: UserSessionMock(.init()),
|
||||
selectedUsers: .init([]),
|
||||
roomType: .draft,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
@@ -10,38 +10,26 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct StartChatScreenCoordinatorParameters {
|
||||
let orientationManager: OrientationManagerProtocol
|
||||
let userSession: UserSessionProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
weak var navigationStackCoordinator: NavigationStackCoordinator?
|
||||
let userDiscoveryService: UserDiscoveryServiceProtocol
|
||||
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let appSettings: AppSettings
|
||||
let analytics: AnalyticsService
|
||||
}
|
||||
|
||||
enum StartChatScreenCoordinatorAction {
|
||||
case close
|
||||
case openRoom(withIdentifier: String)
|
||||
case createRoom
|
||||
case openRoom(roomID: String)
|
||||
case openRoomDirectorySearch
|
||||
}
|
||||
|
||||
final class StartChatScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: StartChatScreenCoordinatorParameters
|
||||
private var viewModel: StartChatScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<StartChatScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var createRoomParameters = CurrentValueSubject<CreateRoomFlowParameters, Never>(.init())
|
||||
private var createRoomParametersPublisher: CurrentValuePublisher<CreateRoomFlowParameters, Never> {
|
||||
createRoomParameters.asCurrentValuePublisher()
|
||||
}
|
||||
|
||||
private let selectedUsers = CurrentValueSubject<[UserProfileProxy], Never>([])
|
||||
private var selectedUsersPublisher: CurrentValuePublisher<[UserProfileProxy], Never> {
|
||||
selectedUsers.asCurrentValuePublisher()
|
||||
}
|
||||
|
||||
private let actionsSubject: PassthroughSubject<StartChatScreenCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<StartChatScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
@@ -63,10 +51,9 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
|
||||
case .close:
|
||||
actionsSubject.send(.close)
|
||||
case .createRoom:
|
||||
// before creating a room we select the users we would like to invite in that room
|
||||
presentInviteUsersScreen()
|
||||
case .showRoom(let identifier):
|
||||
actionsSubject.send(.openRoom(withIdentifier: identifier))
|
||||
actionsSubject.send(.createRoom)
|
||||
case .showRoom(let roomID):
|
||||
actionsSubject.send(.openRoom(roomID: roomID))
|
||||
case .openRoomDirectorySearch:
|
||||
actionsSubject.send(.openRoomDirectorySearch)
|
||||
}
|
||||
@@ -79,126 +66,4 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(StartChatScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func presentInviteUsersScreen() {
|
||||
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: parameters.userSession,
|
||||
selectedUsers: selectedUsersPublisher,
|
||||
roomType: .draft,
|
||||
userDiscoveryService: parameters.userDiscoveryService,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
appSettings: parameters.appSettings)
|
||||
let coordinator = InviteUsersScreenCoordinator(parameters: inviteParameters)
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .dismiss:
|
||||
fatalError("Not shown in this flow.")
|
||||
case .proceed(let selectedUsers):
|
||||
self.selectedUsers.send(selectedUsers)
|
||||
openCreateRoomScreen()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
parameters.navigationStackCoordinator?.push(coordinator) { [weak self] in
|
||||
self?.createRoomParameters.send(.init())
|
||||
self?.selectedUsers.send([])
|
||||
}
|
||||
}
|
||||
|
||||
private func openCreateRoomScreen() {
|
||||
let createParameters = CreateRoomCoordinatorParameters(userSession: parameters.userSession,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
createRoomParameters: createRoomParametersPublisher,
|
||||
selectedUsers: selectedUsers.value,
|
||||
appSettings: parameters.appSettings,
|
||||
analytics: parameters.analytics)
|
||||
let coordinator = CreateRoomCoordinator(parameters: createParameters)
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .updateSelectedUsers(let users):
|
||||
self.selectedUsers.send(users)
|
||||
case .updateDetails(let details):
|
||||
self.createRoomParameters.send(details)
|
||||
case .openRoom(let identifier):
|
||||
self.actionsSubject.send(.openRoom(withIdentifier: identifier))
|
||||
case .displayMediaPickerWithMode(let mode):
|
||||
self.displayMediaPickerWithMode(mode)
|
||||
case .removeImage:
|
||||
var parameters = self.createRoomParameters.value
|
||||
parameters.avatarImageMedia = nil
|
||||
self.createRoomParameters.send(parameters)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
parameters.navigationStackCoordinator?.push(coordinator)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
orientationManager: parameters.orientationManager) { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .cancel:
|
||||
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
|
||||
case .selectedMediaAtURLs(let urls):
|
||||
guard urls.count == 1,
|
||||
let url = urls.first else {
|
||||
fatalError("Received an invalid number of URLs")
|
||||
}
|
||||
|
||||
processAvatar(from: url)
|
||||
}
|
||||
}
|
||||
|
||||
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
|
||||
|
||||
parameters.navigationStackCoordinator?.setSheetCoordinator(stackCoordinator)
|
||||
}
|
||||
|
||||
private func processAvatar(from url: URL) {
|
||||
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
|
||||
showLoadingIndicator()
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
guard case let .success(maxUploadSize) = await parameters.userSession.clientProxy.maxMediaUploadSize else {
|
||||
MXLog.error("Failed to get max upload size")
|
||||
parameters.userIndicatorController.alertInfo = AlertInfo(id: .init())
|
||||
return
|
||||
}
|
||||
let media = try await parameters.mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize).get()
|
||||
var parameters = createRoomParameters.value
|
||||
parameters.avatarImageMedia = media
|
||||
createRoomParameters.send(parameters)
|
||||
} catch {
|
||||
parameters.userIndicatorController.alertInfo = AlertInfo(id: .init())
|
||||
}
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Loading indicator
|
||||
|
||||
private static let loadingIndicatorIdentifier = "\(StartChatScreenCoordinator.self)-Loading"
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
parameters.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func hideLoadingIndicator() {
|
||||
parameters.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ enum StartChatScreenErrorType: Error {
|
||||
enum StartChatScreenViewModelAction: Equatable {
|
||||
case close
|
||||
case createRoom
|
||||
case showRoom(withIdentifier: String)
|
||||
case showRoom(roomID: String)
|
||||
case openRoomDirectorySearch
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
|
||||
switch currentDirectRoom {
|
||||
case .success(.some(let roomId)):
|
||||
hideLoadingIndicator()
|
||||
actionsSubject.send(.showRoom(withIdentifier: roomId))
|
||||
actionsSubject.send(.showRoom(roomID: roomId))
|
||||
case .success:
|
||||
hideLoadingIndicator()
|
||||
state.bindings.selectedUserToInvite = user
|
||||
@@ -190,7 +190,7 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
|
||||
switch await userSession.clientProxy.createDirectRoom(with: user.userID, expectedRoomName: user.displayName) {
|
||||
case .success(let roomId):
|
||||
analytics.trackCreatedRoom(isDM: true)
|
||||
actionsSubject.send(.showRoom(withIdentifier: roomId))
|
||||
actionsSubject.send(.showRoom(roomID: roomId))
|
||||
case .failure:
|
||||
displayError()
|
||||
}
|
||||
@@ -205,7 +205,7 @@ class StartChatScreenViewModel: StartChatScreenViewModelType, StartChatScreenVie
|
||||
private func joinRoomByAddress() {
|
||||
if case let .addressFound(lastTestedAddress, roomID) = internalRoomAddressState,
|
||||
lastTestedAddress == state.bindings.roomAddress {
|
||||
actionsSubject.send(.showRoom(withIdentifier: roomID))
|
||||
actionsSubject.send(.showRoom(roomID: roomID))
|
||||
} else if let resolveAliasTask {
|
||||
// If the task is still running we wait for it to complete and we check the state again
|
||||
showLoadingIndicator(delay: .milliseconds(250))
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// Copyright 2023-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 Foundation
|
||||
|
||||
/// This parameters are only used in the create room flow for having persisted informations between screens
|
||||
struct CreateRoomFlowParameters {
|
||||
var name = ""
|
||||
var topic = ""
|
||||
var isRoomPrivate = true
|
||||
var isKnockingOnly = false
|
||||
var avatarImageMedia: MediaInfo?
|
||||
var aliasLocalPart: String?
|
||||
}
|
||||
@@ -648,67 +648,48 @@ class MockScreen: Identifiable {
|
||||
retainedState.append(coordinator)
|
||||
coordinator.start()
|
||||
return navigationStackCoordinator
|
||||
case .startChat:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let userDiscoveryMock = UserDiscoveryServiceMock()
|
||||
userDiscoveryMock.searchProfilesWithReturnValue = .success([])
|
||||
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@mock:client.com"))))
|
||||
let parameters: StartChatScreenCoordinatorParameters = .init(orientationManager: OrientationManagerMock(),
|
||||
userSession: userSession,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
userDiscoveryService: userDiscoveryMock,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
let coordinator = StartChatScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .startChatWithSearchResults:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
case .startChatFlow:
|
||||
let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
|
||||
let userDiscoveryMock = UserDiscoveryServiceMock()
|
||||
userDiscoveryMock.searchProfilesWithReturnValue = .success([.mockBob, .mockBobby])
|
||||
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
|
||||
let coordinator = StartChatScreenCoordinator(parameters: .init(orientationManager: OrientationManagerMock(),
|
||||
userSession: userSession,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
userDiscoveryService: userDiscoveryMock,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .createRoom:
|
||||
clientProxy.createRoomNameTopicIsRoomPrivateIsKnockingOnlyUserIDsAvatarURLAliasLocalPartReturnValue = .success("!new-room:client.com")
|
||||
clientProxy.roomForIdentifierClosure = { roomID in .joined(JoinedRoomProxyMock(.init(id: roomID, members: []))) }
|
||||
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockBob, .mockBobby])
|
||||
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
|
||||
let mockUserSession = UserSessionMock(.init(clientProxy: clientProxy))
|
||||
let createRoomParameters = CreateRoomFlowParameters()
|
||||
let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie]
|
||||
let parameters = CreateRoomCoordinatorParameters(userSession: mockUserSession,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
createRoomParameters: .init(createRoomParameters),
|
||||
selectedUsers: .init(selectedUsers),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
let coordinator = CreateRoomCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .createRoomNoUsers:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com"))
|
||||
let mockUserSession = UserSessionMock(.init(clientProxy: clientProxy))
|
||||
let createRoomParameters = CreateRoomFlowParameters()
|
||||
let parameters = CreateRoomCoordinatorParameters(userSession: mockUserSession,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
createRoomParameters: .init(createRoomParameters),
|
||||
selectedUsers: .init([]),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
let coordinator = CreateRoomCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
let flowCoordinator = StartChatFlowCoordinator(userDiscoveryService: userDiscoveryService,
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
bugReportService: BugReportServiceMock(.init()),
|
||||
elementCallService: ElementCallServiceMock(.init()),
|
||||
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
|
||||
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
|
||||
linkMetadataProvider: LinkMetadataProvider(),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
appHooks: AppHooks(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
notificationManager: NotificationManagerMock(),
|
||||
stateMachineFactory: StateMachineFactory()))
|
||||
flowCoordinator.actionsPublisher
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .finished(let roomID):
|
||||
navigationRootCoordinator.setSheetCoordinator(nil)
|
||||
case .showRoomDirectory:
|
||||
break // The test doesn't cover this.
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
retainedState.append(flowCoordinator)
|
||||
flowCoordinator.start()
|
||||
|
||||
// Use a sheet on top the the placeholder so we can test the dismissal.
|
||||
navigationRootCoordinator.setSheetCoordinator(navigationStackCoordinator)
|
||||
return PlaceholderScreenCoordinator(hideBrandChrome: false)
|
||||
case .createPoll:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new,
|
||||
|
||||
@@ -22,8 +22,6 @@ enum UITestsScreenIdentifier: String {
|
||||
case multipleProvidersAuthenticationFlow
|
||||
case bugReport
|
||||
case createPoll
|
||||
case createRoom
|
||||
case createRoomNoUsers
|
||||
case encryptionSettings
|
||||
case encryptionSettingsOutOfSync
|
||||
case encryptionReset
|
||||
@@ -44,8 +42,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case roomWithUndisclosedPolls
|
||||
case serverSelection
|
||||
case sessionVerification
|
||||
case startChat
|
||||
case startChatWithSearchResults
|
||||
case startChatFlow
|
||||
case userSessionScreen
|
||||
case userSessionScreenReply
|
||||
case userSessionSpacesFlow
|
||||
|
||||
Reference in New Issue
Block a user