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:
Doug
2026-01-14 12:07:57 +00:00
committed by GitHub
parent 530e249eee
commit cde9714a83
61 changed files with 1060 additions and 102 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ extension ClientProxyMock {
unignoreUserReturnValue = .success(())
trackRecentlyVisitedRoomReturnValue = .success(())
recentlyVisitedRoomsReturnValue = .success([])
recentlyVisitedRoomsFilterReturnValue = []
recentConversationCounterpartsReturnValue = []
let mediaLoader = MediaLoaderMock()
@@ -5047,15 +5047,15 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
}
//MARK: - recentlyVisitedRooms
var recentlyVisitedRoomsUnderlyingCallsCount = 0
var recentlyVisitedRoomsCallsCount: Int {
var recentlyVisitedRoomsFilterUnderlyingCallsCount = 0
var recentlyVisitedRoomsFilterCallsCount: Int {
get {
if Thread.isMainThread {
return recentlyVisitedRoomsUnderlyingCallsCount
return recentlyVisitedRoomsFilterUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = recentlyVisitedRoomsUnderlyingCallsCount
returnValue = recentlyVisitedRoomsFilterUnderlyingCallsCount
}
return returnValue!
@@ -5063,27 +5063,27 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
}
set {
if Thread.isMainThread {
recentlyVisitedRoomsUnderlyingCallsCount = newValue
recentlyVisitedRoomsFilterUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
recentlyVisitedRoomsUnderlyingCallsCount = newValue
recentlyVisitedRoomsFilterUnderlyingCallsCount = newValue
}
}
}
}
var recentlyVisitedRoomsCalled: Bool {
return recentlyVisitedRoomsCallsCount > 0
var recentlyVisitedRoomsFilterCalled: Bool {
return recentlyVisitedRoomsFilterCallsCount > 0
}
var recentlyVisitedRoomsUnderlyingReturnValue: Result<[String], ClientProxyError>!
var recentlyVisitedRoomsReturnValue: Result<[String], ClientProxyError>! {
var recentlyVisitedRoomsFilterUnderlyingReturnValue: [JoinedRoomProxyProtocol]!
var recentlyVisitedRoomsFilterReturnValue: [JoinedRoomProxyProtocol]! {
get {
if Thread.isMainThread {
return recentlyVisitedRoomsUnderlyingReturnValue
return recentlyVisitedRoomsFilterUnderlyingReturnValue
} else {
var returnValue: Result<[String], ClientProxyError>? = nil
var returnValue: [JoinedRoomProxyProtocol]? = nil
DispatchQueue.main.sync {
returnValue = recentlyVisitedRoomsUnderlyingReturnValue
returnValue = recentlyVisitedRoomsFilterUnderlyingReturnValue
}
return returnValue!
@@ -5091,22 +5091,22 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
}
set {
if Thread.isMainThread {
recentlyVisitedRoomsUnderlyingReturnValue = newValue
recentlyVisitedRoomsFilterUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
recentlyVisitedRoomsUnderlyingReturnValue = newValue
recentlyVisitedRoomsFilterUnderlyingReturnValue = newValue
}
}
}
}
var recentlyVisitedRoomsClosure: (() async -> Result<[String], ClientProxyError>)?
var recentlyVisitedRoomsFilterClosure: (((JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol])?
func recentlyVisitedRooms() async -> Result<[String], ClientProxyError> {
recentlyVisitedRoomsCallsCount += 1
if let recentlyVisitedRoomsClosure = recentlyVisitedRoomsClosure {
return await recentlyVisitedRoomsClosure()
func recentlyVisitedRooms(filter: (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol] {
recentlyVisitedRoomsFilterCallsCount += 1
if let recentlyVisitedRoomsFilterClosure = recentlyVisitedRoomsFilterClosure {
return await recentlyVisitedRoomsFilterClosure(filter)
} else {
return recentlyVisitedRoomsReturnValue
return recentlyVisitedRoomsFilterReturnValue
}
}
//MARK: - recentConversationCounterparts
@@ -17060,6 +17060,76 @@ class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable {
return joinedParentsChildIDReturnValue
}
}
//MARK: - addChild
var addChildToUnderlyingCallsCount = 0
var addChildToCallsCount: Int {
get {
if Thread.isMainThread {
return addChildToUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = addChildToUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
addChildToUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
addChildToUnderlyingCallsCount = newValue
}
}
}
}
var addChildToCalled: Bool {
return addChildToCallsCount > 0
}
var addChildToReceivedArguments: (childID: String, spaceID: String)?
var addChildToReceivedInvocations: [(childID: String, spaceID: String)] = []
var addChildToUnderlyingReturnValue: Result<Void, SpaceServiceProxyError>!
var addChildToReturnValue: Result<Void, SpaceServiceProxyError>! {
get {
if Thread.isMainThread {
return addChildToUnderlyingReturnValue
} else {
var returnValue: Result<Void, SpaceServiceProxyError>? = nil
DispatchQueue.main.sync {
returnValue = addChildToUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
addChildToUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
addChildToUnderlyingReturnValue = newValue
}
}
}
}
var addChildToClosure: ((String, String) async -> Result<Void, SpaceServiceProxyError>)?
func addChild(_ childID: String, to spaceID: String) async -> Result<Void, SpaceServiceProxyError> {
addChildToCallsCount += 1
addChildToReceivedArguments = (childID: childID, spaceID: spaceID)
DispatchQueue.main.async {
self.addChildToReceivedInvocations.append((childID: childID, spaceID: spaceID))
}
if let addChildToClosure = addChildToClosure {
return await addChildToClosure(childID, spaceID)
} else {
return addChildToReturnValue
}
}
}
class SpaceServiceRoomMock: SpaceServiceRoomProtocol, @unchecked Sendable {
var id: String {
@@ -38,6 +38,7 @@ extension SpaceServiceProxyMock {
spaceForIdentifierSpaceIDClosure = { spaceID in
.success(configuration.topLevelSpaces.first { $0.id == spaceID })
}
addChildToReturnValue = .success(())
}
}
+9 -6
View File
@@ -66,6 +66,7 @@ enum UserAvatarSizeOnScreen {
case settings
case roomDetails
case roomMembersList
case roomChangeRoles
case dmDetails
case startChat
case memberDetails
@@ -110,12 +111,14 @@ enum UserAvatarSizeOnScreen {
return 44
case .roomMembersList:
return 32
case .roomChangeRoles:
return 56
case .startChat:
return 36
case .memberDetails:
return 96
case .inviteUsers:
return 56
return 52
case .editUserDetails:
return 96
case .dmDetails:
@@ -153,6 +156,8 @@ enum RoomAvatarSizeOnScreen {
case roomDirectorySearch
case joinRoom
case spaceHeader
case spaceAddRooms
case spaceAddRoomsSelected
case completionSuggestions
var value: CGFloat {
@@ -164,12 +169,10 @@ enum RoomAvatarSizeOnScreen {
return 32
case .notificationSettings:
return 30
case .messageForwarding:
return 36
case .globalSearch:
return 36
case .roomSelection:
case .messageForwarding, .globalSearch, .roomSelection, .spaceAddRooms:
return 36
case .spaceAddRoomsSelected:
return 52
case .details:
return 96
case .joinRoom:
@@ -0,0 +1,48 @@
//
// 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
extension View {
func overlayRemoveItemButton(action: @escaping () -> Void) -> some View {
modifier(RemoveItemButtonViewModifier(action: action))
}
}
private struct RemoveItemButtonViewModifier: ViewModifier {
let action: () -> Void
func body(content: Content) -> some View {
content
.mask {
Rectangle()
.fill(.white)
.overlay(alignment: .topTrailing) {
closeButtonLabel
.hidden()
.padding(2)
.overlay { Circle().fill(.black) }
.offset(x: 2, y: -2)
}
.compositingGroup()
.luminanceToAlpha()
}
.overlay(alignment: .topTrailing) {
Button(action: action) {
closeButtonLabel
}
}
}
var closeButtonLabel: some View {
CompoundIcon(\.close, size: .custom(12), relativeTo: .compound.bodySM)
.foregroundStyle(.compound.iconOnSolidPrimary)
.padding(2)
.background(.compound.iconPrimary, in: Circle())
}
}
@@ -163,6 +163,8 @@ enum TestablePreviewsDictionary {
"SettingsScreen_Previews" : SettingsScreen_Previews.self,
"ShimmerOverlay_Previews" : ShimmerOverlay_Previews.self,
"SoftLogoutScreen_Previews" : SoftLogoutScreen_Previews.self,
"SpaceAddRoomsScreenSelectedItem_Previews" : SpaceAddRoomsScreenSelectedItem_Previews.self,
"SpaceAddRoomsScreen_Previews" : SpaceAddRoomsScreen_Previews.self,
"SpaceHeaderTopicSheetView_Previews" : SpaceHeaderTopicSheetView_Previews.self,
"SpaceHeaderView_Previews" : SpaceHeaderView_Previews.self,
"SpaceListScreen_Previews" : SpaceListScreen_Previews.self,
@@ -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
}
}
@@ -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)
}
}
}
@@ -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) {
@@ -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))
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -870,20 +870,35 @@ class ClientProxy: ClientProxyProtocol {
}
}
func recentlyVisitedRooms() async -> Result<[String], ClientProxyError> {
do {
let result = try await client.getRecentlyVisitedRooms()
return .success(result)
} catch {
MXLog.error("Failed retrieving recently visited rooms with error: \(error)")
return .failure(.sdkError(error))
func recentlyVisitedRooms(filter: (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol] {
let maxResultsToReturn = 5
guard case let .success(roomIdentifiers) = await recentlyVisitedRoomIDs() else {
return []
}
var rooms: [JoinedRoomProxyProtocol] = []
for roomID in roomIdentifiers {
guard case let .joined(roomProxy) = await roomForIdentifier(roomID),
filter(roomProxy) else {
continue
}
rooms.append(roomProxy)
if rooms.count >= maxResultsToReturn {
return rooms
}
}
return rooms
}
func recentConversationCounterparts() async -> [UserProfileProxy] {
let maxResultsToReturn = 5
guard case let .success(roomIdentifiers) = await recentlyVisitedRooms() else {
guard case let .success(roomIdentifiers) = await recentlyVisitedRoomIDs() else {
return []
}
@@ -909,6 +924,16 @@ class ClientProxy: ClientProxyProtocol {
return users.elements
}
private func recentlyVisitedRoomIDs() async -> Result<[String], ClientProxyError> {
do {
let result = try await client.getRecentlyVisitedRooms()
return .success(result)
} catch {
MXLog.error("Failed retrieving recently visited rooms with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: Moderation & Safety
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError> {
@@ -246,8 +246,7 @@ protocol ClientProxyProtocol: AnyObject {
func trackRecentlyVisitedRoom(_ roomID: String) async -> Result<Void, ClientProxyError>
func recentlyVisitedRooms() async -> Result<[String], ClientProxyError>
func recentlyVisitedRooms(filter: (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol]
func recentConversationCounterparts() async -> [UserProfileProxy]
// MARK: - Crypto
@@ -67,6 +67,15 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol {
}
}
func addChild(_ childID: String, to spaceID: String) async -> Result<Void, SpaceServiceProxyError> {
do {
return try await .success(spaceService.addChildToSpace(childId: childID, spaceId: spaceID))
} catch {
MXLog.error("Failed to add child \(childID) to space \(spaceID)")
return .failure(.sdkError(error))
}
}
// MARK: - Private
private func handleUpdates(_ updates: [SpaceListUpdate]) {
@@ -23,4 +23,7 @@ protocol SpaceServiceProxyProtocol {
func leaveSpace(spaceID: String) async -> Result<LeaveSpaceHandleProxy, SpaceServiceProxyError>
/// Returns all the parent spaces of a child that user has joined.
func joinedParents(childID: String) async -> Result<[SpaceServiceRoomProtocol], SpaceServiceProxyError>
/// Adds a room (or space) as a child of another space.
func addChild(_ childID: String, to spaceID: String) async -> Result<Void, SpaceServiceProxyError>
}