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

* Introduce a basic StartChatFlowCoordinator.

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

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

* Refactor CreateRoom… to CreateRoomScreen…
This commit is contained in:
Doug
2025-10-30 16:12:56 +00:00
committed by GitHub
parent 1e124792de
commit b3d4ed0274
68 changed files with 764 additions and 789 deletions

View File

@@ -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)
}
}

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View 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)
}
}

View File

@@ -123,6 +123,7 @@ extension JoinedRoomProxyMock {
powerLevelsReturnValue = .success(powerLevelsProxyMock)
inviteUserIDReturnValue = .success(())
kickUserReasonReturnValue = .success(())
banUserReasonReturnValue = .success(())
unbanUserReturnValue = .success(())

View File

@@ -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 {

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -16,7 +16,7 @@ enum StartChatScreenErrorType: Error {
enum StartChatScreenViewModelAction: Equatable {
case close
case createRoom
case showRoom(withIdentifier: String)
case showRoom(roomID: String)
case openRoomDirectorySearch
}

View File

@@ -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))

View File

@@ -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?
}

View File

@@ -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,

View File

@@ -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