Create space flow (#4957)

* create space flow implementation

# Conflicts:
#	ElementX/Sources/FlowCoordinators/SpacesTabFlowCoordinator.swift

* create space flow fully implemented and working

* updated tests and updated the create room camera button UI

* updated the avatar button in the create room screen, and added power level overrides for spaces

* update power level content overrides to behave just as EW, and removed ask to join when creating a space regardless of the FF

* updated UI tests snapshots

* invite for a public space should always be forced to 50

* pr suggestions + PL override fix

* fix a missed code change
This commit is contained in:
Mauro
2026-01-15 14:52:18 +01:00
committed by GitHub
parent f41424436c
commit 648caa4e82
52 changed files with 446 additions and 116 deletions
@@ -567,7 +567,7 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
private func startStartChatFlow(animated: Bool) {
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = StartChatFlowCoordinator(isSpace: false,
let coordinator = StartChatFlowCoordinator(entryPoint: .startChat,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy),
navigationStackCoordinator: navigationStackCoordinator,
flowParameters: flowParameters)
@@ -576,11 +576,17 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol {
.sink { [weak self] action in
guard let self else { return }
switch action {
case .finished(let roomID):
case .finished(let result):
navigationSplitCoordinator.setSheetCoordinator(nil)
if let roomID {
switch result {
case .room(let roomID):
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .room))
case .space(let spaceRoomListProxy):
// This also automatically handles selecting the space.
stateMachine.processEvent(.selectRoom(roomID: spaceRoomListProxy.id, via: [], entryPoint: .room))
case .cancelled:
break
}
case .showRoomDirectory:
navigationSplitCoordinator.setSheetCoordinator(nil)
@@ -25,12 +25,15 @@ class SpacesTabFlowCoordinator: FlowCoordinatorProtocol {
private let detailNavigationStackCoordinator: NavigationStackCoordinator
private var spaceFlowCoordinator: SpaceFlowCoordinator?
private var startChatFlowCoordinator: StartChatFlowCoordinator?
enum State: StateType {
/// The state machine hasn't started.
case initial
/// The root screen for this flow.
case spacesScreen(selectedSpaceID: String?)
/// The create space flow is currently being presented
case createSpaceFlow
}
enum Event: EventType {
@@ -42,6 +45,10 @@ class SpacesTabFlowCoordinator: FlowCoordinatorProtocol {
case selectSpace
/// The space screen has been dismissed.
case deselectSpace
/// Start the create a new space flow
case startCreateSpaceFlow
/// Create space has finished
case dismissedCreateSpaceFlow
}
private let stateMachine: StateMachine<State, Event>
@@ -81,16 +88,20 @@ class SpacesTabFlowCoordinator: FlowCoordinatorProtocol {
switch stateMachine.state {
case .initial, .spacesScreen:
break
case .createSpaceFlow:
startChatFlowCoordinator?.clearRoute(animated: animated)
clearRoute(animated: animated)
}
}
// MARK: - Private
// swiftlint:disable:next cyclomatic_complexity
private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .spacesScreen(selectedSpaceID: nil)]) { [weak self] _ in
self?.presentSpacesScreen()
}
stateMachine.addRouteMapping { event, fromState, userInfo in
guard event == .selectSpace, case .spacesScreen = fromState else { return nil }
guard let spaceRoomListProxy = userInfo as? SpaceRoomListProxyProtocol else { fatalError("A space proxy must be provided.") }
@@ -110,6 +121,24 @@ class SpacesTabFlowCoordinator: FlowCoordinatorProtocol {
spaceFlowCoordinator = nil
}
stateMachine.addRouteMapping { event, fromState, _ in
guard event == .startCreateSpaceFlow, case .spacesScreen = fromState else { return nil }
return .createSpaceFlow
} handler: { [weak self] _ in
self?.startCreateSpaceFlow()
}
stateMachine.addRouteMapping { event, fromState, userInfo in
guard event == .dismissedCreateSpaceFlow, case .createSpaceFlow = fromState else { return nil }
return .spacesScreen(selectedSpaceID: (userInfo as? SpaceRoomListProxyProtocol)?.id)
} handler: { [weak self] context in
guard let self else { return }
startChatFlowCoordinator = nil
if let spaceRoomListProxy = context.userInfo as? SpaceRoomListProxyProtocol {
startSpaceFlow(spaceRoomListProxy: spaceRoomListProxy)
}
}
stateMachine.addErrorHandler { context in
fatalError("Unexpected transition: \(context)")
}
@@ -129,6 +158,8 @@ class SpacesTabFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.selectSpace, userInfo: spaceRoomListProxy)
case .showSettings:
actionsSubject.send(.showSettings)
case .showCreateSpace:
stateMachine.tryEvent(.startCreateSpaceFlow)
}
}
.store(in: &cancellables)
@@ -167,4 +198,38 @@ class SpacesTabFlowCoordinator: FlowCoordinatorProtocol {
coordinator.start()
selectedSpaceSubject.send(spaceRoomListProxy.id)
}
private func startCreateSpaceFlow() {
let coordinator = NavigationStackCoordinator()
let flowCoordinator = StartChatFlowCoordinator(entryPoint: .createSpace,
userDiscoveryService: UserDiscoveryService(clientProxy: flowParameters.userSession.clientProxy),
navigationStackCoordinator: coordinator,
flowParameters: flowParameters)
var spaceRoomListProxy: SpaceRoomListProxyProtocol?
flowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .finished(let result):
switch result {
case .space(let value):
spaceRoomListProxy = value
case .room, .cancelled:
break
}
navigationSplitCoordinator.setSheetCoordinator(nil)
case .showRoomDirectory:
fatalError("Not handled here")
}
}
.store(in: &cancellables)
navigationSplitCoordinator.setSheetCoordinator(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedCreateSpaceFlow, userInfo: spaceRoomListProxy)
}
flowCoordinator.start(animated: true)
startChatFlowCoordinator = flowCoordinator
}
}
@@ -11,12 +11,29 @@ import Foundation
import SwiftState
enum StartChatFlowCoordinatorAction {
case finished(roomID: String?)
case finished(Result)
case showRoomDirectory
enum Result {
case room(id: String)
case space(spaceRoomListProxy: SpaceRoomListProxyProtocol)
case cancelled
}
}
/// A value that represents where the flow will be started.
enum StartChatFlowCoordinatorEntryPoint {
case startChat
case createSpace
}
class StartChatFlowCoordinator: FlowCoordinatorProtocol {
private let isSpace: Bool
struct CreatedRoomResult {
let roomProxy: JoinedRoomProxyProtocol
let spaceRoomListProxy: SpaceRoomListProxyProtocol?
}
private let entryPoint: StartChatFlowCoordinatorEntryPoint
private let userDiscoveryService: UserDiscoveryServiceProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
@@ -40,7 +57,7 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
enum Event: EventType {
/// The flow is being started.
case start
case start(entryPoint: StartChatFlowCoordinatorEntryPoint)
/// The user would like to create a room.
case createRoom
@@ -64,11 +81,11 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.eraseToAnyPublisher()
}
init(isSpace: Bool,
init(entryPoint: StartChatFlowCoordinatorEntryPoint,
userDiscoveryService: UserDiscoveryServiceProtocol,
navigationStackCoordinator: NavigationStackCoordinator,
flowParameters: CommonFlowParameters) {
self.isSpace = isSpace
self.entryPoint = entryPoint
self.userDiscoveryService = userDiscoveryService
self.navigationStackCoordinator = navigationStackCoordinator
@@ -79,7 +96,7 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
}
func start(animated: Bool) {
stateMachine.tryEvent(.start)
stateMachine.tryEvent(.start(entryPoint: entryPoint))
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
@@ -109,12 +126,16 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Private
private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .startChat]) { [weak self] _ in
stateMachine.addRoutes(event: .start(entryPoint: .startChat), transitions: [.initial => .startChat]) { [weak self] _ in
self?.presentStartChatScreen()
}
stateMachine.addRoutes(event: .start(entryPoint: .createSpace), transitions: [.initial => .createRoom]) { [weak self] _ in
self?.presentCreateRoomScreen(isSpace: true, isRoot: true)
}
stateMachine.addRoutes(event: .createRoom, transitions: [.startChat => .createRoom]) { [weak self] _ in
self?.presentCreateRoomScreen()
self?.presentCreateRoomScreen(isSpace: false, isRoot: false)
}
stateMachine.addRoutes(event: .dismissedCreateRoom, transitions: [.createRoom => .startChat]) { [weak self] _ in
self?.createRoomScreenCoordinator = nil
@@ -129,10 +150,10 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
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 {
guard let result = context.userInfo as? CreatedRoomResult else {
fatalError("A room proxy is required to invite users.")
}
self?.presentInviteUsersScreen(roomProxy: roomProxy)
self?.presentInviteUsersScreen(roomProxy: result.roomProxy, spaceRoomListProxy: result.spaceRoomListProxy)
}
stateMachine.addErrorHandler { context in
@@ -156,11 +177,11 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .close:
actionsSubject.send(.finished(roomID: nil))
actionsSubject.send(.finished(.cancelled))
case .createRoom:
stateMachine.tryEvent(.createRoom)
case .openRoom(let roomID):
actionsSubject.send(.finished(roomID: roomID))
actionsSubject.send(.finished(.room(id: roomID)))
case .openRoomDirectorySearch:
actionsSubject.send(.showRoomDirectory)
}
@@ -170,8 +191,9 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.setRootCoordinator(coordinator)
}
private func presentCreateRoomScreen() {
private func presentCreateRoomScreen(isSpace: Bool, isRoot: Bool) {
let createParameters = CreateRoomScreenCoordinatorParameters(isSpace: isSpace,
shouldShowCancelButton: isRoot,
userSession: flowParameters.userSession,
userIndicatorController: flowParameters.userIndicatorController,
appSettings: flowParameters.appSettings,
@@ -180,17 +202,25 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .createdRoom(let roomProxy):
stateMachine.tryEvent(.createdRoom, userInfo: roomProxy)
case .createdRoom(let roomProxy, let spaceRoomListProxy):
stateMachine.tryEvent(.createdRoom, userInfo: CreatedRoomResult(roomProxy: roomProxy, spaceRoomListProxy: spaceRoomListProxy))
case .displayMediaPickerWithMode(let mode):
stateMachine.tryEvent(.presentRoomAvatarPicker, userInfo: mode)
case .dismiss:
// Only used when isRoot
actionsSubject.send(.finished(.cancelled))
}
}
.store(in: &cancellables)
createRoomScreenCoordinator = coordinator
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedCreateRoom)
if isRoot {
navigationStackCoordinator.setRootCoordinator(coordinator)
} else {
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissedCreateRoom)
}
}
}
@@ -219,7 +249,7 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol) {
private func presentInviteUsersScreen(roomProxy: JoinedRoomProxyProtocol, spaceRoomListProxy: SpaceRoomListProxyProtocol?) {
let inviteParameters = InviteUsersScreenCoordinatorParameters(userSession: flowParameters.userSession,
roomProxy: roomProxy,
isSkippable: true,
@@ -231,7 +261,11 @@ class StartChatFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .dismiss:
actionsSubject.send(.finished(roomID: roomProxy.id))
if let spaceRoomListProxy {
actionsSubject.send(.finished(.space(spaceRoomListProxy: spaceRoomListProxy)))
} else {
actionsSubject.send(.finished(.room(id: roomProxy.id)))
}
}
}
.store(in: &cancellables)
@@ -10,18 +10,17 @@ import SwiftUI
struct ToolbarButton: View {
enum Role {
case cancel
case done
case save
static let cancel = Role.cancel(title: L10n.actionCancel)
static let done = Role.confirm(title: L10n.actionDone)
static let save = Role.confirm(title: L10n.actionSave)
case cancel(title: String)
case confirm(title: String)
var title: String {
switch self {
case .cancel:
L10n.actionCancel
case .done:
L10n.actionDone
case .save:
L10n.actionSave
case .cancel(let title), .confirm(let title):
title
}
}
@@ -31,7 +30,7 @@ struct ToolbarButton: View {
case .cancel:
CompoundIcon(\.close)
.foregroundStyle(.compound.iconPrimary)
case .done, .save:
case .confirm:
CompoundIcon(\.check)
.foregroundStyle(.compound.iconOnSolidPrimary)
}
@@ -41,7 +40,7 @@ struct ToolbarButton: View {
switch self {
case .cancel:
.compound.bgCanvasDefault
case .done, .save:
case .confirm:
.compound.bgAccentRest
}
}
@@ -11,6 +11,7 @@ import SwiftUI
struct CreateRoomScreenCoordinatorParameters {
let isSpace: Bool
let shouldShowCancelButton: Bool
let userSession: UserSessionProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let appSettings: AppSettings
@@ -18,8 +19,9 @@ struct CreateRoomScreenCoordinatorParameters {
}
enum CreateRoomScreenCoordinatorAction {
case createdRoom(JoinedRoomProxyProtocol)
case createdRoom(JoinedRoomProxyProtocol, SpaceRoomListProxyProtocol?)
case displayMediaPickerWithMode(MediaPickerScreenMode)
case dismiss
}
final class CreateRoomScreenCoordinator: CoordinatorProtocol {
@@ -33,6 +35,7 @@ final class CreateRoomScreenCoordinator: CoordinatorProtocol {
init(parameters: CreateRoomScreenCoordinatorParameters) {
viewModel = CreateRoomScreenViewModel(isSpace: parameters.isSpace,
shouldShowCancelButton: parameters.shouldShowCancelButton,
userSession: parameters.userSession,
analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController,
@@ -43,12 +46,14 @@ final class CreateRoomScreenCoordinator: CoordinatorProtocol {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .createdRoom(let roomProxy):
actionsSubject.send(.createdRoom(roomProxy))
case .createdRoom(let roomProxy, let spaceRoomListProxy):
actionsSubject.send(.createdRoom(roomProxy, spaceRoomListProxy))
case .displayCameraPicker:
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single)))
case .displayMediaPicker:
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single)))
case .dismiss:
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)
@@ -17,13 +17,15 @@ enum CreateRoomScreenErrorType: Error {
}
enum CreateRoomScreenViewModelAction {
case createdRoom(JoinedRoomProxyProtocol)
case createdRoom(JoinedRoomProxyProtocol, SpaceRoomListProxyProtocol?)
case displayMediaPicker
case displayCameraPicker
case dismiss
}
struct CreateRoomScreenViewState: BindableState {
let isSpace: Bool
let shouldShowCancelButton: Bool
var roomName: String
let serverName: String
let isKnockingFeatureEnabled: Bool
@@ -47,7 +49,7 @@ struct CreateRoomScreenViewState: BindableState {
var availableAccessTypes: [CreateRoomAccessType] {
var availableTypes = CreateRoomAccessType.allCases
if !isKnockingFeatureEnabled {
if isSpace || !isKnockingFeatureEnabled {
availableTypes.removeAll { $0 == .askToJoin }
}
return availableTypes
@@ -64,6 +66,7 @@ struct CreateRoomScreenViewStateBindings {
}
enum CreateRoomScreenViewAction {
case dismiss
case createRoom
case displayCameraPicker
case displayMediaPicker
@@ -27,6 +27,7 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
}
init(isSpace: Bool,
shouldShowCancelButton: Bool,
userSession: UserSessionProtocol,
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol,
@@ -40,6 +41,7 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
selectedAccessType: .private)
super.init(initialViewState: CreateRoomScreenViewState(isSpace: isSpace,
shouldShowCancelButton: shouldShowCancelButton,
roomName: "",
serverName: userSession.clientProxy.userIDServerName ?? "",
isKnockingFeatureEnabled: appSettings.knockingEnabled,
@@ -54,6 +56,8 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
override func process(viewAction: CreateRoomScreenViewAction) {
switch viewAction {
case .dismiss:
actionsSubject.send(.dismiss)
case .createRoom:
Task { await createRoom() }
case .displayCameraPicker:
@@ -233,7 +237,19 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
return
}
analytics.trackCreatedRoom(isDM: false)
actionsSubject.send(.createdRoom(roomProxy))
var spaceRoomListProxy: SpaceRoomListProxyProtocol?
if state.isSpace {
switch await userSession.clientProxy.spaceService.spaceRoomList(spaceID: roomProxy.id) {
case .success(let value):
spaceRoomListProxy = value
case .failure:
MXLog.error("Failed to get space room list for newly created space with id: \(roomProxy.id)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
actionsSubject.send(.createdRoom(roomProxy, spaceRoomListProxy))
case .failure:
state.bindings.alertInfo = AlertInfo(id: .failedCreatingRoom,
title: L10n.commonError,
@@ -54,8 +54,18 @@ struct CreateRoomScreen: View {
.shouldScrollOnKeyboardDidShow(focus == .alias, to: Focus.alias)
}
private var nameTextFieldShape: AnyShape {
if #available(iOS 26, *) {
AnyShape(ConcentricRectangle(corners: .concentric(minimum: 26)))
} else {
AnyShape(RoundedRectangle(cornerRadius: 12))
}
}
private var roomSection: some View {
Section {
EmptyView()
} header: {
HStack(alignment: .center, spacing: 16) {
roomAvatarButton
let nameLabel = if #available(iOS 26, *) {
@@ -80,11 +90,11 @@ struct CreateRoomScreen: View {
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomName)
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, ListRowPadding.vertical)
.background(.compound.bgCanvasDefaultLevel1, in: RoundedRectangle(cornerRadius: 12))
.background(.compound.bgCanvasDefaultLevel1, in: nameTextFieldShape)
}
}
.listRowInsets(.init())
.listRowBackground(Color.clear)
.padding(.top, 16)
}
}
@@ -101,15 +111,27 @@ struct CreateRoomScreen: View {
} placeholder: {
ProgressView()
}
.scaledFrame(size: 70)
.clipShape(Circle())
.scaledFrame(size: 70, relativeTo: .title)
.clipShape(context.viewState.isSpace ? AnyShape(RoundedRectangle(cornerRadius: 16)) : AnyShape(Circle()))
.overlay(alignment: .bottomTrailing) {
editAvatarBadge
.scaledOffset(x: 12, y: 4, relativeTo: .title)
.accessibilityHidden(true)
}
} else {
CompoundIcon(\.takePhoto, size: .custom(36), relativeTo: .title)
.foregroundColor(.compound.iconSecondary)
.scaledFrame(size: 70, relativeTo: .title)
.background(.compound.bgSubtlePrimary, in: Circle())
CompoundIcon(\.takePhoto, size: .medium, relativeTo: .title)
.foregroundColor(.compound.iconPrimary)
.scaledFrame(size: 50, relativeTo: .title)
.background(.compound.bgCanvasDefault, in: Circle())
.overlay {
Circle()
.stroke(.compound.borderInteractiveSecondary, lineWidth: 1)
}
.padding(10)
.accessibilityHidden(true)
}
}
.accessibilityLabel(L10n.a11yEditAvatar)
.buttonStyle(.plain)
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomAvatar)
.confirmationDialog("", isPresented: $context.showAttachmentConfirmationDialog) {
@@ -129,6 +151,23 @@ struct CreateRoomScreen: View {
}
}
private var editAvatarBadge: some View {
CompoundIcon(\.edit, size: .small, relativeTo: .body)
.foregroundStyle(.compound.iconPrimary)
.scaledPadding(5, relativeTo: .title)
.background {
Circle()
.fill(Color.compound.bgCanvasDefault)
.overlay {
Circle()
.inset(by: 0.5)
.stroke(.compound.borderInteractiveSecondary, lineWidth: 1)
}
}
.scaledPadding(3.5, relativeTo: .title)
.background(.compound.bgSubtleSecondaryLevel0, in: Circle())
}
private var topicSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenCreateRoomTopicPlaceholder),
@@ -180,9 +219,18 @@ struct CreateRoomScreen: View {
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.shouldShowCancelButton {
ToolbarItem(placement: .topBarLeading) {
ToolbarButton(role: .cancel) {
context.send(viewAction: .dismiss)
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionCreate) {
ToolbarButton(role: .confirm(title: L10n.actionCreate)) {
focus = nil
context.send(viewAction: .createRoom)
}
@@ -247,26 +295,55 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
AppSettings.resetAllSettings()
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
return CreateRoomScreenViewModel(isSpace: false,
shouldShowCancelButton: false,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
}()
static let avatarViewModel = {
AppSettings.resetAllSettings()
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
let viewModel = CreateRoomScreenViewModel(isSpace: false,
shouldShowCancelButton: false,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
viewModel.updateAvatar(fileURL: Bundle.main.url(forResource: "preview_avatar_room", withExtension: "jpg")!)
return viewModel
}()
static let spaceViewModel = {
AppSettings.resetAllSettings()
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
return CreateRoomScreenViewModel(isSpace: true,
shouldShowCancelButton: true,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
}()
static let spaceWithAvatarViewModel = {
AppSettings.resetAllSettings()
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com"))))
let viewModel = CreateRoomScreenViewModel(isSpace: true,
shouldShowCancelButton: true,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
appSettings: ServiceLocator.shared.settings)
viewModel.updateAvatar(fileURL: Bundle.main.url(forResource: "preview_avatar_room", withExtension: "jpg")!)
return viewModel
}()
static let publicRoomViewModel = {
AppSettings.resetAllSettings()
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
let viewModel = CreateRoomScreenViewModel(isSpace: false,
shouldShowCancelButton: false,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
@@ -281,6 +358,7 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
appSettings.knockingEnabled = true
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
let viewModel = CreateRoomScreenViewModel(isSpace: false,
shouldShowCancelButton: false,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
@@ -293,6 +371,7 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
AppSettings.resetAllSettings()
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userIDServerName: "example.org", userID: "@userid:example.com"))))
let viewModel = CreateRoomScreenViewModel(isSpace: false,
shouldShowCancelButton: false,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
@@ -308,6 +387,7 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
clientProxy.isAliasAvailableReturnValue = .success(false)
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let viewModel = CreateRoomScreenViewModel(isSpace: false,
shouldShowCancelButton: false,
userSession: userSession,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock(),
@@ -323,11 +403,23 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
}
.previewDisplayName("Create Room")
NavigationStack {
CreateRoomScreen(context: avatarViewModel.context)
}
.previewDisplayName("Create Room with avatar")
.snapshotPreferences(expect: avatarViewModel.context.$viewState.map { $0.avatarMediaInfo != nil })
NavigationStack {
CreateRoomScreen(context: spaceViewModel.context)
}
.previewDisplayName("Create Space")
NavigationStack {
CreateRoomScreen(context: spaceWithAvatarViewModel.context)
}
.previewDisplayName("Create Space with avatar")
.snapshotPreferences(expect: spaceWithAvatarViewModel.context.$viewState.map { $0.avatarMediaInfo != nil })
NavigationStack {
CreateRoomScreen(context: publicRoomViewModel.context)
}
@@ -19,6 +19,7 @@ struct SpacesScreenCoordinatorParameters {
enum SpacesScreenCoordinatorAction {
case selectSpace(SpaceRoomListProxyProtocol)
case showSettings
case showCreateSpace
}
final class SpacesScreenCoordinator: CoordinatorProtocol {
@@ -51,6 +52,8 @@ final class SpacesScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.selectSpace(spaceRoomListProxy))
case .showSettings:
actionsSubject.send(.showSettings)
case .showCreateSpace:
actionsSubject.send(.showCreateSpace)
}
}
.store(in: &cancellables)
@@ -11,6 +11,7 @@ import Foundation
enum SpacesScreenViewModelAction {
case selectSpace(SpaceRoomListProxyProtocol)
case showSettings
case showCreateSpace
}
struct SpacesScreenViewState: BindableState {
@@ -79,8 +79,7 @@ class SpacesScreenViewModel: SpacesScreenViewModelType, SpacesScreenViewModelPro
case .featureAnnouncementAppeared:
appSettings.hasSeenSpacesAnnouncement = true
case .createSpace:
// TODO: Implement
break
actionsSubject.send(.showCreateSpace)
}
}
@@ -67,7 +67,7 @@ class ClientProxy: ClientProxyProtocol {
ban: nil,
kick: nil,
redact: nil,
invite: nil,
invite: Int32(0),
notifications: nil,
users: [:],
events: [
@@ -91,6 +91,32 @@ class ClientProxy: ClientProxyProtocol {
"org.matrix.msc3401.call.member": Int32(0)
])
}
private static var standardSpaceCreationPowerLevelOverrides: PowerLevels {
.init(usersDefault: nil,
eventsDefault: Int32(100),
stateDefault: nil,
ban: nil,
kick: nil,
redact: nil,
invite: Int32(50),
notifications: nil,
users: [:],
events: [:])
}
private static var publicSpaceCreationPowerLevelOverrides: PowerLevels {
.init(usersDefault: nil,
eventsDefault: Int32(100),
stateDefault: nil,
ban: nil,
kick: nil,
redact: nil,
invite: Int32(0),
notifications: nil,
users: [:],
events: [:])
}
private var loadCachedAvatarURLTask: Task<Void, Never>?
private let userAvatarURLSubject = CurrentValueSubject<URL?, Never>(nil)
@@ -480,6 +506,20 @@ class ClientProxy: ClientProxyProtocol {
avatarURL: URL?,
aliasLocalPart: String?) async -> Result<String, ClientProxyError> {
do {
let powerLevelContentOverride = if isSpace {
if accessType == .public {
Self.publicSpaceCreationPowerLevelOverrides
} else {
Self.standardSpaceCreationPowerLevelOverrides
}
} else {
if accessType == .askToJoin {
Self.knockingRoomCreationPowerLevelOverrides
} else {
Self.roomCreationPowerLevelOverrides
}
}
let parameters = CreateRoomParameters(name: name,
topic: topic,
isEncrypted: accessType.isEncrypted,
@@ -488,7 +528,7 @@ class ClientProxy: ClientProxyProtocol {
preset: accessType.preset,
invite: userIDs,
avatar: avatarURL?.absoluteString,
powerLevelContentOverride: accessType == .askToJoin ? Self.knockingRoomCreationPowerLevelOverrides : Self.roomCreationPowerLevelOverrides,
powerLevelContentOverride: powerLevelContentOverride,
joinRuleOverride: accessType.joinRuleOverride,
historyVisibilityOverride: accessType.historyVisibilityOverride,
// This is an FFI naming mistake, what is required is the `aliasLocalPart` not the whole alias
@@ -659,7 +659,7 @@ class MockScreen: Identifiable {
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockBob, .mockBobby])
let navigationStackCoordinator = NavigationStackCoordinator()
let flowCoordinator = StartChatFlowCoordinator(isSpace: false,
let flowCoordinator = StartChatFlowCoordinator(entryPoint: .startChat,
userDiscoveryService: userDiscoveryService,
navigationStackCoordinator: navigationStackCoordinator,
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),