diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index b30fb4e84..c2cce4755 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -16,11 +16,13 @@ import SwiftUI protocol CommonSettingsProtocol { var logLevel: LogLevel { get } var traceLogPacks: Set { get } + var bugReportRageshakeURL: RemotePreference { get } + var enableOnlySignedDeviceIsolationMode: Bool { get } var enableKeyShareOnInvite: Bool { get } - var hideQuietNotificationAlerts: Bool { get } var threadsEnabled: Bool { get } - var bugReportRageshakeURL: RemotePreference { 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 + let bugReportRageshakeURL: RemotePreference = .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 = .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 { } diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index cb1474481..66d6e9417 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -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), diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift index 4ec4b2b6b..8baff73e2 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift @@ -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, 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) @@ -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) diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index 538945969..c1ecb51d3 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 4c0794fd9..383872017 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift index c10036043..0c5ba961d 100644 --- a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/MediaPickerScreen/CameraPicker.swift b/ElementX/Sources/Screens/MediaPickerScreen/CameraPicker.swift index 236459bf7..2d05c7a04 100644 --- a/ElementX/Sources/Screens/MediaPickerScreen/CameraPicker.swift +++ b/ElementX/Sources/Screens/MediaPickerScreen/CameraPicker.swift @@ -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 } diff --git a/ElementX/Sources/Screens/MediaPickerScreen/DocumentPicker.swift b/ElementX/Sources/Screens/MediaPickerScreen/DocumentPicker.swift index a50f3a256..67ab09b06 100644 --- a/ElementX/Sources/Screens/MediaPickerScreen/DocumentPicker.swift +++ b/ElementX/Sources/Screens/MediaPickerScreen/DocumentPicker.swift @@ -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)) } } } diff --git a/ElementX/Sources/Screens/MediaPickerScreen/MediaPickerScreenCoordinator.swift b/ElementX/Sources/Screens/MediaPickerScreen/MediaPickerScreenCoordinator.swift index 297e7f57e..175510bf5 100644 --- a/ElementX/Sources/Screens/MediaPickerScreen/MediaPickerScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaPickerScreen/MediaPickerScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift b/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift index 1f6726b5c..f927ed5c7 100644 --- a/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift +++ b/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift @@ -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) + } } } } diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift index af3f37166..0a131a0e8 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift @@ -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, diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift index be6ec26e5..4e20781d4 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenModels.swift @@ -12,7 +12,7 @@ enum MediaUploadPreviewScreenViewModelAction { } struct MediaUploadPreviewScreenViewState: BindableState { - let url: URL + let mediaURLs: [URL] let title: String? let shouldShowCaptionWarning: Bool let isRoomEncrypted: Bool diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift index 79996877f..2b0de1b39 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift @@ -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, Never> + private var processingTask: Task, 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, Never> { + clientProxy: ClientProxyProtocol) -> Task, 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) { }) diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift index 3cd9b916a..3720bda8b 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift @@ -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 = [] + + init(currentIndex: Binding) { + 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, diff --git a/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift index 63d253b47..3ac172d2b 100644 --- a/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetailsEditScreen/RoomDetailsEditScreenCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift index e416695ff..11d3092d5 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarModels.swift @@ -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) diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index 27e284814..03356601c 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift index bcacde2ef..06ca0e994 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/ComposerToolbar.swift @@ -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])) } } diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift index 4915d09bf..e0407c5e1 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift index 26b5419bd..94ac6442f 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index d4cf385fb..f40741b08 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -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): diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 0e10b4adc..5c72786c9 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -38,15 +38,19 @@ enum DeveloperOptionsScreenViewAction { protocol DeveloperOptionsProtocol: AnyObject { var logLevel: LogLevel { get set } var traceLogPacks: Set { 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 } } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index c08cf3c98..61ffc9ab5 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -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") diff --git a/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift index c8368bf92..58d896b8b 100644 --- a/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/UserDetailsEditScreen/UserDetailsEditScreenCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift index b209039af..95aad2525 100644 --- a/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift +++ b/ElementX/Sources/Screens/StartChatScreen/StartChatScreenCoordinator.swift @@ -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) } } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 8dfc350c4..74e16f74c 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -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): diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index f1b3f6a63..3d6681ffe 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -14,7 +14,7 @@ enum TimelineInteractionHandlerAction { case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) 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)) + } } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index ae2c00895..894562e1d 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -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) diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index beef9df5d..0059264d8 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -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): diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index fcf50f2f4..a98adb490 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -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 } } diff --git a/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift b/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift index 4e33a3247..a408fb77d 100644 --- a/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift +++ b/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift @@ -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 diff --git a/ElementX/Sources/ShareExtension/ShareExtensionModels.swift b/ElementX/Sources/ShareExtension/ShareExtensionModels.swift index e00a9f358..6b61c3ece 100644 --- a/ElementX/Sources/ShareExtension/ShareExtensionModels.swift +++ b/ElementX/Sources/ShareExtension/ShareExtensionModels.swift @@ -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 } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 5d457b5e8..0fd42b26c 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -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: diff --git a/ShareExtension/Sources/ShareExtensionViewController.swift b/ShareExtension/Sources/ShareExtensionViewController.swift index 52d8c35a6..8e2b2f928 100644 --- a/ShareExtension/Sources/ShareExtensionViewController.swift +++ b/ShareExtension/Sources/ShareExtensionViewController.swift @@ -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 { diff --git a/ShareExtension/SupportingFiles/Info.plist b/ShareExtension/SupportingFiles/Info.plist index d8ad392ed..1121f1686 100644 --- a/ShareExtension/SupportingFiles/Info.plist +++ b/ShareExtension/SupportingFiles/Info.plist @@ -31,15 +31,15 @@ NSExtensionActivationRule NSExtensionActivationSupportsFileWithMaxCount - 1 + 10 NSExtensionActivationSupportsImageWithMaxCount - 1 + 10 NSExtensionActivationSupportsMovieWithMaxCount - 1 + 10 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount - 1 + 10 NSExtensionPointIdentifier diff --git a/ShareExtension/SupportingFiles/target.yml b/ShareExtension/SupportingFiles/target.yml index 9abf2f76b..31dc0b843 100644 --- a/ShareExtension/SupportingFiles/target.yml +++ b/ShareExtension/SupportingFiles/target.yml @@ -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 diff --git a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift index 353a454b8..5bab86850 100644 --- a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift +++ b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift @@ -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, diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 7fa8c6dcd..234262d8e 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -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) diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index 185578ce1..91c91032b 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -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"))