mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Add SpaceRoomCell & Space…ProxyProtocols and use them on the SpaceListScreen. (#4404)
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user