Add a SpaceScreen for listing rooms and subspaces within a space. (#4412)

This commit is contained in:
Doug
2025-08-14 17:24:20 +01:00
committed by GitHub
parent b34a3dae59
commit 1e5a5b36b2
34 changed files with 756 additions and 93 deletions

View File

@@ -0,0 +1,195 @@
//
// 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 SpaceHeaderView: View {
let spaceRoomProxy: SpaceRoomProxyProtocol
let mediaProvider: MediaProviderProtocol?
var title: String { spaceRoomProxy.name ?? "" }
var body: some View {
VStack(spacing: 16) {
RoomAvatarImage(avatar: spaceRoomProxy.avatar,
avatarSize: .room(on: .spaceHeader),
mediaProvider: mediaProvider)
VStack(spacing: 8) {
Text(title)
.font(.compound.headingLGBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
spaceDetails
SpaceHeaderMembersView(heroes: spaceRoomProxy.heroes,
joinedCount: spaceRoomProxy.joinedMembersCount,
mediaProvider: mediaProvider)
}
if let topic = spaceRoomProxy.topic {
Text(topic)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
}
.fixedSize(horizontal: false, vertical: true)
.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)
}
}
var spaceDetails: some View {
Label {
Text(L10n.screenSpaceListDetails(spaceDetailsVisibilityTitle, L10n.commonRooms(spaceRoomProxy.childrenCount)))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
} icon: {
CompoundIcon(spaceDetailsVisibilityIcon, size: .small, relativeTo: .compound.bodyLG)
.foregroundStyle(.compound.iconTertiary)
}
}
var spaceDetailsVisibilityTitle: String {
switch spaceRoomProxy.joinRule {
case .public:
L10n.commonPublicSpace
case .restricted(let rules), .knockRestricted(let rules):
// FIXME: Get this from the rule (falling back to a passed in parent??)
"<Parent name> space"
case .invite, .knock, .private, .custom, .none:
L10n.commonPrivateSpace
}
}
var spaceDetailsVisibilityIcon: KeyPath<CompoundIcons, Image> {
switch spaceRoomProxy.joinRule {
case .public:
\.public
case .restricted, .knockRestricted:
\.space
case .invite, .knock, .private, .custom, .none:
\.lock
}
}
}
import MatrixRustSDK
struct SpaceHeaderMembersView: View {
let heroes: [UserProfileProxy]
let joinedCount: Int
let mediaProvider: MediaProviderProtocol?
var body: some View {
if heroes.isEmpty {
Label(title: title) {
CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.labelStyle(.custom(spacing: 4))
.padding(.trailing, 8)
.background(.compound.bgSubtleSecondary, in: Capsule())
} else {
Label(title: title) {
heroesFacePile
}
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.labelStyle(.custom(spacing: 6))
}
}
func title() -> Text {
Text("\(joinedCount)")
}
var heroesFacePile: some View {
HStack(spacing: -8) {
ForEach(heroes.prefix(3).reversed()) { hero in
LoadableAvatarImage(url: hero.avatarURL,
name: hero.displayName,
contentID: hero.userID,
avatarSize: .user(on: .spaceHeader),
mediaProvider: mediaProvider)
.mask {
Circle()
.fill(Color.white)
.overlay {
if hero != heroes.first {
Circle()
.inset(by: -2)
.fill(Color.black)
.offset(x: 12)
}
}
.compositingGroup()
.luminanceToAlpha()
}
}
}
}
}
struct SpaceHeaderView_Previews: PreviewProvider, TestablePreview {
static let mediaProvider = MediaProviderMock(configuration: .init())
static let spaces = makeSpaceRooms()
static var previews: some View {
VStack(spacing: 0) {
ForEach(spaces, id: \.id) { space in
SpaceHeaderView(spaceRoomProxy: space, mediaProvider: mediaProvider)
}
}
}
static func makeSpaceRooms() -> [SpaceRoomProxyMock] {
[
SpaceRoomProxyMock(.init(id: "!space1:matrix.org",
name: "Company Space",
isSpace: true,
childrenCount: 10,
joinedMembersCount: 50)),
SpaceRoomProxyMock(.init(id: "!space2:matrix.org",
name: "Community Space",
avatarURL: .mockMXCAvatar,
isSpace: true,
childrenCount: 20,
joinedMembersCount: 78,
topic: "Description of the space goes right here.",
joinRule: .public)),
SpaceRoomProxyMock(.init(id: "!space3:matrix.org",
name: "Subspace",
isSpace: true,
childrenCount: 30,
joinedMembersCount: 123,
heroes: [.mockDan, .mockBob, .mockCharlie, .mockVerbose],
topic: ["Description of the space goes right here.",
"Lorem ipsum dolor sit amet consectetur.",
"Leo viverra morbi habitant in.",
"Sem amet enim habitant nibh augue mauris.",
"Interdum mauris ultrices tincidunt proin morbi erat aenean risus nibh.",
"Diam amet sit fermentum vulputate faucibus."].joined(separator: " "),
joinRule: .knockRestricted(rules: [.roomMembership(roomId: "")])))
]
}
}

View File

@@ -136,8 +136,7 @@ struct SpaceRoomCellButtonStyle: ButtonStyle {
struct SpaceRoomCell_Previews: PreviewProvider, TestablePreview {
static let mediaProvider = MediaProviderMock(configuration: .init())
static let spaces = makeSpaceRooms(isSpace: true)
static let rooms = makeSpaceRooms(isSpace: false)
static let spaces = [SpaceRoomProxyProtocol].mockSpaceList
static var previews: some View {
VStack(spacing: 0) {
@@ -146,34 +145,6 @@ struct SpaceRoomCell_Previews: PreviewProvider, TestablePreview {
isSelected: false,
mediaProvider: mediaProvider) { _ in }
}
ForEach(rooms, id: \.id) { room in
SpaceRoomCell(spaceRoom: room,
isSelected: false,
mediaProvider: mediaProvider) { _ in }
}
}
}
static func makeSpaceRooms(isSpace: Bool) -> [SpaceRoomProxyMock] {
let name = isSpace ? "Space" : "Room"
return [
SpaceRoomProxyMock(.init(id: "!space1:matrix.org",
name: "Company \(name)",
isSpace: isSpace)),
SpaceRoomProxyMock(.init(id: "!space2:matrix.org",
name: "Public \(name)",
avatarURL: .mockMXCAvatar,
isSpace: isSpace,
joinedMembersCount: 78,
topic: "Discussion on specific topic goes here.",
joinRule: .public)),
SpaceRoomProxyMock(.init(id: "!space3:matrix.org",
name: "Joined \(name)",
isSpace: isSpace,
joinedMembersCount: 123,
topic: "Discussion on specific topic goes here.",
state: .joined))
]
}
}

View File

@@ -28,6 +28,7 @@ class SpaceListScreenViewModel: SpaceListScreenViewModelType, SpaceListScreenVie
mediaProvider: userSession.mediaProvider)
spaceServiceProxy.joinedSpacesPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.joinedSpaces, on: self)
.store(in: &cancellables)

