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