[iOS] Create public space #143

- Update after design review
This commit is contained in:
Gil Eluard
2021-12-01 23:56:59 +01:00
parent 1217be55ab
commit 4890ce2108
109 changed files with 1122 additions and 529 deletions
@@ -46,7 +46,7 @@ final class SpaceCreationPostProcessCoordinator: Coordinator, Presentable {
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
spaceCreationPostProcessViewModel = viewModel
let hostingController = VectorHostingController(rootView: view)
hostingController.hidesBackTitleWhenPushed = true
hostingController.isNavigationBarHidden = true
spaceCreationPostProcessHostingController = hostingController
}
@@ -1,44 +0,0 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
enum SpaceCreationPostProcessPresence {
case online
case idle
case offline
}
extension SpaceCreationPostProcessPresence {
var title: String {
switch self {
case .online:
return VectorL10n.roomParticipantsOnline
case .idle:
return VectorL10n.roomParticipantsIdle
case .offline:
return VectorL10n.roomParticipantsOffline
}
}
}
extension SpaceCreationPostProcessPresence: CaseIterable { }
extension SpaceCreationPostProcessPresence: Identifiable {
var id: Self { self }
}
@@ -16,7 +16,7 @@
import Foundation
enum SpaceCreationPostProcessTaskState: CaseIterable {
enum SpaceCreationPostProcessTaskState: CaseIterable, Equatable {
static var allCases: [SpaceCreationPostProcessTaskState] = [.none, .started, .success, .failure]
case none
@@ -25,7 +25,7 @@ enum SpaceCreationPostProcessTaskState: CaseIterable {
case failure
}
enum SpaceCreationPostProcessTaskType {
enum SpaceCreationPostProcessTaskType: Equatable {
case createSpace
case uploadAvatar
case createRoom(_ roomName: String)
@@ -33,7 +33,7 @@ enum SpaceCreationPostProcessTaskType {
case inviteUsersByEmail
}
struct SpaceCreationPostProcessTask {
struct SpaceCreationPostProcessTask: Equatable {
let type: SpaceCreationPostProcessTaskType
let title: String
var state: SpaceCreationPostProcessTaskState
@@ -41,4 +41,8 @@ struct SpaceCreationPostProcessTask {
return state == .failure || state == .success
}
var subTasks: [SpaceCreationPostProcessTask] = []
static func == (lhs: SpaceCreationPostProcessTask, rhs: SpaceCreationPostProcessTask) -> Bool {
return lhs.type == rhs.type && lhs.title == rhs.title && lhs.state == rhs.state && lhs.subTasks == lhs.subTasks
}
}
@@ -19,6 +19,8 @@
import Foundation
struct SpaceCreationPostProcessViewState: BindableState {
var avatar: AvatarInput
var avatarImage: UIImage?
var tasks: [SpaceCreationPostProcessTask]
var isFinished: Bool
var errorCount: Int
@@ -39,7 +39,6 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
createdSpaceId = createdSpace?.spaceId
}
}
private(set) var createdSpaceId: String?
private var createdRoomsByName: [String: MXRoom] = [:]
private var currentSubTaskIndex = 0
@@ -57,7 +56,15 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
// MARK: Public
private(set) var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never>
private(set) var createdSpaceId: String?
var avatar: AvatarInput {
let alias = creationParams.userDefinedAddress.isEmptyOrNil ? creationParams.address : creationParams.userDefinedAddress
return AvatarInput(mxContentUri: alias, matrixItemId: "", displayName: creationParams.name)
}
var avatarImage: UIImage? {
return creationParams.userSelectedAvatar
}
// MARK: - Setup
init(session: MXSession, creationParams: SpaceCreationParameters) {
@@ -90,7 +97,14 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
if creationParams.userSelectedAvatar != nil {
tasks.append(SpaceCreationPostProcessTask(type: .uploadAvatar, title: VectorL10n.spacesCreationPostProcessUploadingAvatar, state: .none))
}
if creationParams.addedRoomIds.isEmpty {
if let addedRoomIds = creationParams.addedRoomIds {
if !addedRoomIds.isEmpty {
let subTasks = addedRoomIds.map { roomId in
SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none)
}
tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(addedRoomIds.count)"), state: .none, subTasks: subTasks))
}
} else {
tasks.append(contentsOf: creationParams.newRooms.compactMap({ room in
guard !room.name.isEmpty else {
return nil
@@ -98,11 +112,6 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
return SpaceCreationPostProcessTask(type: .createRoom(room.name), title: VectorL10n.spacesCreationPostProcessCreatingRoom(room.name), state: .none)
}))
} else {
let subTasks = creationParams.addedRoomIds.map { roomId in
SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none)
}
tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(creationParams.addedRoomIds.count)"), state: .none, subTasks: subTasks))
}
if creationParams.userIdInvites.isEmpty {
@@ -132,6 +141,7 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
return
}
// createdSpaceId = session.spaceService.rootSpaceSummaries.first?.roomId
// fakeTaskExecution(task: task)
// return
@@ -154,32 +164,19 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
}
private func createSpace(andUpdate task: SpaceCreationPostProcessTask) {
let parameters = MXSpaceCreationParameters()
parameters.name = creationParams.name
parameters.topic = creationParams.topic
parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat
parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate
if creationParams.isPublic {
var alias = creationParams.address
if let userDefinedAlias = creationParams.userDefinedAddress {
alias = userDefinedAlias
}
parameters.roomAlias = alias?.fullLocalAlias(with: session)
let guestAccessStateEvent = self.stateEventBuilder.buildGuestAccessEvent(withAccess: .canJoin)
parameters.addOrUpdateInitialStateEvent(guestAccessStateEvent)
let historyVisibilityStateEvent = self.stateEventBuilder.buildHistoryVisibilityEvent(withVisibility: .worldReadable)
parameters.addOrUpdateInitialStateEvent(historyVisibilityStateEvent)
}
parameters.inviteArray = creationParams.userIdInvites
updateCurrentTask(with: .started)
session.spaceService.createSpace(with: parameters) { [weak self] response in
var alias = creationParams.address
if let userDefinedAlias = creationParams.userDefinedAddress, !userDefinedAlias.isEmpty {
alias = userDefinedAlias
}
session.spaceService.createSpace(withName: creationParams.name, topic: creationParams.topic, isPublic: creationParams.isPublic, aliasLocalPart: alias, inviteArray: creationParams.userIdInvites) { [weak self] response in
guard let self = self else { return }
if response.isFailure {
self.updateCurrentTask(with: .failure)
} else {
self.updateCurrentTask(with: .success)
self.createdSpace = response.value
self.updateCurrentTask(with: .success)
self.runNextTask()
}
}
@@ -213,6 +210,8 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
}
private func setAvatar(ofRoom room: MXRoom, withURL url: URL, andUpdate task: SpaceCreationPostProcessTask) {
updateCurrentTask(with: .started)
room.setAvatar(url: url) { [weak self] (response) in
guard let self = self else { return }
@@ -222,13 +221,17 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
}
private func createRoom(withName roomName: String, andUpdate task: SpaceCreationPostProcessTask) {
let parameters = MXRoomCreationParameters()
parameters.name = roomName
parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate
parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat
guard let createdSpace = self.createdSpace else {
updateCurrentTask(with: .failure)
runNextTask()
return
}
updateCurrentTask(with: .started)
session.createRoom(parameters: parameters) { [weak self] response in
let joinRule: MXRoomJoinRule = creationParams.isPublic ? .public : .restricted
let parentRoomId = creationParams.isPublic ? nil : createdSpace.spaceId
session.createRoom(withName: roomName, joinRule: joinRule, topic: nil, parentRoomId: parentRoomId, aliasLocalPart: nil) { [weak self] response in
guard let self = self else { return }
guard response.isSuccess, let createdRoom = response.value else {
@@ -236,14 +239,20 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
self.runNextTask()
return
}
self.createdRoomsByName[roomName] = createdRoom
self.addToSpace(room: createdRoom)
}
}
private func addToSpace(room: MXRoom) {
self.createdSpace?.addChild(roomId: room.matrixItemId, completion: { response in
guard let createdSpace = self.createdSpace else {
updateCurrentTask(with: .failure)
runNextTask()
return
}
createdSpace.addChild(roomId: room.matrixItemId, completion: { response in
self.updateCurrentTask(with: response.isFailure ? .failure : .success)
self.runNextTask()
})
@@ -301,7 +310,13 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
return
}
createdSpace.addChild(roomId: creationParams.addedRoomIds[currentSubTaskIndex], completion: { [weak self] response in
guard let roomId = creationParams.addedRoomIds?[currentSubTaskIndex] else {
updateCurrentTask(with: .failure)
runNextTask()
return
}
createdSpace.addChild(roomId: roomId, completion: { [weak self] response in
guard let self = self else { return }
self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure
@@ -23,14 +23,16 @@ import SwiftUI
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockSpaceCreationPostProcessScreenState: MockScreenState {
static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.tasks]
static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.running, MockSpaceCreationPostProcessScreenState.done, MockSpaceCreationPostProcessScreenState.doneWithError]
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case tasks
case running
case done
case doneWithError
/// The associated screen
var screenType: Any.Type {
SpaceCreationPostProcess.self
@@ -40,8 +42,12 @@ enum MockSpaceCreationPostProcessScreenState: MockScreenState {
var screenView: ([Any], AnyView) {
let service: MockSpaceCreationPostProcessService
switch self {
case .tasks:
case .running:
service = MockSpaceCreationPostProcessService()
case .done:
service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.lastTaskDoneSuccesfully)
case .doneWithError:
service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.lastTaskDoneWithError)
}
let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service)
@@ -22,27 +22,51 @@ import Combine
@available(iOS 14.0, *)
class MockSpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
static let defaultTasks: [SpaceCreationPostProcessTask] = [
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none)
]
static let nextStepTasks: [SpaceCreationPostProcessTask] = [
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started)
]
static let lastTaskDoneWithError: [SpaceCreationPostProcessTask] = [
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .success)
]
static let lastTaskDoneSuccesfully: [SpaceCreationPostProcessTask] = [
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .success)
]
var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never>
private(set) var createdSpaceId: String?
var avatar: AvatarInput {
return AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: "Some space")
}
var avatarImage: UIImage? {
return nil
}
init(
tasks: [SpaceCreationPostProcessTask] = [
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none)
]
tasks: [SpaceCreationPostProcessTask] = defaultTasks
) {
self.tasksSubject = CurrentValueSubject<[SpaceCreationPostProcessTask], Never>(tasks)
}
func simulateUpdate(presence: SpaceCreationPostProcessPresence) {
self.tasksSubject.send([
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started)
])
func simulateUpdate(tasks: [SpaceCreationPostProcessTask]) {
self.tasksSubject.send(tasks)
}
func run() {
@@ -23,5 +23,7 @@ import Combine
protocol SpaceCreationPostProcessServiceProtocol: AnyObject {
var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> { get }
var createdSpaceId: String? { get }
var avatar: AvatarInput { get }
var avatarImage: UIImage? { get }
func run()
}
@@ -33,23 +33,12 @@ class SpaceCreationPostProcessUITests: MockScreenTest {
func verifySpaceCreationPostProcessScreen() throws {
guard let screenState = screenState as? MockSpaceCreationPostProcessScreenState else { fatalError("no screen") }
switch screenState {
case .presence(let presence):
verifySpaceCreationPostProcessPresence(presence: presence)
case .longDisplayName(let name):
verifySpaceCreationPostProcessLongName(name: name)
case .tasks:
verifyTasksList()
}
}
func verifySpaceCreationPostProcessPresence(presence: SpaceCreationPostProcessPresence) {
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func verifySpaceCreationPostProcessLongName(name: String) {
let displayNameText = app.staticTexts["displayNameText"]
XCTAssert(displayNameText.exists)
XCTAssertEqual(displayNameText.label, name)
func verifyTasksList() {
}
}
@@ -23,37 +23,38 @@ import Combine
@available(iOS 14.0, *)
class SpaceCreationPostProcessViewModelTests: XCTestCase {
private enum Constants {
static let presenceInitialValue: SpaceCreationPostProcessPresence = .offline
static let displayName = "Alice"
}
var service: MockSpaceCreationPostProcessService!
var viewModel: SpaceCreationPostProcessViewModelProtocol!
var context: SpaceCreationPostProcessViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
service = MockSpaceCreationPostProcessService(displayName: Constants.displayName, presence: Constants.presenceInitialValue)
service = MockSpaceCreationPostProcessService(tasks: Constant.defaultTasks)
viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.displayName, Constants.displayName)
XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue)
XCTAssertEqual(context.viewState.tasks, Constant.defaultTasks)
XCTAssertEqual(context.viewState.errorCount, 1)
XCTAssertEqual(context.viewState.isFinished, false)
}
func testUpateToNextTask() {
let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates()
let awaitDeferred = xcAwaitDeferred(tasksPublisher)
service.simulateUpdate(tasks: Constant.nextStepTasks)
XCTAssertEqual(try awaitDeferred(), Constant.nextStepTasks)
XCTAssertEqual(context.viewState.errorCount, 2)
XCTAssertEqual(context.viewState.isFinished, false)
}
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: SpaceCreationPostProcessPresence = .online
let newPresenceValue2: SpaceCreationPostProcessPresence = .idle
service.simulateUpdate(presence: newPresenceValue1)
service.simulateUpdate(presence: newPresenceValue2)
XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2])
func testLastTaskDone() {
let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates()
let awaitDeferred = xcAwaitDeferred(tasksPublisher)
service.simulateUpdate(tasks: Constant.lastTaskDone)
XCTAssertEqual(try awaitDeferred(), Constant.lastTaskDone)
XCTAssertEqual(context.viewState.errorCount, 2)
XCTAssertEqual(context.viewState.isFinished, true)
}
}
@@ -34,31 +34,11 @@ struct SpaceCreationPostProcess: View {
var body: some View {
VStack {
Spacer()
VStack(spacing: 13) {
ProgressView()
.isHidden(viewModel.viewState.isFinished)
.scaleEffect(1.5, anchor: .center)
.progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent))
Text(VectorL10n.spacesCreationPostProcessCreatingSpace)
.font(theme.fonts.calloutSB)
.foregroundColor(theme.colors.secondaryContent)
}
headerView
Spacer()
VStack(alignment: .leading, spacing: 11) {
ForEach(viewModel.viewState.tasks.indices) { index in
SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state)
}
}
tasksList
Spacer()
HStack {
ThemableButton(icon: nil, title: VectorL10n.done) {
viewModel.send(viewAction: .cancel)
}
ThemableButton(icon: nil, title: VectorL10n.retry) {
viewModel.send(viewAction: .retry)
}
}
.isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0)
buttonsPanel
}
.animation(.easeIn(duration: 0.2), value: viewModel.viewState.errorCount)
.padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
@@ -69,6 +49,54 @@ struct SpaceCreationPostProcess: View {
viewModel.send(viewAction: .runTasks)
}
}
@ViewBuilder
private var headerView: some View {
VStack(spacing: 13) {
avatarView
Text(VectorL10n.spacesCreationPostProcessCreatingSpace)
.font(theme.fonts.calloutSB)
.foregroundColor(theme.colors.secondaryContent)
}
}
@ViewBuilder
private var tasksList: some View {
VStack(alignment: .leading, spacing: 11) {
ForEach(viewModel.viewState.tasks.indices) { index in
SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state)
}
}
}
@ViewBuilder
private var buttonsPanel: some View {
HStack {
ThemableButton(icon: nil, title: VectorL10n.done) {
viewModel.send(viewAction: .cancel)
}
ThemableButton(icon: nil, title: VectorL10n.retry) {
viewModel.send(viewAction: .retry)
}
}
.isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0)
}
@ViewBuilder
private var avatarView: some View {
ZStack {
SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xLarge)
.padding(6)
if let image = viewModel.viewState.avatarImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 52, height: 52, alignment: .center)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Previews
@@ -54,6 +54,8 @@ class SpaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelType,
private static func defaultState(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewState {
let tasks = spaceCreationPostProcessService.tasksSubject.value
return SpaceCreationPostProcessViewState(
avatar: spaceCreationPostProcessService.avatar,
avatarImage: spaceCreationPostProcessService.avatarImage,
tasks: tasks,
isFinished: tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }),
errorCount: tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) })