Support sending gallery messages behind a feature flag

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 <svetoslav.mitov@sportinno.com>
This commit is contained in:
Mauro Romito
2026-07-21 11:34:44 +02:00
parent 762e5ad0ff
commit 6a2ed331fa
15 changed files with 387 additions and 41 deletions
@@ -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)