Files
letro-ios/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/View/MessageComposerTextField.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

363 lines
13 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
struct MessageComposerTextField: View {
let placeholder: String
@Binding var text: NSAttributedString
@Binding var presendCallback: (() -> Void)?
@Binding var selectedRange: NSRange
let maxHeight: CGFloat
let keyHandler: GenericKeyHandler
let pasteHandler: PasteHandler
var body: some View {
UITextViewWrapper(text: $text,
presendCallback: $presendCallback,
selectedRange: $selectedRange,
maxHeight: maxHeight,
keyHandler: keyHandler,
pasteHandler: pasteHandler)
.accessibilityLabel(placeholder)
.background(placeholderView, alignment: .topLeading)
.background { keyboardShortcuts }
}
@ViewBuilder
private var placeholderView: some View {
if text.string.isEmpty {
Text(placeholder)
.font(Font(UIFont.preferredFont(forTextStyle: .body)))
.foregroundColor(.compound.textSecondary)
.accessibilityHidden(true)
}
}
private var keyboardShortcuts: some View {
Group {
Button("") {
keyHandler(.keyboardEscape)
}
// Need this to enable escape on the textView and forward the presses
.keyboardShortcut(.escape, modifiers: [])
}
}
}
private struct UITextViewWrapper: UIViewRepresentable {
@Environment(\.timelineContext) private var timelineContext
@Binding var text: NSAttributedString
@Binding var presendCallback: (() -> Void)?
@Binding var selectedRange: NSRange
let maxHeight: CGFloat
let keyHandler: GenericKeyHandler
let pasteHandler: PasteHandler
private let font = UIFont.preferredFont(forTextStyle: .body)
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
// Need to use TextKit 1 for mentions
let textView = ElementTextView(timelineContext: timelineContext,
presendCallback: $presendCallback)
textView.delegate = context.coordinator
textView.elementDelegate = context.coordinator
textView.textColor = .compound.textPrimary
textView.isEditable = true
textView.font = font
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.backgroundColor = UIColor.clear
textView.returnKeyType = .default
textView.textContainer.lineFragmentPadding = 0.0
textView.textContainerInset = .zero
textView.keyboardType = .default
// AutoCorrection doesn't work properly when running on the Mac
// https://github.com/element-hq/element-x-ios/issues/1786
if ProcessInfo.processInfo.isiOSAppOnMac {
textView.autocorrectionType = .no
}
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
// Note: Coalescing a width of zero here returns a size for the view with 1 line of text visible.
let newSize = uiView.sizeThatFits(CGSize(width: proposal.width ?? .zero, height: maxHeight))
let width = proposal.width ?? newSize.width
let height = min(maxHeight, newSize.height)
return CGSize(width: width, height: height)
}
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
// Prevent the textView from inheriting attributes from mention pills
textView.typingAttributes = [.font: font,
.foregroundColor: UIColor.compound.textPrimary]
if textView.attributedText != text {
// Remember the selection if only the attributes have changed.
let selection = textView.attributedText.string == text.string ? textView.selectedTextRange : nil
// Fixes pill views not loading on the first attempt on iOS 18
// because the textContainers's superview comes in as nil
// https://github.com/element-hq/element-x-ios/issues/3369
_ = textView.layoutManager
textView.attributedText = text
// Re-apply the default font when setting text for e.g. edits.
textView.font = font
textView.textColor = .compound.textPrimary
if text.string.isEmpty {
// text cleared, probably because the written text is sent
// reload keyboard type
if textView.isFirstResponder {
textView.keyboardType = .twitter
textView.reloadInputViews()
textView.keyboardType = .default
textView.reloadInputViews()
}
} else if let selection {
// Fixes a bug where pressing Return in the middle of two paragraphs
// moves the caret back to the bottom of the composer.
// https://github.com/element-hq/element-x-ios/issues/3104
textView.selectedTextRange = selection
} else {
// Re-setting the selected range is important when inserting pills
// but we need to not do that when entering edit mode, where the
// cursor needs to stay at the end of the text
// https://github.com/element-hq/element-x-ios/issues/3830
if textView.selectedRange.location != text.length {
textView.selectedRange = selectedRange
}
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text,
selectedRange: $selectedRange,
maxHeight: maxHeight,
keyHandler: keyHandler,
pasteHandler: pasteHandler)
}
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
private var text: Binding<NSAttributedString>
private var selectedRange: Binding<NSRange>
private let maxHeight: CGFloat
private let keyHandler: GenericKeyHandler
private let pasteHandler: PasteHandler
init(text: Binding<NSAttributedString>,
selectedRange: Binding<NSRange>,
maxHeight: CGFloat,
keyHandler: @escaping GenericKeyHandler,
pasteHandler: @escaping PasteHandler) {
self.text = text
self.selectedRange = selectedRange
self.maxHeight = maxHeight
self.keyHandler = keyHandler
self.pasteHandler = pasteHandler
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.attributedText
}
func textViewDidReceiveKeyPress(_ textView: UITextView, key: UIKeyboardHIDUsage) {
keyHandler(key)
}
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) {
textView.insertText("\n")
}
func textView(_ textView: UITextView, didReceivePasteWith providers: [NSItemProvider]) {
pasteHandler(providers)
}
func textViewDidChangeSelection(_ textView: UITextView) {
DispatchQueue.main.async {
if self.selectedRange.wrappedValue != textView.selectedRange {
self.selectedRange.wrappedValue = textView.selectedRange
}
}
}
}
}
private protocol ElementTextViewDelegate: AnyObject {
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView)
func textViewDidReceiveKeyPress(_ textView: UITextView, key: UIKeyboardHIDUsage)
func textView(_ textView: UITextView, didReceivePasteWith providers: [NSItemProvider])
}
private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
private(set) var timelineContext: TimelineViewModel.Context?
private var presendCallback: Binding<(() -> Void)?>
private var pillViews = NSHashTable<UIView>.weakObjects()
weak var elementDelegate: ElementTextViewDelegate?
init(timelineContext: TimelineViewModel.Context?,
presendCallback: Binding<(() -> Void)?>) {
self.timelineContext = timelineContext
self.presendCallback = presendCallback
super.init(frame: .zero, textContainer: nil)
// Avoid `Publishing changes from within view update` warnings
DispatchQueue.main.async {
presendCallback.wrappedValue = { [weak self] in
self?.acceptCurrentSuggestion()
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}
override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))]
}
@objc func shiftEnterKeyPressed(sender: UIKeyCommand) {
elementDelegate?.textViewDidReceiveShiftEnterKeyPress(self)
}
@objc func enterKeyPressed(sender: UIKeyCommand) {
elementDelegate?.textViewDidReceiveKeyPress(self, key: .keyboardReturnOrEnter)
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
if key.keyCode == .keyboardUpArrow, selectedRange.location == 0 {
elementDelegate?.textViewDidReceiveKeyPress(self, key: key.keyCode)
return
}
if key.keyCode == .keyboardEscape {
elementDelegate?.textViewDidReceiveKeyPress(self, key: key.keyCode)
return
}
super.pressesBegan(presses, with: event)
}
// Pasting support
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if super.canPerformAction(action, withSender: sender) {
return true
}
guard action == #selector(paste(_:)) else {
return false
}
return UIPasteboard.general.itemProviders.filter { !$0.isSupportedForPasteOrDrop }.isEmpty
}
override func paste(_ sender: Any?) {
let providers = UIPasteboard.general.itemProviders
// Use the default behavior if there are any unsupported providers
guard providers.filter({ !$0.isSupportedForPasteOrDrop }).isEmpty else {
super.paste(sender)
return
}
elementDelegate?.textView(self, didReceivePasteWith: providers)
}
// MARK: PillAttachmentViewProviderDelegate
func invalidateTextAttachmentsDisplay() {
attributedText.enumerateAttribute(.attachment,
in: NSRange(location: 0, length: attributedText.length),
options: []) { value, range, _ in
guard value != nil else {
return
}
self.layoutManager.invalidateDisplay(forCharacterRange: range)
}
}
func registerPillView(_ pillView: UIView) {
pillViews.add(pillView)
}
func flushPills() {
for view in pillViews.allObjects {
view.alpha = 0.0
view.removeFromSuperview()
}
pillViews.removeAllObjects()
}
// MARK: - Private
private func acceptCurrentSuggestion() {
guard isFirstResponder else {
return
}
inputDelegate?.selectionWillChange(self)
inputDelegate?.selectionDidChange(self)
}
}
struct MessageComposerTextField_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 16) {
PreviewWrapper(text: "123")
PreviewWrapper(text: "")
PreviewWrapper(text: "A really long message that will wrap to multiple lines on a phone in portrait.")
}
}
struct PreviewWrapper: View {
@State var text: NSAttributedString
init(text: String) {
_text = .init(initialValue: .init(string: text, attributes: [.font: UIFont.preferredFont(forTextStyle: .body),
.foregroundColor: UIColor.compound.textPrimary]))
}
var body: some View {
MessageComposerTextField(placeholder: "Placeholder",
text: $text,
presendCallback: .constant(nil),
selectedRange: .constant(NSRange(location: 0, length: 0)),
maxHeight: 300,
keyHandler: { _ in },
pasteHandler: { _ in })
}
}
}