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,11 +16,13 @@ import SwiftUI
protocol CommonSettingsProtocol {
var logLevel: LogLevel { get }
var traceLogPacks: Set<TraceLogPack> { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
var enableOnlySignedDeviceIsolationMode: Bool { get }
var enableKeyShareOnInvite: Bool { get }
var hideQuietNotificationAlerts: Bool { get }
var threadsEnabled: Bool { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
var hideQuietNotificationAlerts: Bool { get }
var multipleAttachmentUploadEnabled: Bool { get }
}
/// Store Element specific app settings.
@@ -59,6 +61,7 @@ final class AppSettings {
case threadsEnabled
case developerOptionsEnabled
case sharePosEnabledV2
case multipleAttachmentUploadEnabled
// Doug's tweaks 🔧
case hideUnreadMessagesBadge
@@ -70,6 +73,16 @@ final class AppSettings {
/// UserDefaults to be used on reads and writes.
private static var store: UserDefaults! = UserDefaults(suiteName: suiteName)
/// Whether or not the app is a development build that isn't in production.
static var isDevelopmentBuild: Bool = {
#if DEBUG
true
#else
let apps = ["io.element.elementx.nightly", "io.element.elementx.pr"]
return apps.contains(InfoPlistReader.main.baseBundleIdentifier)
#endif
}()
#if IS_MAIN_APP
static func resetAllSettings() {
@@ -135,16 +148,6 @@ final class AppSettings {
// MARK: - Application
/// Whether or not the app is a development build that isn't in production.
static var isDevelopmentBuild: Bool = {
#if DEBUG
true
#else
let apps = ["io.element.elementx.nightly", "io.element.elementx.pr"]
return apps.contains(InfoPlistReader.main.baseBundleIdentifier)
#endif
}()
/// The last known version of the app that was launched on this device, which is
/// used to detect when migrations should be run. When `nil` the app may have been
/// deleted between runs so should clear data in the shared container and keychain.
@@ -362,6 +365,8 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.traceLogPacks, defaultValue: [], storageType: .userDefaults(store))
var traceLogPacks: Set<TraceLogPack>
let bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> = .init(Secrets.rageshakeURL.map { .url(URL(string: $0)!) } ?? .disabled) // swiftlint:disable:this force_unwrapping
/// Configuration to enable only signed device isolation mode for crypto. In this mode only devices signed by their owner will be considered in e2ee rooms.
@UserPreference(key: UserDefaultsKeys.enableOnlySignedDeviceIsolationMode, defaultValue: false, storageType: .userDefaults(store))
var enableOnlySignedDeviceIsolationMode
@@ -370,13 +375,14 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.enableKeyShareOnInvite, defaultValue: false, storageType: .userDefaults(store))
var enableKeyShareOnInvite
@UserPreference(key: UserDefaultsKeys.hideQuietNotificationAlerts, defaultValue: false, storageType: .userDefaults(store))
var hideQuietNotificationAlerts
@UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store))
var threadsEnabled
let bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> = .init(Secrets.rageshakeURL.map { .url(URL(string: $0)!) } ?? .disabled) // swiftlint:disable:this force_unwrapping
@UserPreference(key: UserDefaultsKeys.hideQuietNotificationAlerts, defaultValue: false, storageType: .userDefaults(store))
var hideQuietNotificationAlerts
@UserPreference(key: UserDefaultsKeys.multipleAttachmentUploadEnabled, defaultValue: isDevelopmentBuild, storageType: .userDefaults(store))
var multipleAttachmentUploadEnabled
}
extension AppSettings: CommonSettingsProtocol { }
@@ -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)
}
@@ -19,7 +19,7 @@ enum CreateRoomCoordinatorAction {
case openRoom(withIdentifier: String)
case deselectUser(UserProfileProxy)
case updateDetails(CreateRoomFlowParameters)
case displayMediaPickerWithSource(MediaPickerScreenSource)
case displayMediaPickerWithMode(MediaPickerScreenMode)
case removeImage
}
@@ -52,9 +52,9 @@ final class CreateRoomCoordinator: CoordinatorProtocol {
case .updateDetails(let details):
actionsSubject.send(.updateDetails(details))
case .displayCameraPicker:
actionsSubject.send(.displayMediaPickerWithSource(.camera))
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single)))
case .displayMediaPicker:
actionsSubject.send(.displayMediaPickerWithSource(.photoLibrary))
actionsSubject.send(.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single)))
case .removeImage:
actionsSubject.send(.removeImage)
}
@@ -23,7 +23,8 @@ struct CameraPicker: UIViewControllerRepresentable {
private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (CameraPickerAction) -> Void
init(userIndicatorController: UserIndicatorControllerProtocol, callback: @escaping (CameraPickerAction) -> Void) {
init(userIndicatorController: UserIndicatorControllerProtocol,
callback: @escaping (CameraPickerAction) -> Void) {
self.userIndicatorController = userIndicatorController
self.callback = callback
}
@@ -8,29 +8,35 @@
import SwiftUI
enum DocumentPickerAction {
case selectFile(URL)
case selectedMediaAtURLs([URL])
case cancel
case error(Error)
}
enum DocumentPickerError: Error {
case unknown
}
struct DocumentPicker: UIViewControllerRepresentable {
private let selectionType: MediaPickerScreenSelectionType
private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (DocumentPickerAction) -> Void
init(userIndicatorController: UserIndicatorControllerProtocol, callback: @escaping (DocumentPickerAction) -> Void) {
init(selectionType: MediaPickerScreenSelectionType,
userIndicatorController: UserIndicatorControllerProtocol,
callback: @escaping (DocumentPickerAction) -> Void) {
self.selectionType = selectionType
self.userIndicatorController = userIndicatorController
self.callback = callback
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data])
documentPicker.allowsMultipleSelection = false
documentPicker.delegate = context.coordinator
documentPicker.allowsMultipleSelection = switch selectionType {
case .single:
false
case .multiple:
true
}
return documentPicker
}
@@ -56,11 +62,6 @@ struct DocumentPicker: UIViewControllerRepresentable {
private static let loadingIndicatorIdentifier = "\(DocumentPicker.self)-Loading"
func documentPicker(_ picker: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else {
documentPicker.callback(.error(DocumentPickerError.unknown))
return
}
picker.delegate = nil
documentPicker.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading))
@@ -68,14 +69,19 @@ struct DocumentPicker: UIViewControllerRepresentable {
documentPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
do {
_ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
url.stopAccessingSecurityScopedResource()
documentPicker.callback(.selectFile(newURL))
} catch {
documentPicker.callback(.error(error))
var selectedURLs = [URL]()
for url in urls.prefix(10) {
do {
_ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
url.stopAccessingSecurityScopedResource()
selectedURLs.append(newURL)
} catch {
documentPicker.callback(.error(error))
}
}
documentPicker.callback(.selectedMediaAtURLs(selectedURLs))
}
}
}
@@ -7,31 +7,45 @@
import SwiftUI
struct MediaPickerScreenMode: Hashable {
let source: MediaPickerScreenSource
let selectionType: MediaPickerScreenSelectionType
}
enum MediaPickerScreenSource {
case camera
case photoLibrary
case documents
}
enum MediaPickerScreenSelectionType {
case single
case multiple
}
enum MediaPickerScreenCoordinatorAction {
case selectMediaAtURL(URL)
case selectedMediaAtURLs([URL])
case cancel
}
class MediaPickerScreenCoordinator: CoordinatorProtocol {
private let orientationManager: OrientationManagerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let source: MediaPickerScreenSource
private let mode: MediaPickerScreenMode
private let callback: (MediaPickerScreenCoordinatorAction) -> Void
init(userIndicatorController: UserIndicatorControllerProtocol,
source: MediaPickerScreenSource,
init(mode: MediaPickerScreenMode,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol,
orientationManager: OrientationManagerProtocol,
callback: @escaping (MediaPickerScreenCoordinatorAction) -> Void) {
self.userIndicatorController = userIndicatorController
self.source = source
self.orientationManager = orientationManager
self.callback = callback
// The users of the media picker chose their ideal selection type leaving
// the feature flag to only be checked and enforced on this level.
self.mode = appSettings.multipleAttachmentUploadEnabled ? mode : .init(source: mode.source, selectionType: .single)
}
func toPresentable() -> AnyView {
@@ -39,51 +53,47 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
}
func start() {
guard source == .camera else {
return
if mode.source == .camera {
orientationManager.setOrientation(.portrait)
orientationManager.lockOrientation(.portrait)
}
orientationManager.setOrientation(.portrait)
orientationManager.lockOrientation(.portrait)
}
func stop() {
guard source == .camera else {
return
if mode.source == .camera {
orientationManager.lockOrientation(.all)
}
orientationManager.lockOrientation(.all)
}
@ViewBuilder
private var mediaPicker: some View {
switch source {
switch mode.source {
case .camera:
cameraPicker
case .photoLibrary:
PhotoLibraryPicker(userIndicatorController: userIndicatorController) { [weak self] action in
PhotoLibraryPicker(selectionType: mode.selectionType, userIndicatorController: userIndicatorController) { [weak self] action in
switch action {
case .cancel:
self?.callback(.cancel)
case .error(let error):
MXLog.error("Failed selecting media from the photo library with error: \(error)")
self?.showError()
case .selectFile(let url):
self?.callback(.selectMediaAtURL(url))
case .selectedMediaAtURLs(let urls):
self?.callback(.selectedMediaAtURLs(urls))
}
}
case .documents:
// The document picker automatically dismisses everything on selection
// Strongly retain self in the callback to forward actions correctly
DocumentPicker(userIndicatorController: userIndicatorController) { action in
DocumentPicker(selectionType: mode.selectionType, userIndicatorController: userIndicatorController) { action in
switch action {
case .cancel:
self.callback(.cancel)
case .error(let error):
MXLog.error("Failed selecting media from the document picker with error: \(error)")
self.showError()
case .selectFile(let url):
self.callback(.selectMediaAtURL(url))
case .selectedMediaAtURLs(let urls):
self.callback(.selectedMediaAtURLs(urls))
}
}
}
@@ -98,7 +108,7 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
MXLog.error("Failed selecting media from the camera picker with error: \(error)")
self?.showError()
case .selectFile(let url):
self?.callback(.selectMediaAtURL(url))
self?.callback(.selectedMediaAtURLs([url]))
}
}
.background(.black, ignoresSafeAreaEdges: .bottom)
@@ -9,7 +9,7 @@ import PhotosUI
import SwiftUI
enum PhotoLibraryPickerAction {
case selectFile(URL)
case selectedMediaAtURLs([URL])
case cancel
case error(PhotoLibraryPickerError)
}
@@ -20,17 +20,27 @@ enum PhotoLibraryPickerError: Error {
}
struct PhotoLibraryPicker: UIViewControllerRepresentable {
private let selectionType: MediaPickerScreenSelectionType
private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (PhotoLibraryPickerAction) -> Void
init(userIndicatorController: UserIndicatorControllerProtocol, callback: @escaping (PhotoLibraryPickerAction) -> Void) {
init(selectionType: MediaPickerScreenSelectionType,
userIndicatorController: UserIndicatorControllerProtocol,
callback: @escaping (PhotoLibraryPickerAction) -> Void) {
self.selectionType = selectionType
self.userIndicatorController = userIndicatorController
self.callback = callback
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.selectionLimit = switch selectionType {
case .single:
1
case .multiple:
10
}
let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = context.coordinator
@@ -56,38 +66,73 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable {
private static let loadingIndicatorIdentifier = "\(PhotoLibraryPicker.self)-Loading"
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider,
let contentType = provider.preferredContentType else {
guard !results.isEmpty else {
photoLibraryPicker.callback(.cancel)
return
}
picker.delegate = nil
photoLibraryPicker.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading))
photoLibraryPicker.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading))
defer {
photoLibraryPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
provider.loadFileRepresentation(forTypeIdentifier: contentType.type.identifier) { [weak self] url, error in
guard let url else {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(error)))
Task {
let selectedURLs = await withTaskGroup { taskGroup in
for result in results {
taskGroup.addTask { await self.processResult(result) }
}
return
var selectedURLs = [URL]()
for await url in taskGroup {
if let url {
selectedURLs.append(url)
}
}
return selectedURLs
}
do {
_ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
url.stopAccessingSecurityScopedResource()
Task { @MainActor in
self?.photoLibraryPicker.callback(.selectFile(newURL))
photoLibraryPicker.callback(.selectedMediaAtURLs(selectedURLs))
}
}
// MARK: - Private
func processResult(_ result: PHPickerResult) async -> URL? {
let provider = result.itemProvider
guard let contentType = provider.preferredContentType else {
return nil
}
return await withCheckedContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: contentType.type.identifier) { [weak self] url, error in
guard let url else {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(error)))
}
continuation.resume(returning: nil)
return
}
} catch {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedCopyingFile))
do {
_ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
url.stopAccessingSecurityScopedResource()
Task { @MainActor in
continuation.resume(returning: newURL)
}
} catch {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedCopyingFile))
continuation.resume(returning: nil)
}
}
}
}
@@ -9,7 +9,7 @@ import Combine
import SwiftUI
struct MediaUploadPreviewScreenCoordinatorParameters {
let url: URL
let mediaURLs: [URL]
let title: String?
let isRoomEncrypted: Bool
let shouldShowCaptionWarning: Bool
@@ -33,7 +33,7 @@ final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol {
}
init(parameters: MediaUploadPreviewScreenCoordinatorParameters) {
viewModel = MediaUploadPreviewScreenViewModel(url: parameters.url,
viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: parameters.mediaURLs,
title: parameters.title,
isRoomEncrypted: parameters.isRoomEncrypted,
shouldShowCaptionWarning: parameters.shouldShowCaptionWarning,
@@ -12,7 +12,7 @@ enum MediaUploadPreviewScreenViewModelAction {
}
struct MediaUploadPreviewScreenViewState: BindableState {
let url: URL
let mediaURLs: [URL]
let title: String?
let shouldShowCaptionWarning: Bool
let isRoomEncrypted: Bool
@@ -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) { })
@@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import GameController
import QuickLook
@@ -16,6 +17,7 @@ struct MediaUploadPreviewScreen: View {
@Bindable var context: MediaUploadPreviewScreenViewModel.Context
@State private var captionWarningFrame: CGRect = .zero
@State private var currentIndex = 0
@FocusState private var isComposerFocussed
private var title: String { ProcessInfo.processInfo.isiOSAppOnMac ? context.viewState.title ?? "" : "" }
@@ -23,8 +25,15 @@ struct MediaUploadPreviewScreen: View {
var body: some View {
mainContent
.id(context.viewState.url)
.id(context.viewState.mediaURLs)
.ignoresSafeArea(edges: [.horizontal])
.safeAreaInset(edge: .top) {
if context.viewState.mediaURLs.count > 1 {
Text("\(currentIndex + 1) / \(context.viewState.mediaURLs.count)")
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
.padding(.horizontal, 12)
@@ -50,8 +59,9 @@ struct MediaUploadPreviewScreen: View {
.foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
PreviewView(fileURL: context.viewState.url,
title: context.viewState.title)
PreviewView(mediaURLs: context.viewState.mediaURLs,
title: context.viewState.title,
currentIndex: $currentIndex)
}
}
@@ -154,11 +164,12 @@ struct MediaUploadPreviewScreen: View {
}
private struct PreviewView: UIViewControllerRepresentable {
let fileURL: URL
let mediaURLs: [URL]
let title: String?
@Binding var currentIndex: Int
func makeUIViewController(context: Context) -> UIViewController {
let previewController = PreviewViewController()
let previewController = PreviewViewController(currentIndex: $currentIndex)
previewController.dataSource = context.coordinator
previewController.delegate = context.coordinator
@@ -185,11 +196,11 @@ private struct PreviewView: UIViewControllerRepresentable {
// MARK: - QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
view.mediaURLs.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
PreviewItem(previewItemURL: view.fileURL, previewItemTitle: view.title)
PreviewItem(previewItemURL: view.mediaURLs[index], previewItemTitle: view.title)
}
// MARK: - QLPreviewControllerDelegate
@@ -211,6 +222,28 @@ private class PreviewItem: NSObject, QLPreviewItem {
}
private class PreviewViewController: QLPreviewController {
private var cancellables: Set<AnyCancellable> = []
init(currentIndex: Binding<Int>) {
super.init(nibName: nil, bundle: nil)
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { index in
DispatchQueue.main.async {
if index != Int.max { // Because reasons
currentIndex.wrappedValue = index
}
}
}
.store(in: &cancellables)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
@@ -228,7 +261,7 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
static let snapshotURL = URL.picturesDirectory
static let testURL = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png")
static let viewModel = MediaUploadPreviewScreenViewModel(url: snapshotURL,
static let viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: [snapshotURL],
title: "App Icon.png",
isRoomEncrypted: true,
shouldShowCaptionWarning: true,
@@ -16,6 +16,7 @@ struct RoomDetailsEditScreenCoordinatorParameters {
weak var navigationStackCoordinator: NavigationStackCoordinator?
let userIndicatorController: UserIndicatorControllerProtocol
let orientationManager: OrientationManagerProtocol
let appSettings: AppSettings
}
enum RoomDetailsEditScreenCoordinatorAction {
@@ -49,9 +50,9 @@ final class RoomDetailsEditScreenCoordinator: CoordinatorProtocol {
case .cancel, .saveFinished:
self?.actionsSubject.send(.dismiss)
case .displayCameraPicker:
self?.displayMediaPickerWithSource(.camera)
self?.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single))
case .displayMediaPicker:
self?.displayMediaPickerWithSource(.photoLibrary)
self?.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single))
}
}
.store(in: &cancellables)
@@ -63,17 +64,23 @@ final class RoomDetailsEditScreenCoordinator: CoordinatorProtocol {
// MARK: Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) {
private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: parameters.userIndicatorController,
source: source,
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController,
orientationManager: parameters.orientationManager) { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
case .selectedMediaAtURLs(let urls):
guard urls.count == 1,
let url = urls.first else {
fatalError("Received an invalid number of URLs")
}
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
viewModel.didSelectMediaUrl(url: url)
}
@@ -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):
@@ -38,15 +38,19 @@ enum DeveloperOptionsScreenViewAction {
protocol DeveloperOptionsProtocol: AnyObject {
var logLevel: LogLevel { get set }
var traceLogPacks: Set<TraceLogPack> { get set }
var publicSearchEnabled: Bool { get set }
var hideUnreadMessagesBadge: Bool { get set }
var fuzzyRoomListSearchEnabled: Bool { get set }
var enableOnlySignedDeviceIsolationMode: Bool { get set }
var enableKeyShareOnInvite: Bool { get set }
var elementCallBaseURLOverride: URL? { get set }
var knockingEnabled: Bool { get set }
var threadsEnabled: Bool { get set }
var hideQuietNotificationAlerts: Bool { get set }
var multipleAttachmentUploadEnabled: Bool { get set }
var hideUnreadMessagesBadge: Bool { get set }
var elementCallBaseURLOverride: URL? { get set }
var publicSearchEnabled: Bool { get set }
var fuzzyRoomListSearchEnabled: Bool { get set }
var knockingEnabled: Bool { get set }
var sharePosEnabled: Bool { get set }
}
@@ -52,6 +52,12 @@ struct DeveloperOptionsScreen: View {
}
}
Section("Timeline") {
Toggle(isOn: $context.multipleAttachmentUploadEnabled) {
Text("Allow selecting multiple attachments for upload")
}
}
Section("Join rules") {
Toggle(isOn: $context.knockingEnabled) {
Text("Knocking")
@@ -15,6 +15,7 @@ struct UserDetailsEditScreenCoordinatorParameters {
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
weak var navigationStackCoordinator: NavigationStackCoordinator?
let userIndicatorController: UserIndicatorControllerProtocol
let appSettings: AppSettings
}
final class UserDetailsEditScreenCoordinator: CoordinatorProtocol {
@@ -36,11 +37,11 @@ final class UserDetailsEditScreenCoordinator: CoordinatorProtocol {
.sink { [weak self] action in
switch action {
case .displayCameraPicker:
self?.displayMediaPickerWithSource(.camera)
self?.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single))
case .displayMediaPicker:
self?.displayMediaPickerWithSource(.photoLibrary)
self?.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single))
case .displayFilePicker:
self?.displayMediaPickerWithSource(.documents)
self?.displayMediaPickerWithMode(.init(source: .documents, selectionType: .single))
}
}
.store(in: &cancellables)
@@ -52,15 +53,23 @@ final class UserDetailsEditScreenCoordinator: CoordinatorProtocol {
// MARK: Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) {
private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: parameters.userIndicatorController, source: source, orientationManager: parameters.orientationManager) { [weak self] action in
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController,
orientationManager: parameters.orientationManager) { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
case .selectedMediaAtURLs(let urls):
guard urls.count == 1,
let url = urls.first else {
fatalError("Received an invalid number of URLs")
}
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
viewModel.didSelectMediaURL(url: url)
}
@@ -15,6 +15,7 @@ struct StartChatScreenCoordinatorParameters {
weak var navigationStackCoordinator: NavigationStackCoordinator?
let userDiscoveryService: UserDiscoveryServiceProtocol
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
let appSettings: AppSettings
}
enum StartChatScreenCoordinatorAction {
@@ -124,8 +125,8 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
self.createRoomParameters.send(details)
case .openRoom(let identifier):
self.actionsSubject.send(.openRoom(withIdentifier: identifier))
case .displayMediaPickerWithSource(let source):
self.displayMediaPickerWithSource(source)
case .displayMediaPickerWithMode(let mode):
self.displayMediaPickerWithMode(mode)
case .removeImage:
var parameters = self.createRoomParameters.value
parameters.avatarImageMedia = nil
@@ -139,15 +140,23 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
// MARK: - Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) {
private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: parameters.userIndicatorController, source: source, orientationManager: parameters.orientationManager) { [weak self] action in
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController,
orientationManager: parameters.orientationManager) { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
case .selectedMediaAtURLs(let urls):
guard urls.count == 1,
let url = urls.first else {
fatalError("Received an invalid number of URLs")
}
processAvatar(from: url)
}
}
@@ -27,8 +27,8 @@ struct ThreadTimelineScreenCoordinatorParameters {
enum ThreadTimelineScreenCoordinatorAction {
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case presentMediaUploadPicker(MediaPickerScreenSource)
case presentMediaUploadPreviewScreen(url: URL)
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case presentMediaUploadPreviewScreen(mediaURLs: [URL])
case presentLocationPicker
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
case presentPollForm(mode: PollFormMode)
@@ -96,11 +96,11 @@ final class ThreadTimelineScreenCoordinator: 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):
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker:
@@ -111,8 +111,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
description: description))
case .displayPollForm(let mode):
actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.presentMediaUploadPreviewScreen(url: url))
case .displayMediaUploadPreviewScreen(let mediaURLs):
actionsSubject.send(.presentMediaUploadPreviewScreen(mediaURLs: mediaURLs))
case .displaySenderDetails(userID: let userID):
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem):
@@ -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
}
}
@@ -94,6 +94,33 @@ struct MediaUploadingPreprocessor {
static let videoThumbnailTime = 5.0 // seconds
}
/// Processes media at the given URLs. It will generate thumbnails for images and videos, convert videos to 1080p mp4, strip GPS locations
/// from images and retrieve associated media information
/// - Parameter urls: the file URL
/// - Returns: a collection of results containing specific type of `MediaInfo` depending on the file type
/// and its associated details or any resulting error
func processMedia(at urls: [URL], maxUploadSize: UInt) async -> Result<[MediaInfo], MediaUploadingPreprocessorError> {
await withTaskGroup { taskGroup in
for url in urls {
taskGroup.addTask {
await processMedia(at: url, maxUploadSize: maxUploadSize)
}
}
var mediaInfos = [MediaInfo]()
for await result in taskGroup {
switch result {
case .success(let mediaInfo):
mediaInfos.append(mediaInfo)
case .failure(let error):
return .failure(error)
}
}
return .success(mediaInfos)
}
}
/// Processes media at a given URL. It will generate thumbnails for images and videos, convert videos to 1080p mp4, strip GPS locations
/// from images and retrieve associated media information
/// - Parameter url: the file URL
@@ -12,12 +12,12 @@ enum ShareExtensionConstants {
}
enum ShareExtensionPayload: Hashable, Codable {
case mediaFile(roomID: String?, mediaFile: ShareExtensionMediaFile)
case mediaFiles(roomID: String?, mediaFiles: [ShareExtensionMediaFile])
case text(roomID: String?, text: String)
var roomID: String? {
switch self {
case .mediaFile(let roomID, _),
case .mediaFiles(let roomID, _),
.text(let roomID, _):
roomID
}
@@ -27,14 +27,16 @@ enum ShareExtensionPayload: Hashable, Codable {
/// system's `temporaryDirectory` returning a modified payload with updated file URLs.
func withDefaultTemporaryDirectory() throws -> Self {
switch self {
case .mediaFile(let roomID, let mediaFile):
let path = mediaFile.url.path.replacing(URL.appGroupTemporaryDirectory.path, with: "").trimmingPrefix("/")
let newURL = URL.temporaryDirectory.appending(path: path)
try? FileManager.default.removeItem(at: newURL)
try FileManager.default.moveItem(at: mediaFile.url, to: newURL)
return .mediaFile(roomID: roomID, mediaFile: mediaFile.replacingURL(with: newURL))
case .mediaFiles(let roomID, let mediaFiles):
return try .mediaFiles(roomID: roomID, mediaFiles: mediaFiles.map { mediaFile in
let path = mediaFile.url.path.replacing(URL.appGroupTemporaryDirectory.path, with: "").trimmingPrefix("/")
let newURL = URL.temporaryDirectory.appending(path: path)
try? FileManager.default.removeItem(at: newURL)
try FileManager.default.moveItem(at: mediaFile.url, to: newURL)
return mediaFile.replacingURL(with: newURL)
})
case .text:
return self
}
@@ -639,7 +639,8 @@ class MockScreen: Identifiable {
userIndicatorController: UserIndicatorControllerMock(),
navigationStackCoordinator: navigationStackCoordinator,
userDiscoveryService: userDiscoveryMock,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings))
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
appSettings: ServiceLocator.shared.settings)
let coordinator = StartChatScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
@@ -654,7 +655,8 @@ class MockScreen: Identifiable {
userIndicatorController: UserIndicatorControllerMock(),
navigationStackCoordinator: navigationStackCoordinator,
userDiscoveryService: userDiscoveryMock,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings)))
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
appSettings: ServiceLocator.shared.settings))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .createRoom:
@@ -63,23 +63,32 @@ class ShareExtensionViewController: UIViewController {
// MARK: - Private
private func prepareSharePayload() async -> ShareExtensionPayload? {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
guard let extensionContext,
let extensionItem = extensionContext.inputItems.first as? NSExtensionItem,
let itemProviders = extensionItem.attachments else {
return nil
}
let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier
let roomID = (extensionContext.intent as? INSendMessageIntent)?.conversationIdentifier
if let fileURL = await itemProvider.storeData(withinAppGroupContainer: true) {
return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent))
} else if let url = await itemProvider.loadTransferable(type: URL.self) {
return .text(roomID: roomID, text: url.absoluteString)
} else if let string = await itemProvider.loadString() {
return .text(roomID: roomID, text: string)
} else {
MXLog.error("Failed loading NSItemProvider data: \(itemProvider)")
return nil
var mediaFiles = [ShareExtensionMediaFile]()
for itemProvider in itemProviders {
if let fileURL = await itemProvider.storeData(withinAppGroupContainer: true) {
mediaFiles.append(.init(url: fileURL, suggestedName: fileURL.lastPathComponent))
} else if let url = await itemProvider.loadTransferable(type: URL.self) {
return .text(roomID: roomID, text: url.absoluteString)
} else if let string = await itemProvider.loadString() {
return .text(roomID: roomID, text: string)
} else {
MXLog.error("Failed loading NSItemProvider data: \(itemProvider)")
}
}
if !mediaFiles.isEmpty {
return .mediaFiles(roomID: roomID, mediaFiles: mediaFiles)
}
return nil
}
private func openMainApp(payload: ShareExtensionPayload) async {
+4 -4
View File
@@ -31,15 +31,15 @@
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
<integer>10</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<integer>10</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<integer>10</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<integer>10</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
+4 -4
View File
@@ -53,11 +53,11 @@ targets:
NSExtensionAttributes:
IntentsSupported: [INSendMessageIntent]
NSExtensionActivationRule:
NSExtensionActivationSupportsFileWithMaxCount: 1
NSExtensionActivationSupportsImageWithMaxCount: 1
NSExtensionActivationSupportsMovieWithMaxCount: 1
NSExtensionActivationSupportsFileWithMaxCount: 10
NSExtensionActivationSupportsImageWithMaxCount: 10
NSExtensionActivationSupportsMovieWithMaxCount: 10
NSExtensionActivationSupportsText: true
NSExtensionActivationSupportsWebURLWithMaxCount: 1
NSExtensionActivationSupportsWebURLWithMaxCount: 10
entitlements:
path: ShareExtension.entitlements
@@ -173,7 +173,7 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase {
clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult
}
viewModel = MediaUploadPreviewScreenViewModel(url: url,
viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: [url],
title: "Some File",
isRoomEncrypted: true,
shouldShowCaptionWarning: true,
@@ -225,7 +225,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
let sharePayload: ShareExtensionPayload = .mediaFile(roomID: "1", mediaFile: .init(url: .picturesDirectory, suggestedName: nil))
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "1", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
@@ -246,7 +246,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFile(roomID: nil, mediaFile: .init(url: .picturesDirectory, suggestedName: nil))
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedState: .shareExtensionRoomList(sharePayload: sharePayload))
@@ -257,7 +257,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFile(roomID: "2", mediaFile: .init(url: .picturesDirectory, suggestedName: nil))
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedState: .roomList(roomListSelectedRoomID: "2"))