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
@@ -14,7 +14,7 @@ enum TimelineInteractionHandlerAction {
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayMessageForwarding(itemID: TimelineItemIdentifier)
case displayMediaUploadPreviewScreen(url: URL)
case displayMediaUploadPreviewScreen(mediaURLs: [URL])
case displayPollForm(mode: PollFormMode)
case showActionMenu(TimelineItemActionMenuInfo)
@@ -277,7 +277,7 @@ class TimelineInteractionHandler {
// MARK: Pasting and dropping
func handlePasteOrDrop(_ provider: NSItemProvider) {
func handlePasteOrDrop(_ providers: [NSItemProvider]) {
Task {
let loadingIndicatorIdentifier = UUID().uuidString
self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
@@ -285,13 +285,19 @@ class TimelineInteractionHandler {
self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
guard let fileURL = await provider.storeData() else {
MXLog.error("Failed storing NSItemProvider data \(provider)")
self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia))
return
var mediaURLs = [URL]()
for provider in providers {
if let fileURL = await provider.storeData() {
mediaURLs.append(fileURL)
} else {
MXLog.error("Failed storing NSItemProvider data \(provider)")
self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia))
}
}
self.actionsSubject.send(.displayMediaUploadPreviewScreen(url: fileURL))
if !mediaURLs.isEmpty {
self.actionsSubject.send(.displayMediaUploadPreviewScreen(mediaURLs: mediaURLs))
}
}
}
@@ -18,7 +18,7 @@ enum TimelineViewModelAction {
case displayDocumentPicker
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL)
case displayMediaUploadPreviewScreen(mediaURLs: [URL])
case displaySenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
@@ -63,7 +63,7 @@ enum TimelineViewAction {
case displayReadReceipts(itemID: TimelineItemIdentifier)
case displayThread(itemID: TimelineItemIdentifier)
case handlePasteOrDrop(provider: NSItemProvider)
case handlePasteOrDrop(providers: [NSItemProvider])
case handlePollAction(TimelineViewPollAction)
case handleAudioPlayerAction(TimelineAudioPlayerAction)
@@ -192,8 +192,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
displayReadReceipts(for: itemID)
case .displayThread(let itemID):
actionsSubject.send(.displayThread(itemID: itemID))
case .handlePasteOrDrop(let provider):
timelineInteractionHandler.handlePasteOrDrop(provider)
case .handlePasteOrDrop(let providers):
timelineInteractionHandler.handlePasteOrDrop(providers)
case .handlePollAction(let pollAction):
handlePollAction(pollAction)
case .handleAudioPlayerAction(let audioPlayerAction):
@@ -231,8 +231,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
editLastMessage()
case .attach(let attachment):
attach(attachment)
case .handlePasteOrDrop(let provider):
timelineInteractionHandler.handlePasteOrDrop(provider)
case .handlePasteOrDrop(let providers):
timelineInteractionHandler.handlePasteOrDrop(providers)
case .composerModeChanged(mode: let mode):
trackComposerMode(mode)
case .composerFocusedChanged(isFocused: let isFocused):
@@ -480,8 +480,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
actionsSubject.send(.displayPollForm(mode: mode))
case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
case .displayMediaUploadPreviewScreen(let mediaURLs):
actionsSubject.send(.displayMediaUploadPreviewScreen(mediaURLs: mediaURLs))
case .showActionMenu(let actionMenuInfo):
self.state.bindings.actionMenuInfo = actionMenuInfo
case .showDebugInfo(let debugInfo):
@@ -55,12 +55,13 @@ struct TimelineView: View {
.environmentObject(timelineContext)
}
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
provider.isSupportedForPasteOrDrop else {
let supportedProviders = providers.filter(\.isSupportedForPasteOrDrop)
guard !supportedProviders.isEmpty else {
return false
}
timelineContext.send(viewAction: .handlePasteOrDrop(provider: provider))
timelineContext.send(viewAction: .handlePasteOrDrop(providers: supportedProviders))
return true
}
}