mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Implement the SpaceAddRoomsScreen. (#4951)
* Update proxies ready for adding rooms to a space. - ClientProxy.recentlyVisitedRooms to take a filter and to return room proxies. - Expose SpaceServiceProxy.addChildToSpace. * Implement the SpaceAddRoomsScreen. * Fix the designs of the selected carousels. Add more snapshot tests for inviting users. * Fix for screen using wrong avatar size.
This commit is contained in:
@@ -37,8 +37,6 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
!isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty
|
||||
}
|
||||
|
||||
var scrollToLastID: String?
|
||||
|
||||
func isUserSelected(_ user: UserProfileProxy) -> Bool {
|
||||
isUserDisabled(user) || selectedUsers.contains { $0.userID == user.userID }
|
||||
}
|
||||
@@ -69,6 +67,7 @@ struct InviteUsersScreenViewState: BindableState {
|
||||
|
||||
struct InviteUsersScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
var selectedUsersPosition: String?
|
||||
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<InviteUsersScreenErrorType>?
|
||||
|
||||
@@ -69,11 +69,10 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
|
||||
|
||||
private func toggleUser(_ user: UserProfileProxy) {
|
||||
if state.selectedUsers.contains(user) {
|
||||
state.scrollToLastID = nil
|
||||
state.selectedUsers.removeAll { $0.userID == user.userID }
|
||||
} else {
|
||||
state.scrollToLastID = user.userID
|
||||
state.selectedUsers.append(user)
|
||||
withElementAnimation(.easeInOut) { state.bindings.selectedUsersPosition = user.userID }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,28 +101,22 @@ struct InviteUsersScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ScaledMetric private var cellWidth: CGFloat = 72
|
||||
@ScaledMetric private var selectedUserCellWidth: CGFloat = 80
|
||||
|
||||
private var selectedUsersSection: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
ScrollViewReader { scrollView in
|
||||
HStack(spacing: 16) {
|
||||
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: context.mediaProvider) {
|
||||
deselect(user)
|
||||
}
|
||||
.frame(width: cellWidth)
|
||||
HStack(spacing: 8) {
|
||||
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: context.mediaProvider) {
|
||||
deselect(user)
|
||||
}
|
||||
.frame(width: selectedUserCellWidth)
|
||||
}
|
||||
.onChange(of: context.viewState.scrollToLastID) { _, lastAddedID in
|
||||
guard let id = lastAddedID else { return }
|
||||
withElementAnimation(.easeInOut) {
|
||||
scrollView.scrollTo(id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollPosition(id: $context.selectedUsersPosition, anchor: .trailing)
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
@@ -152,20 +146,54 @@ struct InviteUsersScreen: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = {
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
||||
return InviteUsersScreenViewModel(userSession: UserSessionMock(.init()),
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}()
|
||||
static let viewModel = makeViewModel()
|
||||
static let searchingViewModel = makeViewModel(searchQuery: "Alice")
|
||||
static let selectedViewModel = makeViewModel(hasSelection: true)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
InviteUsersScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("Suggestions")
|
||||
.snapshotPreferences(expect: viewModel.context.$viewState.map { !$0.usersSection.users.isEmpty })
|
||||
|
||||
NavigationStack {
|
||||
InviteUsersScreen(context: searchingViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Searching")
|
||||
.snapshotPreferences(expect: searchingViewModel.context.$viewState.map {
|
||||
$0.usersSection.type == .searchResult && !$0.usersSection.users.isEmpty
|
||||
})
|
||||
|
||||
NavigationStack {
|
||||
InviteUsersScreen(context: selectedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Selected")
|
||||
.snapshotPreferences(expect: selectedViewModel.context.$viewState.map { !$0.selectedUsers.isEmpty })
|
||||
}
|
||||
|
||||
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false) -> InviteUsersScreenViewModel {
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose]
|
||||
|
||||
let userDiscoveryService = UserDiscoveryServiceMock()
|
||||
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
||||
|
||||
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
||||
isSkippable: true,
|
||||
userDiscoveryService: userDiscoveryService,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
if let searchQuery {
|
||||
viewModel.context.searchQuery = searchQuery
|
||||
}
|
||||
|
||||
if hasSelection {
|
||||
viewModel.state.selectedUsers = [.mockAlice]
|
||||
}
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -6,6 +6,7 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct InviteUsersScreenSelectedItem: View {
|
||||
@@ -14,13 +15,13 @@ struct InviteUsersScreenSelectedItem: View {
|
||||
let dismissAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 10) {
|
||||
avatar
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(user.displayName ?? user.userID)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -35,15 +36,14 @@ struct InviteUsersScreenSelectedItem: View {
|
||||
contentID: user.userID,
|
||||
avatarSize: .user(on: .inviteUsers),
|
||||
mediaProvider: mediaProvider)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button(action: dismissAction) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.resizable()
|
||||
.scaledFrame(size: 20)
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(Color.compound.iconOnSolidPrimary, Color.compound.iconPrimary)
|
||||
}
|
||||
}
|
||||
.overlayRemoveItemButton(action: dismissAction)
|
||||
}
|
||||
|
||||
var closeButtonLabel: some View {
|
||||
CompoundIcon(\.close, size: .custom(12), relativeTo: .compound.bodySM)
|
||||
.foregroundStyle(.compound.iconOnSolidPrimary)
|
||||
.padding(2)
|
||||
.background(.compound.iconPrimary, in: Circle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ struct InviteUsersScreenSelectedItem_Previews: PreviewProvider, TestablePreview
|
||||
|
||||
static var previews: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 28) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(people, id: \.userID) { user in
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: MediaProviderMock(configuration: .init())) { }
|
||||
.frame(width: 72)
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ struct RoomChangeRolesScreenSelectedItem: View {
|
||||
LoadableAvatarImage(url: member.avatarURL,
|
||||
name: member.name,
|
||||
contentID: member.id,
|
||||
avatarSize: .user(on: .inviteUsers),
|
||||
avatarSize: .user(on: .roomChangeRoles),
|
||||
mediaProvider: mediaProvider)
|
||||
.accessibilityHidden(true)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations 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
|
||||
|
||||
struct SpaceAddRoomsScreenCoordinatorParameters {
|
||||
let spaceRoomListProxy: SpaceRoomListProxyProtocol
|
||||
let userSession: UserSessionProtocol
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum SpaceAddRoomsScreenCoordinatorAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
final class SpaceAddRoomsScreenCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: SpaceAddRoomsScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<SpaceAddRoomsScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<SpaceAddRoomsScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: SpaceAddRoomsScreenCoordinatorParameters) {
|
||||
viewModel = SpaceAddRoomsScreenViewModel(spaceRoomListProxy: parameters.spaceRoomListProxy,
|
||||
userSession: parameters.userSession,
|
||||
roomSummaryProvider: parameters.roomSummaryProvider,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(SpaceAddRoomsScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations 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
|
||||
|
||||
enum SpaceAddRoomsScreenViewModelAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
struct SpaceAddRoomsScreenViewState: BindableState {
|
||||
var roomsSection: Section
|
||||
var selectedRooms: [SpaceAddRoomsScreenRoom] = []
|
||||
|
||||
var bindings = SpaceAddRoomsScreenViewStateBindings()
|
||||
|
||||
struct Section {
|
||||
enum SectionType: Equatable { case searchResults, suggestions }
|
||||
let type: SectionType
|
||||
|
||||
let rooms: [SpaceAddRoomsScreenRoom]
|
||||
|
||||
var title: String? {
|
||||
switch type {
|
||||
case .searchResults:
|
||||
return nil
|
||||
case .suggestions:
|
||||
return rooms.isEmpty ? nil : L10n.commonSuggestions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SpaceAddRoomsScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
var selectedRoomsPosition: String?
|
||||
}
|
||||
|
||||
enum SpaceAddRoomsScreenViewAction {
|
||||
case cancel
|
||||
case reachedTop
|
||||
case reachedBottom
|
||||
case searchQueryChanged
|
||||
case toggleRoom(SpaceAddRoomsScreenRoom)
|
||||
case save
|
||||
}
|
||||
|
||||
struct SpaceAddRoomsScreenRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String
|
||||
let avatar: RoomAvatar
|
||||
}
|
||||
|
||||
extension SpaceAddRoomsScreenRoom {
|
||||
init(summary: RoomSummary) {
|
||||
self.init(id: summary.id,
|
||||
title: summary.name,
|
||||
description: summary.roomListDescription,
|
||||
avatar: summary.avatar)
|
||||
}
|
||||
|
||||
init(roomProxy: JoinedRoomProxyProtocol) {
|
||||
self.init(id: roomProxy.id,
|
||||
title: roomProxy.infoPublisher.value.displayName ?? roomProxy.id,
|
||||
description: roomProxy.infoPublisher.value.roomListDescription,
|
||||
avatar: roomProxy.infoPublisher.value.avatar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension RoomInfoProxyProtocol {
|
||||
var roomListDescription: String {
|
||||
if isDirect {
|
||||
return canonicalAlias ?? ""
|
||||
}
|
||||
|
||||
if let alias = canonicalAlias {
|
||||
return alias
|
||||
}
|
||||
|
||||
guard heroes.count > 0 else {
|
||||
return ""
|
||||
}
|
||||
|
||||
var heroComponents = heroes.compactMap(\.displayName)
|
||||
|
||||
let othersCount = Int(activeMembersCount) - heroes.count
|
||||
if othersCount > 0 {
|
||||
heroComponents.append(L10n.commonManyMembers(othersCount))
|
||||
}
|
||||
|
||||
return heroComponents.formatted(.list(type: .and))
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations 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 SpaceAddRoomsScreenViewModelType = StateStoreViewModelV2<SpaceAddRoomsScreenViewState, SpaceAddRoomsScreenViewAction>
|
||||
|
||||
class SpaceAddRoomsScreenViewModel: SpaceAddRoomsScreenViewModelType, SpaceAddRoomsScreenViewModelProtocol {
|
||||
private let spaceRoomListProxy: SpaceRoomListProxyProtocol
|
||||
private let spaceServiceProxy: SpaceServiceProxyProtocol
|
||||
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private var suggestedRooms: [SpaceAddRoomsScreenRoom] = []
|
||||
|
||||
private var actionsSubject: PassthroughSubject<SpaceAddRoomsScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<SpaceAddRoomsScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(spaceRoomListProxy: SpaceRoomListProxyProtocol,
|
||||
userSession: UserSessionProtocol,
|
||||
roomSummaryProvider: RoomSummaryProviderProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.spaceRoomListProxy = spaceRoomListProxy
|
||||
spaceServiceProxy = userSession.clientProxy.spaceService
|
||||
self.roomSummaryProvider = roomSummaryProvider
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: SpaceAddRoomsScreenViewState(roomsSection: .init(type: .suggestions, rooms: [])),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
roomSummaryProvider.roomListPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateRooms()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task {
|
||||
let existingRooms = spaceRoomListProxy.spaceRoomsPublisher.value
|
||||
suggestedRooms = await userSession.clientProxy
|
||||
.recentlyVisitedRooms { roomProxy in
|
||||
!roomProxy.infoPublisher.value.isDirect && !existingRooms.contains { $0.id == roomProxy.id }
|
||||
}
|
||||
.map { .init(roomProxy: $0) }
|
||||
|
||||
if state.roomsSection.type == .suggestions {
|
||||
state.roomsSection = .init(type: .suggestions, rooms: suggestedRooms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func process(viewAction: SpaceAddRoomsScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .reachedTop:
|
||||
updateVisibleRange(edge: .top)
|
||||
case .reachedBottom:
|
||||
updateVisibleRange(edge: .bottom)
|
||||
case .searchQueryChanged where state.bindings.searchQuery.isEmpty:
|
||||
roomSummaryProvider.setFilter(.all(filters: []))
|
||||
case .searchQueryChanged:
|
||||
roomSummaryProvider.setFilter(.search(query: state.bindings.searchQuery))
|
||||
case .toggleRoom(let room):
|
||||
toggleRoom(room)
|
||||
case .save:
|
||||
Task { await save() }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
// This is a shared provider so we should reset the filtering when we are done with the view
|
||||
roomSummaryProvider.setFilter(.all(filters: []))
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func updateRooms() {
|
||||
guard !state.bindings.searchQuery.isEmpty else {
|
||||
state.roomsSection = .init(type: .suggestions, rooms: suggestedRooms)
|
||||
return
|
||||
}
|
||||
|
||||
let existingRooms = spaceRoomListProxy.spaceRoomsPublisher.value
|
||||
var rooms = [SpaceAddRoomsScreenRoom]()
|
||||
|
||||
for summary in roomSummaryProvider.roomListPublisher.value where !summary.isDirect && !existingRooms.contains(where: { $0.id == summary.id }) {
|
||||
rooms.append(.init(summary: summary))
|
||||
}
|
||||
|
||||
state.roomsSection = .init(type: .searchResults, rooms: rooms)
|
||||
|
||||
// rooms crap
|
||||
}
|
||||
|
||||
/// The actual range values don't matter as long as they contain the lower
|
||||
/// or upper bounds. updateVisibleRange is a hybrid API that powers both
|
||||
/// sliding sync visible range update and list paginations
|
||||
/// For lists other than the home screen one we don't care about visible ranges,
|
||||
/// we just need the respective bounds to be there to trigger a next page load or
|
||||
/// a reset to just one page
|
||||
private func updateVisibleRange(edge: UIRectEdge) {
|
||||
switch edge {
|
||||
case .top:
|
||||
roomSummaryProvider.updateVisibleRange(0..<0)
|
||||
case .bottom:
|
||||
let roomCount = roomSummaryProvider.roomListPublisher.value.count
|
||||
roomSummaryProvider.updateVisibleRange(roomCount..<roomCount)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleRoom(_ room: SpaceAddRoomsScreenRoom) {
|
||||
if state.selectedRooms.contains(room) {
|
||||
state.selectedRooms.removeAll { $0.id == room.id }
|
||||
} else {
|
||||
state.selectedRooms.append(room)
|
||||
withElementAnimation(.easeInOut) { state.bindings.selectedRoomsPosition = room.id }
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
for room in state.selectedRooms {
|
||||
if case .failure(let error) = await spaceServiceProxy.addChild(room.id, to: spaceRoomListProxy.id) {
|
||||
MXLog.error("Failed adding room to space: \(error)")
|
||||
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
updateRooms() // Hide any rooms that were already added.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations 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 SpaceAddRoomsScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<SpaceAddRoomsScreenViewModelAction, Never> { get }
|
||||
var context: SpaceAddRoomsScreenViewModelType.Context { get }
|
||||
|
||||
func stop()
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations 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 SpaceAddRoomsScreen: View {
|
||||
@Bindable var context: SpaceAddRoomsScreenViewModel.Context
|
||||
|
||||
@State private var formWidth = CGFloat.zero
|
||||
|
||||
var showTopSection: Bool {
|
||||
!context.viewState.selectedRooms.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
EmptyView()
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
Text(L10n.screenSpaceAddRoomsRoomAccessDescription)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
if showTopSection {
|
||||
selectedRoomsSection
|
||||
.textCase(.none)
|
||||
.frame(width: formWidth)
|
||||
.padding(.bottom, -8)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
|
||||
if !context.viewState.roomsSection.rooms.isEmpty {
|
||||
roomsSection
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
.navigationTitle(L10n.actionAddExistingRooms)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.searchController(query: $context.searchQuery, showsCancelButton: false)
|
||||
.compoundSearchField()
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: context.searchQuery) { context.send(viewAction: .searchQueryChanged) }
|
||||
.readWidth($formWidth)
|
||||
}
|
||||
|
||||
@ScaledMetric private var selectedRoomCellWidth: CGFloat = 80
|
||||
|
||||
private var selectedRoomsSection: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(context.viewState.selectedRooms, id: \.id) { room in
|
||||
SpaceAddRoomsScreenSelectedItem(room: room, mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .toggleRoom(room))
|
||||
}
|
||||
.frame(width: selectedRoomCellWidth)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollPosition(id: $context.selectedRoomsPosition, anchor: .trailing)
|
||||
}
|
||||
|
||||
private var roomsSection: some View {
|
||||
Section {
|
||||
ForEach(context.viewState.roomsSection.rooms) { room in
|
||||
SpaceAddRoomsListRow(room: room,
|
||||
isSelected: context.viewState.selectedRooms.contains { $0.id == room.id },
|
||||
context: context)
|
||||
}
|
||||
// Replace these with ScrollView's `scrollPosition` when dropping iOS 16.
|
||||
} header: {
|
||||
switch context.viewState.roomsSection.type {
|
||||
case .searchResults:
|
||||
emptyRectangle
|
||||
.onAppear {
|
||||
context.send(viewAction: .reachedTop)
|
||||
}
|
||||
case .suggestions:
|
||||
if let sectionTitle = context.viewState.roomsSection.title {
|
||||
Text(sectionTitle)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if context.viewState.roomsSection.type == .searchResults {
|
||||
emptyRectangle
|
||||
.onAppear {
|
||||
context.send(viewAction: .reachedBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
|
||||
private var emptyRectangle: some View {
|
||||
Rectangle()
|
||||
.frame(width: 0, height: 0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
ToolbarButton(role: .cancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
ToolbarButton(role: .save) {
|
||||
context.send(viewAction: .save)
|
||||
}
|
||||
.disabled(context.viewState.selectedRooms.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SpaceAddRoomsListRow: View {
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
let room: SpaceAddRoomsScreenRoom
|
||||
let isSelected: Bool
|
||||
let context: SpaceAddRoomsScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ListRow(label: .avatar(title: room.title,
|
||||
description: room.description,
|
||||
icon: avatar),
|
||||
kind: .multiSelection(isSelected: isSelected) {
|
||||
context.send(viewAction: .toggleRoom(room))
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder @MainActor
|
||||
var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
RoomAvatarImage(avatar: room.avatar,
|
||||
avatarSize: .room(on: .spaceAddRooms),
|
||||
mediaProvider: context.mediaProvider)
|
||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct SpaceAddRoomsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let summaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
|
||||
static let viewModel = makeViewModel()
|
||||
static let searchingViewModel = makeViewModel(searchQuery: "Foundation")
|
||||
static let selectedViewModel = makeViewModel(searchQuery: "Foundation", hasSelection: true)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
SpaceAddRoomsScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("Suggested")
|
||||
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.roomsSection).map {
|
||||
$0.type == .suggestions && !$0.rooms.isEmpty
|
||||
})
|
||||
|
||||
NavigationStack {
|
||||
SpaceAddRoomsScreen(context: searchingViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Searching")
|
||||
|
||||
NavigationStack {
|
||||
SpaceAddRoomsScreen(context: selectedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Selected")
|
||||
}
|
||||
|
||||
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false) -> SpaceAddRoomsScreenViewModel {
|
||||
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: SpaceServiceRoomMock(.init(isSpace: true))))
|
||||
|
||||
let clientProxy = ClientProxyMock(.init())
|
||||
clientProxy.recentlyVisitedRoomsFilterReturnValue = [
|
||||
JoinedRoomProxyMock(.init(id: "1", name: "Room Name", canonicalAlias: "#room-name:example.com")),
|
||||
JoinedRoomProxyMock(.init(id: "2", name: "Room Name", canonicalAlias: "#room-name:example.com")),
|
||||
JoinedRoomProxyMock(.init(id: "3", name: "Room Name", canonicalAlias: "#room-name:example.com"))
|
||||
]
|
||||
|
||||
let viewModel = SpaceAddRoomsScreenViewModel(spaceRoomListProxy: spaceRoomListProxy,
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
roomSummaryProvider: summaryProvider,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
if let searchQuery {
|
||||
viewModel.context.searchQuery = searchQuery
|
||||
viewModel.context.send(viewAction: .searchQueryChanged)
|
||||
}
|
||||
|
||||
if hasSelection {
|
||||
viewModel.state.selectedRooms = Array(summaryProvider.roomListPublisher.value.prefix(2)).map(SpaceAddRoomsScreenRoom.init)
|
||||
}
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
// Copyright 2023-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 SpaceAddRoomsScreenSelectedItem: View {
|
||||
let room: SpaceAddRoomsScreenRoom
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let dismissAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
avatar
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(room.title)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAction(named: L10n.actionRemove, dismissAction)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
var avatar: some View {
|
||||
RoomAvatarImage(avatar: room.avatar,
|
||||
avatarSize: .room(on: .spaceAddRoomsSelected),
|
||||
mediaProvider: mediaProvider)
|
||||
.overlayRemoveItemButton(action: dismissAction)
|
||||
}
|
||||
}
|
||||
|
||||
struct SpaceAddRoomsScreenSelectedItem_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
SpaceAddRoomsScreenSelectedItem(room: .init(id: "",
|
||||
title: "Selected Room",
|
||||
description: "#selected:matrix.org",
|
||||
avatar: .room(id: "",
|
||||
name: "Selected Room",
|
||||
avatarURL: .mockMXCAvatar)),
|
||||
mediaProvider: MediaProviderMock(configuration: .init())) { }
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user