mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ enum MediaUploadPreviewScreenViewModelAction {
|
||||
}
|
||||
|
||||
struct MediaUploadPreviewScreenViewState: BindableState {
|
||||
let url: URL
|
||||
let mediaURLs: [URL]
|
||||
let title: String?
|
||||
let shouldShowCaptionWarning: Bool
|
||||
let isRoomEncrypted: Bool
|
||||
|
||||
+26
-20
@@ -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) { })
|
||||
|
||||
+41
-8
@@ -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,
|
||||
|
||||
+13
-6
@@ -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
|
||||
|
||||
+10
-10
@@ -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):
|
||||
|
||||
+9
-5
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user