Add a SpaceScreen for listing rooms and subspaces within a space. (#4412)
This commit is contained in:
195
ElementX/Sources/Screens/Spaces/Common/SpaceHeaderView.swift
Normal file
195
ElementX/Sources/Screens/Spaces/Common/SpaceHeaderView.swift
Normal 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: "")])))
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user