From 5f6edacc1a9031fcc7f31aebfb1f94752bac3845 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 28 Mar 2023 17:47:34 +0300 Subject: [PATCH] Implements #745, #746, #747 - Initial media uploading flows - room composer contextual menu for selecting source - coordinator for presenting the different sources - system picker handling and URL passback - file preview after selection - feature flag --- .../en.lproj/Untranslated.strings | 8 ++ .../Sources/Application/AppSettings.swift | 6 + .../Generated/Strings+Untranslated.swift | 6 + .../Mocks/Generated/GeneratedMocks.swift | 21 ++++ .../Other/Extensions/FileManager.swift | 17 +++ .../DeveloperOptionsScreenModels.swift | 2 + .../DeveloperOptionsScreenViewModel.swift | 6 +- .../View/DeveloperOptionsScreenScreen.swift | 8 ++ .../Screens/MediaPicker/CameraPicker.swift | 81 +++++++++++++ .../Screens/MediaPicker/DocumentPicker.swift | 69 +++++++++++ .../MediaPicker/MediaPickerCoordinator.swift | 89 ++++++++++++++ .../MediaPicker/PhotoLibraryPicker.swift | 84 +++++++++++++ .../MediaPickerPreviewScreenCoordinator.swift | 56 +++++++++ .../MediaPickerPreviewScreenModels.swift} | 27 ++--- .../MediaPickerPreviewScreenViewModel.swift | 36 ++++++ ...PickerPreviewScreenViewModelProtocol.swift | 23 ++++ .../View/MediaPickerPreviewScreenScreen.swift | 110 ++++++++++++++++++ .../RoomScreen/RoomScreenCoordinator.swift | 32 ++++- .../Screens/RoomScreen/RoomScreenModels.swift | 8 ++ .../RoomScreen/RoomScreenViewModel.swift | 11 ++ .../Screens/RoomScreen/View/RoomScreen.swift | 46 ++++++-- .../Sources/Services/Room/RoomProxy.swift | 18 +-- .../Services/Room/RoomProxyProtocol.swift | 3 + ElementX/SupportingFiles/Info.plist | 4 + ElementX/SupportingFiles/target.yml | 3 + ...ediaPickerPreviewScreenScreenUITests.swift | 20 ++++ ...diaPickerPreviewScreenViewModelTests.swift | 22 ++++ 27 files changed, 787 insertions(+), 29 deletions(-) create mode 100644 ElementX/Sources/Screens/MediaPicker/CameraPicker.swift create mode 100644 ElementX/Sources/Screens/MediaPicker/DocumentPicker.swift create mode 100644 ElementX/Sources/Screens/MediaPicker/MediaPickerCoordinator.swift create mode 100644 ElementX/Sources/Screens/MediaPicker/PhotoLibraryPicker.swift create mode 100644 ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenCoordinator.swift rename ElementX/Sources/Screens/{Other/ActivityCoordinator.swift => MediaPickerPreviewScreen/MediaPickerPreviewScreenModels.swift} (63%) create mode 100644 ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModel.swift create mode 100644 ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/MediaPickerPreviewScreen/View/MediaPickerPreviewScreenScreen.swift create mode 100644 UITests/Sources/MediaPickerPreviewScreenScreenUITests.swift create mode 100644 UnitTests/Sources/MediaPickerPreviewScreenViewModelTests.swift diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index add322fe0..7b4d02329 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -24,3 +24,11 @@ "soft_logout_clear_data_submit" = "Clear all data"; "soft_logout_clear_data_dialog_title" = "Clear data"; "soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages."; + +// MARK: - Media upload + +"media_upload_camera_picker" = "Camera"; + +"media_upload_photo_and_video_picker" = "Photo & Video Library"; + +"media_upload_document_picker" = "Document"; diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 122f994a1..203277f26 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -28,6 +28,7 @@ final class AppSettings: ObservableObject { case pusherProfileTag case shouldCollapseRoomStateEvents case showStartChatFlow + case mediaUploadingFlowEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -162,4 +163,9 @@ final class AppSettings: ObservableObject { @UserSetting(key: UserDefaultsKeys.showStartChatFlow.rawValue, defaultValue: false, persistIn: store) var startChatFlowFeatureFlag + + // MARK: Media Uploading + + @UserSetting(key: UserDefaultsKeys.mediaUploadingFlowEnabled.rawValue, defaultValue: false, persistIn: nil) + var mediaUploadingFlowEnabled } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 659cc1c48..08f222a5a 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -28,6 +28,12 @@ public enum UntranslatedL10n { public static func analyticsOptInTitle(_ p1: Any) -> String { return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_title", String(describing: p1)) } + /// Camera + public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") } + /// Document + public static var mediaUploadDocumentPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_document_picker") } + /// Photo & Video Library + public static var mediaUploadPhotoAndVideoPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_photo_and_video_picker") } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. public static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 59c694ce7..3f64e102e 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -326,6 +326,27 @@ class RoomProxyMock: RoomProxyProtocol { return sendReactionToReturnValue } } + //MARK: - sendImage + + var sendImageBodyUrlCallsCount = 0 + var sendImageBodyUrlCalled: Bool { + return sendImageBodyUrlCallsCount > 0 + } + var sendImageBodyUrlReceivedArguments: (body: String, url: URL)? + var sendImageBodyUrlReceivedInvocations: [(body: String, url: URL)] = [] + var sendImageBodyUrlReturnValue: Result! + var sendImageBodyUrlClosure: ((String, URL) async -> Result)? + + func sendImage(body: String, url: URL) async -> Result { + sendImageBodyUrlCallsCount += 1 + sendImageBodyUrlReceivedArguments = (body: body, url: url) + sendImageBodyUrlReceivedInvocations.append((body: body, url: url)) + if let sendImageBodyUrlClosure = sendImageBodyUrlClosure { + return await sendImageBodyUrlClosure(body, url) + } else { + return sendImageBodyUrlReturnValue + } + } //MARK: - editMessage var editMessageOriginalCallsCount = 0 diff --git a/ElementX/Sources/Other/Extensions/FileManager.swift b/ElementX/Sources/Other/Extensions/FileManager.swift index eac66442d..f114827dd 100644 --- a/ElementX/Sources/Other/Extensions/FileManager.swift +++ b/ElementX/Sources/Other/Extensions/FileManager.swift @@ -31,4 +31,21 @@ extension FileManager { } try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories) } + + func copyFileToTemporaryLocation(url: URL) throws -> URL { + let newURL = URL.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + + try? removeItem(at: newURL) + try copyItem(at: url, to: newURL) + + return newURL + } + + func writeDataToTemporaryLocation(data: Data, fileName: String) throws -> URL { + let newURL = URL.temporaryDirectory.appendingPathComponent(fileName) + + try data.write(to: newURL) + + return newURL + } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 45261610a..1b4529357 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -25,9 +25,11 @@ struct DeveloperOptionsScreenViewState: BindableState { struct DeveloperOptionsScreenViewStateBindings { var shouldCollapseRoomStateEvents: Bool var showStartChatFlow: Bool + var mediaUploadFlowEnabled: Bool } enum DeveloperOptionsScreenViewAction { case changedShouldCollapseRoomStateEvents case changedShowStartChatFlow + case changedShowMediaUploadFlow } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index 9d3066ed5..c320fd659 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -22,7 +22,9 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)? init() { - let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents, showStartChatFlow: ServiceLocator.shared.settings.startChatFlowFeatureFlag) + let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents, + showStartChatFlow: ServiceLocator.shared.settings.startChatFlowFeatureFlag, + mediaUploadFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled) let state = DeveloperOptionsScreenViewState(bindings: bindings) super.init(initialViewState: state) @@ -38,6 +40,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents case .changedShowStartChatFlow: ServiceLocator.shared.settings.startChatFlowFeatureFlag = state.bindings.showStartChatFlow + case .changedShowMediaUploadFlow: + ServiceLocator.shared.settings.mediaUploadingFlowEnabled = state.bindings.mediaUploadFlowEnabled } } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift index 549d144da..ef1cc22eb 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift @@ -31,12 +31,20 @@ struct DeveloperOptionsScreenScreen: View { .onChange(of: context.shouldCollapseRoomStateEvents) { _ in context.send(viewAction: .changedShouldCollapseRoomStateEvents) } + Toggle(isOn: $context.showStartChatFlow) { Text("Show Start Chat flow") } .onChange(of: context.showStartChatFlow) { _ in context.send(viewAction: .changedShowStartChatFlow) } + + Toggle(isOn: $context.mediaUploadFlowEnabled) { + Text("Show Media Uploading flow") + } + .onChange(of: context.mediaUploadFlowEnabled) { _ in + context.send(viewAction: .changedShowMediaUploadFlow) + } } Section { diff --git a/ElementX/Sources/Screens/MediaPicker/CameraPicker.swift b/ElementX/Sources/Screens/MediaPicker/CameraPicker.swift new file mode 100644 index 000000000..d451ce4f1 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPicker/CameraPicker.swift @@ -0,0 +1,81 @@ +// +// Copyright 2023 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 UIKit + +enum CameraPickerAction { + case selectFile(URL) + case cancel + case error(Error?) +} + +struct CameraPicker: UIViewControllerRepresentable { + private let callback: (CameraPickerAction) -> Void + + init(callback: @escaping (CameraPickerAction) -> Void) { + self.callback = callback + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let imagePicker = UIImagePickerController() + imagePicker.sourceType = .camera + imagePicker.allowsEditing = true + imagePicker.delegate = context.coordinator + + if let mediaTypes = UIImagePickerController.availableMediaTypes(for: .camera) { + imagePicker.mediaTypes = mediaTypes + } + + return imagePicker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private var parent: CameraPicker + + init(_ parent: CameraPicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let videoURL = info[.mediaURL] as? URL { + parent.callback(.selectFile(videoURL)) + } else if let image = info[.originalImage] as? UIImage { + guard let jpegData = image.jpegData(compressionQuality: 1.0) else { + parent.callback(.error(nil)) + return + } + + let fileName = "\(UUID().uuidString).jpg" + + do { + let url = try FileManager.default.writeDataToTemporaryLocation(data: jpegData, fileName: fileName) + parent.callback(.selectFile(url)) + } catch { + parent.callback(.error(error)) + } + } else { + parent.callback(.error(nil)) + } + } + } +} diff --git a/ElementX/Sources/Screens/MediaPicker/DocumentPicker.swift b/ElementX/Sources/Screens/MediaPicker/DocumentPicker.swift new file mode 100644 index 000000000..f884d1897 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPicker/DocumentPicker.swift @@ -0,0 +1,69 @@ +// +// Copyright 2023 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 UIKit + +enum DocumentPickerAction { + case selectFile(URL) + case cancel + case error(Error?) +} + +struct DocumentPicker: UIViewControllerRepresentable { + private let callback: (DocumentPickerAction) -> Void + + init(callback: @escaping (DocumentPickerAction) -> Void) { + self.callback = callback + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data]) + documentPicker.allowsMultipleSelection = false + documentPicker.delegate = context.coordinator + + return documentPicker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, UIDocumentPickerDelegate { + private var parent: DocumentPicker + + init(_ parent: DocumentPicker) { + self.parent = parent + } + + // MARK: UIDocumentPickerDelegate + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + parent.callback(.cancel) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + parent.callback(.error(nil)) + return + } + + parent.callback(.selectFile(url)) + } + } +} diff --git a/ElementX/Sources/Screens/MediaPicker/MediaPickerCoordinator.swift b/ElementX/Sources/Screens/MediaPicker/MediaPickerCoordinator.swift new file mode 100644 index 000000000..062cdd9c6 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPicker/MediaPickerCoordinator.swift @@ -0,0 +1,89 @@ +// +// Copyright 2023 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 + +enum MediaPickerSource { + case camera + case photoLibrary + case documents +} + +enum MediaPickerCoordinatorAction { + case selectMediaAtURL(URL) + case cancel + case error(Error?) +} + +class MediaPickerCoordinator: CoordinatorProtocol { + private let source: MediaPickerSource + private let callback: ((MediaPickerCoordinatorAction) -> Void)? + + init(source: MediaPickerSource, callback: @escaping (MediaPickerCoordinatorAction) -> Void) { + self.source = source + self.callback = callback + } + + func toPresentable() -> AnyView { + AnyView(mediaPicker) + } + + @ViewBuilder + private var mediaPicker: some View { + switch source { + case .camera: + cameraPicker + case .photoLibrary: + PhotoLibraryPicker { [weak self] action in + switch action { + case .cancel: + self?.callback?(.cancel) + case .error(let error): + self?.callback?(.error(error)) + case .selectFile(let url): + self?.callback?(.selectMediaAtURL(url)) + } + } + case .documents: + // The document picker automatically dismisses everything on selection + // Strongly retain self in the callback to forward actions correctly + DocumentPicker { action in + switch action { + case .cancel: + self.callback?(.cancel) + case .error(let error): + self.callback?(.error(error)) + case .selectFile(let url): + self.callback?(.selectMediaAtURL(url)) + } + } + } + } + + private var cameraPicker: some View { + CameraPicker { [weak self] action in + switch action { + case .cancel: + self?.callback?(.cancel) + case .error(let error): + self?.callback?(.error(error)) + case .selectFile(let url): + self?.callback?(.selectMediaAtURL(url)) + } + } + .background(.black, ignoresSafeAreaEdges: .bottom) + } +} diff --git a/ElementX/Sources/Screens/MediaPicker/PhotoLibraryPicker.swift b/ElementX/Sources/Screens/MediaPicker/PhotoLibraryPicker.swift new file mode 100644 index 000000000..dfa1b6986 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPicker/PhotoLibraryPicker.swift @@ -0,0 +1,84 @@ +// +// Copyright 2023 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 PhotosUI +import SwiftUI + +enum PhotoLibraryPickerAction { + case selectFile(URL) + case cancel + case error(Error?) +} + +struct PhotoLibraryPicker: UIViewControllerRepresentable { + private let callback: (PhotoLibraryPickerAction) -> Void + + init(callback: @escaping (PhotoLibraryPickerAction) -> Void) { + self.callback = callback + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = 1 + + let pickerViewController = PHPickerViewController(configuration: configuration) + pickerViewController.delegate = context.coordinator + + return pickerViewController + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + private var parent: PhotoLibraryPicker + + init(_ parent: PhotoLibraryPicker) { + self.parent = parent + } + + // MARK: PHPickerViewControllerDelegate + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard let provider = results.first?.itemProvider else { + parent.callback(.cancel) + return + } + + provider.loadFileRepresentation(forTypeIdentifier: "public.item") { @MainActor [weak self] url, error in + guard let url else { + self?.parent.callback(.error(error)) + return + } + + do { + let _ = url.startAccessingSecurityScopedResource() + let newURL = try FileManager.default.copyFileToTemporaryLocation(url: url) + url.stopAccessingSecurityScopedResource() + + Task { @MainActor in + self?.parent.callback(.selectFile(newURL)) + } + } catch { + self?.parent.callback(.error(error)) + } + } + } + } +} diff --git a/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenCoordinator.swift b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenCoordinator.swift new file mode 100644 index 000000000..536ed3d05 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenCoordinator.swift @@ -0,0 +1,56 @@ +// +// Copyright 2022 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 MediaPickerPreviewScreenCoordinatorParameters { + let url: URL + let title: String? +} + +enum MediaPickerPreviewScreenCoordinatorAction { + case send + case cancel +} + +final class MediaPickerPreviewScreenCoordinator: CoordinatorProtocol { + private let parameters: MediaPickerPreviewScreenCoordinatorParameters + + private var viewModel: MediaPickerPreviewScreenViewModelProtocol + private let callback: (MediaPickerPreviewScreenCoordinatorAction) -> Void + + init(parameters: MediaPickerPreviewScreenCoordinatorParameters, callback: @escaping (MediaPickerPreviewScreenCoordinatorAction) -> Void) { + self.parameters = parameters + self.callback = callback + + viewModel = MediaPickerPreviewScreenViewModel(url: parameters.url, title: parameters.title) + } + + func start() { + viewModel.callback = { [weak self] action in + switch action { + case .send: + self?.callback(.send) + case .cancel: + self?.callback(.cancel) + } + } + } + + func toPresentable() -> AnyView { + AnyView(MediaPickerPreviewScreenScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/Other/ActivityCoordinator.swift b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenModels.swift similarity index 63% rename from ElementX/Sources/Screens/Other/ActivityCoordinator.swift rename to ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenModels.swift index 8ca35bfe5..806dcbe92 100644 --- a/ElementX/Sources/Screens/Other/ActivityCoordinator.swift +++ b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenModels.swift @@ -14,18 +14,19 @@ // limitations under the License. // -import SwiftUI +import Foundation -class ActivityCoordinator: CoordinatorProtocol { - let items: [Any] - - init(items: [Any]) { - self.items = items - } - - func toPresentable() -> AnyView { - AnyView(UIActivityViewControllerWrapper(activityItems: items) - .presentationDetents([.medium]) - .ignoresSafeArea()) - } +enum MediaPickerPreviewScreenViewModelAction { + case send + case cancel +} + +struct MediaPickerPreviewScreenViewState: BindableState { + let url: URL + let title: String? +} + +enum MediaPickerPreviewScreenViewAction { + case send + case cancel } diff --git a/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModel.swift b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModel.swift new file mode 100644 index 000000000..1d04d6581 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModel.swift @@ -0,0 +1,36 @@ +// +// Copyright 2022 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 + +typealias MediaPickerPreviewScreenViewModelType = StateStoreViewModel + +class MediaPickerPreviewScreenViewModel: MediaPickerPreviewScreenViewModelType, MediaPickerPreviewScreenViewModelProtocol { + var callback: ((MediaPickerPreviewScreenViewModelAction) -> Void)? + + init(url: URL, title: String?) { + super.init(initialViewState: MediaPickerPreviewScreenViewState(url: url, title: title)) + } + + override func process(viewAction: MediaPickerPreviewScreenViewAction) async { + switch viewAction { + case .send: + callback?(.send) + case .cancel: + callback?(.cancel) + } + } +} diff --git a/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModelProtocol.swift b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModelProtocol.swift new file mode 100644 index 000000000..520d6da79 --- /dev/null +++ b/ElementX/Sources/Screens/MediaPickerPreviewScreen/MediaPickerPreviewScreenViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// Copyright 2022 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 + +@MainActor +protocol MediaPickerPreviewScreenViewModelProtocol { + var callback: ((MediaPickerPreviewScreenViewModelAction) -> Void)? { get set } + var context: MediaPickerPreviewScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/MediaPickerPreviewScreen/View/MediaPickerPreviewScreenScreen.swift b/ElementX/Sources/Screens/MediaPickerPreviewScreen/View/MediaPickerPreviewScreenScreen.swift new file mode 100644 index 000000000..023eedced --- /dev/null +++ b/ElementX/Sources/Screens/MediaPickerPreviewScreen/View/MediaPickerPreviewScreenScreen.swift @@ -0,0 +1,110 @@ +// +// Copyright 2022 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 QuickLook +import SwiftUI + +struct MediaPickerPreviewScreenScreen: View { + @ObservedObject var context: MediaPickerPreviewScreenViewModel.Context + + var body: some View { + NavigationStack { + PreviewView(context: context, + fileURL: context.viewState.url, + title: context.viewState.title) + .id(UUID()) + .ignoresSafeArea(edges: .bottom) + .toolbar { toolbar } + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { context.send(viewAction: .cancel) } label: { + Text(L10n.actionCancel) + } + } + ToolbarItem(placement: .confirmationAction) { + Button { context.send(viewAction: .send) } label: { + Text(L10n.actionSend) + } + } + } +} + +private struct PreviewView: UIViewControllerRepresentable { + let context: MediaPickerPreviewScreenViewModel.Context + let fileURL: URL + let title: String? + + func makeUIViewController(context: Context) -> UINavigationController { + let previewController = QLPreviewController() + previewController.dataSource = context.coordinator + previewController.delegate = context.coordinator + + return UINavigationController(rootViewController: previewController) + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(view: self) + } + + class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { + let view: PreviewView + + init(view: PreviewView) { + self.view = view + } + + // MARK: - QLPreviewControllerDataSource + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + PreviewItem(previewItemURL: view.fileURL, previewItemTitle: view.title) + } + + // MARK: - QLPreviewControllerDelegate + + func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode { + .disabled + } + } +} + +private class PreviewItem: NSObject, QLPreviewItem { + var previewItemURL: URL? + var previewItemTitle: String? + + init(previewItemURL: URL?, previewItemTitle: String?) { + self.previewItemURL = previewItemURL + self.previewItemTitle = previewItemTitle + } +} + +// MARK: - Previews + +struct MediaPickerPreviewScreen_Previews: PreviewProvider { + static let viewModel = MediaPickerPreviewScreenViewModel(url: URL.picturesDirectory, title: nil) + static var previews: some View { + MediaPickerPreviewScreenScreen(context: viewModel.context) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 9d1a89882..efc405c7d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -62,6 +62,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol { self.displayEmojiPickerScreen(for: itemId) case .displayReportContent(let itemId): self.displayReportContent(for: itemId) + case .displayCameraPicker: + self.displayMediaPickerWithSource(.camera) + case .displayMediaPicker: + self.displayMediaPickerWithSource(.photoLibrary) + case .displayDocumentPicker: + self.displayMediaPickerWithSource(.documents) } } } @@ -74,8 +80,32 @@ final class RoomScreenCoordinator: CoordinatorProtocol { func toPresentable() -> AnyView { AnyView(RoomScreen(context: viewModel.context)) } - + // MARK: - Private + + private func displayMediaPickerWithSource(_ source: MediaPickerSource) { + let mediaPickerCoordinator = MediaPickerCoordinator(source: source) { [weak self] action in + switch action { + case .cancel: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + case .error: + break + case .selectMediaAtURL(let url): + let mediaPickerPreviewScreenCoordinator = MediaPickerPreviewScreenCoordinator(parameters: .init(url: url, title: url.lastPathComponent)) { action in + switch action { + case .send: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + case .cancel: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + + self?.navigationStackCoordinator.setSheetCoordinator(mediaPickerPreviewScreenCoordinator) + } + } + + navigationStackCoordinator.setSheetCoordinator(mediaPickerCoordinator) + } private func displayFilePreview(for file: MediaFileHandleProxy, with title: String?) { let params = FilePreviewCoordinatorParameters(mediaFile: file, title: title) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index cdaca0968..4639fc2af 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -23,6 +23,9 @@ enum RoomScreenViewModelAction { case displayMediaFile(file: MediaFileHandleProxy, title: String?) case displayEmojiPicker(itemId: String) case displayReportContent(itemId: String) + case displayCameraPicker + case displayMediaPicker + case displayDocumentPicker } enum RoomScreenComposerMode: Equatable { @@ -55,6 +58,10 @@ enum RoomScreenViewAction { /// Mark the entire room as read - this is heavy handed as a starting point for now. case markRoomAsRead case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction) + + case displayCameraPicker + case displayMediaPicker + case displayDocumentPicker } struct RoomScreenViewState: BindableState { @@ -66,6 +73,7 @@ struct RoomScreenViewState: BindableState { var isBackPaginating = false var showLoading = false var timelineStyle: TimelineStyle + var mediaUploadingFlowEnabled: Bool var bindings: RoomScreenViewStateBindings diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 3da61a496..e5aeb9888 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -39,6 +39,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol roomTitle: roomName ?? "Unknown room 💥", roomAvatarURL: roomAvatarUrl, timelineStyle: ServiceLocator.shared.settings.timelineStyle, + mediaUploadingFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled, bindings: .init(composerText: "", composerFocused: false)), imageProvider: mediaProvider) @@ -74,6 +75,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .weakAssign(to: \.state.timelineStyle, on: self) .store(in: &cancellables) + ServiceLocator.shared.settings.$mediaUploadingFlowEnabled + .weakAssign(to: \.state.mediaUploadingFlowEnabled, on: self) + .store(in: &cancellables) + buildTimelineViews() } @@ -111,6 +116,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol await markRoomAsRead() case .contextMenuAction(let itemID, let action): processContentMenuAction(action, itemID: itemID) + case .displayCameraPicker: + callback?(.displayCameraPicker) + case .displayMediaPicker: + callback?(.displayMediaPicker) + case .displayDocumentPicker: + callback?(.displayDocumentPicker) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index d1c50f514..438fb75d8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -23,7 +23,16 @@ struct RoomScreen: View { var body: some View { timeline .background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency. - .safeAreaInset(edge: .bottom, spacing: 0) { messageComposer } + .safeAreaInset(edge: .bottom, spacing: 0) { + HStack(spacing: 4.0) { + if context.viewState.mediaUploadingFlowEnabled { + sendAttachmentButton + } + + messageComposer + } + .padding() + } .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .toolbarRole(.editor) // Hide the back button title. @@ -40,7 +49,7 @@ struct RoomScreen: View { } } - var timeline: some View { + private var timeline: some View { TimelineView() .id(context.viewState.roomId) .environmentObject(context) @@ -48,7 +57,7 @@ struct RoomScreen: View { .overlay(alignment: .bottomTrailing) { scrollToBottomButton } } - var messageComposer: some View { + private var messageComposer: some View { MessageComposer(text: $context.composerText, focused: $context.composerFocused, sendingDisabled: context.viewState.sendButtonDisabled, @@ -59,10 +68,9 @@ struct RoomScreen: View { } editCancellationAction: { context.send(viewAction: .cancelEdit) } - .padding() } - var scrollToBottomButton: some View { + private var scrollToBottomButton: some View { Button { context.viewState.scrollToBottomPublisher.send(()) } label: { Image(systemName: "chevron.down") .font(.element.body) @@ -84,7 +92,7 @@ struct RoomScreen: View { } @ViewBuilder - var loadingIndicator: some View { + private var loadingIndicator: some View { if context.viewState.showLoading { ProgressView() .progressViewStyle(.circular) @@ -96,7 +104,7 @@ struct RoomScreen: View { } @ToolbarContentBuilder - var toolbar: some ToolbarContent { + private var toolbar: some ToolbarContent { // .principal + .primaryAction works better than .navigation leading + trailing // as the latter disables interaction in the action button for rooms with long names ToolbarItem(placement: .principal) { @@ -104,6 +112,30 @@ struct RoomScreen: View { } } + private var sendAttachmentButton: some View { + Menu { + Button { + context.send(viewAction: .displayDocumentPicker) + } label: { + Label(UntranslatedL10n.mediaUploadDocumentPicker, systemImage: "doc") + } + Button { + context.send(viewAction: .displayMediaPicker) + } label: { + Label(UntranslatedL10n.mediaUploadPhotoAndVideoPicker, systemImage: "photo") + } + Button { + context.send(viewAction: .displayCameraPicker) + } label: { + Label(UntranslatedL10n.mediaUploadCameraPicker, systemImage: "camera") + } + } label: { + Image(systemName: "plus.circle") + .font(.element.title1) + .foregroundColor(.element.brand) + } + } + private func sendMessage() { guard !context.viewState.sendButtonDisabled else { return } context.send(viewAction: .sendMessage) diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 5a1013490..a5d6811ab 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -189,15 +189,15 @@ class RoomProxy: RoomProxyProtocol { let transactionId = genTransactionId() return await Task.dispatch(on: userInitiatedDispatchQueue) { - if let eventID { - do { + do { + if let eventID { try self.room.sendReply(msg: message, inReplyToEventId: eventID, txnId: transactionId) - } catch { - return .failure(.failedSendingMessage) + } else { + let messageContent = messageEventContentFromMarkdown(md: message) + try self.room.send(msg: messageContent, txnId: transactionId) } - } else { - let messageContent = messageEventContentFromMarkdown(md: message) - self.room.send(msg: messageContent, txnId: transactionId) + } catch { + return .failure(.failedSendingMessage) } return .success(()) } @@ -218,6 +218,10 @@ class RoomProxy: RoomProxyProtocol { } } } + + func sendImage(body: String, url: URL) async -> Result { + .failure(.failedSendingMedia) + } func editMessage(_ newMessage: String, original eventID: String) async -> Result { sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 3905f4568..0f1c90607 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -26,6 +26,7 @@ enum RoomProxyError: Error { case failedSendingReadReceipt case failedSendingMessage case failedSendingReaction + case failedSendingMedia case failedEditingMessage case failedRedactingEvent case failedReportingContent @@ -69,6 +70,8 @@ protocol RoomProxyProtocol { func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result func sendReaction(_ reaction: String, to eventID: String) async -> Result + + func sendImage(body: String, url: URL) async -> Result func editMessage(_ newMessage: String, original eventID: String) async -> Result diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index a7b84056f..eef6537f7 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -22,6 +22,10 @@ $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption + NSCameraUsageDescription + The camera is used to take and upload photos and videos. + NSMicrophoneUsageDescription + The microphone is used to take videos. NSUserActivityTypes INSendMessageIntent diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 6b5da5bf4..2c964b5d4 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -64,6 +64,9 @@ targets: NSUserActivityTypes: [ INSendMessageIntent ] + NSCameraUsageDescription: The camera is used to take and upload photos and videos. + NSMicrophoneUsageDescription: The microphone is used to take videos. + settings: base: diff --git a/UITests/Sources/MediaPickerPreviewScreenScreenUITests.swift b/UITests/Sources/MediaPickerPreviewScreenScreenUITests.swift new file mode 100644 index 000000000..ceead69b6 --- /dev/null +++ b/UITests/Sources/MediaPickerPreviewScreenScreenUITests.swift @@ -0,0 +1,20 @@ +// +// Copyright 2022 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 ElementX +import XCTest + +class MediaPickerPreviewScreenScreenUITests: XCTestCase { } diff --git a/UnitTests/Sources/MediaPickerPreviewScreenViewModelTests.swift b/UnitTests/Sources/MediaPickerPreviewScreenViewModelTests.swift new file mode 100644 index 000000000..098cdec09 --- /dev/null +++ b/UnitTests/Sources/MediaPickerPreviewScreenViewModelTests.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022 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 + +@testable import ElementX + +@MainActor +class MediaPickerPreviewScreenScreenViewModelTests: XCTestCase { }