mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
+148
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user