View File

@@ -105,56 +105,9 @@ struct SpaceListScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(counterValue: Int = 0) -> SpaceListScreenViewModel {
let clientProxy = ClientProxyMock(.init())
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let spaceService = SpaceServiceProxyMock(
.init(
joinedSpaces: [
SpaceRoomProxyMock(.init(id: "space1",
name: "The Foundation",
isSpace: true,
childrenCount: 1,
joinedMembersCount: 500,
state: .joined)),
SpaceRoomProxyMock(.init(id: "space2",
name: "The Second Foundation",
isSpace: true,
childrenCount: 1,
joinedMembersCount: 100,
state: .joined)),
SpaceRoomProxyMock(.init(id: "space3",
name: "The Galactic Empire",
isSpace: true,
childrenCount: 25000,
joinedMembersCount: 1_000_000_000,
state: .joined)),
SpaceRoomProxyMock(.init(id: "space4",
name: "The Korellians",
isSpace: true,
childrenCount: 27,
joinedMembersCount: 2_000_000,
state: .joined)),
SpaceRoomProxyMock(.init(id: "space5",
name: "The Luminists",
isSpace: true,
childrenCount: 1,
joinedMembersCount: 100_000,
state: .joined)),
SpaceRoomProxyMock(.init(id: "space6",
name: "The Anacreons",
isSpace: true,
childrenCount: 25,
joinedMembersCount: 400_000,
state: .joined)),
SpaceRoomProxyMock(.init(id: "space7",
name: "The Thespians",
isSpace: true,
childrenCount: 15,
joinedMembersCount: 300_000,
state: .joined))
]
)
)
let viewModel = SpaceListScreenViewModel(userSession: userSession,
spaceServiceProxy: spaceService)
let spaceService = SpaceServiceProxyMock(.init(joinedSpaces: .mockJoinedSpaces))
let viewModel = SpaceListScreenViewModel(userSession: userSession, spaceServiceProxy: spaceService)
return viewModel
}

View File

