From b5b28b99867ec3c71ce5f070682bf31c549979a9 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:25:30 +0200 Subject: [PATCH] Support sending gallery messages behind a feature flag (#5909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dds sending of multi-media "gallery" messages, gated behind the new galleryEnabled developer option (Developer Options → Room, off by default). Rendering of received galleries ships in a separate PR. Wires sendGallery through TimelineProxy/TimelineController onto the SDK's Timeline.sendGallery. MediaUploadPreviewScreen sends selected items as one gallery when the flag is on. Co-authored with the original author of #5601. NOTE: This PR allows to send gallery messages, but they won't be rendered without the gallery timeline item PR. --- .../Application/Settings/AppSettings.swift | 5 ++ .../RoomFlowCoordinator.swift | 1 + .../Mocks/Generated/GeneratedMocks.swift | 84 +++++++++++++++++++ .../Mocks/TimelineControllerMock.swift | 10 +++ .../MediaUploadPreviewScreenCoordinator.swift | 3 + .../MediaUploadPreviewScreenViewModel.swift | 57 +++++++++++-- .../View/MediaUploadPreviewScreen.swift | 27 +++--- .../DeveloperOptionsScreenModels.swift | 2 + .../View/DeveloperOptionsScreen.swift | 5 ++ .../TimelineController.swift | 8 ++ .../TimelineControllerProtocol.swift | 4 + .../Services/Timeline/TimelineProxy.swift | 21 +++++ .../Timeline/TimelineProxyProtocol.swift | 4 + ...diaUploadPreviewScreenViewModelTests.swift | 51 +++++++---- 14 files changed, 252 insertions(+), 30 deletions(-) diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index bdf323e4a..a7f53da41 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. + @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..8fc414104 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1120,6 +1120,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { caption: caption, title: title, shouldShowCaptionWarning: flowParameters.appSettings.shouldShowMediaCaptionWarning, + galleryEnabled: flowParameters.appSettings.galleryEnabled, mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), timelineController: timelineController, clientProxy: userSession.clientProxy, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 5df8a7c17..2f7c3674d 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 sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = 0 + var sendGalleryItemInfosCaptionInReplyToEventIDCallsCount: Int { + get { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount } } + set { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = newValue } } + } + var sendGalleryItemInfosCaptionInReplyToEventIDCalled: Bool { + return sendGalleryItemInfosCaptionInReplyToEventIDCallsCount > 0 + } + private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)? + var sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)? { + get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments } } + set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } } + } + private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] = [] + var sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] { + get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations } } + set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } } + } + + private let sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue: Result! + var sendGalleryItemInfosCaptionInReplyToEventIDReturnValue: Result! { + get { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue } } + set { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?) async -> Result)? + + @concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?) async -> Result { + sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount += 1 } + sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID) + sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID)) } + if let sendGalleryItemInfosCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionInReplyToEventIDClosure { + return await sendGalleryItemInfosCaptionInReplyToEventIDClosure(itemInfos, caption, inReplyToEventID) + } else { + return sendGalleryItemInfosCaptionInReplyToEventIDReturnValue + } + } //MARK: - createPoll private let createPollQuestionAnswersMaxSelectionsPollKindCallsCountLock = NSLock() @@ -13022,6 +13064,48 @@ nonisolated class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable return sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue } } + //MARK: - sendGallery + + private let sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = 0 + var sendGalleryItemInfosCaptionInReplyToEventIDCallsCount: Int { + get { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount } } + set { sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount = newValue } } + } + var sendGalleryItemInfosCaptionInReplyToEventIDCalled: Bool { + return sendGalleryItemInfosCaptionInReplyToEventIDCallsCount > 0 + } + private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)? + var sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments: (itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)? { + get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments } } + set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedArgumentsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedArguments = newValue } } + } + private let sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] = [] + var sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocations: [(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?)] { + get { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations } } + set { sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations = newValue } } + } + + private let sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock = NSLock() + private nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue: Result! + var sendGalleryItemInfosCaptionInReplyToEventIDReturnValue: Result! { + get { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue } } + set { sendGalleryItemInfosCaptionInReplyToEventIDReturnValueLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var sendGalleryItemInfosCaptionInReplyToEventIDClosure: (([GalleryItemInfo], String?, String?) async -> Result)? + + @concurrent func sendGallery(itemInfos: [GalleryItemInfo], caption: String?, inReplyToEventID: String?) async -> Result { + sendGalleryItemInfosCaptionInReplyToEventIDCallsCountLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingCallsCount += 1 } + sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments = (itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID) + sendGalleryItemInfosCaptionInReplyToEventIDReceivedInvocationsLock.withLock { sendGalleryItemInfosCaptionInReplyToEventIDUnderlyingReceivedInvocations.append((itemInfos: itemInfos, caption: caption, inReplyToEventID: inReplyToEventID)) } + if let sendGalleryItemInfosCaptionInReplyToEventIDClosure = sendGalleryItemInfosCaptionInReplyToEventIDClosure { + return await sendGalleryItemInfosCaptionInReplyToEventIDClosure(itemInfos, caption, inReplyToEventID) + } else { + return sendGalleryItemInfosCaptionInReplyToEventIDReturnValue + } + } //MARK: - sendReadReceipt private let sendReadReceiptForTypeCallsCountLock = NSLock() diff --git a/ElementX/Sources/Mocks/TimelineControllerMock.swift b/ElementX/Sources/Mocks/TimelineControllerMock.swift index 0a9660b19..5cc90a60c 100644 --- a/ElementX/Sources/Mocks/TimelineControllerMock.swift +++ b/ElementX/Sources/Mocks/TimelineControllerMock.swift @@ -162,6 +162,16 @@ struct TimelineControllerMockConfiguration { return .success(()) } + sendGalleryItemInfosCaptionInReplyToEventIDClosure = { [weak self, timelineProxy] itemInfos, caption, inReplyToEventID in + self?.callbacks.send(.messageSentOrEdited) + if let timelineProxy { + return await timelineProxy.sendGallery(itemInfos: itemInfos, + caption: caption, + 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..8007dad77 100644 --- a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenCoordinator.swift @@ -14,6 +14,8 @@ struct MediaUploadPreviewScreenCoordinatorParameters { let caption: NSAttributedString? let title: String? let shouldShowCaptionWarning: Bool + /// When `false`, multiple attachments are sent as individual messages. + let galleryEnabled: Bool let mediaUploadingPreprocessor: MediaUploadingPreprocessor let timelineController: TimelineControllerProtocol let clientProxy: ClientProxyProtocol @@ -38,6 +40,7 @@ final class MediaUploadPreviewScreenCoordinator: CoordinatorProtocol { caption: parameters.caption, title: parameters.title, shouldShowCaptionWarning: parameters.shouldShowCaptionWarning, + galleryEnabled: parameters.galleryEnabled, mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor, timelineController: parameters.timelineController, clientProxy: parameters.clientProxy, diff --git a/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift b/ElementX/Sources/Screens/MediaUploadPreviewScreen/MediaUploadPreviewScreenViewModel.swift index 6205d3ef8..7fbd12712 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() @@ -33,6 +34,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, caption: NSAttributedString?, title: String?, shouldShowCaptionWarning: Bool, + galleryEnabled: Bool, mediaUploadingPreprocessor: MediaUploadingPreprocessor, timelineController: TimelineControllerProtocol, clientProxy: ClientProxyProtocol, @@ -42,6 +44,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, 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,39 @@ 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): + .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): + .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): + .audio(audioInfo: audioInfo, + source: .file(filename: audioURL.path(percentEncoded: false)), + caption: nil, + formattedCaption: nil) + case let .file(fileURL, fileInfo): + .file(fileInfo: fileInfo, + source: .file(filename: fileURL.path(percentEncoded: false)), + caption: nil, + formattedCaption: nil) + } + } + + return await timelineController.sendGallery(itemInfos: itemInfos, + caption: caption, + 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..76b8ac25f 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,19 @@ 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(.compound.bgCanvasDefault.opacity(0.85), in: .capsule) + .padding(.top, 12) + } + } + @ViewBuilder private var mainContent: some View { if ProcessInfo.processInfo.isiOSAppOnMac { @@ -393,6 +397,7 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview { caption: nil, title: "App Icon.png", shouldShowCaptionWarning: true, + galleryEnabled: true, mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), timelineController: TimelineControllerMock(.init()), clientProxy: ClientProxyMock(.init()), 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..eed3c0ec6 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -402,6 +402,14 @@ class TimelineController: TimelineControllerProtocol { } } + func sendGallery(itemInfos: [GalleryItemInfo], + caption: String?, + inReplyToEventID: String?) async -> Result { + await activeTimeline.sendGallery(itemInfos: itemInfos, + caption: caption, + 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..419bd31d2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineControllerProtocol.swift @@ -134,6 +134,10 @@ protocol TimelineControllerProtocol: Sendable { waveform: [Float], requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result + func sendGallery(itemInfos: [GalleryItemInfo], + caption: 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..477c560e6 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -374,6 +374,27 @@ final class TimelineProxy: TimelineProxyProtocol { return .success(()) } + func sendGallery(itemInfos: [GalleryItemInfo], + caption: String?, + inReplyToEventID: String?) async -> Result { + MXLog.info("Sending gallery with \(itemInfos.count) items") + + do { + let handle = try timeline.sendGallery(params: .init(caption: caption, + formattedCaption: nil, // Rust will build this from the caption's markdown. + mentions: nil, + inReplyTo: inReplyToEventID), + 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..d321c0d67 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -110,6 +110,10 @@ protocol TimelineProxyProtocol: Sendable { waveform: [Float], requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result + func sendGallery(itemInfos: [GalleryItemInfo], + caption: 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..a087583dc 100644 --- a/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift +++ b/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift @@ -177,7 +177,7 @@ final class MediaUploadPreviewScreenViewModelTests { } @Test - func multipleFiles() async throws { + func gallery() async throws { // Given an upload screen with multiple media files. setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil) #expect(!context.viewState.shouldDisableInteraction) @@ -190,15 +190,17 @@ 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.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1) + #expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments?.itemInfos.count == 3) + #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0) + #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 0) #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.") } @Test - func multipleFilesWithProcessingFailure() async throws { + func galleryWithProcessingFailure() async throws { // Given an upload screen for a non-existent file. setUpViewModel(urls: [imageURL, fileURL, badImageURL], expectedCaption: nil) #expect(!context.viewState.shouldDisableInteraction) @@ -217,9 +219,9 @@ 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) + func galleryWithSendFailure() async throws { + // 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,27 @@ 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.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1) + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.") + } + + @Test + func galleryDisabledSendsIndividualMessages() async throws { + // Given an upload screen with multiple media files and gallery sending disabled. + setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil, galleryEnabled: false) + #expect(!context.viewState.shouldDisableInteraction) + + // When attempting to send the files. + let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss } + context.send(viewAction: .send) + + // Then each file should be sent as an individual message rather than a single gallery. + try await deferredDismiss.fulfill() + #expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 0) #expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2) - #expect(userIndicatorController.submitIndicatorDelayCallsCount == 3, "Error indicators for each failure should be shown.") + #expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1) } // MARK: - Helpers @@ -268,7 +286,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 +296,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.sendGalleryItemInfosCaptionInReplyToEventIDClosure = { [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 { @@ -293,6 +315,7 @@ final class MediaUploadPreviewScreenViewModelTests { caption: nil, title: "Some File", shouldShowCaptionWarning: true, + galleryEnabled: galleryEnabled, mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: appSettings), timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), clientProxy: clientProxy,