Support sending gallery messages behind a feature flag (#5909)

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.
This commit is contained in:
Mauro
2026-07-21 17:25:30 +02:00
committed by GitHub
parent 711cb0a05c
commit b5b28b9986
14 changed files with 252 additions and 30 deletions
@@ -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,
@@ -22,6 +22,7 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
private var processingTask: Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never>
private var requestHandle: SendAttachmentJoinHandleProtocol?
private let clientProxy: ClientProxyProtocol
private let galleryEnabled: Bool
private var actionsSubject: PassthroughSubject<MediaUploadPreviewScreenViewModelAction, Never> = .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<Void, TimelineControllerError> {
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()
}
@@ -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()),