Add SpaceRoomCell & Space…ProxyProtocols and use them on the SpaceListScreen. (#4404)

This commit is contained in:
Doug
2025-08-13 12:06:29 +01:00
committed by GitHub
parent 192f4765c3
commit 3dc6d73899
32 changed files with 781 additions and 39 deletions
@@ -87,13 +87,16 @@ class SpaceExplorerFlowCoordinator: FlowCoordinatorProtocol {
}
private func presentSpaceList() {
let parameters = SpaceListScreenCoordinatorParameters(userSession: userSession)
// Temporarily using the mock until the SDK is updated.
let parameters = SpaceListScreenCoordinatorParameters(userSession: userSession, spaceServiceProxy: SpaceServiceProxyMock(.init()))
let coordinator = SpaceListScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .showSettings:
actionsSubject.send(.showSettings)
case .selectSpace:
break
}
}
.store(in: &cancellables)
@@ -15834,6 +15834,173 @@ class SessionVerificationControllerProxyMock: SessionVerificationControllerProxy
}
}
}
class SpaceRoomListProxyMock: SpaceRoomListProxyProtocol, @unchecked Sendable {
var spaceRoom: SpaceRoomProxyProtocol {
get { return underlyingSpaceRoom }
set(value) { underlyingSpaceRoom = value }
}
var underlyingSpaceRoom: SpaceRoomProxyProtocol!
var spaceRoomsPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never> {
get { return underlyingSpaceRoomsPublisher }
set(value) { underlyingSpaceRoomsPublisher = value }
}
var underlyingSpaceRoomsPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never>!
var paginationStatePublisher: CurrentValuePublisher<SpaceRoomListProxyPaginationState, Never> {
get { return underlyingPaginationStatePublisher }
set(value) { underlyingPaginationStatePublisher = value }
}
var underlyingPaginationStatePublisher: CurrentValuePublisher<SpaceRoomListProxyPaginationState, Never>!
//MARK: - paginate
var paginateUnderlyingCallsCount = 0
var paginateCallsCount: Int {
get {
if Thread.isMainThread {
return paginateUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = paginateUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
paginateUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
paginateUnderlyingCallsCount = newValue
}
}
}
}
var paginateCalled: Bool {
return paginateCallsCount > 0
}
var paginateClosure: (() async -> Void)?
func paginate() async {
paginateCallsCount += 1
await paginateClosure?()
}
}
class SpaceRoomProxyMock: SpaceRoomProxyProtocol, @unchecked Sendable {
var id: String {
get { return underlyingId }
set(value) { underlyingId = value }
}
var underlyingId: String!
var name: String?
var avatarURL: URL?
var isSpace: Bool {
get { return underlyingIsSpace }
set(value) { underlyingIsSpace = value }
}
var underlyingIsSpace: Bool!
var childrenCount: Int {
get { return underlyingChildrenCount }
set(value) { underlyingChildrenCount = value }
}
var underlyingChildrenCount: Int!
var joinedMembersCount: Int {
get { return underlyingJoinedMembersCount }
set(value) { underlyingJoinedMembersCount = value }
}
var underlyingJoinedMembersCount: Int!
var heroes: [UserProfileProxy] = []
var topic: String?
var canonicalAlias: String?
var joinRule: JoinRule?
var worldReadable: Bool?
var guestCanJoin: Bool {
get { return underlyingGuestCanJoin }
set(value) { underlyingGuestCanJoin = value }
}
var underlyingGuestCanJoin: Bool!
var state: Membership?
}
class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable {
var joinedSpacesPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never> {
get { return underlyingJoinedSpacesPublisher }
set(value) { underlyingJoinedSpacesPublisher = value }
}
var underlyingJoinedSpacesPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never>!
//MARK: - spaceRoomList
var spaceRoomListForUnderlyingCallsCount = 0
var spaceRoomListForCallsCount: Int {
get {
if Thread.isMainThread {
return spaceRoomListForUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = spaceRoomListForUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
spaceRoomListForUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
spaceRoomListForUnderlyingCallsCount = newValue
}
}
}
}
var spaceRoomListForCalled: Bool {
return spaceRoomListForCallsCount > 0
}
var spaceRoomListForReceivedSpaceRoom: SpaceRoomProxyProtocol?
var spaceRoomListForReceivedInvocations: [SpaceRoomProxyProtocol] = []
var spaceRoomListForUnderlyingReturnValue: Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>!
var spaceRoomListForReturnValue: Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>! {
get {
if Thread.isMainThread {
return spaceRoomListForUnderlyingReturnValue
} else {
var returnValue: Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>? = nil
DispatchQueue.main.sync {
returnValue = spaceRoomListForUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
spaceRoomListForUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
spaceRoomListForUnderlyingReturnValue = newValue
}
}
}
}
var spaceRoomListForClosure: ((SpaceRoomProxyProtocol) async -> Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>)?
func spaceRoomList(for spaceRoom: SpaceRoomProxyProtocol) async -> Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError> {
spaceRoomListForCallsCount += 1
spaceRoomListForReceivedSpaceRoom = spaceRoom
DispatchQueue.main.async {
self.spaceRoomListForReceivedInvocations.append(spaceRoom)
}
if let spaceRoomListForClosure = spaceRoomListForClosure {
return await spaceRoomListForClosure(spaceRoom)
} else {
return spaceRoomListForReturnValue
}
}
}
class StaticRoomSummaryProviderMock: StaticRoomSummaryProviderProtocol, @unchecked Sendable {
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
get { return underlyingRoomListPublisher }
@@ -0,0 +1,23 @@
//
// 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
import MatrixRustSDK
extension SpaceRoomListProxyMock {
struct Configuration {
var spaceRoomProxy: SpaceRoomProxyProtocol
var spaceRooms: [SpaceRoomProxyProtocol] = []
}
convenience init(_ configuration: Configuration) {
self.init()
spaceRoom = configuration.spaceRoomProxy
spaceRoomsPublisher = .init(configuration.spaceRooms)
}
}
@@ -0,0 +1,48 @@
//
// 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
import MatrixRustSDK
extension SpaceRoomProxyMock {
struct Configuration {
var id: String = UUID().uuidString
var name: String?
var avatarURL: URL?
var isSpace: Bool
var childrenCount = 0
var joinedMembersCount = 0
var heroes: [UserProfileProxy] = []
var topic: String?
var canonicalAlias: String?
var joinRule: JoinRule?
var worldReadable: Bool?
var guestCanJoin = true
var state: Membership?
}
convenience init(_ configuration: Configuration) {
self.init()
id = configuration.id
name = configuration.name
avatarURL = configuration.avatarURL
isSpace = configuration.isSpace
childrenCount = configuration.childrenCount
joinedMembersCount = configuration.joinedMembersCount
heroes = configuration.heroes
topic = configuration.topic
canonicalAlias = configuration.canonicalAlias
joinRule = configuration.joinRule
worldReadable = configuration.worldReadable
guestCanJoin = configuration.guestCanJoin
state = configuration.state
}
}
@@ -0,0 +1,29 @@
//
// 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
import MatrixRustSDK
extension SpaceServiceProxyMock {
struct Configuration {
var joinedSpaces: [SpaceRoomProxyProtocol] = []
var spaceRoomLists: [String: SpaceRoomListProxyMock] = [:]
}
convenience init(_ configuration: Configuration) {
self.init()
joinedSpacesPublisher = .init(configuration.joinedSpaces)
spaceRoomListForClosure = { spaceRoom in
if let spaceRoomList = configuration.spaceRoomLists[spaceRoom.id] {
.success(spaceRoomList)
} else {
.failure(.sdkError(ClientProxyMockError.generic))
}
}
}
}
@@ -42,6 +42,7 @@ enum A11yIdentifiers {
static let pollFormScreen = PollFormScreen()
static let roomPollsHistoryScreen = RoomPollsHistoryScreen()
static let manageRoomMemberSheet = ManageRoomMemberSheet()
static let spaceListScreen = SpaceListScreen()
struct AlertInfo {
let primaryButton = "alert_info-primary_button"
@@ -297,4 +298,13 @@ enum A11yIdentifiers {
struct ManageRoomMemberSheet {
let viewProfile = "manage_room_member_sheet-view_profile"
}
struct SpaceListScreen {
let userAvatar = "space_list_screen-user_avatar"
let roomNamePrefix = "space_list_screen-room_name"
func spaceRoomName(_ name: String) -> String {
"\(roomNamePrefix):\(name)"
}
}
}
+15 -10
View File
@@ -59,8 +59,9 @@ enum Avatars {
}
enum UserAvatarSizeOnScreen {
case chats
case spaces
case timeline
case home
case settings
case roomDetails
case dmDetails
@@ -82,13 +83,13 @@ enum UserAvatarSizeOnScreen {
var value: CGFloat {
switch self {
case .readReceipt:
return 16
case .readReceiptSheet:
case .chats, .spaces:
return 32
case .timeline:
return 32
case .home:
case .readReceipt:
return 16
case .readReceiptSheet:
return 32
case .completionSuggestions:
return 32
@@ -127,8 +128,9 @@ enum UserAvatarSizeOnScreen {
}
enum RoomAvatarSizeOnScreen {
case chats
case spaces
case timeline
case home
case messageForwarding
case globalSearch
case roomSelection
@@ -136,14 +138,17 @@ enum RoomAvatarSizeOnScreen {
case notificationSettings
case roomDirectorySearch
case joinRoom
case spaceHeader
case completionSuggestions
var value: CGFloat {
switch self {
case .notificationSettings:
return 30
case .chats, .spaces:
return 52
case .timeline:
return 32
case .notificationSettings:
return 30
case .roomDirectorySearch:
return 32
case .completionSuggestions:
@@ -154,12 +159,12 @@ enum RoomAvatarSizeOnScreen {
return 36
case .roomSelection:
return 36
case .home:
return 52
case .details:
return 96
case .joinRoom:
return 96
case .spaceHeader:
return 64
}
}
}
@@ -132,46 +132,46 @@ struct RoomAvatarImage_Previews: PreviewProvider, TestablePreview {
RoomAvatarImage(avatar: .room(id: "!1:server.com",
name: "Room",
avatarURL: nil),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .room(id: "!2:server.com",
name: "Room",
avatarURL: .mockMXCAvatar),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .space(id: "!space:server.com",
name: "Room",
avatarURL: nil),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .space(id: "!otherspace:server.com",
name: "Room",
avatarURL: .mockMXCAvatar),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .home), mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .chats), mediaProvider: MediaProviderMock(configuration: .init()))
}
HStack(spacing: 12) {
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: nil)]),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: .mockMXCAvatar)]),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@alice:server.com", displayName: "Alice", avatarURL: nil),
.init(userID: "@bob:server.net", displayName: "Bob", avatarURL: nil)]),
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
}
}
@@ -37,7 +37,7 @@ struct TombstonedAvatarImage: View {
struct TombstonedAvatarImage_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
TombstonedAvatarImage(avatarSize: .room(on: .home))
TombstonedAvatarImage(avatarSize: .room(on: .chats))
.previewLayout(.sizeThatFits)
}
}
@@ -154,6 +154,7 @@ enum TestablePreviewsDictionary {
"ShimmerOverlay_Previews" : ShimmerOverlay_Previews.self,
"SoftLogoutScreen_Previews" : SoftLogoutScreen_Previews.self,
"SpaceListScreen_Previews" : SpaceListScreen_Previews.self,
"SpaceRoomCell_Previews" : SpaceRoomCell_Previews.self,
"SplashScreen_Previews" : SplashScreen_Previews.self,
"StackedAvatarsView_Previews" : StackedAvatarsView_Previews.self,
"StartChatScreen_Previews" : StartChatScreen_Previews.self,
@@ -40,7 +40,7 @@ struct HomeScreen: View {
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .user(on: .home),
avatarSize: .user(on: .chats),
mediaProvider: context.mediaProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
.overlayBadge(10, isBadged: context.viewState.requiresExtraAccountSetup)
@@ -50,7 +50,7 @@ struct HomeScreenRoomCell: View {
private var avatar: some View {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .home),
avatarSize: .room(on: .chats),
mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
@@ -0,0 +1,179 @@
//
// 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 Compound
import SwiftUI
struct SpaceRoomCell: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let spaceRoom: SpaceRoomProxyProtocol
let isSelected: Bool
let mediaProvider: MediaProviderProtocol!
enum Action { case select(SpaceRoomProxyProtocol), join(SpaceRoomProxyProtocol) }
let action: (Action) -> Void
private let verticalInsets = 12.0
private let horizontalInsets = 16.0
private var subtitle: String {
if spaceRoom.isSpace {
spaceRoom.joinRule == .public ? L10n.commonPublicSpace : L10n.commonPrivateSpace
} else {
L10n.commonMemberCount(spaceRoom.joinedMembersCount)
}
}
private var details: String {
if spaceRoom.isSpace {
L10n.screenSpaceListDetails(L10n.commonRooms(spaceRoom.childrenCount),
L10n.commonMemberCount(spaceRoom.joinedMembersCount))
} else {
spaceRoom.topic ?? " " // Use a single space to reserve a consistent amount of space.
}
}
var body: some View {
Button {
action(.select(spaceRoom))
} label: {
HStack(spacing: 16.0) {
avatar
content
.padding(.vertical, verticalInsets)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.compound.borderDisabled)
.frame(height: 1 / UIScreen.main.scale)
.padding(.trailing, -horizontalInsets)
}
}
.padding(.horizontal, horizontalInsets)
.accessibilityElement(children: .combine)
}
.buttonStyle(SpaceRoomCellButtonStyle(isSelected: isSelected))
.accessibilityIdentifier(A11yIdentifiers.spaceListScreen.spaceRoomName(spaceRoom.name ?? spaceRoom.id))
}
@ViewBuilder @MainActor
private var avatar: some View {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: spaceRoom.avatar,
avatarSize: .room(on: .spaces),
mediaProvider: mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
}
private var content: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) {
Text(spaceRoom.name ?? spaceRoom.id)
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textPrimary)
.lineLimit(1)
visibilityLabel
Text(details)
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
accessory
}
}
private var visibilityLabel: some View {
Label {
Text(subtitle)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.lineLimit(1)
} icon: {
CompoundIcon(spaceRoom.joinRule == .public ? \.public : \.lockSolid,
size: .xSmall,
relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconTertiary)
}
.labelStyle(.custom(spacing: 4))
}
@ViewBuilder
private var accessory: some View {
switch spaceRoom.state {
case .none, .left, .invited:
Button(L10n.actionJoin) { action(.join(spaceRoom)) }
.font(.compound.bodyLG)
.foregroundStyle(.compound.textActionAccent)
case .joined, .knocked, .banned:
EmptyView()
}
}
}
struct SpaceRoomCellButtonStyle: ButtonStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(isSelected || configuration.isPressed ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault)
.contentShape(Rectangle())
.animation(isSelected ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isSelected)
}
}
struct SpaceRoomCell_Previews: PreviewProvider, TestablePreview {
static let mediaProvider = MediaProviderMock(configuration: .init())
static let spaces = makeSpaceRooms(isSpace: true)
static let rooms = makeSpaceRooms(isSpace: false)
static var previews: some View {
VStack(spacing: 0) {
ForEach(spaces, id: \.id) { space in
SpaceRoomCell(spaceRoom: space,
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))
]
}
}
@@ -12,9 +12,11 @@ import SwiftUI
struct SpaceListScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let spaceServiceProxy: SpaceServiceProxyProtocol
}
enum SpaceListScreenCoordinatorAction {
case selectSpace(SpaceRoomProxyProtocol)
case showSettings
}
@@ -32,7 +34,8 @@ final class SpaceListScreenCoordinator: CoordinatorProtocol {
init(parameters: SpaceListScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SpaceListScreenViewModel(userSession: parameters.userSession)
viewModel = SpaceListScreenViewModel(userSession: parameters.userSession,
spaceServiceProxy: parameters.spaceServiceProxy)
}
func start() {
@@ -41,6 +44,8 @@ final class SpaceListScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .selectSpace(let spaceRoom):
actionsSubject.send(.selectSpace(spaceRoom))
case .showSettings:
actionsSubject.send(.showSettings)
}
@@ -8,6 +8,7 @@
import Foundation
enum SpaceListScreenViewModelAction {
case selectSpace(SpaceRoomProxyProtocol)
case showSettings
}
@@ -16,18 +17,19 @@ struct SpaceListScreenViewState: BindableState {
var userDisplayName: String?
var userAvatarURL: URL?
var rooms: [HomeScreenRoom]
var joinedSpaces: [SpaceRoomProxyProtocol]
var joinedRoomsCount: Int
var bindings: SpaceListScreenViewStateBindings
var subtitle: String {
L10n.screenSpaceListDetails(L10n.commonSpaces(rooms.count), L10n.commonRooms(joinedRoomsCount))
L10n.screenSpaceListDetails(L10n.commonSpaces(joinedSpaces.count), L10n.commonRooms(joinedRoomsCount))
}
}
struct SpaceListScreenViewStateBindings { }
enum SpaceListScreenViewAction {
case spaceAction(SpaceRoomCell.Action)
case showSettings
}
@@ -11,18 +11,26 @@ import SwiftUI
typealias SpaceListScreenViewModelType = StateStoreViewModelV2<SpaceListScreenViewState, SpaceListScreenViewAction>
class SpaceListScreenViewModel: SpaceListScreenViewModelType, SpaceListScreenViewModelProtocol {
private let spaceServiceProxy: SpaceServiceProxyProtocol
private let actionsSubject: PassthroughSubject<SpaceListScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceListScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(userSession: UserSessionProtocol) {
init(userSession: UserSessionProtocol, spaceServiceProxy: SpaceServiceProxyProtocol) {
self.spaceServiceProxy = spaceServiceProxy
super.init(initialViewState: SpaceListScreenViewState(userID: userSession.clientProxy.userID,
rooms: [],
joinedSpaces: spaceServiceProxy.joinedSpacesPublisher.value,
joinedRoomsCount: 0,
bindings: .init()),
mediaProvider: userSession.mediaProvider)
spaceServiceProxy.joinedSpacesPublisher
.weakAssign(to: \.state.joinedSpaces, on: self)
.store(in: &cancellables)
userSession.clientProxy.userAvatarURLPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userAvatarURL, on: self)
@@ -40,6 +48,10 @@ class SpaceListScreenViewModel: SpaceListScreenViewModelType, SpaceListScreenVie
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .spaceAction(.select(let spaceRoom)):
actionsSubject.send(.selectSpace(spaceRoom))
case .spaceAction(.join(let spaceRoom)):
#warning("Implement joining.")
case .showSettings:
actionsSubject.send(.showSettings)
}
@@ -15,6 +15,7 @@ struct SpaceListScreen: View {
ScrollView {
LazyVStack(spacing: 0) {
header
spaces
}
}
.navigationTitle(L10n.screenSpaceListTitle)
@@ -56,6 +57,16 @@ struct SpaceListScreen: View {
}
}
var spaces: some View {
ForEach(context.viewState.joinedSpaces, id: \.id) { spaceRoom in
SpaceRoomCell(spaceRoom: spaceRoom,
isSelected: false,
mediaProvider: context.mediaProvider) { action in
context.send(viewAction: .spaceAction(action))
}
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
@@ -65,7 +76,7 @@ struct SpaceListScreen: View {
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .user(on: .home),
avatarSize: .user(on: .spaces),
mediaProvider: context.mediaProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
.compositingGroup()
@@ -94,7 +105,56 @@ struct SpaceListScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(counterValue: Int = 0) -> SpaceListScreenViewModel {
let clientProxy = ClientProxyMock(.init())
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let viewModel = SpaceListScreenViewModel(userSession: userSession)
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)
return viewModel
}
@@ -0,0 +1,25 @@
//
// 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 MatrixRustSDK
// Temporary until the SDK is updated.
enum SpaceRoomListProxyPaginationState {
case idle(endReached: Bool)
case loading
}
// sourcery: AutoMockable
protocol SpaceRoomListProxyProtocol {
var spaceRoom: SpaceRoomProxyProtocol { get }
var spaceRoomsPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never> { get }
var paginationStatePublisher: CurrentValuePublisher<SpaceRoomListProxyPaginationState, Never> { get }
func paginate() async
}
@@ -0,0 +1,39 @@
//
// 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
import MatrixRustSDK
// sourcery: AutoMockable
protocol SpaceRoomProxyProtocol {
var id: String { get }
var name: String? { get }
var avatarURL: URL? { get }
var isSpace: Bool { get }
var childrenCount: Int { get }
var joinedMembersCount: Int { get }
var heroes: [UserProfileProxy] { get }
var topic: String? { get }
var canonicalAlias: String? { get }
var joinRule: JoinRule? { get }
var worldReadable: Bool? { get }
var guestCanJoin: Bool { get }
var state: Membership? { get }
}
extension SpaceRoomProxyProtocol {
var avatar: RoomAvatar {
if isSpace {
.space(id: id, name: name, avatarURL: avatarURL)
} else { // We don't need to check for heroes, we only do that for DMs.
.room(id: id, name: name, avatarURL: avatarURL)
}
}
}
@@ -0,0 +1,19 @@
//
// 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 SpaceServiceProxyError: Error {
case sdkError(Error)
}
// sourcery: AutoMockable
protocol SpaceServiceProxyProtocol {
var joinedSpacesPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never> { get }
func spaceRoomList(for spaceRoom: SpaceRoomProxyProtocol) async -> Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>
}