Files
element-x-ios/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposer.swift
T
Stefan Ceriu df335bd2b1 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

360 lines
14 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 Compound
import SwiftUI
import WysiwygComposer
typealias GenericKeyHandler = (_ key: UIKeyboardHIDUsage) -> Void
typealias PasteHandler = ([NSItemProvider]) -> Void
struct MessageComposer: View {
@Binding var plainComposerText: NSAttributedString
@Binding var presendCallback: (() -> Void)?
@Binding var selectedRange: NSRange
let composerView: WysiwygComposerView
let mode: ComposerMode
let placeholder: String
let composerFormattingEnabled: Bool
let showResizeGrabber: Bool
@Binding var isExpanded: Bool
let sendAction: () -> Void
let editAction: () -> Void
let pasteAction: PasteHandler
let cancellationAction: () -> Void
let onAppearAction: () -> Void
@State private var composerTranslation: CGFloat = 0
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
var body: some View {
VStack(spacing: 0) {
if showResizeGrabber {
resizeGrabber
}
composerTextField
.messageComposerStyle(header: header)
// Explicitly disable all animations to fix weirdness with the header immediately
// appearing whilst the text field and keyboard are still animating up to it.
.animation(.noAnimation, value: mode)
}
.gesture(showResizeGrabber ? dragGesture : nil)
}
// MARK: - Private
@State private var composerFrame = CGRect.zero
@ViewBuilder
private var composerTextField: some View {
if composerFormattingEnabled {
Color.clear
.overlay(alignment: .top) {
composerView
.clipped()
.readFrame($composerFrame)
}
.frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height),
alignment: .top)
.onAppear {
onAppearAction()
}
} else {
MessageComposerTextField(placeholder: placeholder,
text: $plainComposerText,
presendCallback: $presendCallback,
selectedRange: $selectedRange,
maxHeight: ComposerConstant.maxHeight,
keyHandler: { handleKeyPress($0) },
pasteHandler: pasteAction)
}
}
private var composerHeight: CGFloat {
let baseHeight = isExpanded ? ComposerConstant.maxHeight : ComposerConstant.minHeight
return (baseHeight - composerTranslation).clamped(to: ComposerConstant.allowedHeightRange)
}
@ViewBuilder
private var header: some View {
switch mode {
case .reply(_, let replyDetails, _):
MessageComposerReplyHeader(replyDetails: replyDetails, action: cancellationAction)
case .edit(_, let editType):
MessageComposerEditHeader(editType: editType, action: cancellationAction)
case .recordVoiceMessage, .previewVoiceMessage, .default:
EmptyView()
}
}
private var resizeGrabber: some View {
Capsule()
.foregroundStyle(.tertiary)
.frame(width: 36, height: 5)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
}
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
composerTranslation += value.translation.height
}
.onEnded { _ in
withElementAnimation(.easeIn(duration: 0.3)) {
if composerTranslation > ComposerConstant.translationThreshold {
isExpanded = false
} else if composerTranslation < -ComposerConstant.translationThreshold {
isExpanded = true
}
composerTranslation = 0
}
}
}
private func handleKeyPress(_ key: UIKeyboardHIDUsage) {
switch key {
case .keyboardReturnOrEnter:
sendAction()
case .keyboardUpArrow:
editAction()
case .keyboardEscape:
cancellationAction()
default:
break
}
}
}
private struct MessageComposerReplyHeader: View {
let replyDetails: TimelineItemReplyDetails
let action: () -> Void
var body: some View {
TimelineReplyView(placement: .composer, timelineItemReplyDetails: replyDetails)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 13, style: .circular))
.overlay(alignment: .topTrailing) {
Button(action: action) {
CompoundIcon(\.close, size: .small, relativeTo: .compound.bodySMSemibold)
.foregroundColor(.compound.iconTertiary)
.padding(4.0)
}
.accessibilityLabel(L10n.actionClose)
}
.padding(.vertical, 8.0)
.padding(.horizontal, -4.0)
}
}
private struct MessageComposerEditHeader: View {
let editType: ComposerMode.EditType
let action: () -> Void
private var title: String {
switch editType {
case .default: L10n.commonEditing
case .addCaption: L10n.commonAddingCaption
case .editCaption: L10n.commonEditingCaption
}
}
var body: some View {
HStack(alignment: .center, spacing: 8) {
Label(title, icon: \.editSolid, iconSize: .xSmall, relativeTo: .compound.bodySMSemibold)
.labelStyle(MessageComposerHeaderLabelStyle())
Spacer()
Button(action: action) {
CompoundIcon(\.close, size: .small, relativeTo: .compound.bodySMSemibold)
.foregroundColor(.compound.iconTertiary)
.padding([.leading, .vertical], 6.0)
}
.accessibilityLabel(L10n.actionClose)
}
}
}
private struct MessageComposerHeaderLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .center, spacing: 4) {
configuration.icon
configuration.title
}
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
}
}
// MARK: - Style
extension View {
func messageComposerStyle(header: some View = EmptyView()) -> some View {
modifier(MessageComposerStyleModifier(header: header))
}
}
private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
let header: Header
func body(content: Content) -> some View {
VStack(alignment: .leading, spacing: -6) {
header
content
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
}
.padding(.horizontal, 12.0)
.clipShape(composerShape)
.background {
ZStack {
composerShape
.fill(Color.compound.bgSubtleSecondary)
composerShape
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
}
}
}
}
// MARK: - Previews
struct MessageComposer_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static let replyTypes: [TimelineItemReplyDetails] = [
.loaded(sender: .init(id: "Dave"),
eventID: "123",
eventContent: .message(.audio(.init(filename: "lightning.mp3",
caption: "Audio: Ride the lightning",
duration: 100,
waveform: nil,
source: nil,
fileSize: nil,
contentType: nil)))),
.loaded(sender: .init(id: "James"),
eventID: "123",
eventContent: .message(.emote(.init(body: "Emote: James thinks he's the phantom lord")))),
.loaded(sender: .init(id: "Robert"),
eventID: "123",
eventContent: .message(.file(.init(filename: "brain-surgery.pdf",
caption: "File: Crash course in brain surgery",
source: nil,
fileSize: nil,
thumbnailSource: nil,
contentType: nil)))),
.loaded(sender: .init(id: "Cliff"),
eventID: "123",
eventContent: .message(.image(.init(filename: "head.png",
caption: "Image: Pushead",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail)))),
.loaded(sender: .init(id: "Jason"),
eventID: "123",
eventContent: .message(.notice(.init(body: "Notice: Too far gone?")))),
.loaded(sender: .init(id: "Kirk"),
eventID: "123",
eventContent: .message(.text(.init(body: "Text: Where the wild things are")))),
.loaded(sender: .init(id: "Lars"),
eventID: "123",
eventContent: .message(.video(.init(filename: "never.mov",
caption: "Video: Through the never",
videoInfo: .mockVideo,
thumbnailInfo: .mockVideoThumbnail)))),
.loading(eventID: "")
]
static func messageComposer(_ content: NSAttributedString = .init(string: ""),
mode: ComposerMode = .default,
placeholder: String = L10n.richTextEditorComposerPlaceholder) -> MessageComposer {
let viewModel = WysiwygComposerViewModel(minHeight: 22,
maxExpandedHeight: 250)
viewModel.setMarkdownContent(content.string)
let composerView = WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder,
viewModel: viewModel,
itemProviderHelper: nil,
keyCommands: nil,
pasteHandler: nil)
return MessageComposer(plainComposerText: .constant(content),
presendCallback: .constant(nil),
selectedRange: .constant(NSRange(location: 0, length: 0)),
composerView: composerView,
mode: mode,
placeholder: placeholder,
composerFormattingEnabled: false,
showResizeGrabber: false,
isExpanded: .constant(false),
sendAction: { },
editAction: { },
pasteAction: { _ in },
cancellationAction: { },
onAppearAction: { viewModel.setup() })
}
static var previews: some View {
VStack(spacing: 8) {
messageComposer()
messageComposer(.init(string: "Some message"),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default))
let longMessage = "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic."
messageComposer(.init(string: longMessage),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default))
messageComposer(mode: .reply(eventID: UUID().uuidString,
replyDetails: .loaded(sender: .init(id: "Kirk"),
eventID: "123",
eventContent: .message(.text(.init(body: "Text: Where the wild things are")))),
isThread: false))
messageComposer(.init(string: "Some new caption"),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .addCaption))
messageComposer(.init(string: "Some updated caption"),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .editCaption))
}
.padding(.horizontal)
.environmentObject(viewModel.context)
ScrollView {
VStack(spacing: 8) {
ForEach(replyTypes, id: \.self) { replyDetails in
messageComposer(mode: .reply(eventID: UUID().uuidString,
replyDetails: replyDetails, isThread: false))
}
}
}
.padding(.horizontal)
.environmentObject(viewModel.context)
.previewDisplayName("Replying")
ScrollView {
VStack(spacing: 8) {
ForEach(replyTypes, id: \.self) { replyDetails in
messageComposer(mode: .reply(eventID: UUID().uuidString,
replyDetails: replyDetails, isThread: true),
placeholder: L10n.actionReplyInThread)
}
}
}
.padding(.horizontal)
.environmentObject(viewModel.context)
.previewDisplayName("Replying in thread")
}
}