diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift index 0b9692ca1..cad3c5b03 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift @@ -28,6 +28,8 @@ enum CreateRoomCoordinatorAction { case openRoom(withIdentifier: String) case deselectUser(UserProfileProxy) case updateDetails(CreateRoomFlowParameters) + case displayMediaPickerWithSource(MediaPickerScreenSource) + case removeImage } final class CreateRoomCoordinator: CoordinatorProtocol { @@ -53,11 +55,17 @@ final class CreateRoomCoordinator: CoordinatorProtocol { guard let self else { return } switch action { case .deselectUser(let user): - self.actionsSubject.send(.deselectUser(user)) + actionsSubject.send(.deselectUser(user)) case .openRoom(let identifier): - self.actionsSubject.send(.openRoom(withIdentifier: identifier)) + actionsSubject.send(.openRoom(withIdentifier: identifier)) case .updateDetails(let details): - self.actionsSubject.send(.updateDetails(details)) + actionsSubject.send(.updateDetails(details)) + case .displayCameraPicker: + actionsSubject.send(.displayMediaPickerWithSource(.camera)) + case .displayMediaPicker: + actionsSubject.send(.displayMediaPickerWithSource(.photoLibrary)) + case .removeImage: + actionsSubject.send(.removeImage) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift index 8cf6f2297..9801e13ea 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift @@ -18,6 +18,9 @@ import Foundation enum CreateRoomScreenErrorType: Error { case failedCreatingRoom + case failedUploadingMedia + case fileTooLarge + case mediaFileError case unknown } @@ -25,12 +28,15 @@ enum CreateRoomViewModelAction { case openRoom(withIdentifier: String) case deselectUser(UserProfileProxy) case updateDetails(CreateRoomFlowParameters) + case displayMediaPicker + case displayCameraPicker + case removeImage } struct CreateRoomViewState: BindableState { var selectedUsers: [UserProfileProxy] var bindings: CreateRoomViewStateBindings - + var avatarURL: URL? var canCreateRoom: Bool { !bindings.roomName.isEmpty } @@ -40,6 +46,7 @@ struct CreateRoomViewStateBindings { var roomName: String var roomTopic: String var isRoomPrivate: Bool + var showAttachmentConfirmationDialog = false /// Information describing the currently displayed alert. var alertInfo: AlertInfo? @@ -48,4 +55,7 @@ struct CreateRoomViewStateBindings { enum CreateRoomViewAction { case createRoom case deselectUser(UserProfileProxy) + case displayCameraPicker + case displayMediaPicker + case removeImage } diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift index c3945760a..2af703571 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift @@ -41,6 +41,22 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol super.init(initialViewState: CreateRoomViewState(selectedUsers: selectedUsers.value, bindings: bindings), imageProvider: userSession.mediaProvider) + createRoomParameters + .map(\.avatarImageMedia) + .removeDuplicates { $0?.url == $1?.url } + .sink { [weak self] mediaInfo in + self?.createRoomParameters.avatarImageMedia = mediaInfo + switch mediaInfo { + case .image(_, let thumbnailURL, _): + self?.state.avatarURL = thumbnailURL + case nil: + self?.state.avatarURL = nil + default: + break + } + } + .store(in: &cancellables) + selectedUsers .sink { [weak self] users in self?.state.selectedUsers = users @@ -60,6 +76,12 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol } case .deselectUser(let user): actionsSubject.send(.deselectUser(user)) + case .displayCameraPicker: + actionsSubject.send(.displayCameraPicker) + case .displayMediaPicker: + actionsSubject.send(.displayMediaPicker) + case .removeImage: + actionsSubject.send(.removeImage) } } @@ -69,6 +91,9 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol context.$viewState .map(\.bindings) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) + .removeDuplicates { old, new in + old.roomName == new.roomName && old.roomTopic == new.roomTopic && old.isRoomPrivate == new.isRoomPrivate + } .sink { [weak self] bindings in guard let self else { return } createRoomParameters.name = bindings.roomName @@ -87,6 +112,16 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol message: L10n.screenStartChatErrorStartingChat) case .failedSearchingUsers: state.bindings.alertInfo = AlertInfo(id: .unknown) + case .failedUploadingMedia(let matrixError): + switch matrixError { + case .fileTooLarge: + // waiting for proper copy + state.bindings.alertInfo = AlertInfo(id: .fileTooLarge) + default: + state.bindings.alertInfo = AlertInfo(id: .failedUploadingMedia) + } + case .mediaFileError: + state.bindings.alertInfo = AlertInfo(id: .mediaFileError) default: break } @@ -101,7 +136,25 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol hideLoadingIndicator() } showLoadingIndicator() - switch await clientProxy.createRoom(with: createRoomParameters, userIDs: state.selectedUsers.map(\.userID)) { + + let avatarURL: URL? + if let media = createRoomParameters.avatarImageMedia { + switch await clientProxy.uploadMedia(media) { + case .success(let url): + avatarURL = URL(string: url) + case .failure(let error): + displayError(error) + return + } + } else { + avatarURL = nil + } + + switch await clientProxy.createRoom(name: createRoomParameters.name, + topic: createRoomParameters.topic, + isRoomPrivate: createRoomParameters.isRoomPrivate, + userIDs: state.selectedUsers.map(\.userID), + avatarURL: avatarURL) { case .success(let roomId): actionsSubject.send(.openRoom(withIdentifier: roomId)) case .failure(let failure): diff --git a/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift b/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift index 064826a73..e6484bbd7 100644 --- a/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift +++ b/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift @@ -18,7 +18,13 @@ import SwiftUI struct CreateRoomScreen: View { @ObservedObject var context: CreateRoomViewModel.Context - + @FocusState private var focus: Focus? + + private enum Focus { + case name + case topic + } + var body: some View { mainContent .scrollDismissesKeyboard(.immediately) @@ -32,6 +38,7 @@ struct CreateRoomScreen: View { } } .background(ViewFrameReader(frame: $frame)) + .alert(item: $context.alertInfo) { $0.alert } } /// The main content of the view to be shown in a scroll view. @@ -49,11 +56,38 @@ struct CreateRoomScreen: View { private var roomSection: some View { Section { HStack(alignment: .center, spacing: 16) { - Image(systemName: "camera") - .foregroundColor(.element.secondaryContent) - .frame(width: roomIconSize, height: roomIconSize) - .background(Color.element.quinaryContent) - .clipShape(Circle()) + Button { + focus = nil + context.showAttachmentConfirmationDialog = true + } label: { + if let url = context.viewState.avatarURL { + AsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + ProgressView() + } + .frame(width: roomIconSize, height: roomIconSize) + .clipShape(Circle()) + } else { + cameraImage + } + } + .buttonStyle(.plain) + .confirmationDialog("", isPresented: $context.showAttachmentConfirmationDialog) { + Button(L10n.actionTakePhoto) { + context.send(viewAction: .displayCameraPicker) + } + Button(L10n.actionChoosePhoto) { + context.send(viewAction: .displayMediaPicker) + } + if context.viewState.avatarURL != nil { + Button(L10n.actionRemove, role: .destructive) { + context.send(viewAction: .removeImage) + } + } + } VStack(alignment: .leading, spacing: 8) { Text(L10n.screenCreateRoomRoomNameLabel.uppercased()) .font(.compound.bodyXS) @@ -63,6 +97,7 @@ struct CreateRoomScreen: View { text: $context.roomName, prompt: Text(L10n.screenCreateRoomRoomNamePlaceholder), axis: .horizontal) + .focused($focus, equals: .name) .accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomName) .padding(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) .background(Color.element.formRowBackground) @@ -75,12 +110,21 @@ struct CreateRoomScreen: View { .formSectionStyle() } + private var cameraImage: some View { + Image(systemName: "camera") + .foregroundColor(.element.secondaryContent) + .frame(width: roomIconSize, height: roomIconSize) + .background(Color.element.quinaryContent) + .clipShape(Circle()) + } + private var topicSection: some View { Section { TextField(L10n.screenCreateRoomTopicLabel, text: $context.roomTopic, prompt: Text(L10n.screenCreateRoomTopicPlaceholder), axis: .vertical) + .focused($focus, equals: .topic) .accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomTopic) .lineLimit(3, reservesSpace: false) } header: { @@ -150,7 +194,10 @@ struct CreateRoomScreen: View { } private var createButton: some View { - Button { context.send(viewAction: .createRoom) } label: { + Button { + focus = nil + context.send(viewAction: .createRoom) + } label: { Text(L10n.actionCreate) } .disabled(!context.viewState.canCreateRoom) diff --git a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift index 7aadd500e..61e248815 100644 --- a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift +++ b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift @@ -20,7 +20,7 @@ import SwiftUI struct StartChatScreenCoordinatorParameters { let userSession: UserSessionProtocol weak var userIndicatorController: UserIndicatorControllerProtocol? - let navigationStackCoordinator: NavigationStackCoordinator? + let navigationStackCoordinator: NavigationStackCoordinator let userDiscoveryService: UserDiscoveryServiceProtocol } @@ -45,6 +45,14 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { selectedUsers.asCurrentValuePublisher() } + private var navigationStackCoordinator: NavigationStackCoordinator { + parameters.navigationStackCoordinator + } + + private var userIndicatorController: UserIndicatorControllerProtocol? { + parameters.userIndicatorController + } + var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } @@ -60,12 +68,12 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { case .close: - self.actionsSubject.send(.close) + actionsSubject.send(.close) case .createRoom: // before creating a room we select the users we would like to invite in that room - self.presentInviteUsersScreen() + presentInviteUsersScreen() case .openRoom(let identifier): - self.actionsSubject.send(.openRoom(withIdentifier: identifier)) + actionsSubject.send(.openRoom(withIdentifier: identifier)) } } .store(in: &cancellables) @@ -99,7 +107,7 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { } .store(in: &cancellables) - parameters.navigationStackCoordinator?.push(coordinator) { [weak self] in + navigationStackCoordinator.push(coordinator) { [weak self] in self?.createRoomParameters.send(.init()) self?.selectedUsers.send([]) } @@ -107,27 +115,71 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { private func openCreateRoomScreen() { let createParameters = CreateRoomCoordinatorParameters(userSession: parameters.userSession, - userIndicatorController: parameters.userIndicatorController, + userIndicatorController: userIndicatorController, createRoomParameters: createRoomParametersPublisher, selectedUsers: selectedUsersPublisher) let coordinator = CreateRoomCoordinator(parameters: createParameters) coordinator.actions.sink { [weak self] result in + guard let self else { return } switch result { case .deselectUser(let user): - self?.toggleUser(user) + self.toggleUser(user) case .updateDetails(let details): - self?.createRoomParameters.send(details) + self.createRoomParameters.send(details) case .openRoom(let identifier): - self?.actionsSubject.send(.openRoom(withIdentifier: identifier)) + self.actionsSubject.send(.openRoom(withIdentifier: identifier)) + case .displayMediaPickerWithSource(let source): + self.displayMediaPickerWithSource(source) + case .removeImage: + var parameters = self.createRoomParameters.value + parameters.avatarImageMedia = nil + self.createRoomParameters.send(parameters) } } .store(in: &cancellables) - parameters.navigationStackCoordinator?.push(coordinator) + navigationStackCoordinator.push(coordinator) } // MARK: - Private + let mediaUploadingPreprocessor = MediaUploadingPreprocessor() + private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) { + let stackCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator) + + let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in + guard let self else { return } + switch action { + case .cancel: + navigationStackCoordinator.setSheetCoordinator(nil) + case .selectMediaAtURL(let url): + processAvatar(from: url) + } + } + + stackCoordinator.setRootCoordinator(mediaPickerCoordinator) + + navigationStackCoordinator.setSheetCoordinator(userIndicatorController) + } + + private func processAvatar(from url: URL) { + navigationStackCoordinator.setSheetCoordinator(nil) + showLoadingIndicator() + Task { [weak self] in + guard let self else { return } + do { + let media = try await mediaUploadingPreprocessor.processMedia(at: url).get() + var parameters = createRoomParameters.value + parameters.avatarImageMedia = media + createRoomParameters.send(parameters) + } catch { + userIndicatorController?.alertInfo = AlertInfo(id: .init(), title: L10n.commonError, message: L10n.errorUnknown) + } + hideLoadingIndicator() + } + } + private func toggleUser(_ user: UserProfileProxy) { var selectedUsers = selectedUsers.value if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { @@ -137,4 +189,19 @@ final class StartChatScreenCoordinator: CoordinatorProtocol { } self.selectedUsers.send(selectedUsers) } + + // MARK: Loading indicator + + private static let loadingIndicatorIdentifier = "StartChatCoordinatorLoading" + + private func showLoadingIndicator() { + userIndicatorController?.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, + type: .modal, + title: L10n.commonLoading, + persistent: true)) + } + + private func hideLoadingIndicator() { + userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } } diff --git a/ElementX/Sources/Services/Client/ClientError.swift b/ElementX/Sources/Services/Client/ClientError.swift index 6718dc99b..894ff106d 100644 --- a/ElementX/Sources/Services/Client/ClientError.swift +++ b/ElementX/Sources/Services/Client/ClientError.swift @@ -21,6 +21,19 @@ enum MatrixErrorCode: String, CaseIterable { case unknown = "M_UNKNOWN" case userDeactivated = "M_USER_DEACTIVATED" case forbidden = "M_FORBIDDEN" + case fileTooLarge = "M_TOO_LARGE" +} + +extension ClientError { + var code: MatrixErrorCode { + guard case let .Generic(message) = self else { return .unknown } + + guard let first = MatrixErrorCode.allCases.first(where: { message.contains($0.rawValue) }) else { + return .unknown + } + + return first + } } extension AuthenticationError { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 50da3a175..37ef68e52 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -197,17 +197,17 @@ class ClientProxy: ClientProxyProtocol { return await waitForRoomSummary(with: result, name: expectedRoomName) } - func createRoom(with parameters: CreateRoomFlowParameters, userIDs: [String]) async -> Result { + func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result { let result: Result = await Task.dispatch(on: clientQueue) { do { - let parameters = CreateRoomParameters(name: parameters.name, - topic: parameters.topic, - isEncrypted: parameters.isRoomPrivate, + let parameters = CreateRoomParameters(name: name, + topic: topic, + isEncrypted: isRoomPrivate, isDirect: false, - visibility: parameters.isRoomPrivate ? .private : .public, - preset: parameters.isRoomPrivate ? .privateChat : .publicChat, + visibility: isRoomPrivate ? .private : .public, + preset: isRoomPrivate ? .privateChat : .publicChat, invite: userIDs, - avatar: nil) + avatar: avatarURL?.absoluteString) let roomId = try self.client.createRoom(request: parameters) return .success(roomId) } catch { @@ -215,7 +215,22 @@ class ClientProxy: ClientProxyProtocol { } } - return await waitForRoomSummary(with: result, name: parameters.name) + return await waitForRoomSummary(with: result, name: name) + } + + func uploadMedia(_ media: MediaInfo) async -> Result { + guard let mimeType = media.mimeType else { return .failure(ClientProxyError.mediaFileError) } + return await Task.dispatch(on: clientQueue) { + do { + let data = try Data(contentsOf: media.url) + let matrixUrl = try self.client.uploadMedia(mimeType: mimeType, data: [UInt8](data)) + return .success(matrixUrl) + } catch let error as ClientError { + return .failure(ClientProxyError.failedUploadingMedia(error.code)) + } catch { + return .failure(ClientProxyError.mediaFileError) + } + } } /// Await the room to be available in the room summary list diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 85a99b9b9..da0d3a426 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -41,6 +41,8 @@ enum ClientProxyError: Error { case failedSettingAccountData case failedRetrievingSessionVerificationController case failedLoadingMedia + case mediaFileError + case failedUploadingMedia(MatrixErrorCode) case failedSearchingUsers case failedGettingUserProfile } @@ -92,7 +94,9 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result - func createRoom(with parameters: CreateRoomFlowParameters, userIDs: [String]) async -> Result + func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result + + func uploadMedia(_ media: MediaInfo) async -> Result func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 8fb0ac8f8..c7436e1dd 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -57,10 +57,14 @@ class MockClientProxy: ClientProxyProtocol { .failure(.failedCreatingRoom) } - func createRoom(with parameters: CreateRoomFlowParameters, userIDs: [String]) async -> Result { + func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result { .failure(.failedCreatingRoom) } + func uploadMedia(_ media: MediaInfo) async -> Result { + .failure(.failedUploadingMedia(.unknown)) + } + var roomForIdentifierMocks: [String: RoomProxyMock] = .init() @MainActor func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? { diff --git a/ElementX/Sources/Services/CreateRoom/CreateRoomFlowParameters.swift b/ElementX/Sources/Services/CreateRoom/CreateRoomFlowParameters.swift index 88eed2f61..4639534de 100644 --- a/ElementX/Sources/Services/CreateRoom/CreateRoomFlowParameters.swift +++ b/ElementX/Sources/Services/CreateRoom/CreateRoomFlowParameters.swift @@ -21,4 +21,5 @@ struct CreateRoomFlowParameters { var name = "" var topic = "" var isRoomPrivate = true + var avatarImageMedia: MediaInfo? } diff --git a/changelog.d/961.feature b/changelog.d/961.feature new file mode 100644 index 000000000..316bf4d76 --- /dev/null +++ b/changelog.d/961.feature @@ -0,0 +1 @@ +Add the image picker flow for the creation of a room \ No newline at end of file