SP3.1: Update room settings for Spaces #5231

- Update after review
This commit is contained in:
Gil Eluard
2022-02-28 16:07:09 +01:00
parent 3a9b6b248b
commit bca69bb7c8
52 changed files with 878 additions and 519 deletions
@@ -0,0 +1,73 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import MatrixSDK
struct RoomUpgradeCoordinatorParameters {
let session: MXSession
let roomId: String
let versionOverride: String
}
final class RoomUpgradeCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: RoomUpgradeCoordinatorParameters
private let roomUpgradeHostingController: UIViewController
private var roomUpgradeViewModel: RoomUpgradeViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((RoomUpgradeCoordinatorResult) -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: RoomUpgradeCoordinatorParameters) {
self.parameters = parameters
let viewModel = RoomUpgradeViewModel.makeRoomUpgradeViewModel(roomUpgradeService: RoomUpgradeService(session: parameters.session, roomId: parameters.roomId, versionOverride: parameters.versionOverride))
let view = RoomUpgrade(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
roomUpgradeViewModel = viewModel
roomUpgradeHostingController = VectorHostingController(rootView: view)
roomUpgradeHostingController.view.backgroundColor = .clear
}
// MARK: - Public
func start() {
MXLog.debug("[RoomUpgradeCoordinator] did start.")
roomUpgradeViewModel.completion = { [weak self] result in
MXLog.debug("[RoomUpgradeCoordinator] RoomUpgradeViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .cancel(let roomId):
self.completion?(.cancel(roomId))
case .done(let roomId):
self.completion?(.done(roomId))
}
}
}
func toPresentable() -> UIViewController {
return self.roomUpgradeHostingController
}
}
@@ -0,0 +1,56 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockRoomUpgradeScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case initial
/// The associated screen
var screenType: Any.Type {
RoomUpgrade.self
}
/// A list of screen state definitions
static var allCases: [MockRoomUpgradeScreenState] {
[.initial]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: MockRoomUpgradeService
switch self {
case .initial:
service = MockRoomUpgradeService()
}
let viewModel = RoomUpgradeViewModel.makeRoomUpgradeViewModel(roomUpgradeService: service)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
AnyView(RoomUpgrade(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}
@@ -0,0 +1,47 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// MARK: - Coordinator
enum RoomUpgradeCoordinatorResult {
case cancel(_ roomId: String)
case done(_ roomId: String)
}
// MARK: View model
enum RoomUpgradeViewModelResult {
case cancel(_ roomId: String)
case done(_ roomId: String)
}
// MARK: View
struct RoomUpgradeViewState: BindableState {
var bindings: RoomUpgradeViewModelBindings
}
struct RoomUpgradeViewModelBindings {
var waitingMessage: String?
var isLoading: Bool
}
enum RoomUpgradeViewAction {
case cancel
case done(_ autoInviteUsers: Bool)
}
@@ -0,0 +1,78 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import Combine
@available(iOS 14, *)
typealias RoomUpgradeViewModelType = StateStoreViewModel<RoomUpgradeViewState,
Never,
RoomUpgradeViewAction>
@available(iOS 14, *)
class RoomUpgradeViewModel: RoomUpgradeViewModelType, RoomUpgradeViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let roomUpgradeService: RoomUpgradeServiceProtocol
// MARK: Public
var completion: ((RoomUpgradeViewModelResult) -> Void)?
// MARK: - Setup
static func makeRoomUpgradeViewModel(roomUpgradeService: RoomUpgradeServiceProtocol) -> RoomUpgradeViewModelProtocol {
return RoomUpgradeViewModel(roomUpgradeService: roomUpgradeService)
}
private init(roomUpgradeService: RoomUpgradeServiceProtocol) {
self.roomUpgradeService = roomUpgradeService
super.init(initialViewState: Self.defaultState(roomUpgradeService: roomUpgradeService))
setupObservers()
}
private static func defaultState(roomUpgradeService: RoomUpgradeServiceProtocol) -> RoomUpgradeViewState {
let bindings = RoomUpgradeViewModelBindings(waitingMessage: nil, isLoading: false)
return RoomUpgradeViewState(bindings: bindings)
}
private func setupObservers() {
roomUpgradeService
.upgradingSubject
.sink { [weak self] isUpgrading in
self?.state.bindings = RoomUpgradeViewModelBindings(waitingMessage: isUpgrading ? VectorL10n.roomAccessSettingsScreenUpgradeAlertUpgrading: nil, isLoading: isUpgrading)
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: RoomUpgradeViewAction) {
switch viewAction {
case .cancel:
completion?(.cancel(roomUpgradeService.currentRoomId))
case .done(let autoInviteUsers):
roomUpgradeService.upgradeRoom(autoInviteUsers: autoInviteUsers) { [weak self] success, roomId in
guard let self = self else { return }
if success {
self.completion?(.done(self.roomUpgradeService.currentRoomId))
}
}
}
}
}
@@ -0,0 +1,26 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol RoomUpgradeViewModelProtocol {
var completion: ((RoomUpgradeViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
static func makeRoomUpgradeViewModel(roomUpgradeService: RoomUpgradeServiceProtocol) -> RoomUpgradeViewModelProtocol
@available(iOS 14, *)
var context: RoomUpgradeViewModelType.Context { get }
}
@@ -0,0 +1,164 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
import MatrixSDK
@available(iOS 14.0, *)
class RoomUpgradeService: RoomUpgradeServiceProtocol {
// MARK: - Properties
// MARK: Private
private let session: MXSession
private let versionOverride: String
private var currentOperation: MXHTTPOperation?
private var didBuildSpaceGraphObserver: Any?
// MARK: Public
private(set) var upgradingSubject: CurrentValueSubject<Bool, Never>
private(set) var errorSubject: CurrentValueSubject<Error?, Never>
private(set) var currentRoomId: String
// MARK: - Setup
init(session: MXSession, roomId: String, versionOverride: String) {
self.session = session
self.currentRoomId = roomId
self.versionOverride = versionOverride
self.upgradingSubject = CurrentValueSubject(false)
self.errorSubject = CurrentValueSubject(nil)
}
deinit {
currentOperation?.cancel()
if let observer = self.didBuildSpaceGraphObserver {
NotificationCenter.default.removeObserver(observer)
}
}
func upgradeRoom(autoInviteUsers: Bool, completion: @escaping (Bool, String) -> Void) {
upgradingSubject.send(true)
if autoInviteUsers, let room = session.room(withRoomId: self.currentRoomId) {
self.currentOperation = room.members { [weak self] response in
guard let self = self else { return }
switch response {
case .success(let members):
let memberIds: [String] = members?.members.compactMap({ member in
guard member.membership == .join, member.userId != self.session.myUserId else {
return nil
}
return member.userId
}) ?? []
self.upgradeRoom(to: self.versionOverride, inviteUsers: memberIds, completion: completion)
case .failure(let error):
self.upgradingSubject.send(false)
self.errorSubject.send(error)
}
}
} else {
self.upgradeRoom(to: versionOverride, inviteUsers: [], completion: completion)
}
}
// MARK: - Private
private func upgradeRoom(to versionOverride: String, inviteUsers userIds: [String], completion: @escaping (Bool, String) -> Void) {
// Need to disable graph update during this process as a lot of syncs will occure
session.spaceService.graphUpdateEnabled = false
currentOperation = session.matrixRestClient.upgradeRoom(withId: self.currentRoomId, to: versionOverride) { [weak self] response in
guard let self = self else { return }
switch response {
case .success(let replacementRoomId):
let oldRoomId = self.currentRoomId
self.currentRoomId = replacementRoomId
let parentSpaces = self.session.spaceService.directParentIds(ofRoomWithId: oldRoomId)
self.moveRoom(from: oldRoomId, to: replacementRoomId, within: Array(parentSpaces), at: 0) {
self.session.spaceService.graphUpdateEnabled = true
self.didBuildSpaceGraphObserver = NotificationCenter.default.addObserver(forName: MXSpaceService.didBuildSpaceGraph, object: nil, queue: OperationQueue.main) { [weak self] notification in
guard let self = self else { return }
if let observer = self.didBuildSpaceGraphObserver {
NotificationCenter.default.removeObserver(observer)
self.didBuildSpaceGraphObserver = nil
}
DispatchQueue.main.async {
self.inviteUser(from: userIds, at: 0, completion: completion)
}
}
}
case .failure(let error):
self.session.spaceService.graphUpdateEnabled = true
self.upgradingSubject.send(false)
self.errorSubject.send(error)
}
}
}
/// Move room with roomId to new room ID for each space which ID belongs to`parentIds` list.
/// Recurse to the next index once done.
private func moveRoom(from roomId: String, to newRoomId: String, within parentIds: [String], at index: Int, completion: @escaping () -> Void) {
guard index < parentIds.count else {
completion()
return
}
guard let space = session.spaceService.getSpace(withId: parentIds[index]) else {
MXLog.warning("[RoomUpgradeService] moveRoom \(roomId) to \(newRoomId) within \(parentIds[index]): space not found")
moveRoom(from: roomId, to: newRoomId, within: parentIds, at: index + 1, completion: completion)
return
}
space.moveChild(withRoomId: roomId, to: newRoomId) { [weak self] response in
guard let self = self else { return }
if let error = response.error {
MXLog.warning("[RoomUpgradeService] moveRoom \(roomId) to \(newRoomId) within \(space.spaceId): failed due to error: \(error)")
}
self.moveRoom(from: roomId, to: newRoomId, within: parentIds, at: index + 1, completion: completion)
}
}
/// Invite all users within `userIds` list
/// Recurse to the next index once done.
private func inviteUser(from userIds: [String], at index: Int, completion: @escaping (Bool, String) -> Void) {
guard index < userIds.count else {
self.upgradingSubject.send(false)
completion(true, currentRoomId)
return
}
currentOperation = session.matrixRestClient.invite(.userId(userIds[index]), toRoom: currentRoomId) { [weak self] response in
guard let self = self else { return }
self.currentOperation = nil
if let error = response.error {
MXLog.warning("[RoomUpgradeService] inviteUser: failed to invite \(userIds[index]) to \(self.currentRoomId) due to error: \(error)")
}
self.inviteUser(from: userIds, at: index + 1, completion: completion)
}
}
}
@@ -0,0 +1,35 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
@available(iOS 14.0, *)
class MockRoomUpgradeService: RoomUpgradeServiceProtocol {
var currentRoomId: String = "!sfdlksjdflkfjds:matrix.org"
var errorSubject: CurrentValueSubject<Error?, Never>
var upgradingSubject: CurrentValueSubject<Bool, Never>
init() {
self.errorSubject = CurrentValueSubject(nil)
self.upgradingSubject = CurrentValueSubject(false)
}
func upgradeRoom(autoInviteUsers: Bool, completion: @escaping (Bool, String) -> Void) {
}
}
@@ -0,0 +1,27 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
@available(iOS 14.0, *)
protocol RoomUpgradeServiceProtocol {
var currentRoomId: String { get }
var upgradingSubject: CurrentValueSubject<Bool, Never> { get }
var errorSubject: CurrentValueSubject<Error?, Never> { get }
func upgradeRoom(autoInviteUsers: Bool, completion: @escaping (Bool, String) -> Void)
}
@@ -0,0 +1,53 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class RoomUpgradeUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockRoomUpgradeScreenState.self
}
override class func createTest() -> MockScreenTest {
return RoomUpgradeUITests(selector: #selector(verifyRoomUpgradeScreen))
}
func verifyRoomUpgradeScreen() throws {
guard let screenState = screenState as? MockRoomUpgradeScreenState else { fatalError("no screen") }
switch screenState {
case .presence(let presence):
verifyRoomUpgradePresence(presence: presence)
case .longDisplayName(let name):
verifyRoomUpgradeLongName(name: name)
}
}
func verifyRoomUpgradePresence(presence: RoomUpgradePresence) {
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func verifyRoomUpgradeLongName(name: String) {
let displayNameText = app.staticTexts["displayNameText"]
XCTAssert(displayNameText.exists)
XCTAssertEqual(displayNameText.label, name)
}
}
@@ -0,0 +1,57 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class RoomUpgradeViewModelTests: XCTestCase {
private enum Constants {
static let presenceInitialValue: RoomUpgradePresence = .offline
static let displayName = "Alice"
}
var service: MockRoomUpgradeService!
var viewModel: RoomUpgradeViewModelProtocol!
var context: RoomUpgradeViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
service = MockRoomUpgradeService(displayName: Constants.displayName, presence: Constants.presenceInitialValue)
viewModel = RoomUpgradeViewModel.makeRoomUpgradeViewModel(roomUpgradeService: service)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.displayName, Constants.displayName)
XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue)
}
func testFirstPresenceReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first()
XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue])
}
func testPresenceUpdatesReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first()
let awaitDeferred = xcAwaitDeferred(presencePublisher)
let newPresenceValue1: RoomUpgradePresence = .online
let newPresenceValue2: RoomUpgradePresence = .idle
service.simulateUpdate(presence: newPresenceValue1)
service.simulateUpdate(presence: newPresenceValue2)
XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2])
}
}
@@ -0,0 +1,103 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct RoomUpgrade: View {
// MARK: - Properties
@State var autoInviteUsers: Bool = true
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: RoomUpgradeViewModel.Context
// MARK: - Public
var body: some View {
ZStack {
Color.black.opacity(0.6)
alertContent
.waitOverlay(show: viewModel.isLoading, message: viewModel.waitingMessage, allowUserInteraction: false)
}
.edgesIgnoringSafeArea(.all)
}
// MARK: - Private
@ViewBuilder
private var alertContent: some View {
VStack(alignment: .center) {
Text(VectorL10n.roomAccessSettingsScreenUpgradeAlertTitle)
.font(theme.fonts.title3SB)
.foregroundColor(theme.colors.primaryContent)
.padding(.top, 16)
.padding(.bottom, 24)
Text(VectorL10n.roomAccessSettingsScreenUpgradeAlertMessage)
.multilineTextAlignment(.center)
.font(theme.fonts.subheadline)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 35)
.padding(.horizontal, 12)
Toggle(isOn: $autoInviteUsers) {
Text(VectorL10n.roomAccessSettingsScreenUpgradeAlertAutoInviteSwitch)
.font(theme.fonts.body)
.foregroundColor(theme.colors.secondaryContent)
}
.toggleStyle(SwitchToggleStyle(tint: theme.colors.accent))
.padding(.horizontal, 28)
Divider()
.padding(.horizontal, 28)
Button {
viewModel.send(viewAction: .done(autoInviteUsers))
} label: {
Text(VectorL10n.roomAccessSettingsScreenUpgradeAlertUpgradeButton)
}
.buttonStyle(PrimaryActionButtonStyle())
.accessibilityIdentifier("upgradeButton")
.padding(.horizontal, 24)
.padding(.top, 16)
Button {
viewModel.send(viewAction: .cancel)
} label: {
Text(VectorL10n.cancel)
}
.buttonStyle(SecondaryActionButtonStyle())
.accessibilityIdentifier("cancelButton")
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
.background(RoundedRectangle.init(cornerRadius: 8).foregroundColor(theme.colors.background))
.padding(.horizontal, 20)
.frame(minWidth: 0, maxWidth: 500)
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct RoomUpgrade_Previews: PreviewProvider {
static let stateRenderer = MockRoomUpgradeScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}