Files
element-x-ios/ElementX/Sources/Screens/Spaces/SpaceScreen/SpaceScreenViewModel.swift
T

282 lines
13 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2025 Element Creations Ltd.
// 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 SwiftUI
typealias SpaceScreenViewModelType = StateStoreViewModelV2<SpaceScreenViewState, SpaceScreenViewAction>
class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtocol {
private let spaceRoomListProxy: SpaceRoomListProxyProtocol
private let spaceServiceProxy: SpaceServiceProxyProtocol
private let clientProxy: ClientProxyProtocol
private let mediaProvider: MediaProviderProtocol
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<SpaceScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(spaceRoomListProxy: SpaceRoomListProxyProtocol,
spaceServiceProxy: SpaceServiceProxyProtocol,
selectedSpaceRoomPublisher: CurrentValuePublisher<String?, Never>,
userSession: UserSessionProtocol,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.spaceRoomListProxy = spaceRoomListProxy
self.spaceServiceProxy = spaceServiceProxy
clientProxy = userSession.clientProxy
mediaProvider = userSession.mediaProvider
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
super.init(initialViewState: SpaceScreenViewState(space: spaceRoomListProxy.spaceServiceRoomPublisher.value,
rooms: spaceRoomListProxy.spaceRoomsPublisher.value,
selectedSpaceRoomID: selectedSpaceRoomPublisher.value),
mediaProvider: userSession.mediaProvider)
spaceRoomListProxy.spaceServiceRoomPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.space, on: self)
.store(in: &cancellables)
spaceRoomListProxy.spaceRoomsPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.rooms, on: self)
.store(in: &cancellables)
// As the server is slow, we just let the screen automatically paginate everything in. We can
// switch this to use the scroll position once Synapse receives some performance improvements.
spaceRoomListProxy.paginationStatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] paginationState in
guard let self else { return }
switch paginationState {
case .idle(endReached: false):
state.paginationState = .idle
Task { await spaceRoomListProxy.paginate() }
case .idle(endReached: true):
state.paginationState = .endReached
case .loading:
state.paginationState = .paginating
}
}
.store(in: &cancellables)
selectedSpaceRoomPublisher
.weakAssign(to: \.state.selectedSpaceRoomID, on: self)
.store(in: &cancellables)
appSettings.$createSpaceEnabled
.weakAssign(to: \.state.canCreateRoom, on: self)
.store(in: &cancellables)
Task {
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.id) {
// Required to listen for membership updates in the members flow
await roomProxy.subscribeForUpdates()
state.roomProxy = roomProxy
if case let .success(permalinkURL) = await roomProxy.matrixToPermalink() {
state.permalink = permalinkURL
}
appSettings.$spaceSettingsEnabled
.combineLatest(roomProxy.infoPublisher)
.sink { [weak self] isEnabled, roomInfo in
guard let self else { return }
guard isEnabled, let powerLevels = roomInfo.powerLevels else {
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)
}
}
}
// MARK: - Public
override func process(viewAction: SpaceScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .spaceAction(.select(let spaceServiceRoom)) where state.editMode == .inactive:
if spaceServiceRoom.isSpace {
if spaceServiceRoom.state != .joined {
actionsSubject.send(.selectUnjoinedSpace(spaceServiceRoom))
} else {
Task { await selectSpace(spaceServiceRoom) }
}
} else {
// No need to check the join state, the room flow will show an appropriately configured join screen if needed.
actionsSubject.send(.selectRoom(roomID: spaceServiceRoom.id))
}
case .spaceAction(.select(let spaceServiceRoom)): // isEditModeActive == true
withTransaction(\.disablesAnimations, true) { // The button adds an unwanted animation.
if state.editModeSelectedIDs.contains(spaceServiceRoom.id) {
state.editModeSelectedIDs.remove(spaceServiceRoom.id)
} else {
state.editModeSelectedIDs.insert(spaceServiceRoom.id)
}
}
case .spaceAction(.join(let spaceServiceRoom)):
Task { await join(spaceServiceRoom) }
case .leaveSpace:
Task { await showLeaveSpaceConfirmation() }
case .displayMembers(let roomProxy):
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
}
case .removeSelectedChildren:
state.bindings.isPresentingRemoveChildrenConfirmation = true
case .confirmRemoveSelectedChildren:
Task { await removeSelectedChildren() }
case .finishManagingChildren:
withAnimation(.easeOut(duration: 0.25).disabledDuringTests()) {
state.editMode = .inactive
} completion: {
self.state.editModeSelectedIDs.removeAll()
}
case .createChildRoom:
Task { await createChildRoom() }
}
}
func stop() {
// If we pop this screen with running join operations, we don't want them to do anything.
state.joiningRoomIDs.removeAll()
}
// MARK: - Private
private func createChildRoom() async {
switch await spaceServiceProxy.spaceForIdentifier(spaceID: spaceRoomListProxy.id) {
case .success(.some(let space)):
actionsSubject.send(.displayCreateChildRoomFlow(space: space))
default:
MXLog.error("Unable to create child room: space not found")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func join(_ spaceServiceRoom: SpaceServiceRoomProtocol) async {
state.joiningRoomIDs.insert(spaceServiceRoom.id)
defer { state.joiningRoomIDs.remove(spaceServiceRoom.id) }
guard case .success = await clientProxy.joinRoom(spaceServiceRoom.id, via: spaceServiceRoom.via) else {
showFailureIndicator()
return
}
// We don't want to show the space room after joining it this way 🤷‍♂️
}
private func selectSpace(_ spaceServiceRoom: SpaceServiceRoomProtocol) async {
switch await spaceServiceProxy.spaceRoomList(spaceID: spaceServiceRoom.id) {
case .success(let spaceRoomListProxy):
actionsSubject.send(.selectSpace(spaceRoomListProxy))
case .failure(let error):
MXLog.error("Unable to select space: \(error)")
showFailureIndicator()
}
}
private func removeSelectedChildren() async {
showRemovingIndicator()
defer { hideRemovingIndicator() }
state.bindings.isPresentingRemoveChildrenConfirmation = false
for childID in state.editModeSelectedIDs {
switch await spaceServiceProxy.removeChild(childID, from: spaceRoomListProxy.id) {
case .success:
MXLog.info("Successfully removed \(childID) from \(spaceRoomListProxy.id)")
case .failure:
showFailureIndicator()
return
}
}
await spaceRoomListProxy.resetAndWaitForFullReload(timeout: .seconds(10))
process(viewAction: .finishManagingChildren)
}
private func showLeaveSpaceConfirmation() async {
guard case let .success(leaveHandle) = await spaceServiceProxy.leaveSpace(spaceID: spaceRoomListProxy.id) else {
showFailureIndicator()
return
}
let leaveSpaceViewModel = LeaveSpaceViewModel(spaceName: state.space.name,
canEditRolesAndPermissions: appSettings.spaceSettingsEnabled && state.canEditRolesAndPermissions,
leaveHandle: leaveHandle,
userIndicatorController: userIndicatorController,
mediaProvider: mediaProvider)
leaveSpaceViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .didCancel:
state.bindings.leaveSpaceViewModel = nil
case .presentRolesAndPermissions:
guard let roomProxy = state.roomProxy else {
fatalError("The space screen should always have a room proxy")
}
state.bindings.leaveSpaceViewModel = nil
actionsSubject.send(.presentRolesAndPermissions(roomProxy: roomProxy))
case .didLeaveSpace:
state.bindings.leaveSpaceViewModel = nil
actionsSubject.send(.leftSpace)
}
}
.store(in: &cancellables)
state.bindings.leaveSpaceViewModel = leaveSpaceViewModel
}
// MARK: - Indicators
private static var removingIndicatorID: String { "\(Self.self)-Removing" }
private static var failureIndicatorID: String { "\(Self.self)-Failure" }
private func showRemovingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.removingIndicatorID,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonRemoving,
persistent: true))
}
private func hideRemovingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.removingIndicatorID)
}
private func showFailureIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.failureIndicatorID,
type: .toast,
title: L10n.errorUnknown,
iconName: "xmark"))
}
}