Files
letro-ios/ElementX/Sources/Screens/MediaUploadPreviewScreen/View/MediaUploadPreviewScreen.swift
Stefan Ceriu 0915cb81a8 Multi file uploads (#4358)
* Allow MediaPickerScreen users to select the media selection mode (single or multiple)

* Fix cancellation

* Add support for multiple media URLs on the MediaUploadPreviewScreen.

* Support processing more URLs on the `MediaUploadingPreprocessor` and sending more on the `MediaUploadPreviewScreen`

* Add feature flag for `multipleAttachmentUploadEnabled`

* Add a label showing the current preview item index in the MediaUploadPreviewScreen

* Add support for dragging and dropping or pasting multiple items at the same time.

* Support sharing more than one file through the share extension.

* Limit the number of items that can be shared in one go to 5.

* Fix unit tests

* Fix incorrect fatal error when dealing with single selection media pickers.

* Document the `multipleAttachmentUploadEnabled` usage in the context of the MediaPicker.

* Use a task group for processing selected media in the photo library picker.

* Use a task group for processing multiple selected media in the MediaUploadingPreprocessor

* Switch the maximum number of items that can be shared to 10.

* Allow multiple items to be pasted at the same time.
2025-07-30 15:44:05 +03:00

282 lines
10 KiB
Swift

//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import GameController
import QuickLook
import SwiftUI
struct MediaUploadPreviewScreen: View {
@Environment(\.colorScheme) private var colorScheme
@Bindable var context: MediaUploadPreviewScreenViewModel.Context
@State private var captionWarningFrame: CGRect = .zero
@State private var currentIndex = 0
@FocusState private var isComposerFocussed
private var title: String { ProcessInfo.processInfo.isiOSAppOnMac ? context.viewState.title ?? "" : "" }
private var colorSchemeOverride: ColorScheme { ProcessInfo.processInfo.isiOSAppOnMac ? colorScheme : .dark }
var body: some View {
mainContent
.id(context.viewState.mediaURLs)
.ignoresSafeArea(edges: [.horizontal])
.safeAreaInset(edge: .top) {
if context.viewState.mediaURLs.count > 1 {
Text("\(currentIndex + 1) / \(context.viewState.mediaURLs.count)")
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
.padding(.horizontal, 12)
.padding(.vertical, 16)
.background() // Don't use compound so we match the QLPreviewController.
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.disabled(context.viewState.shouldDisableInteraction)
.interactiveDismissDisabled()
.presentationBackground(.background) // Fix a bug introduced by the caption warning.
.preferredColorScheme(colorSchemeOverride)
.onAppear(perform: focusComposerIfHardwareKeyboardConnected)
.alert(item: $context.alertInfo)
}
@ViewBuilder
private var mainContent: some View {
if ProcessInfo.processInfo.isiOSAppOnMac {
Text(title)
.font(.compound.headingMD)
.foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
PreviewView(mediaURLs: context.viewState.mediaURLs,
title: context.viewState.title,
currentIndex: $currentIndex)
}
}
private var composer: some View {
HStack(spacing: 12) {
HStack(spacing: 6) {
MessageComposerTextField(placeholder: L10n.richTextEditorComposerCaptionPlaceholder,
text: $context.caption,
presendCallback: $context.presendCallback,
selectedRange: $context.selectedRange,
maxHeight: ComposerConstant.maxHeight,
keyHandler: handleKeyPress) { _ in }
.focused($isComposerFocussed)
if context.viewState.shouldShowCaptionWarning {
captionWarningButton
}
}
.messageComposerStyle()
SendButton {
context.send(viewAction: .send)
}
}
}
private var captionWarningButton: some View {
Button {
context.isPresentingMediaCaptionWarning = true
} label: {
CompoundIcon(\.infoSolid, size: .xSmall, relativeTo: .compound.bodyLG)
}
.tint(.compound.iconCriticalPrimary)
.popover(isPresented: $context.isPresentingMediaCaptionWarning, arrowEdge: .bottom) {
captionWarningContent
.presentationDetents([.height(captionWarningFrame.height)])
.presentationDragIndicator(.visible)
.padding(.top, 19) // For the drag indicator
.presentationBackground(.compound.bgCanvasDefault)
.preferredColorScheme(colorSchemeOverride)
}
}
var captionWarningContent: some View {
VStack(spacing: 0) {
VStack(spacing: 16) {
BigIcon(icon: \.infoSolid, style: .alertSolid)
Text(L10n.screenMediaUploadPreviewCaptionWarning)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(24)
.padding(.bottom, 8)
Button(L10n.actionOk) {
context.isPresentingMediaCaptionWarning = false
}
.buttonStyle(.compound(.secondary))
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.readFrame($captionWarningFrame)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .cancel) } label: {
Text(L10n.actionCancel)
}
// Fix a bug with the preferredColorScheme on iOS 18 where the button doesn't
// follow the dark colour scheme on devices running with dark mode disabled.
.tint(.compound.textActionPrimary)
}
}
private func handleKeyPress(_ key: UIKeyboardHIDUsage) {
switch key {
case .keyboardReturnOrEnter:
context.send(viewAction: .send)
case .keyboardEscape:
context.send(viewAction: .cancel)
default:
break
}
}
private func focusComposerIfHardwareKeyboardConnected() {
// The simulator always detects the hardware keyboard as connected
#if !targetEnvironment(simulator)
if GCKeyboard.coalesced != nil {
MXLog.info("Hardware keyboard is connected")
isComposerFocussed = true
}
#endif
}
}
private struct PreviewView: UIViewControllerRepresentable {
let mediaURLs: [URL]
let title: String?
@Binding var currentIndex: Int
func makeUIViewController(context: Context) -> UIViewController {
let previewController = PreviewViewController(currentIndex: $currentIndex)
previewController.dataSource = context.coordinator
previewController.delegate = context.coordinator
if ProcessInfo.processInfo.isiOSAppOnMac {
return previewController
} else {
return UINavigationController(rootViewController: previewController)
}
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(view: self)
}
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
let view: PreviewView
init(view: PreviewView) {
self.view = view
}
// MARK: - QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
view.mediaURLs.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
PreviewItem(previewItemURL: view.mediaURLs[index], previewItemTitle: view.title)
}
// MARK: - QLPreviewControllerDelegate
func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {
.disabled
}
}
}
private class PreviewItem: NSObject, QLPreviewItem {
var previewItemURL: URL?
var previewItemTitle: String?
init(previewItemURL: URL?, previewItemTitle: String?) {
self.previewItemURL = previewItemURL
self.previewItemTitle = previewItemTitle
}
}
private class PreviewViewController: QLPreviewController {
private var cancellables: Set<AnyCancellable> = []
init(currentIndex: Binding<Int>) {
super.init(nibName: nil, bundle: nil)
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { index in
DispatchQueue.main.async {
if index != Int.max { // Because reasons
currentIndex.wrappedValue = index
}
}
}
.store(in: &cancellables)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Remove top file details bar
navigationController?.navigationBar.isHidden = true
// Hide toolbar share button
toolbarItems?.first?.isHidden = true
}
}
// MARK: - Previews
struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
static let snapshotURL = URL.picturesDirectory
static let testURL = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png")
static let viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: [snapshotURL],
title: "App Icon.png",
isRoomEncrypted: true,
shouldShowCaptionWarning: true,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
timelineController: MockTimelineController(),
clientProxy: ClientProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock.default)
static var previews: some View {
NavigationStack {
MediaUploadPreviewScreen(context: viewModel.context)
}
MediaUploadPreviewScreen(context: viewModel.context)
.captionWarningContent
.previewDisplayName("Caption warning")
}
}