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
@@ -27,7 +27,7 @@ enum ComposerToolbarViewModelAction {
case editLastMessage
case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider)
case handlePasteOrDrop(providers: [NSItemProvider])
case composerModeChanged(mode: ComposerMode)
case composerFocusedChanged(isFocused: Bool)
@@ -46,7 +46,7 @@ enum ComposerToolbarViewAction {
case cancelReply
case cancelEdit
case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider)
case handlePasteOrDrop(providers: [NSItemProvider])
case enableTextFormatting
case composerAction(action: ComposerAction)
case selectedSuggestion(_ suggestion: SuggestionItem)
@@ -216,8 +216,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
case .attach(let attachment):
state.bindings.composerFocused = false
actionsSubject.send(.attach(attachment))
case .handlePasteOrDrop(let provider):
actionsSubject.send(.handlePasteOrDrop(provider: provider))
case .handlePasteOrDrop(let providers):
actionsSubject.send(.handlePasteOrDrop(providers: providers))
case .enableTextFormatting:
state.bindings.composerFormattingEnabled = true
state.bindings.composerFocused = true
@@ -181,8 +181,8 @@ struct ComposerToolbar: View {
sendMessage()
} editAction: {
context.send(viewAction: .editLastMessage)
} pasteAction: { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider))
} pasteAction: { providers in
context.send(viewAction: .handlePasteOrDrop(providers: providers))
} cancellationAction: {
switch context.viewState.composerMode {
case .edit:
@@ -259,7 +259,7 @@ struct ComposerToolbar: View {
viewModel: context.viewState.wysiwygViewModel,
itemProviderHelper: ItemProviderHelper(),
keyCommands: context.viewState.keyCommands) { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider))
context.send(viewAction: .handlePasteOrDrop(providers: [provider]))
}
}
@@ -10,7 +10,7 @@ import SwiftUI
import WysiwygComposer
typealias GenericKeyHandler = (_ key: UIKeyboardHIDUsage) -> Void
typealias PasteHandler = (NSItemProvider) -> Void
typealias PasteHandler = ([NSItemProvider]) -> Void
struct MessageComposer: View {
@Binding var plainComposerText: NSAttributedString
@@ -190,8 +190,8 @@ private struct UITextViewWrapper: UIViewRepresentable {
textView.insertText("\n")
}
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) {
pasteHandler(provider)
func textView(_ textView: UITextView, didReceivePasteWith providers: [NSItemProvider]) {
pasteHandler(providers)
}
func textViewDidChangeSelection(_ textView: UITextView) {
@@ -207,7 +207,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
private protocol ElementTextViewDelegate: AnyObject {
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView)
func textViewDidReceiveKeyPress(_ textView: UITextView, key: UIKeyboardHIDUsage)
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider)
func textView(_ textView: UITextView, didReceivePasteWith providers: [NSItemProvider])
}
private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
@@ -280,19 +280,19 @@ private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
return false
}
return UIPasteboard.general.itemProviders.first?.isSupportedForPasteOrDrop ?? false
return UIPasteboard.general.itemProviders.filter { !$0.isSupportedForPasteOrDrop }.isEmpty
}
override func paste(_ sender: Any?) {
guard let provider = UIPasteboard.general.itemProviders.first,
provider.isSupportedForPasteOrDrop else {
// If the item is not supported for media upload then
// just try pasting its contents into the textfield
let providers = UIPasteboard.general.itemProviders
// Use the default behavior if there are any unsupported providers
guard providers.filter({ !$0.isSupportedForPasteOrDrop }).isEmpty else {
super.paste(sender)
return
}
elementDelegate?.textView(self, didReceivePasteWith: provider)
elementDelegate?.textView(self, didReceivePasteWith: providers)
}
// MARK: PillAttachmentViewProviderDelegate
@@ -32,8 +32,8 @@ struct RoomScreenCoordinatorParameters {
enum RoomScreenCoordinatorAction {
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case presentMediaUploadPicker(MediaPickerScreenSource)
case presentMediaUploadPreviewScreen(URL)
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case presentMediaUploadPreviewScreen(mediaURLs: [URL])
case presentRoomDetails
case presentLocationPicker
case presentPollForm(mode: PollFormMode)
@@ -121,19 +121,19 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID))
case .displayCameraPicker:
actionsSubject.send(.presentMediaUploadPicker(.camera))
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple)))
case .displayMediaPicker:
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary))
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple)))
case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents))
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents, selectionType: .multiple)))
case .displayMediaPreview(let mediaPreviewViewModel):
roomViewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker)
case .displayPollForm(let mode):
actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
case .displayMediaUploadPreviewScreen(let mediaURLs):
actionsSubject.send(.presentMediaUploadPreviewScreen(mediaURLs: mediaURLs))
case .displaySenderDetails(userID: let userID):
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem):