mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Tidy up the naming of spaces a bit more. (#4955)
* Rename SpaceListScreen to SpacesScreen To avoid confusion with the SpaceRoomListProxy. * Rename SpaceExplorerFlowCoordinator to SpacesTabFlowCoordinator and ChatsFlowCoordinator to ChatsTabFlowCoordinator.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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 SwiftUI
|
||||
|
||||
struct SpacesScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let selectedSpacePublisher: CurrentValuePublisher<String?, Never>
|
||||
let appSettings: AppSettings
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum SpacesScreenCoordinatorAction {
|
||||
case selectSpace(SpaceRoomListProxyProtocol)
|
||||
case showSettings
|
||||
}
|
||||
|
||||
final class SpacesScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: SpacesScreenCoordinatorParameters
|
||||
private let viewModel: SpacesScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<SpacesScreenCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<SpacesScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: SpacesScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = SpacesScreenViewModel(userSession: parameters.userSession,
|
||||
selectedSpacePublisher: parameters.selectedSpacePublisher,
|
||||
appSettings: parameters.appSettings,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
MXLog.info("Coordinator: received view model action: \(action)")
|
||||
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .selectSpace(let spaceRoomListProxy):
|
||||
actionsSubject.send(.selectSpace(spaceRoomListProxy))
|
||||
case .showSettings:
|
||||
actionsSubject.send(.showSettings)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(SpacesScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
enum SpacesScreenViewModelAction {
|
||||
case selectSpace(SpaceRoomListProxyProtocol)
|
||||
case showSettings
|
||||
}
|
||||
|
||||
struct SpacesScreenViewState: BindableState {
|
||||
let userID: String
|
||||
var userDisplayName: String?
|
||||
var userAvatarURL: URL?
|
||||
|
||||
var topLevelSpaces: [SpaceServiceRoomProtocol]
|
||||
var selectedSpaceID: String?
|
||||
|
||||
var isCreateSpaceEnabled: Bool
|
||||
|
||||
var bindings: SpacesScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct SpacesScreenViewStateBindings {
|
||||
var isPresentingFeatureAnnouncement = false
|
||||
}
|
||||
|
||||
enum SpacesScreenViewAction {
|
||||
case spaceAction(SpaceRoomCell.Action)
|
||||
case showSettings
|
||||
case screenAppeared
|
||||
case featureAnnouncementAppeared
|
||||
case createSpace
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// 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 SwiftUI
|
||||
|
||||
typealias SpacesScreenViewModelType = StateStoreViewModelV2<SpacesScreenViewState, SpacesScreenViewAction>
|
||||
|
||||
class SpacesScreenViewModel: SpacesScreenViewModelType, SpacesScreenViewModelProtocol {
|
||||
private let spaceServiceProxy: SpaceServiceProxyProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<SpacesScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<SpacesScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
selectedSpacePublisher: CurrentValuePublisher<String?, Never>,
|
||||
appSettings: AppSettings,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
spaceServiceProxy = userSession.clientProxy.spaceService
|
||||
self.appSettings = appSettings
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: SpacesScreenViewState(userID: userSession.clientProxy.userID,
|
||||
topLevelSpaces: spaceServiceProxy.topLevelSpacesPublisher.value,
|
||||
isCreateSpaceEnabled: appSettings.createSpaceEnabled,
|
||||
bindings: .init()),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
spaceServiceProxy.topLevelSpacesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.topLevelSpaces, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
selectedSpacePublisher
|
||||
.weakAssign(to: \.state.selectedSpaceID, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
userSession.clientProxy.userAvatarURLPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.userAvatarURL, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
userSession.clientProxy.userDisplayNamePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.userDisplayName, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$createSpaceEnabled
|
||||
.weakAssign(to: \.state.isCreateSpaceEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: SpacesScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
case .spaceAction(.select(let spaceServiceRoom)):
|
||||
Task { await selectSpace(spaceServiceRoom) }
|
||||
case .spaceAction(.join):
|
||||
fatalError("There shouldn't be any unjoined spaces in the joined spaces list.")
|
||||
case .showSettings:
|
||||
actionsSubject.send(.showSettings)
|
||||
case .screenAppeared:
|
||||
if !appSettings.hasSeenSpacesAnnouncement {
|
||||
// Use a task otherwise the presentation isn't animated.
|
||||
Task { state.bindings.isPresentingFeatureAnnouncement = true }
|
||||
}
|
||||
case .featureAnnouncementAppeared:
|
||||
appSettings.hasSeenSpacesAnnouncement = true
|
||||
case .createSpace:
|
||||
// TODO: Implement
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func selectSpace(_ spaceServiceRoom: SpaceServiceRoomProtocol) async {
|
||||
switch await spaceServiceProxy.spaceRoomList(spaceID: spaceServiceRoom.id) {
|
||||
case .success(let spaceRoomListProxy):
|
||||
actionsSubject.send(.selectSpace(spaceRoomListProxy))
|
||||
case .failure(let error):
|
||||
MXLog.error("Unable to select space: \(error)")
|
||||
showFailureIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Indicators
|
||||
|
||||
private static var failureIndicatorID: String { "\(Self.self)-Failure" }
|
||||
|
||||
private func showFailureIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: Self.failureIndicatorID,
|
||||
type: .toast,
|
||||
title: L10n.errorUnknown,
|
||||
iconName: "xmark"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
protocol SpacesScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<SpacesScreenViewModelAction, Never> { get }
|
||||
var context: SpacesScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// 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 SpacesAnnouncementSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let context: SpacesScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
FullscreenDialog(topPadding: 44, horizontalPadding: 24) {
|
||||
content
|
||||
} bottomContent: {
|
||||
buttons
|
||||
}
|
||||
.background()
|
||||
.backgroundStyle(.compound.bgCanvasDefault)
|
||||
.padding(.top, 14) // For the drag indicator
|
||||
.presentationDragIndicator(.visible)
|
||||
.onAppear { context.send(viewAction: .featureAnnouncementAppeared) }
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack(spacing: 16) {
|
||||
BigIcon(icon: \.spaceSolid, style: .defaultSolid)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text(L10n.screenSpaceAnnouncementTitle)
|
||||
.font(.compound.headingMDBold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L10n.commonBeta)
|
||||
.font(.compound.bodyXSSemibold)
|
||||
.foregroundStyle(.compound.textInfoPrimary)
|
||||
.textCase(.uppercase)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.compound.bgInfoSubtle)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(.compound.borderInfoSubtle)
|
||||
}
|
||||
}
|
||||
Text(L10n.screenSpaceAnnouncementSubtitle)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
visualListItems
|
||||
|
||||
Text(L10n.screenSpaceAnnouncementNotice)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
var visualListItems: some View {
|
||||
VStack(spacing: 4) {
|
||||
VisualListItem(title: L10n.screenSpaceAnnouncementItem1, position: .top) {
|
||||
CompoundIcon(\.visibilityOn)
|
||||
.foregroundStyle(.compound.iconSecondary)
|
||||
.alignmentGuide(.top) { _ in 2 }
|
||||
}
|
||||
VisualListItem(title: L10n.screenSpaceAnnouncementItem2, position: .middle) {
|
||||
CompoundIcon(\.email)
|
||||
.foregroundStyle(.compound.iconSecondary)
|
||||
.alignmentGuide(.top) { _ in 2 }
|
||||
}
|
||||
VisualListItem(title: L10n.screenSpaceAnnouncementItem3, position: .middle) {
|
||||
CompoundIcon(\.search)
|
||||
.foregroundStyle(.compound.iconSecondary)
|
||||
.alignmentGuide(.top) { _ in 2 }
|
||||
}
|
||||
// This isn't possible until we enabled the room directory.
|
||||
// VisualListItem(title: L10n.screenSpaceAnnouncementItem4, position: .middle) {
|
||||
// CompoundIcon(\.explore)
|
||||
// .foregroundStyle(.compound.iconSecondary)
|
||||
// .alignmentGuide(.top) { _ in 2 }
|
||||
// }
|
||||
VisualListItem(title: L10n.screenSpaceAnnouncementItem5, position: .bottom) {
|
||||
CompoundIcon(\.leave)
|
||||
.foregroundStyle(.compound.iconSecondary)
|
||||
.alignmentGuide(.top) { _ in 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buttons: some View {
|
||||
Button(L10n.actionContinue, action: dismiss.callAsFunction)
|
||||
.buttonStyle(.compound(.primary))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct SpacesAnnouncementSheetView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = SpacesScreenViewModel(userSession: UserSessionMock(.init()),
|
||||
selectedSpacePublisher: .init(nil),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
static var previews: some View {
|
||||
SpacesAnnouncementSheetView(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// 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 Compound
|
||||
import SwiftUI
|
||||
|
||||
struct SpacesScreen: View {
|
||||
@Bindable var context: SpacesScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
.navigationTitle(L10n.screenSpaceListTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.toolbarBloom(hasSearchBar: false)
|
||||
.onAppear { context.send(viewAction: .screenAppeared) }
|
||||
.sheet(isPresented: $context.isPresentingFeatureAnnouncement) {
|
||||
SpacesAnnouncementSheetView(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if context.viewState.isCreateSpaceEnabled, context.viewState.topLevelSpaces.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
header
|
||||
spaces
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
FullscreenDialog(horizontalPadding: 24) {
|
||||
TitleAndIcon(title: L10n.screenSpaceListEmptyStateTitle,
|
||||
icon: \.spaceSolid,
|
||||
iconStyle: .defaultSolid)
|
||||
} bottomContent: {
|
||||
Button(L10n.actionCreateSpace) {
|
||||
context.send(viewAction: .createSpace)
|
||||
}
|
||||
.buttonStyle(.compound(.primary))
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 16) {
|
||||
BigIcon(icon: \.spaceSolid)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(L10n.screenSpaceListTitle)
|
||||
.font(.compound.headingLGBold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(L10n.commonSpaces(context.viewState.topLevelSpaces.count))
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Text(L10n.screenSpaceListDescription)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 32)
|
||||
.padding(.bottom, 24)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.compound.borderDisabled)
|
||||
.frame(height: 1 / UIScreen.main.scale)
|
||||
}
|
||||
}
|
||||
|
||||
private var spaces: some View {
|
||||
ForEach(context.viewState.topLevelSpaces, id: \.id) { spaceServiceRoom in
|
||||
SpaceRoomCell(spaceServiceRoom: spaceServiceRoom,
|
||||
isSelected: spaceServiceRoom.id == context.viewState.selectedSpaceID,
|
||||
mediaProvider: context.mediaProvider) { action in
|
||||
context.send(viewAction: .spaceAction(action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
context.send(viewAction: .showSettings)
|
||||
} label: {
|
||||
LoadableAvatarImage(url: context.viewState.userAvatarURL,
|
||||
name: context.viewState.userDisplayName,
|
||||
contentID: context.viewState.userID,
|
||||
avatarSize: .user(on: .spaces),
|
||||
mediaProvider: context.mediaProvider)
|
||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
|
||||
.compositingGroup()
|
||||
}
|
||||
.accessibilityLabel(L10n.commonSettings)
|
||||
}
|
||||
.backportSharedBackgroundVisibility(.hidden)
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
// Hides the navigationTitle (which is set for the navigation stack label).
|
||||
Text("").accessibilityHidden(true)
|
||||
}
|
||||
.backportSharedBackgroundVisibility(.hidden)
|
||||
|
||||
if context.viewState.isCreateSpaceEnabled {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
context.send(viewAction: .createSpace)
|
||||
} label: {
|
||||
CompoundIcon(\.plus)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityLabel(L10n.actionCreateSpace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct SpacesScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
static let emptyViewModel = makeViewModel(isEmpty: true)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
SpacesScreen(context: viewModel.context)
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
SpacesScreen(context: emptyViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Empty")
|
||||
}
|
||||
|
||||
static func makeViewModel(isEmpty: Bool = false) -> SpacesScreenViewModel {
|
||||
AppSettings.resetAllSettings()
|
||||
let appSettings = AppSettings()
|
||||
appSettings.createSpaceEnabled = true
|
||||
appSettings.hasSeenSpacesAnnouncement = true
|
||||
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.spaceService = SpaceServiceProxyMock(.init(topLevelSpaces: isEmpty ? [] : .mockJoinedSpaces))
|
||||
|
||||
let viewModel = SpacesScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
selectedSpacePublisher: .init(nil),
|
||||
appSettings: appSettings,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user