SP4: space settings (#5730)

* SP4: Space Settings

- Space settings screen implemented
- No space upgrade available as per Element web
- Need more insights for the space address field
- Added settings live update
- Added local alias implementation
This commit is contained in:
Gil Eluard
2022-03-04 12:53:42 +01:00
committed by GitHub
parent ee04e94e8b
commit 3347354216
43 changed files with 2184 additions and 118 deletions
@@ -0,0 +1,101 @@
//
// 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
struct SpaceSettingsCoordinatorParameters {
let session: MXSession
let spaceId: String
}
final class SpaceSettingsCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: SpaceSettingsCoordinatorParameters
private let spaceSettingsHostingController: UIViewController
private var spaceSettingsViewModel: SpaceSettingsViewModelProtocol
private lazy var singleImagePickerPresenter: SingleImagePickerPresenter = {
let presenter = SingleImagePickerPresenter(session: parameters.session)
presenter.delegate = self
return presenter
}()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((SpaceSettingsCoordinatorResult) -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: SpaceSettingsCoordinatorParameters) {
self.parameters = parameters
let viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: SpaceSettingsService(session: parameters.session, spaceId: parameters.spaceId))
let view = SpaceSettings(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
spaceSettingsViewModel = viewModel
spaceSettingsHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
MXLog.debug("[SpaceSettingsCoordinator] did start.")
spaceSettingsViewModel.completion = { [weak self] result in
MXLog.debug("[SpaceSettingsCoordinator] SpaceSettingsViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .cancel:
self.completion?(.cancel)
case .done:
self.completion?(.done)
case .optionScreen(let optionType):
self.completion?(.optionScreen(optionType))
case .pickImage(let sourceRect):
self.pickImage(from: sourceRect)
}
}
}
func toPresentable() -> UIViewController {
return self.spaceSettingsHostingController
}
// MARK: - Private
private func pickImage(from sourceRect: CGRect) {
let controller = toPresentable()
let adjustedRect = controller.view.convert(sourceRect, from: nil)
singleImagePickerPresenter.present(from: controller, sourceView: controller.view, sourceRect: adjustedRect, animated: true)
}
}
// MARK: - SingleImagePickerPresenterDelegate
extension SpaceSettingsCoordinator: SingleImagePickerPresenterDelegate {
func singleImagePickerPresenter(_ presenter: SingleImagePickerPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) {
spaceSettingsViewModel.updateAvatarImage(with: UIImage(data: imageData))
presenter.dismiss(animated: true, completion: nil)
}
func singleImagePickerPresenterDidCancel(_ presenter: SingleImagePickerPresenter) {
presenter.dismiss(animated: true, completion: nil)
}
}
@@ -0,0 +1,84 @@
//
// 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 MockSpaceSettingsScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case visibility(SpaceSettingsVisibility)
case notEditable
/// The associated screen
var screenType: Any.Type {
SpaceSettings.self
}
/// A list of screen state definitions
static var allCases: [MockSpaceSettingsScreenState] {
SpaceSettingsVisibility.allCases.map(MockSpaceSettingsScreenState.visibility)
+ [.notEditable]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: MockSpaceSettingsService
switch self {
case .visibility(let visibility):
let roomProperties = SpaceSettingsRoomProperties(
name: "Space Name",
topic: "Sapce topic",
address: nil,
avatarUrl: nil,
visibility: visibility,
allowedParentIds: [],
isAvatarEditable: true,
isNameEditable: true,
isTopicEditable: true,
isAddressEditable: true,
isAccessEditable: true)
service = MockSpaceSettingsService(roomProperties: roomProperties)
case .notEditable:
let roomProperties = SpaceSettingsRoomProperties(
name: "Space Name",
topic: "Sapce topic",
address: nil,
avatarUrl: nil,
visibility: .public,
allowedParentIds: [],
isAvatarEditable: false,
isNameEditable: false,
isTopicEditable: false,
isAddressEditable: false,
isAccessEditable: false)
service = MockSpaceSettingsService(roomProperties: roomProperties)
}
let viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: service)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
AnyView(SpaceSettings(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}
@@ -0,0 +1,474 @@
//
// 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 SpaceSettingsService: SpaceSettingsServiceProtocol {
// MARK: - Properties
var userDefinedAddress: String? {
didSet {
validateAddress()
}
}
// MARK: Private
private let session: MXSession
private var roomState: MXRoomState? {
didSet {
updateRoomProperties()
}
}
private let room: MXRoom?
private var roomEventListener: Any?
private var publicAddress: String? {
didSet {
validateAddress()
}
}
private var defaultAddress: String {
didSet {
validateAddress()
}
}
// MARK: Public
var displayName: String? {
room?.displayName
}
private(set) var spaceId: String
private(set) var roomProperties: SpaceSettingsRoomProperties? {
didSet {
roomPropertiesSubject.send(roomProperties)
}
}
private(set) var isLoadingSubject: CurrentValueSubject<Bool, Never>
private(set) var roomPropertiesSubject: CurrentValueSubject<SpaceSettingsRoomProperties?, Never>
private(set) var showPostProcessAlert: CurrentValueSubject<Bool, Never>
private(set) var addressValidationSubject: CurrentValueSubject<SpaceCreationSettingsAddressValidationStatus, Never>
var isAddressValid: Bool {
switch addressValidationSubject.value {
case .none, .valid:
return true
default:
return false
}
}
private var currentOperation: MXHTTPOperation?
private var addressValidationOperation: MXHTTPOperation?
private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: session, initialRange: 0, andRange: 1.0)
// MARK: - Setup
init(session: MXSession, spaceId: String) {
self.session = session
self.spaceId = spaceId
self.room = session.room(withRoomId: spaceId)
self.isLoadingSubject = CurrentValueSubject(false)
self.showPostProcessAlert = CurrentValueSubject(false)
self.roomPropertiesSubject = CurrentValueSubject(self.roomProperties)
self.addressValidationSubject = CurrentValueSubject(.none("#"))
self.defaultAddress = ""
readRoomState()
}
deinit {
if let roomEventListener = self.roomEventListener {
self.room?.removeListener(roomEventListener)
}
currentOperation?.cancel()
addressValidationOperation?.cancel()
}
// MARK: - Public
func addressDidChange(_ newValue: String) {
userDefinedAddress = newValue
}
// MARK: - Private
private func readRoomState() {
isLoadingSubject.send(true)
self.room?.state { [weak self] roomState in
self?.roomState = roomState
self?.isLoadingSubject.send(false)
}
roomEventListener = self.room?.listen(toEvents: { [weak self] event, direction, state in
self?.room?.state({ [weak self] roomState in
self?.roomState = roomState
})
})
}
private func visibility(with roomState: MXRoomState) -> SpaceSettingsVisibility {
switch roomState.joinRule {
case .public:
return .public
case .restricted:
return .restricted
default:
return .private
}
}
private func allowedParentIds(with roomState: MXRoomState) -> [String] {
var allowedParentIds: [String] = []
if roomState.joinRule == .restricted, let joinRuleEvent = roomState.stateEvents(with: .roomJoinRules)?.last {
let allowContent: [[String: String]] = joinRuleEvent.wireContent[kMXJoinRulesContentKeyAllow] as? [[String: String]] ?? []
allowedParentIds = allowContent.compactMap { allowDictionnary in
guard let type = allowDictionnary[kMXJoinRulesContentKeyType], type == kMXEventTypeStringRoomMembership else {
return nil
}
return allowDictionnary[kMXJoinRulesContentKeyRoomId]
}
}
return allowedParentIds
}
private func isField(ofType notification: String, editableWith powerLevels: MXRoomPowerLevels?) -> Bool {
guard let powerLevels = powerLevels else {
return false
}
let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.session.myUserId)
return userPowerLevel >= powerLevels.minimumPowerLevel(forNotifications: notification, defaultPower: powerLevels.stateDefault)
}
private func validateAddress() {
addressValidationOperation?.cancel()
addressValidationOperation = nil
guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else {
let fullAddress = MXTools.fullLocalAlias(from: defaultAddress, with: session)
if let publicAddress = self.publicAddress, !publicAddress.isEmpty {
addressValidationSubject.send(.current(fullAddress))
} else if defaultAddress.isEmpty {
addressValidationSubject.send(.none(fullAddress))
} else {
validate(defaultAddress)
}
return
}
validate(userDefinedAddress)
}
private func validate(_ aliasLocalPart: String) {
let fullAddress = MXTools.fullLocalAlias(from: aliasLocalPart, with: session)
if let publicAddress = self.publicAddress, publicAddress == aliasLocalPart {
self.addressValidationSubject.send(.current(fullAddress))
return
}
addressValidationOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in
guard let self = self else { return }
self.addressValidationOperation = nil
switch result {
case .available:
self.addressValidationSubject.send(.valid(fullAddress))
case .invalid:
self.addressValidationSubject.send(.invalidCharacters(fullAddress))
case .notAvailable:
self.addressValidationSubject.send(.alreadyExists(fullAddress))
case .serverError:
self.addressValidationSubject.send(.none(fullAddress))
}
}
}
private func updateRoomProperties() {
guard let roomState = roomState else {
return
}
if let canonicalAlias = roomState.canonicalAlias {
let localAliasPart = MXTools.extractLocalAliasPart(from: canonicalAlias)
self.publicAddress = localAliasPart
self.defaultAddress = localAliasPart
} else {
self.publicAddress = nil
self.defaultAddress = MXTools.validAliasLocalPart(from: roomState.name)
}
self.roomProperties = SpaceSettingsRoomProperties(
name: roomState.name,
topic: roomState.topic,
address: self.defaultAddress,
avatarUrl: roomState.avatar,
visibility: visibility(with: roomState),
allowedParentIds: allowedParentIds(with: roomState),
isAvatarEditable: isField(ofType: kMXEventTypeStringRoomAvatar, editableWith: roomState.powerLevels),
isNameEditable: isField(ofType: kMXEventTypeStringRoomName, editableWith: roomState.powerLevels),
isTopicEditable: isField(ofType: kMXEventTypeStringRoomTopic, editableWith: roomState.powerLevels),
isAddressEditable: isField(ofType: kMXEventTypeStringRoomAliases, editableWith: roomState.powerLevels),
isAccessEditable: isField(ofType: kMXEventTypeStringRoomJoinRules, editableWith: roomState.powerLevels))
}
// MARK: - Post process
private var currentTaskIndex: Int = 0
private var tasks: [PostProcessTask] = []
private var lastError: Error?
private var completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?
private enum PostProcessTaskType: Equatable {
case updateName(String)
case updateTopic(String)
case updateAlias(String)
case uploadAvatar(UIImage)
}
private enum PostProcessTaskState: CaseIterable, Equatable {
case none
case started
case success
case failure
}
private struct PostProcessTask: Equatable {
let type: PostProcessTaskType
var state: PostProcessTaskState = .none
var isFinished: Bool {
return state == .failure || state == .success
}
static func == (lhs: PostProcessTask, rhs: PostProcessTask) -> Bool {
return lhs.type == rhs.type && lhs.state == rhs.state
}
}
func update(roomName: String, topic: String, address: String, avatar: UIImage?,
completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?) {
// First attempt
if self.tasks.isEmpty {
var tasks: [PostProcessTask] = []
if roomProperties?.name ?? "" != roomName {
tasks.append(PostProcessTask(type: .updateName(roomName)))
}
if roomProperties?.topic ?? "" != topic {
tasks.append(PostProcessTask(type: .updateTopic(topic)))
}
if roomProperties?.address ?? "" != address {
tasks.append(PostProcessTask(type: .updateAlias(address)))
}
if let avatarImage = avatar {
tasks.append(PostProcessTask(type: .uploadAvatar(avatarImage)))
}
self.tasks = tasks
} else {
// Retry -> restart failed tasks
self.tasks = tasks.map({ task in
if task.state == .failure {
return PostProcessTask(type: task.type, state: .none)
}
return task
})
}
self.isLoadingSubject.send(true)
self.completion = completion
self.lastError = nil
currentTaskIndex = -1
runNextTask()
}
private func runNextTask() {
currentTaskIndex += 1
guard currentTaskIndex < tasks.count else {
self.isLoadingSubject.send(false)
if let error = lastError {
showPostProcessAlert.send(true)
completion?(.failure(error))
} else {
completion?(.success)
}
return
}
let task = tasks[currentTaskIndex]
guard !task.isFinished else {
runNextTask()
return
}
switch task.type {
case .updateName(let roomName):
update(roomName: roomName)
case .updateTopic(let topic):
update(topic: topic)
case .updateAlias(let address):
update(canonicalAlias: address)
case .uploadAvatar(let image):
upload(avatar: image)
}
}
private func updateCurrentTaskState(with state: PostProcessTaskState) {
guard currentTaskIndex < tasks.count else {
return
}
tasks[currentTaskIndex].state = state
}
private func update(roomName: String) {
updateCurrentTaskState(with: .started)
currentOperation = room?.setName(roomName, completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
})
}
private func update(topic: String) {
updateCurrentTaskState(with: .started)
currentOperation = room?.setTopic(topic, completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
})
}
private func update(canonicalAlias: String) {
updateCurrentTaskState(with: .started)
currentOperation = room?.addAlias(MXTools.fullLocalAlias(from: canonicalAlias, with: session), completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
if let publicAddress = self.publicAddress {
self.currentOperation = self.room?.removeAlias(MXTools.fullLocalAlias(from: publicAddress, with: self.session), completion: { [weak self] response in
guard let self = self else { return }
self.setup(canonicalAlias: canonicalAlias)
})
} else {
self.setup(canonicalAlias: canonicalAlias)
}
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
}
})
}
private func setup(canonicalAlias: String) {
currentOperation = room?.setCanonicalAlias(MXTools.fullLocalAlias(from: canonicalAlias, with: session), completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
})
}
private func upload(avatar: UIImage) {
updateCurrentTaskState(with: .started)
let avatarUp = MXKTools.forceImageOrientationUp(avatar)
mediaUploader.uploadData(avatarUp?.jpegData(compressionQuality: 0.5), filename: nil, mimeType: "image/jpeg",
success: { [weak self] (urlString) in
guard let self = self else { return }
guard let urlString = urlString else {
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
return
}
guard let url = URL(string: urlString) else {
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
return
}
self.setAvatar(withURL: url)
},
failure: { [weak self] (error) in
guard let self = self else { return }
self.lastError = error
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
})
}
private func setAvatar(withURL url: URL) {
currentOperation = room?.setAvatar(url: url) { [weak self] (response) in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
}
}
}
@@ -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 Combine
@available(iOS 14.0, *)
class MockSpaceSettingsService: SpaceSettingsServiceProtocol {
var spaceId: String
var roomProperties: SpaceSettingsRoomProperties?
private(set) var displayName: String?
var roomPropertiesSubject: CurrentValueSubject<SpaceSettingsRoomProperties?, Never>
private(set) var isLoadingSubject: CurrentValueSubject<Bool, Never>
private(set) var showPostProcessAlert: CurrentValueSubject<Bool, Never>
private(set) var addressValidationSubject: CurrentValueSubject<SpaceCreationSettingsAddressValidationStatus, Never>
init(spaceId: String = "!\(UUID().uuidString):matrix.org",
roomProperties: SpaceSettingsRoomProperties? = nil,
displayName: String? = nil,
isLoading: Bool = false,
showPostProcessAlert: Bool = false) {
self.spaceId = spaceId
self.roomProperties = roomProperties
self.displayName = displayName
self.isLoadingSubject = CurrentValueSubject(isLoading)
self.showPostProcessAlert = CurrentValueSubject(showPostProcessAlert)
self.roomPropertiesSubject = CurrentValueSubject(roomProperties)
self.addressValidationSubject = CurrentValueSubject(.none(spaceId))
}
func update(roomName: String, topic: String, address: String, avatar: UIImage?, completion: ((SpaceSettingsServiceCompletionResult) -> Void)?) {
}
func addressDidChange(_ newValue: String) {
}
func simulateUpdate(addressValidationStatus: SpaceCreationSettingsAddressValidationStatus) {
self.addressValidationSubject.value = addressValidationStatus
}
}
@@ -0,0 +1,49 @@
//
// 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
enum SpaceSettingsServiceCompletionResult {
case success
case failure(Error)
}
@available(iOS 14.0, *)
protocol SpaceSettingsServiceProtocol: Avatarable {
var spaceId: String { get }
var roomProperties: SpaceSettingsRoomProperties? { get }
var isLoadingSubject: CurrentValueSubject<Bool, Never> { get }
var roomPropertiesSubject: CurrentValueSubject<SpaceSettingsRoomProperties?, Never> { get }
var showPostProcessAlert: CurrentValueSubject<Bool, Never> { get }
var addressValidationSubject: CurrentValueSubject<SpaceCreationSettingsAddressValidationStatus, Never> { get }
func update(roomName: String, topic: String, address: String, avatar: UIImage?, completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?)
func addressDidChange(_ newValue: String)
}
// MARK: Avatarable
@available(iOS 14.0, *)
extension SpaceSettingsServiceProtocol {
var mxContentUri: String? {
roomProperties?.avatarUrl
}
var matrixItemId: String {
spaceId
}
}
@@ -0,0 +1,123 @@
//
// 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 SpaceSettingsCoordinatorResult {
case cancel
case done
case optionScreen(_ optionType: SpaceSettingsOptionType)
}
// MARK: View model
enum SpaceSettingsViewModelResult {
case cancel
case done
case optionScreen(_ optionType: SpaceSettingsOptionType)
case pickImage(_ sourceRect: CGRect)
}
// MARK: View
enum SpaceSettingsVisibility: CaseIterable {
case `private`
case restricted
case `public`
var stringValue: String {
switch self {
case .private:
return VectorL10n.private
case .public:
return VectorL10n.public
case .restricted:
return VectorL10n.createRoomTypeRestricted
}
}
}
struct SpaceSettingsRoomProperties {
let name: String?
let topic: String?
let address: String?
let avatarUrl: String?
let visibility: SpaceSettingsVisibility
let allowedParentIds: [String]
let isAvatarEditable: Bool
let isNameEditable: Bool
let isTopicEditable: Bool
let isAddressEditable: Bool
let isAccessEditable: Bool
}
struct SpaceSettingsViewState: BindableState {
let defaultAddress: String
let avatar: AvatarInputProtocol
var roomProperties: SpaceSettingsRoomProperties?
var userSelectedAvatar: UIImage?
var showRoomAddress: Bool
let roomNameError: String?
var addressMessage: String?
var isAddressValid: Bool
var isLoading: Bool
var visibilityString: String
var options: [SpaceSettingsOption]
var isModified: Bool {
userSelectedAvatar != nil || isRoomNameModified || isTopicModified || isAddressModified
}
var isRoomNameModified: Bool {
(roomProperties?.name ?? "") != bindings.name
}
var isTopicModified: Bool {
(roomProperties?.topic ?? "") != bindings.topic
}
var isAddressModified: Bool {
(roomProperties?.address ?? "") != bindings.address
}
var bindings: SpaceSettingsViewModelBindings
}
struct SpaceSettingsViewModelBindings {
var name: String
var topic: String
var address: String
var showPostProcessAlert: Bool
}
struct SpaceSettingsOption: Identifiable {
let id: SpaceSettingsOptionType
let icon: UIImage?
let title: String?
let value: String?
let isEnabled: Bool
}
enum SpaceSettingsOptionType {
case visibility
case rooms
case members
}
enum SpaceSettingsViewAction {
case done(_ name: String, _ topic: String, _ address: String, _ userSelectedAvatar: UIImage?)
case cancel
case pickImage(_ sourceRect: CGRect)
case optionSelected(_ optionType: SpaceSettingsOptionType)
case addressChanged(_ newValue: String)
}
@@ -0,0 +1,155 @@
//
// 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 SpaceSettingsViewModelType = StateStoreViewModel<SpaceSettingsViewState,
Never,
SpaceSettingsViewAction>
@available(iOS 14, *)
class SpaceSettingsViewModel: SpaceSettingsViewModelType, SpaceSettingsViewModelProtocol {
// MARK: - Properties
private static let options: [SpaceSettingsOption] = [
SpaceSettingsOption(id: .rooms, icon: Asset.Images.spaceRoomIcon.image, title: VectorL10n.titleRooms, value: nil, isEnabled: true),
SpaceSettingsOption(id: .members, icon: Asset.Images.spaceMenuMembers.image, title: VectorL10n.roomDetailsPeople, value: nil, isEnabled: true)
]
// MARK: Private
private let service: SpaceSettingsServiceProtocol
// MARK: Public
var completion: ((SpaceSettingsViewModelResult) -> Void)?
// MARK: - Setup
static func makeSpaceSettingsViewModel(service: SpaceSettingsServiceProtocol) -> SpaceSettingsViewModelProtocol {
return SpaceSettingsViewModel(service: service)
}
private init(service: SpaceSettingsServiceProtocol) {
self.service = service
super.init(initialViewState: Self.defaultState(with: service, validationStatus: service.addressValidationSubject.value))
setupObservers()
}
private static func defaultState(with service: SpaceSettingsServiceProtocol, validationStatus: SpaceCreationSettingsAddressValidationStatus) -> SpaceSettingsViewState {
let bindings = SpaceSettingsViewModelBindings(
name: service.roomProperties?.name ?? "",
topic: service.roomProperties?.topic ?? "",
address: service.roomProperties?.address ?? "",
showPostProcessAlert: service.showPostProcessAlert.value)
return SpaceSettingsViewState(
defaultAddress: service.roomProperties?.address ?? "",
avatar: AvatarInput(mxContentUri: service.mxContentUri, matrixItemId: service.matrixItemId, displayName: service.displayName),
roomProperties: service.roomProperties,
userSelectedAvatar: nil,
showRoomAddress: service.roomProperties?.visibility == .public,
roomNameError: nil,
addressMessage: validationStatus.message,
isAddressValid: validationStatus.isValid,
isLoading: service.isLoadingSubject.value,
visibilityString: (service.roomProperties?.visibility ?? .private).stringValue,
options: options,
bindings: bindings)
}
private func setupObservers() {
service.isLoadingSubject.sink { [weak self] isLoading in
self?.state.isLoading = isLoading
}
.store(in: &cancellables)
service.showPostProcessAlert.sink { [weak self] showPostProcessAlert in
self?.state.bindings.showPostProcessAlert = showPostProcessAlert
}
.store(in: &cancellables)
service.roomPropertiesSubject.sink { [weak self] roomProperties in
guard let roomProperties = roomProperties, let self = self else {
return
}
self.propertiesUpdated(roomProperties)
}
.store(in: &cancellables)
service.addressValidationSubject.sink { [weak self] validationStatus in
self?.state.addressMessage = validationStatus.message
self?.state.isAddressValid = validationStatus.isValid
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SpaceSettingsViewAction) {
switch viewAction {
case .cancel:
cancel()
case .pickImage(let sourceRect):
completion?(.pickImage(sourceRect))
case .optionSelected(let optionType):
completion?(.optionScreen(optionType))
case .done(let name, let topic, let address, let userSelectedAvatar):
service.update(roomName: name, topic: topic, address: address, avatar: userSelectedAvatar) { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.done()
case .failure:
break
}
}
case .addressChanged(let newValue):
service.addressDidChange(newValue)
}
}
func updateAvatarImage(with image: UIImage?) {
state.userSelectedAvatar = image
}
private func propertiesUpdated(_ roomProperties: SpaceSettingsRoomProperties) {
state.roomProperties = roomProperties
if !state.isRoomNameModified {
state.bindings.name = roomProperties.name ?? ""
}
if !state.isTopicModified {
state.bindings.topic = roomProperties.topic ?? ""
}
if !state.isAddressModified {
state.bindings.address = roomProperties.address ?? ""
}
state.visibilityString = roomProperties.visibility.stringValue
state.showRoomAddress = roomProperties.visibility == .public
}
private func done() {
completion?(.done)
}
private func cancel() {
completion?(.cancel)
}
}
@@ -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
protocol SpaceSettingsViewModelProtocol {
var completion: ((SpaceSettingsViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
static func makeSpaceSettingsViewModel(service: SpaceSettingsServiceProtocol) -> SpaceSettingsViewModelProtocol
@available(iOS 14, *)
var context: SpaceSettingsViewModelType.Context { get }
func updateAvatarImage(with image: UIImage?)
}
@@ -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 XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class SpaceSettingsUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockSpaceSettingsScreenState.self
}
override class func createTest() -> MockScreenTest {
return SpaceSettingsUITests(selector: #selector(verifySpaceSettingsScreen))
}
func verifySpaceSettingsScreen() throws {
guard let screenState = screenState as? MockSpaceSettingsScreenState else { fatalError("no screen") }
}
}
@@ -0,0 +1,60 @@
//
// 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 SpaceSettingsViewModelTests: XCTestCase {
let creationParameters = SpaceCreationParameters()
var service: MockSpaceSettingsService!
var viewModel: SpaceSettingsViewModelProtocol!
var context: SpaceSettingsViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
let roomProperties = SpaceSettingsRoomProperties(
name: "Space Name",
topic: "Sapce topic",
address: "#fake:matrix.org",
avatarUrl: nil,
visibility: .public,
allowedParentIds: [],
isAvatarEditable: true,
isNameEditable: true,
isTopicEditable: true,
isAddressEditable: true,
isAccessEditable: true)
service = MockSpaceSettingsService(roomProperties: roomProperties, displayName: roomProperties.name, isLoading: false, showPostProcessAlert: false)
viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: service)
context = viewModel.context
}
func testAddressAlready() throws {
service.simulateUpdate(addressValidationStatus: .alreadyExists("#fake:matrix.org"))
XCTAssertEqual(context.viewState.isAddressValid, false)
XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressAlreadyExists("#fake:matrix.org"))
}
func testInvalidAddress() throws {
service.simulateUpdate(addressValidationStatus: .invalidCharacters("#fake:matrix.org"))
XCTAssertEqual(context.viewState.isAddressValid, false)
XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressInvalidCharacters("#fake:matrix.org"))
}
}
@@ -0,0 +1,206 @@
//
// 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 SpaceSettings: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: SpaceSettingsViewModel.Context
var body: some View {
ScrollView {
VStack {
avatarView
Spacer().frame(height:32)
formView
roomAccess
options
.padding(.bottom, 32)
}
}
.background(theme.colors.navigation)
.waitOverlay(show: viewModel.viewState.isLoading, allowUserInteraction: false)
.ignoresSafeArea(.container, edges: .bottom)
.frame(maxHeight: .infinity)
.navigationBarBackButtonHidden(true)
.navigationTitle(VectorL10n.settingsTitle)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(VectorL10n.done) {
updateSpace()
}
.disabled(!viewModel.viewState.isModified || !viewModel.viewState.isAddressValid)
}
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
}
}
.accentColor(theme.colors.accent)
.alert(isPresented: $viewModel.showPostProcessAlert, content: {
Alert(title: Text(VectorL10n.settingsTitle),
message: Text(VectorL10n.spaceSettingsUpdateFailedMessage),
primaryButton: .default(Text(VectorL10n.retry), action: {
updateSpace()
}),
secondaryButton: .cancel())
})
}
// MARK: - Private
@ViewBuilder
private var avatarView: some View {
ZStack(alignment: .bottomTrailing) {
GeometryReader { reader in
ZStack {
SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge)
.padding(6)
if let image = viewModel.viewState.userSelectedAvatar {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80, alignment: .center)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}.padding(10)
.onTapGesture {
guard viewModel.viewState.roomProperties?.isAvatarEditable == true else {
return
}
ResponderManager.resignFirstResponder()
viewModel.send(viewAction: .pickImage(reader.frame(in: .global)))
}
}
if viewModel.viewState.roomProperties?.isAvatarEditable == true {
Image(uiImage: Asset.Images.spaceCreationCamera.image)
.renderingMode(.template)
.foregroundColor(theme.colors.secondaryContent)
.frame(width: 32, height: 32, alignment: .center)
.background(theme.colors.background)
.clipShape(Circle())
}
}.frame(width: 104, height: 104)
}
@ViewBuilder
private var formView: some View {
VStack{
RoundedBorderTextField(
title: VectorL10n.createRoomPlaceholderName,
placeHolder: "",
text: $viewModel.name,
footerText: .constant(viewModel.viewState.roomNameError),
isError: .constant(true),
configuration: UIKitTextInputConfiguration( returnKeyType: .next))
.padding(.horizontal, 2)
.padding(.bottom, 20)
.disabled(viewModel.viewState.roomProperties?.isNameEditable != true)
RoundedBorderTextEditor(
title: VectorL10n.spaceTopic,
placeHolder: VectorL10n.spaceTopic,
text: $viewModel.topic,
textMaxHeight: 72,
error: .constant(nil))
.padding(.horizontal, 2)
.padding(.bottom, viewModel.viewState.showRoomAddress ? 20 : 3)
.disabled(viewModel.viewState.roomProperties?.isTopicEditable != true)
if viewModel.viewState.showRoomAddress {
RoundedBorderTextField(
title: VectorL10n.spacesCreationAddress,
placeHolder: "# \(viewModel.viewState.defaultAddress)",
text: $viewModel.address,
footerText: .constant(viewModel.viewState.addressMessage),
isError: .constant(!viewModel.viewState.isAddressValid),
configuration: UIKitTextInputConfiguration(keyboardType: .URL, returnKeyType: .done, autocapitalizationType: .none), onTextChanged: {
newText in
viewModel.send(viewAction: .addressChanged(newText))
})
.disabled(viewModel.viewState.roomProperties?.isAddressEditable != true)
.padding(.horizontal, 2)
.padding(.bottom, 3)
.accessibility(identifier: "addressTextField")
}
}
.padding(.horizontal)
}
@ViewBuilder
private var roomAccess: some View {
VStack(alignment: .leading) {
Spacer().frame(height:24)
Text(VectorL10n.spaceSettingsAccessSection)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.leading)
.padding(.bottom, 4)
SpaceSettingsOptionListItem(
title: VectorL10n.roomDetailsAccessRowTitle,
value: viewModel.viewState.visibilityString) {
ResponderManager.resignFirstResponder()
viewModel.send(viewAction: .optionSelected(.visibility))
}
.disabled(viewModel.viewState.roomProperties?.isAccessEditable != true)
}
}
@ViewBuilder
private var options: some View {
VStack(alignment: .leading, spacing: 1) {
Spacer().frame(height: 50)
Text(VectorL10n.settingsTitle.uppercased())
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.leading)
.padding(.bottom, 8)
ForEach(viewModel.viewState.options) { option in
SpaceSettingsOptionListItem(
icon: option.icon,
title: option.title,
value: option.value) {
ResponderManager.resignFirstResponder()
viewModel.send(viewAction: .optionSelected(option.id))
}
.disabled(!option.isEnabled)
}
}
}
private func updateSpace() {
viewModel.send(viewAction: .done(viewModel.name, viewModel.topic, viewModel.address, viewModel.viewState.userSelectedAvatar))
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct SpaceSettings_Previews: PreviewProvider {
static let stateRenderer = MockSpaceSettingsScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
stateRenderer.screenGroup().theme(.dark).preferredColorScheme(.dark)
}
}
@@ -0,0 +1,106 @@
//
// 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 SpaceSettingsOptionListItem: View {
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
@Environment(\.isEnabled) private var isEnabled
// MARK: - Properties
let icon: UIImage?
let title: String?
let value: String?
let action: (() -> Void)?
// MARK: - Setup
init(icon: UIImage? = nil,
title: String? = nil,
value: String? = nil,
action: (() -> Void)? = nil) {
self.icon = icon
self.title = title
self.value = value
self.action = action
}
// MARK: - Public
var body: some View {
ZStack {
HStack(alignment: .center, spacing: 16) {
if let icon = icon {
Image(uiImage: icon)
.renderingMode(.template)
.frame(width: 22, height: 22)
.foregroundColor(theme.colors.tertiaryContent)
}
if let title = title {
Text(title)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
}
Spacer()
if let value = value {
Text(value)
.font(theme.fonts.body)
.foregroundColor(theme.colors.tertiaryContent)
}
Image(systemName: "chevron.right")
.renderingMode(.template)
.font(.system(size: 16, weight: .regular))
.foregroundColor(theme.colors.quarterlyContent)
}
.opacity(isEnabled ? 1 : 0.5)
}
.frame(height: 44)
.padding(.horizontal)
.background(theme.colors.background)
.onTapGesture {
if isEnabled {
action?()
}
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct SpaceSettingsOptionListItem_Previews: PreviewProvider {
static var previews: some View {
sampleView.theme(.light).preferredColorScheme(.light)
sampleView.theme(.dark).preferredColorScheme(.dark)
}
static var sampleView: some View {
VStack(spacing: 8) {
SpaceSettingsOptionListItem(icon: nil, title: "Some Title", value: nil)
SpaceSettingsOptionListItem(icon: nil, title: "Some Title", value: "Some value")
SpaceSettingsOptionListItem(icon: Asset.Images.spaceRoomIcon.image, title: "Some Title", value: "Some value")
SpaceSettingsOptionListItem(icon: Asset.Images.spaceRoomIcon.image, title: "Some Title", value: "Some value")
.disabled(true)
}
}
}