Add space management to the flows. (#4978)

* Add the menu entries to add/remove rooms to/from a space.

* Add a user indicator to SpaceAddRoomsScreen.

* Reset the SpaceRoomListProxy after adding/removing any children.

* Calm the animations down a bit when entering EditMode on the SpaceScreen.
This commit is contained in:
Doug
2026-01-21 12:46:02 +00:00
committed by GitHub
parent 5577acac19
commit 3dccdf6685
29 changed files with 237 additions and 89 deletions
@@ -63,7 +63,8 @@ struct SpaceRoomCell: View {
action(.select(spaceServiceRoom))
} label: {
HStack(spacing: 0) {
if isEditModeActive {
if isEditModeActive,
!spaceServiceRoom.isSpace { // We only support selection of rooms (so don't show this while removing the cell).
ZStack {
ListRowAccessory.multiSelection(isSelected)
}
@@ -87,6 +88,9 @@ struct SpaceRoomCell: View {
}
}
.padding(.horizontal, horizontalInsets)
// Ensure the EditMode transition stays inside this cell if there are other insertions/removals in the list.
// Seems to slow down the animations a bit in Xcode previews but its fine in the simulator and on a device.
.drawingGroup()
.accessibilityElement(children: .combine)
}
.buttonStyle(SpaceRoomCellButtonStyle(isHighlighted: isHighlighted))
@@ -128,15 +128,43 @@ class SpaceAddRoomsScreenViewModel: SpaceAddRoomsScreenViewModelType, SpaceAddRo
}
private func save() async {
showSavingIndicator()
defer { hideSavingIndicator() }
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))
showErrorIndicator()
updateRooms() // Hide any rooms that were already added.
return
}
}
await spaceRoomListProxy.resetAndWaitForFullReload(timeout: .seconds(10))
actionsSubject.send(.dismiss)
}
// MARK: User Indicators
private var savingIndicatorID: String { "\(Self.self)-Saving" }
private var failureIndicatorID: String { "\(Self.self)-Failure" }
private func showSavingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: savingIndicatorID,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonSaving,
persistent: true))
}
private func hideSavingIndicator() {
userIndicatorController.retractIndicatorWithId(savingIndicatorID)
}
private func showErrorIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: failureIndicatorID,
type: .toast,
title: L10n.errorUnknown,
iconName: "xmark"))
}
}
@@ -28,6 +28,7 @@ enum SpaceScreenCoordinatorAction {
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
case displaySpaceSettings(roomProxy: JoinedRoomProxyProtocol)
case displayRolesAndPermissions(roomProxy: JoinedRoomProxyProtocol)
case addExistingChildren
}
final class SpaceScreenCoordinator: CoordinatorProtocol {
@@ -72,6 +73,8 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.displaySpaceSettings(roomProxy: roomProxy))
case .presentRolesAndPermissions(let roomProxy):
actionsSubject.send(.displayRolesAndPermissions(roomProxy: roomProxy))
case .addExistingChildren:
actionsSubject.send(.addExistingChildren)
}
}
.store(in: &cancellables)
@@ -16,6 +16,7 @@ enum SpaceScreenViewModelAction {
case presentRolesAndPermissions(roomProxy: JoinedRoomProxyProtocol)
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
case displaySpaceSettings(roomProxy: JoinedRoomProxyProtocol)
case addExistingChildren
}
struct SpaceScreenViewState: BindableState {
@@ -32,6 +33,7 @@ struct SpaceScreenViewState: BindableState {
var canEditBaseInfo = false
var canEditRolesAndPermissions = false
var canEditSecurityAndPrivacy = false
var canEditChildren = false
var editMode: EditMode = .inactive
var editModeSelectedIDs: Set<String> = []
@@ -65,6 +67,7 @@ enum SpaceScreenViewAction {
case leaveSpace
case spaceSettings(roomProxy: JoinedRoomProxyProtocol)
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
case addExistingRooms
case manageChildren
case removeSelectedChildren
case confirmRemoveSelectedChildren
@@ -89,12 +89,14 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
state.canEditBaseInfo = false
state.canEditRolesAndPermissions = false
state.canEditSecurityAndPrivacy = false
state.canEditChildren = false
return
}
state.canEditBaseInfo = powerLevels.canOwnUserEditBaseInfo()
state.canEditRolesAndPermissions = powerLevels.canOwnUserEditRolesAndPermissions()
state.canEditSecurityAndPrivacy = powerLevels.canOwnUserEditSecurityAndPrivacy(isSpace: roomInfo.isSpace,
joinRule: roomInfo.joinRule)
state.canEditChildren = powerLevels.canOwnUser(sendStateEvent: .spaceChild)
}
.store(in: &cancellables)
}
@@ -134,6 +136,8 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
actionsSubject.send(.displayMembers(roomProxy: roomProxy))
case .spaceSettings(let roomProxy):
actionsSubject.send(.displaySpaceSettings(roomProxy: roomProxy))
case .addExistingRooms:
actionsSubject.send(.addExistingChildren)
case .manageChildren:
withAnimation(.easeOut(duration: 0.25).disabledDuringTests()) {
state.editMode = .transient
@@ -196,6 +200,8 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
}
await spaceRoomListProxy.resetAndWaitForFullReload(timeout: .seconds(10))
process(viewAction: .finishManagingChildren)
}
@@ -27,17 +27,11 @@ struct SpaceScreen: View {
}
.environment(\.editMode, .constant(context.viewState.editMode))
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.toolbarRole(isEditModeActive ? .automatic : RoomHeaderView.toolbarRole)
.toolbarRole(RoomHeaderView.toolbarRole)
.navigationTitle(context.viewState.space.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(isEditModeActive)
.toolbar {
if isEditModeActive {
editModeToolbar
} else {
toolbar
}
}
.toolbar { toolbar }
.sheet(isPresented: $context.isPresentingRemoveChildrenConfirmation) {
SpaceRemoveChildrenConfirmationView(spaceName: context.viewState.space.name) {
context.send(viewAction: .confirmRemoveSelectedChildren)
@@ -67,6 +61,14 @@ struct SpaceScreen: View {
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
if isEditModeActive {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel, role: .cancel) {
context.send(viewAction: .finishManagingChildren)
}
}
}
// Use the same trick as the RoomScreen for a leading title view that
// also hides the navigation title.
ToolbarItem(placement: .principal) {
@@ -80,62 +82,62 @@ struct SpaceScreen: View {
}
}
// This should really use a ToolbarItemGroup(placement: .secondaryAction), however it
// was crashing on iOS 26.0 when tapping the ShareLink as the popover presentation
// controller attempts to anchor itself to the button that is no longer visible.
ToolbarItem(placement: .primaryAction) {
Menu {
Section {
if let roomProxy = context.viewState.roomProxy {
Button { context.send(viewAction: .displayMembers(roomProxy: roomProxy)) } label: {
Label(L10n.screenSpaceMenuActionMembers, icon: \.user)
}
}
if let permalink = context.viewState.permalink {
ShareLink(item: permalink) {
Label(L10n.actionShare, icon: \.shareIos)
if isEditModeActive {
ToolbarItem(placement: .primaryAction) {
ToolbarButton(role: .destructive(title: L10n.actionRemove)) {
context.send(viewAction: .removeSelectedChildren)
}
.disabled(context.viewState.editModeSelectedIDs.isEmpty)
}
} else {
// This should really use a ToolbarItemGroup(placement: .secondaryAction), however it
// was crashing on iOS 26.0 when tapping the ShareLink as the popover presentation
// controller attempts to anchor itself to the button that is no longer visible.
ToolbarItem(placement: .primaryAction) {
Menu {
if true {
Section {
Button { context.send(viewAction: .addExistingRooms) } label: {
Label(L10n.actionAddExistingRooms, icon: \.room)
}
Button { context.send(viewAction: .manageChildren) } label: {
Label(L10n.actionManageRooms, icon: \.edit)
}
}
}
if context.viewState.isSpaceManagementEnabled,
let roomProxy = context.viewState.roomProxy {
Button { context.send(viewAction: .spaceSettings(roomProxy: roomProxy)) } label: {
Label(L10n.commonSettings, icon: \.settings)
Section {
if let roomProxy = context.viewState.roomProxy {
Button { context.send(viewAction: .displayMembers(roomProxy: roomProxy)) } label: {
Label(L10n.screenSpaceMenuActionMembers, icon: \.user)
}
}
if let permalink = context.viewState.permalink {
ShareLink(item: permalink) {
Label(L10n.actionShare, icon: \.shareIos)
}
}
if context.viewState.isSpaceManagementEnabled,
let roomProxy = context.viewState.roomProxy {
Button { context.send(viewAction: .spaceSettings(roomProxy: roomProxy)) } label: {
Label(L10n.commonSettings, icon: \.settings)
}
}
}
}
Section {
Button(role: .destructive) { context.send(viewAction: .leaveSpace) } label: {
Label(L10n.actionLeaveSpace, icon: \.leave)
Section {
Button(role: .destructive) { context.send(viewAction: .leaveSpace) } label: {
Label(L10n.actionLeaveSpace, icon: \.leave)
}
}
} label: {
// Use an SF Symbol to match what ToolbarItemGroup(placement: .secondaryAction) would give us.
Image(systemSymbol: .ellipsis)
}
} label: {
// Use an SF Symbol to match what ToolbarItemGroup(placement: .secondaryAction) would give us.
Image(systemSymbol: .ellipsis)
}
}
}
@ToolbarContentBuilder
var editModeToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel, role: .cancel) {
context.send(viewAction: .finishManagingChildren)
}
}
ToolbarItem(placement: .principal) {
Text(L10n.commonSelectedCount(context.viewState.editModeSelectedIDs.count))
}
ToolbarItem(placement: .primaryAction) {
ToolbarButton(role: .destructive(title: L10n.actionRemove)) {
context.send(viewAction: .removeSelectedChildren)
}
.disabled(context.viewState.editModeSelectedIDs.isEmpty)
}
}
}
// MARK: - Previews