Multi file uploads (#4358)

* Allow MediaPickerScreen users to select the media selection mode (single or multiple)

* Fix cancellation

* Add support for multiple media URLs on the MediaUploadPreviewScreen.

* Support processing more URLs on the `MediaUploadingPreprocessor` and sending more on the `MediaUploadPreviewScreen`

* Add feature flag for `multipleAttachmentUploadEnabled`

* Add a label showing the current preview item index in the MediaUploadPreviewScreen

* Add support for dragging and dropping or pasting multiple items at the same time.

* Support sharing more than one file through the share extension.

* Limit the number of items that can be shared in one go to 5.

* Fix unit tests

* Fix incorrect fatal error when dealing with single selection media pickers.

* Document the `multipleAttachmentUploadEnabled` usage in the context of the MediaPicker.

* Use a task group for processing selected media in the photo library picker.

* Use a task group for processing multiple selected media in the MediaUploadingPreprocessor

* Switch the maximum number of items that can be shared to 10.

* Allow multiple items to be pasted at the same time.
This commit is contained in:
Stefan Ceriu
2025-07-30 15:44:05 +03:00
committed by GitHub
parent 01033cf53e
commit df335bd2b1
39 changed files with 466 additions and 266 deletions
@@ -16,11 +16,13 @@ import SwiftUI
protocol CommonSettingsProtocol { protocol CommonSettingsProtocol {
var logLevel: LogLevel { get } var logLevel: LogLevel { get }
var traceLogPacks: Set<TraceLogPack> { get } var traceLogPacks: Set<TraceLogPack> { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
var enableOnlySignedDeviceIsolationMode: Bool { get } var enableOnlySignedDeviceIsolationMode: Bool { get }
var enableKeyShareOnInvite: Bool { get } var enableKeyShareOnInvite: Bool { get }
var hideQuietNotificationAlerts: Bool { get }
var threadsEnabled: Bool { get } var threadsEnabled: Bool { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get } var hideQuietNotificationAlerts: Bool { get }
var multipleAttachmentUploadEnabled: Bool { get }
} }
/// Store Element specific app settings. /// Store Element specific app settings.
@@ -59,6 +61,7 @@ final class AppSettings {
case threadsEnabled case threadsEnabled
case developerOptionsEnabled case developerOptionsEnabled
case sharePosEnabledV2 case sharePosEnabledV2
case multipleAttachmentUploadEnabled
// Doug's tweaks 🔧 // Doug's tweaks 🔧
case hideUnreadMessagesBadge case hideUnreadMessagesBadge
@@ -70,6 +73,16 @@ final class AppSettings {
/// UserDefaults to be used on reads and writes. /// UserDefaults to be used on reads and writes.
private static var store: UserDefaults! = UserDefaults(suiteName: suiteName) 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 #if IS_MAIN_APP
static func resetAllSettings() { static func resetAllSettings() {
@@ -135,16 +148,6 @@ final class AppSettings {
// MARK: - Application // 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 /// 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 /// 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. /// 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)) @UserPreference(key: UserDefaultsKeys.traceLogPacks, defaultValue: [], storageType: .userDefaults(store))
var traceLogPacks: Set<TraceLogPack> 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. /// 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)) @UserPreference(key: UserDefaultsKeys.enableOnlySignedDeviceIsolationMode, defaultValue: false, storageType: .userDefaults(store))
var enableOnlySignedDeviceIsolationMode var enableOnlySignedDeviceIsolationMode
@@ -370,13 +375,14 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.enableKeyShareOnInvite, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.enableKeyShareOnInvite, defaultValue: false, storageType: .userDefaults(store))
var enableKeyShareOnInvite var enableKeyShareOnInvite
@UserPreference(key: UserDefaultsKeys.hideQuietNotificationAlerts, defaultValue: false, storageType: .userDefaults(store))
var hideQuietNotificationAlerts
@UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store))
var threadsEnabled 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 { } extension AppSettings: CommonSettingsProtocol { }
@@ -302,11 +302,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (_, .presentReportContent, .reportContent(let itemID, let senderID, _)): case (_, .presentReportContent, .reportContent(let itemID, let senderID, _)):
presentReportContent(for: itemID, from: 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 { guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else {
fatalError("Missing required TimelineController") fatalError("Missing required TimelineController")
} }
presentMediaUploadPickerWithSource(source, timelineController: timelineController, animated: animated) presentMediaUploadPickerWithMode(mode, timelineController: timelineController, animated: animated)
case (_, .presentEmojiPicker, .emojiPicker(let itemID, let selectedEmoji, _)): case (_, .presentEmojiPicker, .emojiPicker(let itemID, let selectedEmoji, _)):
guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else { guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else {
@@ -406,11 +406,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
presentPollForm(mode: mode, timelineController: timelineController) presentPollForm(mode: mode, timelineController: timelineController)
case (_, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL, _)): case (_, .presentMediaUploadPreview, .mediaUploadPreview(let mediaURLs, _)):
guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else { guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else {
fatalError("Missing required TimelineController") fatalError("Missing required TimelineController")
} }
presentMediaUploadPreviewScreen(for: fileURL, timelineController: timelineController, animated: animated)
presentMediaUploadPreviewScreen(for: mediaURLs, timelineController: timelineController, animated: animated)
case (_, .presentInviteUsersScreen, .inviteUsersScreen): case (_, .presentInviteUsersScreen, .inviteUsersScreen):
presentInviteUsersScreen() presentInviteUsersScreen()
@@ -464,8 +465,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch presentationAction { switch presentationAction {
case .eventFocus(let focusedEvent): case .eventFocus(let focusedEvent):
roomScreenCoordinator?.focusOnEvent(focusedEvent) roomScreenCoordinator?.focusOnEvent(focusedEvent)
case .share(.mediaFile(_, let mediaFile)): case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text(_, let text)): case .share(.text(_, let text)):
roomScreenCoordinator?.shareText(text) roomScreenCoordinator?.shareText(text)
@@ -501,8 +502,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
switch presentationAction { switch presentationAction {
case .share(.mediaFile(_, let mediaFile)): case .share(.mediaFiles(_, let mediaFiles)):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .share(.text), .eventFocus: case .share(.text), .eventFocus:
break // These are both handled in the coordinator's init. break // These are both handled in the coordinator's init.
@@ -556,11 +557,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentReportContent(let itemID, let senderID): case .presentReportContent(let itemID, let senderID):
stateMachine.tryEvent(.presentReportContent(itemID: itemID, stateMachine.tryEvent(.presentReportContent(itemID: itemID,
senderID: senderID)) senderID: senderID))
case .presentMediaUploadPicker(let source): case .presentMediaUploadPicker(let mode):
stateMachine.tryEvent(.presentMediaUploadPicker(source: source), stateMachine.tryEvent(.presentMediaUploadPicker(mode: mode),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentMediaUploadPreviewScreen(let url): case .presentMediaUploadPreviewScreen(let url):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: url),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentEmojiPicker(let itemID, let selectedEmojis): case .presentEmojiPicker(let itemID, let selectedEmojis):
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis), stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis),
@@ -645,11 +646,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .presentReportContent(let itemID, let senderID): case .presentReportContent(let itemID, let senderID):
stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID)) stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID))
case .presentMediaUploadPicker(let source): case .presentMediaUploadPicker(let mode):
stateMachine.tryEvent(.presentMediaUploadPicker(source: source), stateMachine.tryEvent(.presentMediaUploadPicker(mode: mode),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentMediaUploadPreviewScreen(let url): case .presentMediaUploadPreviewScreen(let mediaURLs):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaURLs),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
case .presentLocationPicker: case .presentLocationPicker:
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .picker), stateMachine.tryEvent(.presentMapNavigator(interactionMode: .picker),
@@ -864,7 +865,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
navigationStackCoordinator: stackCoordinator, navigationStackCoordinator: stackCoordinator,
userIndicatorController: userIndicatorController, userIndicatorController: userIndicatorController,
orientationManager: appMediator.windowManager) orientationManager: appMediator.windowManager,
appSettings: appSettings)
let roomDetailsEditCoordinator = RoomDetailsEditScreenCoordinator(parameters: roomDetailsEditParameters) let roomDetailsEditCoordinator = RoomDetailsEditScreenCoordinator(parameters: roomDetailsEditParameters)
roomDetailsEditCoordinator.actions.sink { [weak self] action in roomDetailsEditCoordinator.actions.sink { [weak self] action in
@@ -916,13 +918,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
} }
private func presentMediaUploadPickerWithSource(_ source: MediaPickerScreenSource, private func presentMediaUploadPickerWithMode(_ mode: MediaPickerScreenMode,
timelineController: TimelineControllerProtocol, timelineController: TimelineControllerProtocol,
animated: Bool) { animated: Bool) {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
source: source, appSettings: appSettings,
userIndicatorController: userIndicatorController,
orientationManager: appMediator.windowManager) { [weak self] action in orientationManager: appMediator.windowManager) { [weak self] action in
guard let self else { guard let self else {
return return
@@ -930,8 +933,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .cancel: case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
case .selectMediaAtURL(let url): case .selectedMediaAtURLs(let urls):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url), stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: urls),
userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) 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, timelineController: TimelineControllerProtocol,
animated: Bool) { animated: Bool) {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let title: String? = if mediaURLs.count == 1 {
mediaURLs.first?.lastPathComponent
} else {
nil
}
let parameters = MediaUploadPreviewScreenCoordinatorParameters(url: url, let parameters = MediaUploadPreviewScreenCoordinatorParameters(mediaURLs: mediaURLs,
title: url.lastPathComponent, title: title,
isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted, isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted,
shouldShowCaptionWarning: appSettings.shouldShowMediaCaptionWarning, shouldShowCaptionWarning: appSettings.shouldShowMediaCaptionWarning,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
@@ -57,8 +57,8 @@ extension RoomFlowCoordinator {
case roomMemberDetails(userID: String, previousState: State) case roomMemberDetails(userID: String, previousState: State)
case userProfile(userID: String, previousState: State) case userProfile(userID: String, previousState: State)
case inviteUsersScreen(previousState: State) case inviteUsersScreen(previousState: State)
case mediaUploadPicker(source: MediaPickerScreenSource, previousState: State) case mediaUploadPicker(mode: MediaPickerScreenMode, previousState: State)
case mediaUploadPreview(fileURL: URL, previousState: State) case mediaUploadPreview(mediaURLs: [URL], previousState: State)
case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State) case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, previousState: State)
case mapNavigator(previousState: State) case mapNavigator(previousState: State)
case messageForwarding(forwardingItem: MessageForwardingItem, previousState: State) case messageForwarding(forwardingItem: MessageForwardingItem, previousState: State)
@@ -123,10 +123,10 @@ extension RoomFlowCoordinator {
case presentInviteUsersScreen case presentInviteUsersScreen
case dismissInviteUsersScreen case dismissInviteUsersScreen
case presentMediaUploadPicker(source: MediaPickerScreenSource) case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case dismissMediaUploadPicker case dismissMediaUploadPicker
case presentMediaUploadPreview(fileURL: URL) case presentMediaUploadPreview(mediaURLs: [URL])
case dismissMediaUploadPreview case dismissMediaUploadPreview
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>) case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
@@ -185,11 +185,11 @@ extension RoomFlowCoordinator {
case (.room, .presentReportContent(let itemID, let senderID)): case (.room, .presentReportContent(let itemID, let senderID)):
return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState) return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState)
case (.room, .presentMediaUploadPicker(let source)): case (.room, .presentMediaUploadPicker(let mode)):
return .mediaUploadPicker(source: source, previousState: fromState) return .mediaUploadPicker(mode: mode, previousState: fromState)
case (.room, .presentMediaUploadPreview(let fileURL)): case (.room, .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(fileURL: fileURL, previousState: fromState) return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: fromState)
case (.room, .presentEmojiPicker(let itemID, let selectedEmoji)): case (.room, .presentEmojiPicker(let itemID, let selectedEmoji)):
return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState) return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState)
@@ -222,11 +222,11 @@ extension RoomFlowCoordinator {
case (.thread, .presentReportContent(let itemID, let senderID)): case (.thread, .presentReportContent(let itemID, let senderID)):
return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState) return .reportContent(itemID: itemID, senderID: senderID, previousState: fromState)
case (.thread, .presentMediaUploadPicker(let source)): case (.thread, .presentMediaUploadPicker(let mode)):
return .mediaUploadPicker(source: source, previousState: fromState) return .mediaUploadPicker(mode: mode, previousState: fromState)
case (.thread, .presentMediaUploadPreview(let fileURL)): case (.thread, .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(fileURL: fileURL, previousState: fromState) return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: fromState)
case (.thread, .presentEmojiPicker(let itemID, let selectedEmoji)): case (.thread, .presentEmojiPicker(let itemID, let selectedEmoji)):
return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState) return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState)
@@ -362,8 +362,8 @@ extension RoomFlowCoordinator {
case (.pollsHistoryForm, .dismissPollForm): case (.pollsHistoryForm, .dismissPollForm):
return .pollsHistory return .pollsHistory
case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let fileURL)): case (.mediaUploadPicker(_, let previousMediaUploadPickerState), .presentMediaUploadPreview(let mediaURLs)):
return .mediaUploadPreview(fileURL: fileURL, previousState: previousMediaUploadPickerState) return .mediaUploadPreview(mediaURLs: mediaURLs, previousState: previousMediaUploadPickerState)
case (_, .presentInviteUsersScreen): case (_, .presentInviteUsersScreen):
return .inviteUsersScreen(previousState: fromState) return .inviteUsersScreen(previousState: fromState)
@@ -171,7 +171,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
mediaProvider: parameters.userSession.mediaProvider, mediaProvider: parameters.userSession.mediaProvider,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: parameters.appSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: parameters.appSettings),
navigationStackCoordinator: navigationStackCoordinator, navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: parameters.userIndicatorController)) userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings))
navigationStackCoordinator?.push(coordinator) navigationStackCoordinator?.push(coordinator)
} }
@@ -735,7 +735,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: ServiceLocator.shared.userIndicatorController, userIndicatorController: ServiceLocator.shared.userIndicatorController,
navigationStackCoordinator: startChatNavigationStackCoordinator, navigationStackCoordinator: startChatNavigationStackCoordinator,
userDiscoveryService: userDiscoveryService, userDiscoveryService: userDiscoveryService,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings)) mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings),
appSettings: appSettings)
let coordinator = StartChatScreenCoordinator(parameters: parameters) let coordinator = StartChatScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in coordinator.actions.sink { [weak self] action in
@@ -1039,8 +1040,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
navigationSplitCoordinator.setSheetCoordinator(nil) navigationSplitCoordinator.setSheetCoordinator(nil)
case .confirm(let roomID): case .confirm(let roomID):
let sharePayload = switch sharePayload { let sharePayload = switch sharePayload {
case .mediaFile(_, let mediaFile): case .mediaFiles(_, let mediaFiles):
ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile) ShareExtensionPayload.mediaFiles(roomID: roomID, mediaFiles: mediaFiles)
case .text(_, let text): case .text(_, let text):
ShareExtensionPayload.text(roomID: roomID, text: text) ShareExtensionPayload.text(roomID: roomID, text: text)
} }
@@ -19,7 +19,7 @@ enum CreateRoomCoordinatorAction {
case openRoom(withIdentifier: String) case openRoom(withIdentifier: String)
case deselectUser(UserProfileProxy) case deselectUser(UserProfileProxy)
case updateDetails(CreateRoomFlowParameters) case updateDetails(CreateRoomFlowParameters)
case displayMediaPickerWithSource(MediaPickerScreenSource) case displayMediaPickerWithMode(MediaPickerScreenMode)
case removeImage case removeImage
} }
@@ -52,9 +52,9 @@ final class CreateRoomCoordinator: CoordinatorProtocol {
case .updateDetails(let details): case .updateDetails(let details):
actionsSubject.send(.updateDetails(details)) actionsSubject.send(.updateDetails(details))
case .displayCameraPicker: case .displayCameraPicker:
actionsSubject.send(.displayMediaPickerWithSource(.camera)) actionsSubject.send(.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single)))
case .displayMediaPicker: case .displayMediaPicker:
actionsSubject.send(.displayMediaPickerWithSource(.photoLibrary)) actionsSubject.send(.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single)))
case .removeImage: case .removeImage:
actionsSubject.send(.removeImage) actionsSubject.send(.removeImage)
} }
@@ -23,7 +23,8 @@ struct CameraPicker: UIViewControllerRepresentable {
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (CameraPickerAction) -> Void private let callback: (CameraPickerAction) -> Void
init(userIndicatorController: UserIndicatorControllerProtocol, callback: @escaping (CameraPickerAction) -> Void) { init(userIndicatorController: UserIndicatorControllerProtocol,
callback: @escaping (CameraPickerAction) -> Void) {
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.callback = callback self.callback = callback
} }
@@ -8,29 +8,35 @@
import SwiftUI import SwiftUI
enum DocumentPickerAction { enum DocumentPickerAction {
case selectFile(URL) case selectedMediaAtURLs([URL])
case cancel case cancel
case error(Error) case error(Error)
} }
enum DocumentPickerError: Error {
case unknown
}
struct DocumentPicker: UIViewControllerRepresentable { struct DocumentPicker: UIViewControllerRepresentable {
private let selectionType: MediaPickerScreenSelectionType
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (DocumentPickerAction) -> Void 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.userIndicatorController = userIndicatorController
self.callback = callback self.callback = callback
} }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController { func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data]) let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data])
documentPicker.allowsMultipleSelection = false
documentPicker.delegate = context.coordinator documentPicker.delegate = context.coordinator
documentPicker.allowsMultipleSelection = switch selectionType {
case .single:
false
case .multiple:
true
}
return documentPicker return documentPicker
} }
@@ -56,11 +62,6 @@ struct DocumentPicker: UIViewControllerRepresentable {
private static let loadingIndicatorIdentifier = "\(DocumentPicker.self)-Loading" private static let loadingIndicatorIdentifier = "\(DocumentPicker.self)-Loading"
func documentPicker(_ picker: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { func documentPicker(_ picker: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else {
documentPicker.callback(.error(DocumentPickerError.unknown))
return
}
picker.delegate = nil picker.delegate = nil
documentPicker.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading)) documentPicker.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading))
@@ -68,14 +69,19 @@ struct DocumentPicker: UIViewControllerRepresentable {
documentPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) documentPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
} }
do { var selectedURLs = [URL]()
_ = url.startAccessingSecurityScopedResource() for url in urls.prefix(10) {
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url) do {
url.stopAccessingSecurityScopedResource() _ = url.startAccessingSecurityScopedResource()
documentPicker.callback(.selectFile(newURL)) let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
} catch { url.stopAccessingSecurityScopedResource()
documentPicker.callback(.error(error)) selectedURLs.append(newURL)
} catch {
documentPicker.callback(.error(error))
}
} }
documentPicker.callback(.selectedMediaAtURLs(selectedURLs))
} }
} }
} }
@@ -7,31 +7,45 @@
import SwiftUI import SwiftUI
struct MediaPickerScreenMode: Hashable {
let source: MediaPickerScreenSource
let selectionType: MediaPickerScreenSelectionType
}
enum MediaPickerScreenSource { enum MediaPickerScreenSource {
case camera case camera
case photoLibrary case photoLibrary
case documents case documents
} }
enum MediaPickerScreenSelectionType {
case single
case multiple
}
enum MediaPickerScreenCoordinatorAction { enum MediaPickerScreenCoordinatorAction {
case selectMediaAtURL(URL) case selectedMediaAtURLs([URL])
case cancel case cancel
} }
class MediaPickerScreenCoordinator: CoordinatorProtocol { class MediaPickerScreenCoordinator: CoordinatorProtocol {
private let orientationManager: OrientationManagerProtocol private let orientationManager: OrientationManagerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let source: MediaPickerScreenSource private let mode: MediaPickerScreenMode
private let callback: (MediaPickerScreenCoordinatorAction) -> Void private let callback: (MediaPickerScreenCoordinatorAction) -> Void
init(userIndicatorController: UserIndicatorControllerProtocol, init(mode: MediaPickerScreenMode,
source: MediaPickerScreenSource, appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol,
orientationManager: OrientationManagerProtocol, orientationManager: OrientationManagerProtocol,
callback: @escaping (MediaPickerScreenCoordinatorAction) -> Void) { callback: @escaping (MediaPickerScreenCoordinatorAction) -> Void) {
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.source = source
self.orientationManager = orientationManager self.orientationManager = orientationManager
self.callback = callback 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 { func toPresentable() -> AnyView {
@@ -39,51 +53,47 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
} }
func start() { func start() {
guard source == .camera else { if mode.source == .camera {
return orientationManager.setOrientation(.portrait)
orientationManager.lockOrientation(.portrait)
} }
orientationManager.setOrientation(.portrait)
orientationManager.lockOrientation(.portrait)
} }
func stop() { func stop() {
guard source == .camera else { if mode.source == .camera {
return orientationManager.lockOrientation(.all)
} }
orientationManager.lockOrientation(.all)
} }
@ViewBuilder @ViewBuilder
private var mediaPicker: some View { private var mediaPicker: some View {
switch source { switch mode.source {
case .camera: case .camera:
cameraPicker cameraPicker
case .photoLibrary: case .photoLibrary:
PhotoLibraryPicker(userIndicatorController: userIndicatorController) { [weak self] action in PhotoLibraryPicker(selectionType: mode.selectionType, userIndicatorController: userIndicatorController) { [weak self] action in
switch action { switch action {
case .cancel: case .cancel:
self?.callback(.cancel) self?.callback(.cancel)
case .error(let error): case .error(let error):
MXLog.error("Failed selecting media from the photo library with error: \(error)") MXLog.error("Failed selecting media from the photo library with error: \(error)")
self?.showError() self?.showError()
case .selectFile(let url): case .selectedMediaAtURLs(let urls):
self?.callback(.selectMediaAtURL(url)) self?.callback(.selectedMediaAtURLs(urls))
} }
} }
case .documents: case .documents:
// The document picker automatically dismisses everything on selection // The document picker automatically dismisses everything on selection
// Strongly retain self in the callback to forward actions correctly // 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 { switch action {
case .cancel: case .cancel:
self.callback(.cancel) self.callback(.cancel)
case .error(let error): case .error(let error):
MXLog.error("Failed selecting media from the document picker with error: \(error)") MXLog.error("Failed selecting media from the document picker with error: \(error)")
self.showError() self.showError()
case .selectFile(let url): case .selectedMediaAtURLs(let urls):
self.callback(.selectMediaAtURL(url)) self.callback(.selectedMediaAtURLs(urls))
} }
} }
} }
@@ -98,7 +108,7 @@ class MediaPickerScreenCoordinator: CoordinatorProtocol {
MXLog.error("Failed selecting media from the camera picker with error: \(error)") MXLog.error("Failed selecting media from the camera picker with error: \(error)")
self?.showError() self?.showError()
case .selectFile(let url): case .selectFile(let url):
self?.callback(.selectMediaAtURL(url)) self?.callback(.selectedMediaAtURLs([url]))
} }
} }
.background(.black, ignoresSafeAreaEdges: .bottom) .background(.black, ignoresSafeAreaEdges: .bottom)
@@ -9,7 +9,7 @@ import PhotosUI
import SwiftUI import SwiftUI
enum PhotoLibraryPickerAction { enum PhotoLibraryPickerAction {
case selectFile(URL) case selectedMediaAtURLs([URL])
case cancel case cancel
case error(PhotoLibraryPickerError) case error(PhotoLibraryPickerError)
} }
@@ -20,17 +20,27 @@ enum PhotoLibraryPickerError: Error {
} }
struct PhotoLibraryPicker: UIViewControllerRepresentable { struct PhotoLibraryPicker: UIViewControllerRepresentable {
private let selectionType: MediaPickerScreenSelectionType
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let callback: (PhotoLibraryPickerAction) -> Void 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.userIndicatorController = userIndicatorController
self.callback = callback self.callback = callback
} }
func makeUIViewController(context: Context) -> PHPickerViewController { func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared()) var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.selectionLimit = switch selectionType {
case .single:
1
case .multiple:
10
}
let pickerViewController = PHPickerViewController(configuration: configuration) let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = context.coordinator pickerViewController.delegate = context.coordinator
@@ -56,38 +66,73 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable {
private static let loadingIndicatorIdentifier = "\(PhotoLibraryPicker.self)-Loading" private static let loadingIndicatorIdentifier = "\(PhotoLibraryPicker.self)-Loading"
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider, guard !results.isEmpty else {
let contentType = provider.preferredContentType else {
photoLibraryPicker.callback(.cancel) photoLibraryPicker.callback(.cancel)
return return
} }
picker.delegate = nil 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 { defer {
photoLibraryPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) photoLibraryPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
} }
provider.loadFileRepresentation(forTypeIdentifier: contentType.type.identifier) { [weak self] url, error in Task {
guard let url else { let selectedURLs = await withTaskGroup { taskGroup in
Task { @MainActor in for result in results {
self?.photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(error))) taskGroup.addTask { await self.processResult(result) }
} }
return
var selectedURLs = [URL]()
for await url in taskGroup {
if let url {
selectedURLs.append(url)
}
}
return selectedURLs
} }
do { photoLibraryPicker.callback(.selectedMediaAtURLs(selectedURLs))
_ = url.startAccessingSecurityScopedResource() }
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url) }
url.stopAccessingSecurityScopedResource()
// MARK: - Private
Task { @MainActor in
self?.photoLibraryPicker.callback(.selectFile(newURL)) 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 do {
self?.photoLibraryPicker.callback(.error(.failedCopyingFile)) _ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryDirectory(file: url)
url.stopAccessingSecurityScopedResource()
Task { @MainActor in
continuation.resume(returning: newURL)
}
} catch {
Task { @MainActor in
self?.photoLibraryPicker.callback(.error(.failedCopyingFile))
continuation.resume(returning: nil)
}
} }
} }
} }
@@ -9,7 +9,7 @@ import Combine
import SwiftUI import SwiftUI
struct MediaUploadPreviewScreenCoordinatorParameters { struct MediaUploadPreviewScreenCoordinatorParameters {
let url: URL let mediaURLs: [URL]
let title: String? let title: String?
let isRoomEncrypted: Bool let isRoomEncrypted: Bool
let shouldShowCaptionWarning: Bool let shouldShowCaptionWarning: Bool
@@ -33,7 +33,7 @@ final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: MediaUploadPreviewScreenCoordinatorParameters) { init(parameters: MediaUploadPreviewScreenCoordinatorParameters) {
viewModel = MediaUploadPreviewScreenViewModel(url: parameters.url, viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: parameters.mediaURLs,
title: parameters.title, title: parameters.title,
isRoomEncrypted: parameters.isRoomEncrypted, isRoomEncrypted: parameters.isRoomEncrypted,
shouldShowCaptionWarning: parameters.shouldShowCaptionWarning, shouldShowCaptionWarning: parameters.shouldShowCaptionWarning,
@@ -12,7 +12,7 @@ enum MediaUploadPreviewScreenViewModelAction {
} }
struct MediaUploadPreviewScreenViewState: BindableState { struct MediaUploadPreviewScreenViewState: BindableState {
let url: URL let mediaURLs: [URL]
let title: String? let title: String?
let shouldShowCaptionWarning: Bool let shouldShowCaptionWarning: Bool
let isRoomEncrypted: Bool let isRoomEncrypted: Bool
@@ -16,9 +16,9 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor 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 var requestHandle: SendAttachmentJoinHandleProtocol?
private let clientProxy: ClientProxyProtocol private let clientProxy: ClientProxyProtocol
@@ -28,7 +28,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(url: URL, init(mediaURLs: [URL],
title: String?, title: String?,
isRoomEncrypted: Bool, isRoomEncrypted: Bool,
shouldShowCaptionWarning: Bool, shouldShowCaptionWarning: Bool,
@@ -36,16 +36,16 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
timelineController: TimelineControllerProtocol, timelineController: TimelineControllerProtocol,
clientProxy: ClientProxyProtocol, clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) { userIndicatorController: UserIndicatorControllerProtocol) {
self.url = url self.mediaURLs = mediaURLs
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
self.timelineController = timelineController self.timelineController = timelineController
self.clientProxy = clientProxy self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
// Start processing the media whilst the user is reviewing it/adding a caption. // 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, title: title,
shouldShowCaptionWarning: shouldShowCaptionWarning, shouldShowCaptionWarning: shouldShowCaptionWarning,
isRoomEncrypted: isRoomEncrypted)) isRoomEncrypted: isRoomEncrypted))
@@ -53,7 +53,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
override func process(viewAction: MediaUploadPreviewScreenViewAction) { override func process(viewAction: MediaUploadPreviewScreenViewAction) {
// Get the current caption before all the processing starts. // Get the current caption before all the processing starts.
let caption = state.bindings.caption.nonBlankString var caption = state.bindings.caption.nonBlankString
switch viewAction { switch viewAction {
case .send: case .send:
@@ -62,26 +62,32 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
Task { Task {
defer { stopLoading() } defer { stopLoading() }
var shouldDismissOnCompletion = true
switch await processingTask.value { switch await processingTask.value {
case .success(let mediaInfo): case .success(let mediaInfos):
switch await sendAttachment(mediaInfo: mediaInfo, for mediaInfo in mediaInfos {
caption: caption) { switch await sendAttachment(mediaInfo: mediaInfo, caption: caption) {
case .success: case .success:
actionsSubject.send(.dismiss) caption = nil // Set the caption only on the first uploaded file.
case .failure(let error): case .failure(let error):
MXLog.error("Failed sending attachment with error: \(error)") MXLog.error("Failed processing media to upload with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending) showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
}
} }
case .failure(.maxUploadSizeUnknown): case .failure(.maxUploadSizeUnknown):
showAlert(.maxUploadSizeUnknown) showAlert(.maxUploadSizeUnknown)
shouldDismissOnCompletion = false
case .failure(.maxUploadSizeExceeded(let limit)): case .failure(.maxUploadSizeExceeded(let limit)):
showAlert(.maxUploadSizeExceeded(limit: limit)) showAlert(.maxUploadSizeExceeded(limit: limit))
case .failure(let error): case .failure(let error):
MXLog.error("Failed processing media to upload with error: \(error)") MXLog.error("Failed processing media to upload with error: \(error)")
showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing) showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
} }
if shouldDismissOnCompletion {
actionsSubject.send(.dismiss)
}
} }
case .cancel: case .cancel:
requestHandle?.cancel() requestHandle?.cancel()
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
@@ -94,12 +100,12 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
// MARK: - Private // MARK: - Private
private static func processMedia(at url: URL, private static func processMedia(at urls: [URL],
preprocessor: MediaUploadingPreprocessor, preprocessor: MediaUploadingPreprocessor,
clientProxy: ClientProxyProtocol) -> Task<Result<MediaInfo, MediaUploadingPreprocessorError>, Never> { clientProxy: ClientProxyProtocol) -> Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never> {
Task { Task {
guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { return .failure(.maxUploadSizeUnknown) } 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, message: L10n.screenMediaUploadPreviewErrorCouldNotBeUploaded,
primaryButton: .init(title: L10n.actionTryAgain) { [weak self] in primaryButton: .init(title: L10n.actionTryAgain) { [weak self] in
guard let self else { return } 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) process(viewAction: .send)
}, },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { }) secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { })
@@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import Combine
import Compound import Compound
import GameController import GameController
import QuickLook import QuickLook
@@ -16,6 +17,7 @@ struct MediaUploadPreviewScreen: View {
@Bindable var context: MediaUploadPreviewScreenViewModel.Context @Bindable var context: MediaUploadPreviewScreenViewModel.Context
@State private var captionWarningFrame: CGRect = .zero @State private var captionWarningFrame: CGRect = .zero
@State private var currentIndex = 0
@FocusState private var isComposerFocussed @FocusState private var isComposerFocussed
private var title: String { ProcessInfo.processInfo.isiOSAppOnMac ? context.viewState.title ?? "" : "" } private var title: String { ProcessInfo.processInfo.isiOSAppOnMac ? context.viewState.title ?? "" : "" }
@@ -23,8 +25,15 @@ struct MediaUploadPreviewScreen: View {
var body: some View { var body: some View {
mainContent mainContent
.id(context.viewState.url) .id(context.viewState.mediaURLs)
.ignoresSafeArea(edges: [.horizontal]) .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) { .safeAreaInset(edge: .bottom, spacing: 0) {
composer composer
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -50,8 +59,9 @@ struct MediaUploadPreviewScreen: View {
.foregroundColor(.compound.textSecondary) .foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
PreviewView(fileURL: context.viewState.url, PreviewView(mediaURLs: context.viewState.mediaURLs,
title: context.viewState.title) title: context.viewState.title,
currentIndex: $currentIndex)
} }
} }
@@ -154,11 +164,12 @@ struct MediaUploadPreviewScreen: View {
} }
private struct PreviewView: UIViewControllerRepresentable { private struct PreviewView: UIViewControllerRepresentable {
let fileURL: URL let mediaURLs: [URL]
let title: String? let title: String?
@Binding var currentIndex: Int
func makeUIViewController(context: Context) -> UIViewController { func makeUIViewController(context: Context) -> UIViewController {
let previewController = PreviewViewController() let previewController = PreviewViewController(currentIndex: $currentIndex)
previewController.dataSource = context.coordinator previewController.dataSource = context.coordinator
previewController.delegate = context.coordinator previewController.delegate = context.coordinator
@@ -185,11 +196,11 @@ private struct PreviewView: UIViewControllerRepresentable {
// MARK: - QLPreviewControllerDataSource // MARK: - QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1 view.mediaURLs.count
} }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { 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 // MARK: - QLPreviewControllerDelegate
@@ -211,6 +222,28 @@ private class PreviewItem: NSObject, QLPreviewItem {
} }
private class PreviewViewController: QLPreviewController { 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() { override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews() super.viewWillLayoutSubviews()
@@ -228,7 +261,7 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
static let snapshotURL = URL.picturesDirectory static let snapshotURL = URL.picturesDirectory
static let testURL = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png") 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", title: "App Icon.png",
isRoomEncrypted: true, isRoomEncrypted: true,
shouldShowCaptionWarning: true, shouldShowCaptionWarning: true,
@@ -16,6 +16,7 @@ struct RoomDetailsEditScreenCoordinatorParameters {
weak var navigationStackCoordinator: NavigationStackCoordinator? weak var navigationStackCoordinator: NavigationStackCoordinator?
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
let orientationManager: OrientationManagerProtocol let orientationManager: OrientationManagerProtocol
let appSettings: AppSettings
} }
enum RoomDetailsEditScreenCoordinatorAction { enum RoomDetailsEditScreenCoordinatorAction {
@@ -49,9 +50,9 @@ final class RoomDetailsEditScreenCoordinator: CoordinatorProtocol {
case .cancel, .saveFinished: case .cancel, .saveFinished:
self?.actionsSubject.send(.dismiss) self?.actionsSubject.send(.dismiss)
case .displayCameraPicker: case .displayCameraPicker:
self?.displayMediaPickerWithSource(.camera) self?.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single))
case .displayMediaPicker: case .displayMediaPicker:
self?.displayMediaPickerWithSource(.photoLibrary) self?.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -63,17 +64,23 @@ final class RoomDetailsEditScreenCoordinator: CoordinatorProtocol {
// MARK: Private // MARK: Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) { private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: parameters.userIndicatorController, let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
source: source, appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController,
orientationManager: parameters.orientationManager) { [weak self] action in orientationManager: parameters.orientationManager) { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {
case .cancel: case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil) 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) parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
viewModel.didSelectMediaUrl(url: url) viewModel.didSelectMediaUrl(url: url)
} }
@@ -27,7 +27,7 @@ enum ComposerToolbarViewModelAction {
case editLastMessage case editLastMessage
case attach(ComposerAttachmentType) case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider) case handlePasteOrDrop(providers: [NSItemProvider])
case composerModeChanged(mode: ComposerMode) case composerModeChanged(mode: ComposerMode)
case composerFocusedChanged(isFocused: Bool) case composerFocusedChanged(isFocused: Bool)
@@ -46,7 +46,7 @@ enum ComposerToolbarViewAction {
case cancelReply case cancelReply
case cancelEdit case cancelEdit
case attach(ComposerAttachmentType) case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider) case handlePasteOrDrop(providers: [NSItemProvider])
case enableTextFormatting case enableTextFormatting
case composerAction(action: ComposerAction) case composerAction(action: ComposerAction)
case selectedSuggestion(_ suggestion: SuggestionItem) case selectedSuggestion(_ suggestion: SuggestionItem)
@@ -216,8 +216,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
case .attach(let attachment): case .attach(let attachment):
state.bindings.composerFocused = false state.bindings.composerFocused = false
actionsSubject.send(.attach(attachment)) actionsSubject.send(.attach(attachment))
case .handlePasteOrDrop(let provider): case .handlePasteOrDrop(let providers):
actionsSubject.send(.handlePasteOrDrop(provider: provider)) actionsSubject.send(.handlePasteOrDrop(providers: providers))
case .enableTextFormatting: case .enableTextFormatting:
state.bindings.composerFormattingEnabled = true state.bindings.composerFormattingEnabled = true
state.bindings.composerFocused = true state.bindings.composerFocused = true
@@ -181,8 +181,8 @@ struct ComposerToolbar: View {
sendMessage() sendMessage()
} editAction: { } editAction: {
context.send(viewAction: .editLastMessage) context.send(viewAction: .editLastMessage)
} pasteAction: { provider in } pasteAction: { providers in
context.send(viewAction: .handlePasteOrDrop(provider: provider)) context.send(viewAction: .handlePasteOrDrop(providers: providers))
} cancellationAction: { } cancellationAction: {
switch context.viewState.composerMode { switch context.viewState.composerMode {
case .edit: case .edit:
@@ -259,7 +259,7 @@ struct ComposerToolbar: View {
viewModel: context.viewState.wysiwygViewModel, viewModel: context.viewState.wysiwygViewModel,
itemProviderHelper: ItemProviderHelper(), itemProviderHelper: ItemProviderHelper(),
keyCommands: context.viewState.keyCommands) { provider in 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 import WysiwygComposer
typealias GenericKeyHandler = (_ key: UIKeyboardHIDUsage) -> Void typealias GenericKeyHandler = (_ key: UIKeyboardHIDUsage) -> Void
typealias PasteHandler = (NSItemProvider) -> Void typealias PasteHandler = ([NSItemProvider]) -> Void
struct MessageComposer: View { struct MessageComposer: View {
@Binding var plainComposerText: NSAttributedString @Binding var plainComposerText: NSAttributedString
@@ -190,8 +190,8 @@ private struct UITextViewWrapper: UIViewRepresentable {
textView.insertText("\n") textView.insertText("\n")
} }
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) { func textView(_ textView: UITextView, didReceivePasteWith providers: [NSItemProvider]) {
pasteHandler(provider) pasteHandler(providers)
} }
func textViewDidChangeSelection(_ textView: UITextView) { func textViewDidChangeSelection(_ textView: UITextView) {
@@ -207,7 +207,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
private protocol ElementTextViewDelegate: AnyObject { private protocol ElementTextViewDelegate: AnyObject {
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView)
func textViewDidReceiveKeyPress(_ textView: UITextView, key: UIKeyboardHIDUsage) 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 { private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
@@ -280,19 +280,19 @@ private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
return false return false
} }
return UIPasteboard.general.itemProviders.first?.isSupportedForPasteOrDrop ?? false return UIPasteboard.general.itemProviders.filter { !$0.isSupportedForPasteOrDrop }.isEmpty
} }
override func paste(_ sender: Any?) { override func paste(_ sender: Any?) {
guard let provider = UIPasteboard.general.itemProviders.first, let providers = UIPasteboard.general.itemProviders
provider.isSupportedForPasteOrDrop else {
// If the item is not supported for media upload then // Use the default behavior if there are any unsupported providers
// just try pasting its contents into the textfield guard providers.filter({ !$0.isSupportedForPasteOrDrop }).isEmpty else {
super.paste(sender) super.paste(sender)
return return
} }
elementDelegate?.textView(self, didReceivePasteWith: provider) elementDelegate?.textView(self, didReceivePasteWith: providers)
} }
// MARK: PillAttachmentViewProviderDelegate // MARK: PillAttachmentViewProviderDelegate
@@ -32,8 +32,8 @@ struct RoomScreenCoordinatorParameters {
enum RoomScreenCoordinatorAction { enum RoomScreenCoordinatorAction {
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case presentMediaUploadPicker(MediaPickerScreenSource) case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case presentMediaUploadPreviewScreen(URL) case presentMediaUploadPreviewScreen(mediaURLs: [URL])
case presentRoomDetails case presentRoomDetails
case presentLocationPicker case presentLocationPicker
case presentPollForm(mode: PollFormMode) case presentPollForm(mode: PollFormMode)
@@ -121,19 +121,19 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
case .displayReportContent(let itemID, let senderID): case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID))
case .displayCameraPicker: case .displayCameraPicker:
actionsSubject.send(.presentMediaUploadPicker(.camera)) actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple)))
case .displayMediaPicker: case .displayMediaPicker:
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary)) actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple)))
case .displayDocumentPicker: case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents)) actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents, selectionType: .multiple)))
case .displayMediaPreview(let mediaPreviewViewModel): case .displayMediaPreview(let mediaPreviewViewModel):
roomViewModel.displayMediaPreview(mediaPreviewViewModel) roomViewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker: case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker) actionsSubject.send(.presentLocationPicker)
case .displayPollForm(let mode): case .displayPollForm(let mode):
actionsSubject.send(.presentPollForm(mode: mode)) actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let url): case .displayMediaUploadPreviewScreen(let mediaURLs):
actionsSubject.send(.presentMediaUploadPreviewScreen(url)) actionsSubject.send(.presentMediaUploadPreviewScreen(mediaURLs: mediaURLs))
case .displaySenderDetails(userID: let userID): case .displaySenderDetails(userID: let userID):
actionsSubject.send(.presentRoomMemberDetails(userID: userID)) actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem): case .displayMessageForwarding(let forwardingItem):
@@ -38,15 +38,19 @@ enum DeveloperOptionsScreenViewAction {
protocol DeveloperOptionsProtocol: AnyObject { protocol DeveloperOptionsProtocol: AnyObject {
var logLevel: LogLevel { get set } var logLevel: LogLevel { get set }
var traceLogPacks: Set<TraceLogPack> { 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 enableOnlySignedDeviceIsolationMode: Bool { get set }
var enableKeyShareOnInvite: Bool { get set } var enableKeyShareOnInvite: Bool { get set }
var elementCallBaseURLOverride: URL? { get set }
var knockingEnabled: Bool { get set }
var threadsEnabled: Bool { get set } var threadsEnabled: Bool { get set }
var hideQuietNotificationAlerts: 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 } var sharePosEnabled: Bool { get set }
} }
@@ -52,6 +52,12 @@ struct DeveloperOptionsScreen: View {
} }
} }
Section("Timeline") {
Toggle(isOn: $context.multipleAttachmentUploadEnabled) {
Text("Allow selecting multiple attachments for upload")
}
}
Section("Join rules") { Section("Join rules") {
Toggle(isOn: $context.knockingEnabled) { Toggle(isOn: $context.knockingEnabled) {
Text("Knocking") Text("Knocking")
@@ -15,6 +15,7 @@ struct UserDetailsEditScreenCoordinatorParameters {
let mediaUploadingPreprocessor: MediaUploadingPreprocessor let mediaUploadingPreprocessor: MediaUploadingPreprocessor
weak var navigationStackCoordinator: NavigationStackCoordinator? weak var navigationStackCoordinator: NavigationStackCoordinator?
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
let appSettings: AppSettings
} }
final class UserDetailsEditScreenCoordinator: CoordinatorProtocol { final class UserDetailsEditScreenCoordinator: CoordinatorProtocol {
@@ -36,11 +37,11 @@ final class UserDetailsEditScreenCoordinator: CoordinatorProtocol {
.sink { [weak self] action in .sink { [weak self] action in
switch action { switch action {
case .displayCameraPicker: case .displayCameraPicker:
self?.displayMediaPickerWithSource(.camera) self?.displayMediaPickerWithMode(.init(source: .camera, selectionType: .single))
case .displayMediaPicker: case .displayMediaPicker:
self?.displayMediaPickerWithSource(.photoLibrary) self?.displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single))
case .displayFilePicker: case .displayFilePicker:
self?.displayMediaPickerWithSource(.documents) self?.displayMediaPickerWithMode(.init(source: .documents, selectionType: .single))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -52,15 +53,23 @@ final class UserDetailsEditScreenCoordinator: CoordinatorProtocol {
// MARK: Private // MARK: Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) { private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator() 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 } guard let self else { return }
switch action { switch action {
case .cancel: case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil) 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) parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
viewModel.didSelectMediaURL(url: url) viewModel.didSelectMediaURL(url: url)
} }
@@ -15,6 +15,7 @@ struct StartChatScreenCoordinatorParameters {
weak var navigationStackCoordinator: NavigationStackCoordinator? weak var navigationStackCoordinator: NavigationStackCoordinator?
let userDiscoveryService: UserDiscoveryServiceProtocol let userDiscoveryService: UserDiscoveryServiceProtocol
let mediaUploadingPreprocessor: MediaUploadingPreprocessor let mediaUploadingPreprocessor: MediaUploadingPreprocessor
let appSettings: AppSettings
} }
enum StartChatScreenCoordinatorAction { enum StartChatScreenCoordinatorAction {
@@ -124,8 +125,8 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
self.createRoomParameters.send(details) self.createRoomParameters.send(details)
case .openRoom(let identifier): case .openRoom(let identifier):
self.actionsSubject.send(.openRoom(withIdentifier: identifier)) self.actionsSubject.send(.openRoom(withIdentifier: identifier))
case .displayMediaPickerWithSource(let source): case .displayMediaPickerWithMode(let mode):
self.displayMediaPickerWithSource(source) self.displayMediaPickerWithMode(mode)
case .removeImage: case .removeImage:
var parameters = self.createRoomParameters.value var parameters = self.createRoomParameters.value
parameters.avatarImageMedia = nil parameters.avatarImageMedia = nil
@@ -139,15 +140,23 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
// MARK: - Private // MARK: - Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) { private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
let stackCoordinator = NavigationStackCoordinator() 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 } guard let self else { return }
switch action { switch action {
case .cancel: case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil) 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) processAvatar(from: url)
} }
} }
@@ -27,8 +27,8 @@ struct ThreadTimelineScreenCoordinatorParameters {
enum ThreadTimelineScreenCoordinatorAction { enum ThreadTimelineScreenCoordinatorAction {
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case presentMediaUploadPicker(MediaPickerScreenSource) case presentMediaUploadPicker(mode: MediaPickerScreenMode)
case presentMediaUploadPreviewScreen(url: URL) case presentMediaUploadPreviewScreen(mediaURLs: [URL])
case presentLocationPicker case presentLocationPicker
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?) case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
case presentPollForm(mode: PollFormMode) case presentPollForm(mode: PollFormMode)
@@ -96,11 +96,11 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
case .displayReportContent(let itemID, let senderID): case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID))
case .displayCameraPicker: case .displayCameraPicker:
actionsSubject.send(.presentMediaUploadPicker(.camera)) actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple)))
case .displayMediaPicker: case .displayMediaPicker:
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary)) actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple)))
case .displayDocumentPicker: case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents)) actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents, selectionType: .multiple)))
case .displayMediaPreview(let mediaPreviewViewModel): case .displayMediaPreview(let mediaPreviewViewModel):
viewModel.displayMediaPreview(mediaPreviewViewModel) viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker: case .displayLocationPicker:
@@ -111,8 +111,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
description: description)) description: description))
case .displayPollForm(let mode): case .displayPollForm(let mode):
actionsSubject.send(.presentPollForm(mode: mode)) actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let url): case .displayMediaUploadPreviewScreen(let mediaURLs):
actionsSubject.send(.presentMediaUploadPreviewScreen(url: url)) actionsSubject.send(.presentMediaUploadPreviewScreen(mediaURLs: mediaURLs))
case .displaySenderDetails(userID: let userID): case .displaySenderDetails(userID: let userID):
actionsSubject.send(.presentRoomMemberDetails(userID: userID)) actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem): case .displayMessageForwarding(let forwardingItem):
@@ -14,7 +14,7 @@ enum TimelineInteractionHandlerAction {
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>) case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String) case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayMessageForwarding(itemID: TimelineItemIdentifier) case displayMessageForwarding(itemID: TimelineItemIdentifier)
case displayMediaUploadPreviewScreen(url: URL) case displayMediaUploadPreviewScreen(mediaURLs: [URL])
case displayPollForm(mode: PollFormMode) case displayPollForm(mode: PollFormMode)
case showActionMenu(TimelineItemActionMenuInfo) case showActionMenu(TimelineItemActionMenuInfo)
@@ -277,7 +277,7 @@ class TimelineInteractionHandler {
// MARK: Pasting and dropping // MARK: Pasting and dropping
func handlePasteOrDrop(_ provider: NSItemProvider) { func handlePasteOrDrop(_ providers: [NSItemProvider]) {
Task { Task {
let loadingIndicatorIdentifier = UUID().uuidString let loadingIndicatorIdentifier = UUID().uuidString
self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
@@ -285,13 +285,19 @@ class TimelineInteractionHandler {
self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
} }
guard let fileURL = await provider.storeData() else { var mediaURLs = [URL]()
MXLog.error("Failed storing NSItemProvider data \(provider)") for provider in providers {
self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia)) if let fileURL = await provider.storeData() {
return 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 displayDocumentPicker
case displayLocationPicker case displayLocationPicker
case displayPollForm(mode: PollFormMode) case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL) case displayMediaUploadPreviewScreen(mediaURLs: [URL])
case displaySenderDetails(userID: String) case displaySenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem) case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel) case displayMediaPreview(TimelineMediaPreviewViewModel)
@@ -63,7 +63,7 @@ enum TimelineViewAction {
case displayReadReceipts(itemID: TimelineItemIdentifier) case displayReadReceipts(itemID: TimelineItemIdentifier)
case displayThread(itemID: TimelineItemIdentifier) case displayThread(itemID: TimelineItemIdentifier)
case handlePasteOrDrop(provider: NSItemProvider) case handlePasteOrDrop(providers: [NSItemProvider])
case handlePollAction(TimelineViewPollAction) case handlePollAction(TimelineViewPollAction)
case handleAudioPlayerAction(TimelineAudioPlayerAction) case handleAudioPlayerAction(TimelineAudioPlayerAction)
@@ -192,8 +192,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
displayReadReceipts(for: itemID) displayReadReceipts(for: itemID)
case .displayThread(let itemID): case .displayThread(let itemID):
actionsSubject.send(.displayThread(itemID: itemID)) actionsSubject.send(.displayThread(itemID: itemID))
case .handlePasteOrDrop(let provider): case .handlePasteOrDrop(let providers):
timelineInteractionHandler.handlePasteOrDrop(provider) timelineInteractionHandler.handlePasteOrDrop(providers)
case .handlePollAction(let pollAction): case .handlePollAction(let pollAction):
handlePollAction(pollAction) handlePollAction(pollAction)
case .handleAudioPlayerAction(let audioPlayerAction): case .handleAudioPlayerAction(let audioPlayerAction):
@@ -231,8 +231,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
editLastMessage() editLastMessage()
case .attach(let attachment): case .attach(let attachment):
attach(attachment) attach(attachment)
case .handlePasteOrDrop(let provider): case .handlePasteOrDrop(let providers):
timelineInteractionHandler.handlePasteOrDrop(provider) timelineInteractionHandler.handlePasteOrDrop(providers)
case .composerModeChanged(mode: let mode): case .composerModeChanged(mode: let mode):
trackComposerMode(mode) trackComposerMode(mode)
case .composerFocusedChanged(isFocused: let isFocused): case .composerFocusedChanged(isFocused: let isFocused):
@@ -480,8 +480,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
actionsSubject.send(.displayPollForm(mode: mode)) actionsSubject.send(.displayPollForm(mode: mode))
case .displayReportContent(let itemID, let senderID): case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID)) actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
case .displayMediaUploadPreviewScreen(let url): case .displayMediaUploadPreviewScreen(let mediaURLs):
actionsSubject.send(.displayMediaUploadPreviewScreen(url: url)) actionsSubject.send(.displayMediaUploadPreviewScreen(mediaURLs: mediaURLs))
case .showActionMenu(let actionMenuInfo): case .showActionMenu(let actionMenuInfo):
self.state.bindings.actionMenuInfo = actionMenuInfo self.state.bindings.actionMenuInfo = actionMenuInfo
case .showDebugInfo(let debugInfo): case .showDebugInfo(let debugInfo):
@@ -55,12 +55,13 @@ struct TimelineView: View {
.environmentObject(timelineContext) .environmentObject(timelineContext)
} }
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in .onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first, let supportedProviders = providers.filter(\.isSupportedForPasteOrDrop)
provider.isSupportedForPasteOrDrop else {
guard !supportedProviders.isEmpty else {
return false return false
} }
timelineContext.send(viewAction: .handlePasteOrDrop(provider: provider)) timelineContext.send(viewAction: .handlePasteOrDrop(providers: supportedProviders))
return true return true
} }
} }
@@ -94,6 +94,33 @@ struct MediaUploadingPreprocessor {
static let videoThumbnailTime = 5.0 // seconds 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 /// 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 /// from images and retrieve associated media information
/// - Parameter url: the file URL /// - Parameter url: the file URL
@@ -12,12 +12,12 @@ enum ShareExtensionConstants {
} }
enum ShareExtensionPayload: Hashable, Codable { enum ShareExtensionPayload: Hashable, Codable {
case mediaFile(roomID: String?, mediaFile: ShareExtensionMediaFile) case mediaFiles(roomID: String?, mediaFiles: [ShareExtensionMediaFile])
case text(roomID: String?, text: String) case text(roomID: String?, text: String)
var roomID: String? { var roomID: String? {
switch self { switch self {
case .mediaFile(let roomID, _), case .mediaFiles(let roomID, _),
.text(let roomID, _): .text(let roomID, _):
roomID roomID
} }
@@ -27,14 +27,16 @@ enum ShareExtensionPayload: Hashable, Codable {
/// system's `temporaryDirectory` returning a modified payload with updated file URLs. /// system's `temporaryDirectory` returning a modified payload with updated file URLs.
func withDefaultTemporaryDirectory() throws -> Self { func withDefaultTemporaryDirectory() throws -> Self {
switch self { switch self {
case .mediaFile(let roomID, let mediaFile): case .mediaFiles(let roomID, let mediaFiles):
let path = mediaFile.url.path.replacing(URL.appGroupTemporaryDirectory.path, with: "").trimmingPrefix("/") return try .mediaFiles(roomID: roomID, mediaFiles: mediaFiles.map { mediaFile in
let newURL = URL.temporaryDirectory.appending(path: path) 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) try? FileManager.default.removeItem(at: newURL)
try FileManager.default.moveItem(at: mediaFile.url, to: newURL)
return .mediaFile(roomID: roomID, mediaFile: mediaFile.replacingURL(with: newURL))
return mediaFile.replacingURL(with: newURL)
})
case .text: case .text:
return self return self
} }
@@ -639,7 +639,8 @@ class MockScreen: Identifiable {
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
navigationStackCoordinator: navigationStackCoordinator, navigationStackCoordinator: navigationStackCoordinator,
userDiscoveryService: userDiscoveryMock, userDiscoveryService: userDiscoveryMock,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings)) mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
appSettings: ServiceLocator.shared.settings)
let coordinator = StartChatScreenCoordinator(parameters: parameters) let coordinator = StartChatScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
@@ -654,7 +655,8 @@ class MockScreen: Identifiable {
userIndicatorController: UserIndicatorControllerMock(), userIndicatorController: UserIndicatorControllerMock(),
navigationStackCoordinator: navigationStackCoordinator, navigationStackCoordinator: navigationStackCoordinator,
userDiscoveryService: userDiscoveryMock, userDiscoveryService: userDiscoveryMock,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings))) mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
appSettings: ServiceLocator.shared.settings))
navigationStackCoordinator.setRootCoordinator(coordinator) navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator return navigationStackCoordinator
case .createRoom: case .createRoom:
@@ -63,23 +63,32 @@ class ShareExtensionViewController: UIViewController {
// MARK: - Private // MARK: - Private
private func prepareSharePayload() async -> ShareExtensionPayload? { private func prepareSharePayload() async -> ShareExtensionPayload? {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, guard let extensionContext,
let itemProvider = extensionItem.attachments?.first else { let extensionItem = extensionContext.inputItems.first as? NSExtensionItem,
let itemProviders = extensionItem.attachments else {
return nil return nil
} }
let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier let roomID = (extensionContext.intent as? INSendMessageIntent)?.conversationIdentifier
if let fileURL = await itemProvider.storeData(withinAppGroupContainer: true) { var mediaFiles = [ShareExtensionMediaFile]()
return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent)) for itemProvider in itemProviders {
} else if let url = await itemProvider.loadTransferable(type: URL.self) { if let fileURL = await itemProvider.storeData(withinAppGroupContainer: true) {
return .text(roomID: roomID, text: url.absoluteString) mediaFiles.append(.init(url: fileURL, suggestedName: fileURL.lastPathComponent))
} else if let string = await itemProvider.loadString() { } else if let url = await itemProvider.loadTransferable(type: URL.self) {
return .text(roomID: roomID, text: string) return .text(roomID: roomID, text: url.absoluteString)
} else { } else if let string = await itemProvider.loadString() {
MXLog.error("Failed loading NSItemProvider data: \(itemProvider)") return .text(roomID: roomID, text: string)
return nil } 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 { private func openMainApp(payload: ShareExtensionPayload) async {
+4 -4
View File
@@ -31,15 +31,15 @@
<key>NSExtensionActivationRule</key> <key>NSExtensionActivationRule</key>
<dict> <dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key> <key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer> <integer>10</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key> <key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer> <integer>10</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key> <key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer> <integer>10</integer>
<key>NSExtensionActivationSupportsText</key> <key>NSExtensionActivationSupportsText</key>
<true/> <true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key> <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer> <integer>10</integer>
</dict> </dict>
</dict> </dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
+4 -4
View File
@@ -53,11 +53,11 @@ targets:
NSExtensionAttributes: NSExtensionAttributes:
IntentsSupported: [INSendMessageIntent] IntentsSupported: [INSendMessageIntent]
NSExtensionActivationRule: NSExtensionActivationRule:
NSExtensionActivationSupportsFileWithMaxCount: 1 NSExtensionActivationSupportsFileWithMaxCount: 10
NSExtensionActivationSupportsImageWithMaxCount: 1 NSExtensionActivationSupportsImageWithMaxCount: 10
NSExtensionActivationSupportsMovieWithMaxCount: 1 NSExtensionActivationSupportsMovieWithMaxCount: 10
NSExtensionActivationSupportsText: true NSExtensionActivationSupportsText: true
NSExtensionActivationSupportsWebURLWithMaxCount: 1 NSExtensionActivationSupportsWebURLWithMaxCount: 10
entitlements: entitlements:
path: ShareExtension.entitlements path: ShareExtension.entitlements
@@ -173,7 +173,7 @@ class MediaUploadPreviewScreenViewModelTests: XCTestCase {
clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult
} }
viewModel = MediaUploadPreviewScreenViewModel(url: url, viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: [url],
title: "Some File", title: "Some File",
isRoomEncrypted: true, isRoomEncrypted: true,
shouldShowCaptionWarning: true, shouldShowCaptionWarning: true,
@@ -225,7 +225,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) 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)) try await process(route: .share(sharePayload))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
@@ -246,7 +246,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil)) try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) 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), try await process(route: .share(sharePayload),
expectedState: .shareExtensionRoomList(sharePayload: 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")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) 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), try await process(route: .share(sharePayload),
expectedState: .roomList(roomListSelectedRoomID: "2")) expectedState: .roomList(roomListSelectedRoomID: "2"))