mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
@@ -52,6 +52,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case joinSpace
|
||||
/// The root screen for this flow.
|
||||
case space
|
||||
/// The user is adding rooms to the space.
|
||||
case addingRooms
|
||||
/// A child (space) flow is in progress.
|
||||
case presentingChild(childSpaceID: String, previousState: State)
|
||||
/// A room flow is in progress
|
||||
@@ -77,6 +79,11 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
/// The space screen left the space.
|
||||
case leftSpace
|
||||
|
||||
/// Allow the user to add existing rooms to this space.
|
||||
case addRooms
|
||||
/// The user finished adding rooms to this space.
|
||||
case dismissedAddRooms
|
||||
|
||||
/// Request the presentation of a child space flow.
|
||||
///
|
||||
/// The space's `SpaceRoomListProxyProtocol` must be provided in the `userInfo`.
|
||||
@@ -145,6 +152,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
} else {
|
||||
navigationStackCoordinator.setRootCoordinator(nil, animated: animated)
|
||||
}
|
||||
case .addingRooms:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
|
||||
case .presentingChild:
|
||||
childSpaceFlowCoordinator?.clearRoute(animated: animated)
|
||||
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
|
||||
@@ -182,6 +192,11 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
self?.clearRoute(animated: true)
|
||||
}
|
||||
|
||||
stateMachine.addRoutes(event: .addRooms, transitions: [.space => .addingRooms]) { [weak self] _ in
|
||||
self?.presentSpaceAddRoomsScreen()
|
||||
}
|
||||
stateMachine.addRoutes(event: .dismissedAddRooms, transitions: [.addingRooms => .space])
|
||||
|
||||
stateMachine.addRouteMapping { event, fromState, userInfo in
|
||||
guard event == .startChildFlow else { return nil }
|
||||
guard let childEntryPoint = userInfo as? SpaceFlowCoordinatorEntryPoint else { fatalError("An entry point must be provided.") }
|
||||
@@ -306,6 +321,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.startSettingsFlow, userInfo: roomProxy)
|
||||
case .displayRolesAndPermissions(let roomProxy):
|
||||
stateMachine.tryEvent(.startRolesAndPermissionsFlow, userInfo: roomProxy)
|
||||
case .addExistingChildren:
|
||||
stateMachine.tryEvent(.addRooms)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -371,6 +388,31 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
|
||||
presentSpace()
|
||||
}
|
||||
|
||||
private func presentSpaceAddRoomsScreen() {
|
||||
guard case let .space(spaceRoomListProxy) = entryPoint else { fatalError("Attempting to show a space with the wrong entry point.") }
|
||||
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let parameters = SpaceAddRoomsScreenCoordinatorParameters(spaceRoomListProxy: spaceRoomListProxy,
|
||||
userSession: flowParameters.userSession,
|
||||
roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
|
||||
userIndicatorController: flowParameters.userIndicatorController)
|
||||
let coordinator = SpaceAddRoomsScreenCoordinator(parameters: parameters)
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
stackCoordinator.setRootCoordinator(coordinator)
|
||||
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedAddRooms)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Other flows
|
||||
|
||||
private func startChildFlow(with entryPoint: SpaceFlowCoordinatorEntryPoint) {
|
||||
|
||||
@@ -16772,6 +16772,41 @@ class SpaceRoomListProxyMock: SpaceRoomListProxyProtocol, @unchecked Sendable {
|
||||
paginateCallsCount += 1
|
||||
await paginateClosure?()
|
||||
}
|
||||
//MARK: - reset
|
||||
|
||||
var resetUnderlyingCallsCount = 0
|
||||
var resetCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return resetUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = resetUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
resetUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
resetUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var resetCalled: Bool {
|
||||
return resetCallsCount > 0
|
||||
}
|
||||
var resetClosure: (() async -> Void)?
|
||||
|
||||
func reset() async {
|
||||
resetCallsCount += 1
|
||||
await resetClosure?()
|
||||
}
|
||||
}
|
||||
class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable {
|
||||
var topLevelSpacesPublisher: CurrentValuePublisher<[SpaceServiceRoomProtocol], Never> {
|
||||
|
||||
@@ -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))
|
||||
|
||||
+29
-1
@@ -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
|
||||
|
||||
@@ -60,6 +60,10 @@ class SpaceRoomListProxy: SpaceRoomListProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func reset() async {
|
||||
await spaceRoomList.reset()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func handleUpdates(_ updates: [SpaceListUpdate]) {
|
||||
|
||||
@@ -22,4 +22,20 @@ protocol SpaceRoomListProxyProtocol {
|
||||
var paginationStatePublisher: CurrentValuePublisher<SpaceRoomListPaginationState, Never> { get }
|
||||
|
||||
func paginate() async
|
||||
func reset() async
|
||||
}
|
||||
|
||||
extension SpaceRoomListProxyProtocol {
|
||||
/// Resets the list and then waits everything to be paginated back in again before returning.
|
||||
///
|
||||
/// **Note:** It's the caller's responsibility to handle the calls to ``paginate``. This method
|
||||
/// purely acts as a helper to wait until the list has reloaded.
|
||||
func resetAndWaitForFullReload(timeout: Duration) async {
|
||||
await reset()
|
||||
|
||||
let runner = ExpiringTaskRunner { [paginationStatePublisher] in
|
||||
await _ = paginationStatePublisher.values.first { $0 == .idle(endReached: true) }
|
||||
}
|
||||
try? await runner.run(timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user