Media uploading (#851)

* MediaUploadingPreprocessor - Prevent images without any GPS metadata from being changed

* Add support for sending images

* Add support for sending video, audio and file attachments

* Rename MediaPickerPreview to MediaUploadPreview

* Move media uploading to the MediaUploadPreviewScreen, add waiting indicators and error handling

* Add support for pasting and drag&dropping in media for upload

* Adopt new media picker source UI, remove developer flag

* Set minimum heights for timeline loadable images

* Fix invalid camera picker file names

* Fix flakey MediaUploadingPreprocessor image tests, improve gps metadata stripping

* UITests: Update existing screenshots and add new step for the room attachment picker

* Switch all github action runners to macos-13

* Cleanup enter key and paste message composer handlers
This commit is contained in:
Stefan Ceriu
2023-05-04 16:09:29 +03:00
committed by GitHub
parent e9c07c9f8f
commit 93e3a1a80b
100 changed files with 809 additions and 393 deletions
@@ -0,0 +1,57 @@
//
// 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 MediaUploadPreviewScreenCoordinatorParameters {
weak var userIndicatorController: UserIndicatorControllerProtocol?
let roomProxy: RoomProxyProtocol
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
let title: String?
let url: URL
}
enum MediaUploadPreviewScreenCoordinatorAction {
case dismiss
}
final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol {
private var viewModel: MediaUploadPreviewScreenViewModelProtocol
private let callback: (MediaUploadPreviewScreenCoordinatorAction) -> Void
init(parameters: MediaUploadPreviewScreenCoordinatorParameters, callback: @escaping (MediaUploadPreviewScreenCoordinatorAction) -> Void) {
self.callback = callback
viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: parameters.userIndicatorController,
roomProxy: parameters.roomProxy,
mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor,
title: parameters.title,
url: parameters.url)
}
func start() {
viewModel.callback = { [weak self] action in
switch action {
case .dismiss:
self?.callback(.dismiss)
}
}
}
func toPresentable() -> AnyView {
AnyView(MediaUploadPreviewScreen(context: viewModel.context))
}
}
@@ -0,0 +1,31 @@
//
// 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
enum MediaUploadPreviewScreenViewModelAction {
case dismiss
}
struct MediaUploadPreviewScreenViewState: BindableState {
let url: URL
let title: String?
}
enum MediaUploadPreviewScreenViewAction {
case send
case cancel
}
@@ -0,0 +1,105 @@
//
// 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 MediaUploadPreviewScreenViewModelType = StateStoreViewModel<MediaUploadPreviewScreenViewState, MediaUploadPreviewScreenViewAction>
class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, MediaUploadPreviewScreenViewModelProtocol {
private weak var userIndicatorController: UserIndicatorControllerProtocol?
private let roomProxy: RoomProxyProtocol
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor
private let url: URL
var callback: ((MediaUploadPreviewScreenViewModelAction) -> Void)?
init(userIndicatorController: UserIndicatorControllerProtocol?,
roomProxy: RoomProxyProtocol,
mediaUploadingPreprocessor: MediaUploadingPreprocessor,
title: String?,
url: URL) {
self.userIndicatorController = userIndicatorController
self.roomProxy = roomProxy
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
self.url = url
super.init(initialViewState: MediaUploadPreviewScreenViewState(url: url, title: title))
}
override func process(viewAction: MediaUploadPreviewScreenViewAction) {
switch viewAction {
case .send:
Task {
startLoading()
defer {
stopLoading()
}
switch await mediaUploadingPreprocessor.processMedia(at: url) {
case .success(let mediaInfo):
switch await sendAttachment(mediaInfo: mediaInfo) {
case .success:
callback?(.dismiss)
case .failure(let error):
MXLog.error("Failed sending attachment with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
}
case .failure(let error):
MXLog.error("Failed processing media to upload with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
}
}
case .cancel:
callback?(.dismiss)
}
}
// MARK: - Private
private func sendAttachment(mediaInfo: MediaInfo) async -> Result<Void, RoomProxyError> {
switch mediaInfo {
case let .image(imageURL, thumbnailURL, imageInfo):
return await roomProxy.sendImage(url: imageURL, thumbnailURL: thumbnailURL, imageInfo: imageInfo)
case let .video(videoURL, thumbnailURL, videoInfo):
return await roomProxy.sendVideo(url: videoURL, thumbnailURL: thumbnailURL, videoInfo: videoInfo)
case let .audio(audioURL, audioInfo):
return await roomProxy.sendAudio(url: audioURL, audioInfo: audioInfo)
case let .file(fileURL, fileInfo):
return await roomProxy.sendFile(url: fileURL, fileInfo: fileInfo)
}
}
private static let loadingIndicatorIdentifier = "MediaUploadPreviewLoading"
private func startLoading() {
userIndicatorController?.submitIndicator(
UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true)
)
}
private func stopLoading() {
userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
private func showError(label: String) {
userIndicatorController?.submitIndicator(UserIndicator(title: label))
}
}
@@ -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 MediaUploadPreviewScreenViewModelProtocol {
var callback: ((MediaUploadPreviewScreenViewModelAction) -> Void)? { get set }
var context: MediaUploadPreviewScreenViewModelType.Context { get }
}
@@ -0,0 +1,113 @@
//
// 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 MediaUploadPreviewScreen: View {
@ObservedObject var context: MediaUploadPreviewScreenViewModel.Context
var body: some View {
PreviewView(context: context,
fileURL: context.viewState.url,
title: context.viewState.title)
.id(UUID())
.ignoresSafeArea(edges: .bottom)
.toolbar { toolbar }
.interactiveDismissDisabled()
}
@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: MediaUploadPreviewScreenViewModel.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 MediaUploadPreviewScreen_Previews: PreviewProvider {
static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: MockUserIndicatorController(),
roomProxy: RoomProxyMock(),
mediaUploadingPreprocessor: MediaUploadingPreprocessor(),
title: nil,
url: URL.picturesDirectory)
static var previews: some View {
MediaUploadPreviewScreen(context: viewModel.context)
}
}