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
@@ -302,11 +302,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (_, .presentReportContent, .reportContent(let itemID, let senderID, _)):
presentReportContent(for: itemID, from: senderID)
case (_, .presentMediaUploadPicker, .mediaUploadPicker(let source, _)):
case (_, .presentMediaUploadPicker, .mediaUploadPicker(let mode, _)):
guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else {
fatalError("Missing required TimelineController")
}
presentMediaUploadPickerWithSource(source, timelineController: timelineController, animated: animated)
presentMediaUploadPickerWithMode(mode, timelineController: timelineController, animated: animated)
case (_, .presentEmojiPicker, .emojiPicker(let itemID, let selectedEmoji, _)):
guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else {
@@ -406,11 +406,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
presentPollForm(mode: mode, timelineController: timelineController)
case (_, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL, _)):
case (_, .presentMediaUploadPreview, .mediaUploadPreview(let mediaURLs, _)):
guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else {
fatalError("Missing required TimelineController")
}
presentMediaUploadPreviewScreen(for: fileURL, timelineController: timelineController, animated: animated)
presentMediaUploadPreviewScreen(for: mediaURLs, timelineController: timelineController, animated: animated)
case (_, .presentInviteUsersScreen, .inviteUsersScreen):
presentInviteUsersScreen()
@@ -464,8 +465,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch presentationAction {
case .eventFocus(let focusedEvent):
roomScreenCoordinator?.focusOnEvent(focusedEvent)
case .share(.mediaFile(_, let mediaFile)):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url),
case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text(_, let text)):
roomScreenCoordinator?.shareText(text)
@@ -501,8 +502,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
switch presentationAction {
case .share(.mediaFile(_, let mediaFile)):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url),
case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text), .eventFocus:
break // These are both handled in the coordinator's init.
@@ -556,11 +557,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentReportContent(let itemID, let senderID):
stateMachine.tryEvent(.presentReportContent(itemID: itemID,
senderID: senderID))
case .presentMediaUploadPicker(let source):
stateMachine.tryEvent(.presentMediaUploadPicker(source: source),
case .presentMediaUploadPicker(let mode):
stateMachine.tryEvent(.presentMediaUploadPicker(mode: mode),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentMediaUploadPreviewScreen(let url):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url),
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: url),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentEmojiPicker(let itemID, let selectedEmojis):
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis),
@@ -645,11 +646,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .presentReportContent(let itemID, let senderID):
stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID))
case .presentMediaUploadPicker(let source):
stateMachine.tryEvent(.presentMediaUploadPicker(source: source),
case .presentMediaUploadPicker(let mode):
stateMachine.tryEvent(.presentMediaUploadPicker(mode: mode),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentMediaUploadPreviewScreen(let url):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url),
case .presentMediaUploadPreviewScreen(let mediaURLs):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaURLs),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentLocationPicker:
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .picker),
@@ -864,7 +865,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
navigationStackCoordinator: stackCoordinator,
userIndicatorController: userIndicatorController,
orientationManager: appMediator.windowManager)
orientationManager: appMediator.windowManager,
appSettings: appSettings)
let roomDetailsEditCoordinator = RoomDetailsEditScreenCoordinator(parameters: roomDetailsEditParameters)
roomDetailsEditCoordinator.actions.sink { [weak self] action in
@@ -916,13 +918,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentMediaUploadPickerWithSource(_ source: MediaPickerScreenSource,
timelineController: TimelineControllerProtocol,
animated: Bool) {
private func presentMediaUploadPickerWithMode(_ mode: MediaPickerScreenMode,
timelineController: TimelineControllerProtocol,
animated: Bool) {
let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController,
source: source,
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
appSettings: appSettings,
userIndicatorController: userIndicatorController,
orientationManager: appMediator.windowManager) { [weak self] action in
guard let self else {
return
@@ -930,8 +933,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url),
case .selectedMediaAtURLs(let urls):
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: urls),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
}
}
@@ -945,13 +948,19 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentMediaUploadPreviewScreen(for url: URL,
private func presentMediaUploadPreviewScreen(for mediaURLs: [URL],
timelineController: TimelineControllerProtocol,
animated: Bool) {
let stackCoordinator = NavigationStackCoordinator()
let title: String? = if mediaURLs.count == 1 {
mediaURLs.first?.lastPathComponent
} else {
nil
}
let parameters = MediaUploadPreviewScreenCoordinatorParameters(url: url,
title: url.lastPathComponent,
let parameters = MediaUploadPreviewScreenCoordinatorParameters(mediaURLs: mediaURLs,
title: title,
isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted,
shouldShowCaptionWarning: appSettings.shouldShowMediaCaptionWarning,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
@@ -57,8 +57,8 @@ extension RoomFlowCoordinator {
case roomMemberDetails(userID: String, previousState: State)
case userProfile(userID: String, previousState: State)
case inviteUsersScreen(previousState: State)
case mediaUploadPicker(source: MediaPickerScreenSource, previousState: State)
case mediaUploadPreview(fileURL: URL, previousState: State)
case mediaUploadPicker(mode: MediaPickerScreenMode, previousState: State)
case mediaUploadPreview(mediaURLs: [URL], previousState: State)
case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State)
case mapNavigator(previousState: State)
case messageForwarding(forwardingItem: MessageForwardingItem, previousState: State)
@@ -123,10 +123,10 @@ extension RoomFlowCoordinator {
case presentInviteUsersScreen
case dismissInviteUsersScreen
case presentMediaUploadPicker(source: MediaPickerScreenSource)
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case dismissMediaUploadPicker
case presentMediaUploadPreview(fileURL: URL)
case presentMediaUploadPreview(mediaURLs: [URL])
case dismissMediaUploadPreview
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
@@ -185,11 +185,11 @@ extension RoomFlowCoordinator {
case (.room, .presentReportContent(let itemID, let senderID)):
return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState)
case (.room, .presentMediaUploadPicker(let source)):
return .mediaUploadPicker(source: source, previousState: fromState)
case (.room, .presentMediaUploadPicker(let mode)):
return .mediaUploadPicker(mode: mode, previousState: fromState)
case (.room, .presentMediaUploadPreview(let fileURL)):
return .mediaUploadPreview(fileURL: fileURL, previousState: fromState)
case (.room, .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: fromState)
case (.room, .presentEmojiPicker(let itemID, let selectedEmoji)):
return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState)
@@ -222,11 +222,11 @@ extension RoomFlowCoordinator {
case (.thread, .presentReportContent(let itemID, let senderID)):
return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState)
case (.thread, .presentMediaUploadPicker(let source)):
return .mediaUploadPicker(source: source, previousState: fromState)
case (.thread, .presentMediaUploadPicker(let mode)):
return .mediaUploadPicker(mode: mode, previousState: fromState)
case (.thread, .presentMediaUploadPreview(let fileURL)):
return .mediaUploadPreview(fileURL: fileURL, previousState: fromState)
case (.thread, .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: fromState)
case (.thread, .presentEmojiPicker(let itemID, let selectedEmoji)):
return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState)
@@ -362,8 +362,8 @@ extension RoomFlowCoordinator {
case (.pollsHistoryForm, .dismissPollForm):
return .pollsHistory
case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let fileURL)):
return .mediaUploadPreview(fileURL: fileURL, previousState: previousMediaUploadPickerState)
case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: previousMediaUploadPickerState)
case (_, .presentInviteUsersScreen):
return .inviteUsersScreen(previousState: fromState)
@@ -171,7 +171,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
mediaProvider: parameters.userSession.mediaProvider,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: parameters.appSettings),
navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: parameters.userIndicatorController))
userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings))
navigationStackCoordinator?.push(coordinator)
}
@@ -735,7 +735,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
navigationStackCoordinator: startChatNavigationStackCoordinator,
userDiscoveryService: userDiscoveryService,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings))
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
appSettings: appSettings)
let coordinator = StartChatScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
@@ -1039,8 +1040,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
navigationSplitCoordinator.setSheetCoordinator(nil)
case .confirm(let roomID):
let sharePayload = switch sharePayload {
case .mediaFile(_, let mediaFile):
ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile)
case .mediaFiles(_, let mediaFiles):
ShareExtensionPayload.mediaFiles(roomID: roomID, mediaFiles: mediaFiles)
case .text(_, let text):
ShareExtensionPayload.text(roomID: roomID, text: text)
}