@@ -0,0 +1,55 @@
//
// 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.
//
// periphery:ignore:all - this is just a space remove this comment once generating the final file
import Combine
import SwiftUI
struct SpaceScreenCoordinatorParameters {
let spaceRoomListProxy: SpaceRoomListProxyProtocol
let mediaProvider: MediaProviderProtocol
}
enum SpaceScreenCoordinatorAction {
case selectSpace(SpaceRoomProxyProtocol)
}
final class SpaceScreenCoordinator: CoordinatorProtocol {
private let parameters: SpaceScreenCoordinatorParameters
private let viewModel: SpaceScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SpaceScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SpaceScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SpaceScreenViewModel(spaceRoomList: parameters.spaceRoomListProxy, mediaProvider: parameters.mediaProvider)
}
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 spaceRoom):
actionsSubject.send(.selectSpace(spaceRoom))
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(SpaceScreen(context: viewModel.context))
}
}

View File

@@ -0,0 +1,27 @@
//
// 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 SpaceScreenViewModelAction {
case selectSpace(SpaceRoomProxyProtocol)
}
struct SpaceScreenViewState: BindableState {
let space: SpaceRoomProxyProtocol
var isPaginating = false
var rooms: [SpaceRoomProxyProtocol]
var bindings = SpaceScreenViewStateBindings()
}
struct SpaceScreenViewStateBindings { }
enum SpaceScreenViewAction {
case spaceAction(SpaceRoomCell.Action)
}

View File

@@ -0,0 +1,60 @@
//
// 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 SpaceScreenViewModelType = StateStoreViewModelV2<SpaceScreenViewState, SpaceScreenViewAction>
class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<SpaceScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(spaceRoomList: SpaceRoomListProxyProtocol, mediaProvider: MediaProviderProtocol) {
super.init(initialViewState: SpaceScreenViewState(space: spaceRoomList.spaceRoom,
rooms: spaceRoomList.spaceRoomsPublisher.value),
mediaProvider: mediaProvider)
spaceRoomList.spaceRoomsPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.rooms, on: self)
.store(in: &cancellables)
spaceRoomList.paginationStatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] paginationState in
switch paginationState {
case .idle(let endReached):
self?.state.isPaginating = false
guard !endReached else { return }
Task { await spaceRoomList.paginate() }
case .loading:
self?.state.isPaginating = true
}
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SpaceScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .spaceAction(.select(let spaceRoom)):
if spaceRoom.isSpace {
actionsSubject.send(.selectSpace(spaceRoom))
} else {
#warning("Implement joining")
}
case .spaceAction(.join(let spaceID)):
#warning("Implement joining.")
}
}
}

View File

@@ -0,0 +1,14 @@
//
// 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 SpaceScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<SpaceScreenViewModelAction, Never> { get }
var context: SpaceScreenViewModelType.Context { get }
}

View File

@@ -0,0 +1,71 @@
//
// 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 SpaceScreen: View {
@Bindable var context: SpaceScreenViewModel.Context
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
SpaceHeaderView(spaceRoomProxy: context.viewState.space,
mediaProvider: context.mediaProvider)
rooms
}
}
.navigationTitle(context.viewState.space.name ?? L10n.commonSpace)
.navigationBarTitleDisplayMode(.inline)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
}
@ViewBuilder
var rooms: some View {
ForEach(context.viewState.rooms, id: \.id) { spaceRoom in
SpaceRoomCell(spaceRoom: spaceRoom,
isSelected: false,
mediaProvider: context.mediaProvider) { action in
context.send(viewAction: .spaceAction(action))
}
}
if context.viewState.isPaginating {
ProgressView()
.padding()
}
}
}
// MARK: - Previews
struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static var previews: some View {
NavigationStack {
SpaceScreen(context: viewModel.context)
}
}
static func makeViewModel() -> SpaceScreenViewModel {
let spaceRoomProxy = SpaceRoomProxyMock(.init(id: "!eng-space:matrix.org",
name: "Engineering Team",
isSpace: true,
childrenCount: 30,
joinedMembersCount: 76,
heroes: [.mockDan, .mockBob, .mockCharlie, .mockVerbose],
topic: "Description of the space goes right here. Lorem ipsum dolor sit amet consectetur. Leo viverra morbi habitant in.",
joinRule: .knockRestricted(rules: [.roomMembership(roomId: "")])))
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceRoomProxy: spaceRoomProxy,
initialSpaceRooms: .mockSpaceList))
let viewModel = SpaceScreenViewModel(spaceRoomList: spaceRoomListProxy,
mediaProvider: MediaProviderMock(configuration: .init()))
return viewModel
}
}