From 6a2ed331fadc244e6bd60616785e42d985617981 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Tue, 21 Jul 2026 11:34:44 +0200 Subject: [PATCH] Support sending gallery messages behind a feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to send a gallery message (multiple attachments in one message) via the media upload preview, wired through the timeline controller/proxy to the SDK's sendGallery. Gated by a `galleryEnabled` developer flag (default off), matching Element X Android — with the flag off, attachments are sent as individual messages as before. Extends the community PR #5601, rebased onto develop. Tracking: element-hq/element-meta#3201 Co-authored-by: svetoslav.mitov --- ElementX.xcodeproj/project.pbxproj | 16 +- .../Application/Settings/AppSettings.swift | 5 + .../RoomFlowCoordinator.swift | 3 +- .../Mocks/Generated/GeneratedMocks.swift | 84 +++++++++ .../Mocks/TimelineControllerMock.swift | 11 ++ .../MediaUploadPreviewScreenCoordinator.swift | 6 +- .../MediaUploadPreviewScreenViewModel.swift | 60 ++++++- .../View/MediaUploadPreviewScreen.swift | 160 ++++++++++++++++-- .../DeveloperOptionsScreenModels.swift | 2 + .../View/DeveloperOptionsScreen.swift | 5 + .../TimelineController.swift | 10 ++ .../TimelineControllerProtocol.swift | 5 + .../Services/Timeline/TimelineProxy.swift | 24 +++ .../Timeline/TimelineProxyProtocol.swift | 5 + ...diaUploadPreviewScreenViewModelTests.swift | 32 ++-- 15 files changed, 387 insertions(+), 41 deletions(-) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9479ff79c..e258f17e6 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -7610,16 +7610,16 @@ projectDirPath = ""; projectRoot = ""; targets = ( - C0FAEB81CFD9776CD78CE489 /* ElementX */, - FEB53A5BC378C913769656D8 /* NSE */, - 19F0C845D67E9BEA4BE7133E /* ShareExtension */, - 676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */, - 32C23C8D224D46EFE62AFAD0 /* UnitTests */, - 7A17BE29BAC81ADBAC6349D9 /* PreviewTests */, - 0E28CD62691FDBC63147D5E3 /* UITests */, - D3DB351B7FBE0F49649171FC /* IntegrationTests */, C0C687DE1D270F9895FEE186 /* AccessibilityTests */, + C0FAEB81CFD9776CD78CE489 /* ElementX */, + D3DB351B7FBE0F49649171FC /* IntegrationTests */, + 676A87694F0F247B2AF1A142 /* MatrixRustSDKMocks */, + FEB53A5BC378C913769656D8 /* NSE */, F8E276FD6DC43EADB85241BC /* Periphery */, + 7A17BE29BAC81ADBAC6349D9 /* PreviewTests */, + 19F0C845D67E9BEA4BE7133E /* ShareExtension */, + 0E28CD62691FDBC63147D5E3 /* UITests */, + 32C23C8D224D46EFE62AFAD0 /* UnitTests */, ); }; /* End PBXProject section */ diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index bdf323e4a..e904faae4 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -416,6 +416,11 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var linkPreviewsEnabled: Bool + /// Enables *sending* gallery messages (multiple media in a single message). Received galleries + /// are always rendered regardless of this flag, matching Element X Android. + @UserPreference(defaultValue: false) + var galleryEnabled: Bool + @UserPreference(defaultValue: false) var jumpToReadMarkerEnabled: Bool diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index ab90845b4..7ae74513b 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1123,7 +1123,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), timelineController: timelineController, clientProxy: userSession.clientProxy, - userIndicatorController: flowParameters.userIndicatorController) + userIndicatorController: flowParameters.userIndicatorController, + galleryEnabled: flowParameters.appSettings.galleryEnabled) let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 5df8a7c17..5337b954c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -12267,6 +12267,48 @@ nonisolated class TimelineControllerMock: TimelineControllerProtocol, @unchecked return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue } } + //MARK: - sendGallery + + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = 0 + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount: Int { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = newValue } } + } + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCalled: Bool { + return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount > 0 + } + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)? + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)? { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } } + } + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] = [] + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } } + } + + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue: Result! + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue: Result! { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?, String?) async -> Result)? + + @concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?) async -> Result { + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount += 1 } + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID) + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID)) } + if let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure { + return await sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure(itemInfos, caption, formattedCaption, inReplyToEventID) + } else { + return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue + } + } //MARK: - createPoll private let createPollQuestionAnswersMaxSelectionsPollKindCallsCountLock = NSLock() @@ -13022,6 +13064,48 @@ nonisolated class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue } } + //MARK: - sendGallery + + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = 0 + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount: Int { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount = newValue } } + } + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCalled: Bool { + return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount > 0 + } + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)? + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)? { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } } + } + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] = [] + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?)] { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } } + } + + private let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue: Result! + var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue: Result! { + get { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue } } + set { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?, String?) async -> Result)? + + @concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, formattedCaption: String?, inReplyToEventID: String?) async -> Result { + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingCallsCount += 1 } + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID) + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, formattedCaption: formattedCaption, inReplyToEventID: inReplyToEventID)) } + if let sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure { + return await sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure(itemInfos, caption, formattedCaption, inReplyToEventID) + } else { + return sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReturnValue + } + } //MARK: - sendReadReceipt private let sendReadReceiptForTypeCallsCountLock = NSLock() diff --git a/ElementX/Sources/Mocks/TimelineControllerMock.swift b/ElementX/Sources/Mocks/TimelineControllerMock.swift index 0a9660b19..976dfde7e 100644 --- a/ElementX/Sources/Mocks/TimelineControllerMock.swift +++ b/ElementX/Sources/Mocks/TimelineControllerMock.swift @@ -162,6 +162,17 @@ struct TimelineControllerMockConfiguration { return .success(()) } + sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = { [weak self, timelineProxy] itemInfos, caption, formattedCaption, inReplyToEventID in + self?.callbacks.send(.messageSentOrEdited) + if let timelineProxy { + return await timelineProxy.sendGallery(itemInfos: itemInfos, + caption: caption, + formattedCaption: formattedCaption, + inReplyToEventID: inReplyToEventID).mapError(TimelineControllerError.timelineProxyError) + } + return .success(()) + } + sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { [weak self, timelineProxy] url, audioInfo, waveform, requestHandle in self?.callbacks.send(.messageSentOrEdited) if let timelineProxy { diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift index c72a03334..888d8a686 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift @@ -18,6 +18,9 @@ struct MediaUploadPreviewScreenCoordinatorParameters { let timelineController: TimelineControllerProtocol let clientProxy: ClientProxyProtocol let userIndicatorController: UserIndicatorControllerProtocol + /// Whether sending as a gallery is enabled. When `false`, multiple attachments are sent as + /// individual messages (the previous behaviour). + let galleryEnabled: Bool } enum MediaUploadPreviewScreenCoordinatorAction { @@ -41,7 +44,8 @@ final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol { mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor, timelineController: parameters.timelineController, clientProxy: parameters.clientProxy, - userIndicatorController: parameters.userIndicatorController) + userIndicatorController: parameters.userIndicatorController, + galleryEnabled: parameters.galleryEnabled) } func start() { diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift index 6205d3ef8..a2489dacb 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift @@ -22,6 +22,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, private var processingTask: Task, Never> private var requestHandle: SendAttachmentJoinHandleProtocol? private let clientProxy: ClientProxyProtocol + private let galleryEnabled: Bool private var actionsSubject: PassthroughSubject = .init() @@ -36,12 +37,14 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, mediaUploadingPreprocessor: MediaUploadingPreprocessor, timelineController: TimelineControllerProtocol, clientProxy: ClientProxyProtocol, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + galleryEnabled: Bool) { self.mediaURLs = mediaURLs self.mediaUploadingPreprocessor = mediaUploadingPreprocessor self.timelineController = timelineController self.clientProxy = clientProxy self.userIndicatorController = userIndicatorController + self.galleryEnabled = galleryEnabled // Start processing the media whilst the user is reviewing it/adding a caption. processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy) @@ -54,7 +57,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, override func process(viewAction: MediaUploadPreviewScreenViewAction) { // Get the current caption before all the processing starts. - var caption = state.bindings.caption.nonBlankString + let caption = state.bindings.caption.nonBlankString switch viewAction { case .send: @@ -65,14 +68,25 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, switch await processingTask.value { case .success(let mediaInfos): - for mediaInfo in mediaInfos { - switch await sendAttachment(mediaInfo: mediaInfo, caption: caption) { + if galleryEnabled, mediaInfos.count > 1 { + switch await sendGallery(mediaInfos: mediaInfos, caption: caption) { case .success: - caption = nil // Set the caption only on the first uploaded file. + break case .failure(let error): - MXLog.error("Failed sending media with error: \(error)") + MXLog.error("Failed sending gallery with error: \(error)") showError(label: L10n.screenMediaUploadPreviewErrorFailedSending) } + } else { + var perItemCaption = caption + for mediaInfo in mediaInfos { + switch await sendAttachment(mediaInfo: mediaInfo, caption: perItemCaption) { + case .success: + perItemCaption = nil // Set the caption only on the first uploaded file. + case .failure(let error): + MXLog.error("Failed sending media with error: \(error)") + showError(label: L10n.screenMediaUploadPreviewErrorFailedSending) + } + } } actionsSubject.send(.dismiss) @@ -107,6 +121,40 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, } } + private func sendGallery(mediaInfos: [MediaInfo], caption: String?) async -> Result { + let itemInfos: [GalleryItemInfo] = mediaInfos.map { mediaInfo in + switch mediaInfo { + case let .image(imageURL, thumbnailURL, imageInfo): + return .image(imageInfo: imageInfo, + source: .file(filename: imageURL.path(percentEncoded: false)), + caption: nil, + formattedCaption: nil, + thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false))) + case let .video(videoURL, thumbnailURL, videoInfo): + return .video(videoInfo: videoInfo, + source: .file(filename: videoURL.path(percentEncoded: false)), + caption: nil, + formattedCaption: nil, + thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false))) + case let .audio(audioURL, audioInfo): + return .audio(audioInfo: audioInfo, + source: .file(filename: audioURL.path(percentEncoded: false)), + caption: nil, + formattedCaption: nil) + case let .file(fileURL, fileInfo): + return .file(fileInfo: fileInfo, + source: .file(filename: fileURL.path(percentEncoded: false)), + caption: nil, + formattedCaption: nil) + } + } + + return await timelineController.sendGallery(itemInfos: itemInfos, + caption: caption, + formattedCaption: nil, + inReplyToEventID: nil) + } + func stopProcessing() { processingTask.cancel() } diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift index 130dcc9b2..62d1dea0c 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift @@ -6,6 +6,7 @@ // Please see LICENSE files in the repository root for full details. // +import AVFoundation import Combine import Compound import GameController @@ -35,17 +36,7 @@ struct MediaUploadPreviewScreen: View { mainContent .id(context.viewState.mediaURLs) .ignoresSafeArea(edges: [.horizontal]) - .safeAreaInset(edge: .top) { - if context.viewState.mediaURLs.count > 1 { - Text(L10n.screenMediaUploadPreviewItemCount(currentIndex + 1, context.viewState.mediaURLs.count)) - .font(.compound.bodyMD) - .foregroundColor(.compound.textPrimary) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(.compound.bgBadgeDefault) - .clipShape(.capsule) - } - } + .overlay(alignment: .top) { galleryBadge } .safeAreaInset(edge: .bottom, spacing: 0) { composer .padding(.horizontal, 12) @@ -74,6 +65,20 @@ struct MediaUploadPreviewScreen: View { } } + @ViewBuilder + private var galleryBadge: some View { + if context.viewState.mediaURLs.count > 1 { + Text(L10n.screenMediaUploadPreviewItemCount(currentIndex + 1, context.viewState.mediaURLs.count)) + .font(.compound.bodySMSemibold) + .foregroundStyle(.compound.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.compound.bgCanvasDefault.opacity(0.85), + in: Capsule()) + .padding(.top, 12) + } + } + @ViewBuilder private var mainContent: some View { if ProcessInfo.processInfo.isiOSAppOnMac { @@ -81,6 +86,9 @@ struct MediaUploadPreviewScreen: View { .font(.compound.headingMD) .foregroundColor(.compound.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if context.viewState.mediaURLs.count > 1 { + UploadMediaPeekCarousel(mediaURLs: context.viewState.mediaURLs, + currentIndex: $currentIndex) } else { PreviewView(mediaURLs: context.viewState.mediaURLs, title: context.viewState.title, @@ -214,6 +222,133 @@ struct MediaUploadPreviewScreen: View { } } +private struct UploadMediaPeekCarousel: View { + let mediaURLs: [URL] + @Binding var currentIndex: Int + + @State private var scrolledID: Int? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 8) { + ForEach(Array(mediaURLs.enumerated()), id: \.offset) { index, url in + UploadMediaThumbnail(url: url) + .containerRelativeFrame(.horizontal) + .id(index) + } + } + .scrollTargetLayout() + } + .contentMargins(.horizontal, 24, for: .scrollContent) + .scrollTargetBehavior(.viewAligned) + .scrollPosition(id: $scrolledID) + .onAppear { + if scrolledID == nil { + scrolledID = currentIndex + } + } + .onChange(of: scrolledID) { _, newValue in + if let newValue, newValue != currentIndex { + currentIndex = newValue + } + } + } +} + +private struct UploadMediaThumbnail: View { + let url: URL + + private var contentType: UTType? { + UTType(filenameExtension: url.pathExtension) + } + + private var isImageOrVideo: Bool { + guard let contentType else { return false } + return contentType.conforms(to: .image) || contentType.conforms(to: .movie) || contentType.conforms(to: .audiovisualContent) + } + + var body: some View { + Group { + if isImageOrVideo { + UploadMediaImageThumbnail(url: url) + } else { + UploadMediaFilePreview(url: url, title: url.lastPathComponent) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +private struct UploadMediaImageThumbnail: View { + let url: URL + @State private var image: UIImage? + + var body: some View { + ZStack { + Color.black + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else { + ProgressView().tint(.white) + } + } + .task(id: url) { await load() } + } + + private func load() async { + if let image = UIImage(contentsOfFile: url.path(percentEncoded: false)) { + self.image = image + return + } + + // Fall back to a video frame thumbnail. + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + do { + let cgImage = try await generator.image(at: .zero).image + image = UIImage(cgImage: cgImage) + } catch { + image = nil + } + } +} + +private struct UploadMediaFilePreview: UIViewControllerRepresentable { + let url: URL + let title: String + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(url: url, title: title) + } + + final class Coordinator: NSObject, QLPreviewControllerDataSource { + private let item: PreviewItem + + init(url: URL, title: String) { + item = PreviewItem(previewItemURL: url, previewItemTitle: title) + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + item + } + } +} + private struct PreviewView: UIViewControllerRepresentable { let mediaURLs: [URL] let title: String? @@ -396,7 +531,8 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview { mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), timelineController: TimelineControllerMock(.init()), clientProxy: ClientProxyMock(.init()), - userIndicatorController: UserIndicatorControllerMock()) + userIndicatorController: UserIndicatorControllerMock(), + galleryEnabled: true) static var previews: some View { ElementNavigationStack { MediaUploadPreviewScreen(context: viewModel.context) diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index c5ddd12a6..0993cd990 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -67,6 +67,8 @@ protocol DeveloperOptionsProtocol: AnyObject { var linkPreviewsEnabled: Bool { get set } + var galleryEnabled: Bool { get set } + var jumpToReadMarkerEnabled: Bool { get set } var linkNewDeviceEnabled: Bool { get set } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 121c1829b..9627d68bb 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -97,6 +97,11 @@ struct DeveloperOptionsScreen: View { .foregroundStyle(.compound.textCriticalPrimary) } + Toggle(isOn: $context.galleryEnabled) { + Text("Gallery messages") + Text("Allows sending multiple media in a single message. Received galleries always render regardless of this setting.") + } + Toggle(isOn: $context.jumpToReadMarkerEnabled) { Text("Jump to unread") Text("Adds a button to jump to the read marker, plus a presence dot on the scroll-to-bottom button when new messages arrive while scrolled away.") diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index 7e1baceda..4f6d5bd95 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -402,6 +402,16 @@ class TimelineController: TimelineControllerProtocol { } } + func sendGallery(itemInfos: [GalleryItemInfo], + caption: String?, + formattedCaption: String?, + inReplyToEventID: String?) async -> Result { + await activeTimeline.sendGallery(itemInfos: itemInfos, + caption: caption, + formattedCaption: formattedCaption, + inReplyToEventID: inReplyToEventID).mapError(TimelineControllerError.timelineProxyError) + } + // MARK: - Polls func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift index 139a13dcc..74025f1d4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift @@ -134,6 +134,11 @@ protocol TimelineControllerProtocol: Sendable { waveform: [Float], requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result + func sendGallery(itemInfos: [GalleryItemInfo], + caption: String?, + formattedCaption: String?, + inReplyToEventID: String?) async -> Result + // MARK: - Poll func createPoll(question: String, answers: [String], maxSelections: Int, pollKind: Poll.Kind) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index 31785952f..a27875380 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -374,6 +374,30 @@ final class TimelineProxy: TimelineProxyProtocol { return .success(()) } + func sendGallery(itemInfos: [GalleryItemInfo], + caption: String?, + formattedCaption: String?, + inReplyToEventID: String?) async -> Result { + MXLog.info("Sending gallery with \(itemInfos.count) items") + + do { + let formatted = formattedCaption.map { FormattedBody(format: .html, body: $0) } + let params = GalleryUploadParameters(caption: caption, + formattedCaption: formatted, + mentions: nil, + inReplyTo: inReplyToEventID) + + let handle = try timeline.sendGallery(params: params, itemInfos: itemInfos) + try await handle.join() + MXLog.info("Finished sending gallery") + } catch { + MXLog.error("Failed sending gallery with error: \(error)") + return .failure(.sdkError(error)) + } + + return .success(()) + } + func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [Float], diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index 3cef9a6ac..55991d731 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -110,6 +110,11 @@ protocol TimelineProxyProtocol: Sendable { waveform: [Float], requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result + func sendGallery(itemInfos: [GalleryItemInfo], + caption: String?, + formattedCaption: String?, + inReplyToEventID: String?) async -> Result + func sendReadReceipt(for eventID: String, type: ReceiptType) async -> Result func markAsRead(receiptType: ReceiptType) async -> Result diff --git a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift index fe525e686..4169fa1df 100644 --- a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift +++ b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift @@ -190,10 +190,12 @@ final class MediaUploadPreviewScreenViewModelTests { #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator - // Then the screen should be dismissed once all of the files have been sent. + // Then the screen should be dismissed once the gallery has been sent in a single request. try await deferredDismiss.fulfill() - #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1) - #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) + #expect(timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount == 1) + #expect(timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDReceivedArguments?.itemInfos.count == 3) + #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0) + #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 0) #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.") } @@ -218,8 +220,8 @@ final class MediaUploadPreviewScreenViewModelTests { @Test func multipleFilesWithSendFailure() async throws { - // Given an upload screen with multiple media files where one of the files will fail to send. - setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateImageSendFailures: true) + // Given an upload screen with multiple media files where the gallery send fails. + setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateGallerySendFailure: true) #expect(!context.viewState.shouldDisableInteraction) #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) @@ -230,11 +232,10 @@ final class MediaUploadPreviewScreenViewModelTests { #expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.") #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator - // Then the screen should be dismissed so the user can see which files made it into the timeline. + // Then the screen should still be dismissed and an error indicator surfaced. try await deferredDismiss.fulfill() - #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 2) - #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) - #expect(userIndicatorController.submitIndicatorDelayCallsCount == 3, "Error indicators for each failure should be shown.") + #expect(timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDCallsCount == 1) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.") } // MARK: - Helpers @@ -268,7 +269,8 @@ final class MediaUploadPreviewScreenViewModelTests { private func setUpViewModel(urls: [URL], expectedCaption: String?, maxUploadSizeResult: Result? = nil, - simulateImageSendFailures: Bool = false) { + simulateGallerySendFailure: Bool = false, + galleryEnabled: Bool = true) { timelineProxy = TimelineProxyMock(.init()) timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) @@ -277,12 +279,15 @@ final class MediaUploadPreviewScreenViewModelTests { self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) } timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in - guard !simulateImageSendFailures else { return .failure(.sdkError(TestError.unknown)) } - return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) + self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) } timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) } + timelineProxy.sendGalleryItemInfosCaptionFormattedCaptionInReplyToEventIDClosure = { [weak self] _, caption, _, _ in + guard !simulateGallerySendFailure else { return .failure(.sdkError(TestError.unknown)) } + return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown)) + } clientProxy = ClientProxyMock(.init()) if let maxUploadSizeResult { @@ -296,7 +301,8 @@ final class MediaUploadPreviewScreenViewModelTests { mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings), timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), clientProxy: clientProxy, - userIndicatorController: userIndicatorController) + userIndicatorController: userIndicatorController, + galleryEnabled: galleryEnabled) } private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result {