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 401ff49882
commit 933891614c
68 changed files with 764 additions and 789 deletions
@@ -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))