mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
ff5dcb040d
Removes the ServiceLocator singleton and instead directly injects the dependencies it was holding for more control over instantiation of the various component subtrees and better visibility over how they're being used.
469 lines
19 KiB
Swift
469 lines
19 KiB
Swift
//
|
|
// 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 Compound
|
|
import SwiftUI
|
|
|
|
struct CreateRoomScreen: View {
|
|
@ObservedObject var context: CreateRoomScreenViewModel.Context
|
|
@FocusState private var focus: Focus?
|
|
|
|
private enum Focus {
|
|
case name
|
|
case topic
|
|
case alias
|
|
}
|
|
|
|
private var aliasBinding: Binding<String> {
|
|
.init(get: {
|
|
context.viewState.aliasLocalPart
|
|
}, set: {
|
|
context.send(viewAction: .updateAliasLocalPart($0))
|
|
})
|
|
}
|
|
|
|
private var roomNameBinding: Binding<String> {
|
|
.init(get: {
|
|
context.viewState.roomName
|
|
}, set: {
|
|
context.send(viewAction: .updateRoomName($0))
|
|
})
|
|
}
|
|
|
|
var body: some View {
|
|
Form {
|
|
roomSection
|
|
topicSection
|
|
if context.viewState.canSelectSpace {
|
|
selectSpaceSection
|
|
}
|
|
roomAccessSection
|
|
if !context.viewState.roomAccessType.isVisibilityPrivate {
|
|
roomAliasSection
|
|
}
|
|
}
|
|
.compoundList()
|
|
.track(screen: .CreateRoom)
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.navigationTitle(context.viewState.isSpace ? L10n.screenCreateRoomNewSpaceTitle : L10n.screenCreateRoomNewRoomTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar { toolbar }
|
|
.alert(item: $context.alertInfo)
|
|
.shouldScrollOnKeyboardDidShow(focus == .alias, to: Focus.alias)
|
|
.sheet(isPresented: $context.showSpaceSelectionSheet) {
|
|
CreateRoomSpaceSelectionSheet(context: context)
|
|
}
|
|
}
|
|
|
|
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, *) {
|
|
L10n.commonName
|
|
} else {
|
|
L10n.commonName.uppercased()
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(nameLabel)
|
|
.padding(.leading, ListRowPadding.horizontal)
|
|
.compoundListSectionHeader()
|
|
|
|
TextField(L10n.commonName,
|
|
text: roomNameBinding,
|
|
prompt: Text(L10n.screenCreateRoomNamePlaceholder).foregroundColor(.compound.textSecondary),
|
|
axis: .horizontal)
|
|
.font(.compound.bodyLG)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
.tint(.compound.iconAccentTertiary)
|
|
.focused($focus, equals: .name)
|
|
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomName)
|
|
.padding(.horizontal, ListRowPadding.horizontal)
|
|
.padding(.vertical, ListRowPadding.vertical)
|
|
.background(.compound.bgCanvasDefaultLevel1, in: nameTextFieldShape)
|
|
}
|
|
}
|
|
.listRowInsets(.init())
|
|
.padding(.top, 16)
|
|
}
|
|
}
|
|
|
|
private var roomAvatarButton: some View {
|
|
Button {
|
|
focus = nil
|
|
context.showAttachmentConfirmationDialog = true
|
|
} label: {
|
|
if let avatarImage = context.viewState.avatarImage {
|
|
Image(uiImage: avatarImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.scaledFrame(size: 70, relativeTo: .title)
|
|
.avatarShape(context.viewState.isSpace ? .roundedRect : .circle, size: 70)
|
|
.overlay(alignment: .bottomTrailing) {
|
|
EditAvatarBadge()
|
|
.scaledOffset(x: 8, y: 0, relativeTo: .title)
|
|
}
|
|
} else {
|
|
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) {
|
|
Button(L10n.actionTakePhoto) {
|
|
context.send(viewAction: .displayCameraPicker)
|
|
}
|
|
|
|
Button(L10n.actionChoosePhoto) {
|
|
context.send(viewAction: .displayMediaPicker)
|
|
}
|
|
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.mediaPicker)
|
|
|
|
Button(L10n.actionChooseFile) {
|
|
context.send(viewAction: .displayFilePicker)
|
|
}
|
|
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.filePicker)
|
|
|
|
if context.viewState.avatarImage != nil {
|
|
Button(L10n.actionRemove, role: .destructive) {
|
|
context.send(viewAction: .removeImage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var topicSection: some View {
|
|
Section {
|
|
ListRow(label: .plain(title: L10n.screenCreateRoomTopicPlaceholder),
|
|
kind: .textField(text: $context.roomTopic, axis: .vertical))
|
|
.lineLimit(3, reservesSpace: false)
|
|
.focused($focus, equals: .topic)
|
|
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomTopic)
|
|
} header: {
|
|
Text(L10n.screenCreateRoomTopicLabel)
|
|
.compoundListSectionHeader()
|
|
}
|
|
}
|
|
|
|
private var roomAccessSection: some View {
|
|
Section {
|
|
ForEach(context.viewState.availableAccessTypes, id: \.self) { accessType in
|
|
CreateRoomAccessRow(access: accessType,
|
|
spaceName: context.selectedSpace?.name ?? "",
|
|
isSelected: context.selectedAccessType == accessType) {
|
|
context.selectedAccessType = accessType
|
|
}
|
|
}
|
|
} header: {
|
|
Text(L10n.screenCreateRoomRoomAccessSectionTitle)
|
|
.compoundListSectionHeader()
|
|
}
|
|
}
|
|
|
|
private var roomAliasSection: some View {
|
|
Section {
|
|
EditRoomAddressListRow(aliasLocalPart: aliasBinding,
|
|
serverName: context.viewState.serverName,
|
|
shouldDisplayError: context.viewState.aliasErrors.errorDescription != nil)
|
|
.focused($focus, equals: .alias)
|
|
.id(Focus.alias)
|
|
} header: {
|
|
Text(L10n.screenCreateRoomRoomAddressSectionTitle)
|
|
.compoundListSectionHeader()
|
|
} footer: {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if let errorDescription = context.viewState.aliasErrors.errorDescription {
|
|
Label(errorDescription, icon: \.errorSolid, iconSize: .xSmall, relativeTo: .compound.bodySM)
|
|
.foregroundStyle(.compound.textCriticalPrimary)
|
|
.font(.compound.bodySM)
|
|
}
|
|
Text(L10n.screenCreateRoomRoomAddressSectionFooter)
|
|
.compoundListSectionFooter()
|
|
.font(.compound.bodySM)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var selectSpaceSection: some View {
|
|
Section {
|
|
if let selectedSpace = context.selectedSpace {
|
|
ListRow(label: .avatar(title: selectedSpace.name,
|
|
description: selectedSpace.canonicalAlias,
|
|
icon: RoomAvatarImage(avatar: selectedSpace.avatar,
|
|
avatarSize: .room(on: .createRoomSelectSpace),
|
|
mediaProvider: context.mediaProvider)),
|
|
kind: .navigationLink {
|
|
context.showSpaceSelectionSheet = true
|
|
})
|
|
} else {
|
|
ListRow(label: .plain(title: L10n.screenCreateRoomSpaceSelectionNoSpaceTitle),
|
|
kind: .navigationLink {
|
|
context.showSpaceSelectionSheet = true
|
|
})
|
|
}
|
|
} header: {
|
|
Text(L10n.commonSpace)
|
|
.compoundListSectionHeader()
|
|
}
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbar: some ToolbarContent {
|
|
if context.viewState.shouldShowCancelButton {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
ToolbarButton(role: .cancel) {
|
|
context.send(viewAction: .dismiss)
|
|
}
|
|
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.cancel)
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
ToolbarButton(role: .confirm(title: L10n.actionCreate)) {
|
|
focus = nil
|
|
context.send(viewAction: .createRoom)
|
|
}
|
|
.disabled(!context.viewState.canCreateRoom)
|
|
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.create)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CreateRoomAccessRow: View {
|
|
let access: CreateRoomScreenAccessType
|
|
let spaceName: String
|
|
let isSelected: Bool
|
|
let onSelection: () -> Void
|
|
|
|
var title: String {
|
|
switch access {
|
|
case .public:
|
|
L10n.screenCreateRoomRoomAccessSectionPublicOptionTitle
|
|
case .askToJoin:
|
|
L10n.screenCreateRoomRoomAccessSectionKnockingOptionTitle
|
|
case .private:
|
|
L10n.screenCreateRoomRoomAccessSectionPrivateOptionTitle
|
|
case .spaceMembers:
|
|
L10n.screenCreateRoomRoomAccessSectionRestrictedOptionTitle
|
|
case .askToJoinWithSpaceMembers:
|
|
L10n.screenCreateRoomRoomAccessSectionKnockingRestrictedOptionTitle
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch access {
|
|
case .public:
|
|
L10n.screenCreateRoomRoomAccessSectionPublicOptionDescription
|
|
case .askToJoin:
|
|
L10n.screenCreateRoomRoomAccessSectionKnockingOptionDescription
|
|
case .private:
|
|
L10n.screenCreateRoomRoomAccessSectionPrivateOptionDescription
|
|
case .spaceMembers:
|
|
L10n.screenCreateRoomRoomAccessSectionRestrictedOptionDescription(spaceName)
|
|
case .askToJoinWithSpaceMembers:
|
|
L10n.screenCreateRoomRoomAccessSectionKnockingRestrictedOptionDescription(spaceName)
|
|
}
|
|
}
|
|
|
|
var icon: KeyPath<CompoundIcons, Image> {
|
|
switch access {
|
|
case .public:
|
|
\.public
|
|
case .askToJoin:
|
|
\.userAdd
|
|
case .private:
|
|
\.lock
|
|
case .spaceMembers:
|
|
\.space
|
|
case .askToJoinWithSpaceMembers:
|
|
\.userAdd
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ListRow(label: .default(title: title,
|
|
description: description,
|
|
icon: icon),
|
|
kind: .selection(isSelected: isSelected,
|
|
action: onSelection))
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct CreateRoom_Previews: PreviewProvider, TestablePreview {
|
|
static let viewModel = makeViewModel()
|
|
|
|
static let avatarViewModel = {
|
|
let viewModel = makeViewModel()
|
|
viewModel.updateAvatar(fileURL: Bundle.main.url(forResource: "preview_avatar_room", withExtension: "jpg") ?? .picturesDirectory)
|
|
return viewModel
|
|
}()
|
|
|
|
static let spaceViewModel = makeViewModel(isSpace: true, selectionMode: .none)
|
|
|
|
static let spaceWithAvatarViewModel = {
|
|
let viewModel = makeViewModel(isSpace: true, selectionMode: .none)
|
|
viewModel.updateAvatar(fileURL: Bundle.main.url(forResource: "preview_avatar_room", withExtension: "jpg") ?? .picturesDirectory)
|
|
return viewModel
|
|
}()
|
|
|
|
static let publicRoomViewModel = {
|
|
let viewModel = makeViewModel()
|
|
viewModel.context.selectedAccessType = .public
|
|
return viewModel
|
|
}()
|
|
|
|
static let askToJoinViewModel = {
|
|
let viewModel = makeViewModel(isKnockingEnabled: true)
|
|
viewModel.context.selectedAccessType = .askToJoin
|
|
return viewModel
|
|
}()
|
|
|
|
static let publicRoomInvalidAliasViewModel = {
|
|
let viewModel = makeViewModel()
|
|
viewModel.context.selectedAccessType = .public
|
|
viewModel.context.send(viewAction: .updateAliasLocalPart("#:"))
|
|
return viewModel
|
|
}()
|
|
|
|
static let publicRoomExistingAliasViewModel = {
|
|
let viewModel = makeViewModel(isAliasAvailable: false)
|
|
viewModel.context.selectedAccessType = .public
|
|
viewModel.context.send(viewAction: .updateAliasLocalPart("existing"))
|
|
return viewModel
|
|
}()
|
|
|
|
static let selectedSpaceViewModel = makeViewModel(selectionMode: .editableSpacesList(preSelectedSpace: SpaceServiceRoom.mock(id: "awesome-space-id",
|
|
name: "Awesome Space",
|
|
isSpace: true,
|
|
joinRule: .invite)))
|
|
|
|
static let selectedSpaceWithListViewModel = {
|
|
let viewModel = makeViewModel()
|
|
viewModel.context.selectedSpace = [SpaceServiceRoom].mockJoinedSpaces2.first
|
|
return viewModel
|
|
}()
|
|
|
|
static let selectedSpaceWithAskToJoinViewModel = {
|
|
let viewModel = makeViewModel(isKnockingEnabled: true,
|
|
selectionMode: .editableSpacesList(preSelectedSpace: SpaceServiceRoom.mock(id: "awesome-space-id",
|
|
name: "Awesome Space",
|
|
isSpace: true,
|
|
joinRule: .invite)))
|
|
viewModel.context.selectedAccessType = .askToJoinWithSpaceMembers
|
|
return viewModel
|
|
}()
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: viewModel.context)
|
|
}
|
|
.previewDisplayName("Create Room")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: avatarViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Room with avatar")
|
|
.snapshotPreferences(expect: avatarViewModel.context.$viewState.map { $0.avatarImage != nil })
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: spaceViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Space")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: spaceWithAvatarViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Space with avatar")
|
|
.snapshotPreferences(expect: spaceWithAvatarViewModel.context.$viewState.map { $0.avatarImage != nil })
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: publicRoomViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Public Room")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: askToJoinViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Knockable Room")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: publicRoomInvalidAliasViewModel.context)
|
|
}
|
|
.snapshotPreferences(expect: publicRoomInvalidAliasViewModel.context.$viewState.map { !$0.aliasErrors.isEmpty })
|
|
.previewDisplayName("Create Public Room, invalid alias")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: publicRoomExistingAliasViewModel.context)
|
|
}
|
|
.snapshotPreferences(expect: publicRoomExistingAliasViewModel.context.$viewState.map { !$0.aliasErrors.isEmpty })
|
|
.previewDisplayName("Create Public Room, existing alias")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: selectedSpaceViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Room with already selected Space")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: selectedSpaceWithListViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Room with a selected Space from the list")
|
|
|
|
ElementNavigationStack {
|
|
CreateRoomScreen(context: selectedSpaceWithAskToJoinViewModel.context)
|
|
}
|
|
.previewDisplayName("Create Knockable Room with already selected Space")
|
|
}
|
|
|
|
private static func makeViewModel(isKnockingEnabled: Bool = false,
|
|
isSpace: Bool = false,
|
|
selectionMode: CreateRoomScreenSpaceSelectionMode = .editableSpacesList(preSelectedSpace: nil),
|
|
isAliasAvailable: Bool = true) -> CreateRoomScreenViewModel {
|
|
AppSettings.resetAllSettings()
|
|
let appSettings = AppSettings()
|
|
appSettings.knockingEnabled = isKnockingEnabled
|
|
|
|
let clientProxy = ClientProxyMock(.init(userIDServerName: "example.org",
|
|
userID: "@userid:example.com"))
|
|
clientProxy.isAliasAvailableReturnValue = .success(isAliasAvailable)
|
|
let spaces = [SpaceServiceRoom].mockJoinedSpaces2
|
|
clientProxy.spaceService = SpaceServiceProxyMock(.init(editableSpaces: spaces))
|
|
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
|
|
|
|
return CreateRoomScreenViewModel(isSpace: isSpace,
|
|
spaceSelectionMode: selectionMode,
|
|
shouldShowCancelButton: isSpace,
|
|
userSession: userSession,
|
|
analytics: .mock(settings: appSettings),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appSettings: appSettings)
|
|
}
|
|
}
|