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:
Doug
2026-01-14 13:45:25 +00:00
committed by GitHub
parent cde9714a83
commit 1feae5100a
30 changed files with 221 additions and 223 deletions
@@ -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
}
}