Multi file uploads (#4358)

* Allow MediaPickerScreen users to select the media selection mode (single or multiple)

* Fix cancellation

* Add support for multiple media URLs on the MediaUploadPreviewScreen.

* Support processing more URLs on the `MediaUploadingPreprocessor` and sending more on the `MediaUploadPreviewScreen`

* Add feature flag for `multipleAttachmentUploadEnabled`

* Add a label showing the current preview item index in the MediaUploadPreviewScreen

* Add support for dragging and dropping or pasting multiple items at the same time.

* Support sharing more than one file through the share extension.

* Limit the number of items that can be shared in one go to 5.

* Fix unit tests

* Fix incorrect fatal error when dealing with single selection media pickers.

* Document the `multipleAttachmentUploadEnabled` usage in the context of the MediaPicker.

* Use a task group for processing selected media in the photo library picker.

* Use a task group for processing multiple selected media in the MediaUploadingPreprocessor

* Switch the maximum number of items that can be shared to 10.

* Allow multiple items to be pasted at the same time.
This commit is contained in:
Stefan Ceriu
2025-07-30 15:44:05 +03:00
committed by GitHub
parent 01033cf53e
commit df335bd2b1
39 changed files with 466 additions and 266 deletions
@@ -16,9 +16,9 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
private let userIndicatorController: UserIndicatorControllerProtocol
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor
private let url: URL
private var mediaURLs: [URL]
private var processingTask: Task<Result<MediaInfo, MediaUploadingPreprocessorError>, Never>
private var processingTask: Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never>
private var requestHandle: SendAttachmentJoinHandleProtocol?
private let clientProxy: ClientProxyProtocol
@@ -28,7 +28,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
actionsSubject.eraseToAnyPublisher()
}
init(url: URL,
init(mediaURLs: [URL],
title: String?,
isRoomEncrypted: Bool,
shouldShowCaptionWarning: Bool,
@@ -36,16 +36,16 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
timelineController: TimelineControllerProtocol,
clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.url = url
self.mediaURLs = mediaURLs
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
self.timelineController = timelineController
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
// Start processing the media whilst the user is reviewing it/adding a caption.
processingTask = Self.processMedia(at: url, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
super.init(initialViewState: MediaUploadPreviewScreenViewState(url: url,
super.init(initialViewState: MediaUploadPreviewScreenViewState(mediaURLs: mediaURLs,
title: title,
shouldShowCaptionWarning: shouldShowCaptionWarning,
isRoomEncrypted: isRoomEncrypted))
@@ -53,7 +53,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
override func process(viewAction: MediaUploadPreviewScreenViewAction) {
// Get the current caption before all the processing starts.
let caption = state.bindings.caption.nonBlankString
var caption = state.bindings.caption.nonBlankString
switch viewAction {
case .send:
@@ -62,26 +62,32 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
Task {
defer { stopLoading() }
var shouldDismissOnCompletion = true
switch await processingTask.value {
case .success(let mediaInfo):
switch await sendAttachment(mediaInfo: mediaInfo,
caption: caption) {
case .success:
actionsSubject.send(.dismiss)
case .failure(let error):
MXLog.error("Failed sending attachment with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
case .success(let mediaInfos):
for mediaInfo in mediaInfos {
switch await sendAttachment(mediaInfo: mediaInfo, caption: caption) {
case .success:
caption = nil // Set the caption only on the first uploaded file.
case .failure(let error):
MXLog.error("Failed processing media to upload with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
}
}
case .failure(.maxUploadSizeUnknown):
showAlert(.maxUploadSizeUnknown)
shouldDismissOnCompletion = false
case .failure(.maxUploadSizeExceeded(let limit)):
showAlert(.maxUploadSizeExceeded(limit: limit))
case .failure(let error):
MXLog.error("Failed processing media to upload with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
}
if shouldDismissOnCompletion {
actionsSubject.send(.dismiss)
}
}
case .cancel:
requestHandle?.cancel()
actionsSubject.send(.dismiss)
@@ -94,12 +100,12 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
// MARK: - Private
private static func processMedia(at url: URL,
private static func processMedia(at urls: [URL],
preprocessor: MediaUploadingPreprocessor,
clientProxy: ClientProxyProtocol) -> Task<Result<MediaInfo, MediaUploadingPreprocessorError>, Never> {
clientProxy: ClientProxyProtocol) -> Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never> {
Task {
guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { return .failure(.maxUploadSizeUnknown) }
return await preprocessor.processMedia(at: url, maxUploadSize: maxUploadSize)
return await preprocessor.processMedia(at: urls, maxUploadSize: maxUploadSize)
}
}
@@ -164,7 +170,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
message: L10n.screenMediaUploadPreviewErrorCouldNotBeUploaded,
primaryButton: .init(title: L10n.actionTryAgain) { [weak self] in
guard let self else { return }
processingTask = Self.processMedia(at: url, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
process(viewAction: .send)
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { })