* 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.
363 lines
13 KiB
Swift
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 })
|
|
}
|
|
}
|
|
}
|