Files
bundesmessenger-ios/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift
Frank Rotermund 8a186a37d8 Merge commit 'd786f7bb4f37b77478a8a55df44a6e87247f96c1' into feature/5433_foss_merge
* commit 'd786f7bb4f37b77478a8a55df44a6e87247f96c1': (36 commits)
  finish version++
  Release notes
  version++
  changelog.d: Upgrade MatrixSDK version ([v0.27.4](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.27.4)).
  Fix missing placeholder.
  Translated using Weblate (Catalan)
  Translated using Weblate (Catalan)
  Translated using Weblate (Catalan)
  Translated using Weblate (Arabic)
  Translated using Weblate (Arabic)
  Translated using Weblate (Chinese (Simplified))
  Translated using Weblate (Vietnamese)
  Translated using Weblate (Chinese (Simplified))
  Translated using Weblate (Chinese (Simplified))
  Fix: Remove the “Quote” action from the menu of the selected message.
  Update RTE to 2.18.0 to fix an issue with Speech-to-Text
  Code cleanup
  Dismiss the keyboard and minimise the composer when pasting an image, a video or a file
  Fix: focus, keyboard visibility, composer height
  Restore composer tint color
  ...

# Conflicts:
#	Config/AppVersion.xcconfig
#	Riot/Modules/Room/RoomViewController.m
2023-12-20 15:11:56 +01:00

583 lines
22 KiB
Swift

//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Reusable
import WysiwygComposer
import HTMLParser
import SwiftUI
import Combine
import UIKit
import CoreGraphics
@objc protocol HtmlRoomInputToolbarViewProtocol: RoomInputToolbarViewProtocol {
@objc var htmlContent: String { get set }
}
// The toolbar for editing with rich text
class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInputToolbarViewProtocol {
// MARK: - Properties
// MARK: Private
private var keyboardHeight: CGFloat = .zero {
didSet {
updateTextViewHeight()
}
}
private var voiceMessageToolbarView: VoiceMessageToolbarView?
private var cancellables = Set<AnyCancellable>()
private var heightConstraint: NSLayoutConstraint!
private var voiceMessageBottomConstraint: NSLayoutConstraint?
private var hostingViewController: VectorHostingController!
private var wysiwygViewModel = WysiwygComposerViewModel(
parserStyle: WysiwygInputToolbarView.parserStyle
)
/// Compute current HTML parser style for composer.
private static var parserStyle: HTMLParserStyle {
return HTMLParserStyle(
textColor: ThemeService.shared().theme.colors.primaryContent,
linkColor: ThemeService.shared().theme.colors.links,
codeBlockStyle: BlockStyle(backgroundColor: ThemeService.shared().theme.selectedBackgroundColor,
borderColor: ThemeService.shared().theme.textQuinaryColor,
borderWidth: 1.0,
cornerRadius: 4.0,
padding: .init(horizontal: 10.0, vertical: 12.0),
type: .background),
quoteBlockStyle: BlockStyle(backgroundColor: ThemeService.shared().theme.selectedBackgroundColor,
borderColor: ThemeService.shared().theme.selectedBackgroundColor,
borderWidth: 0.0,
cornerRadius: 0.0,
padding: .init(horizontal: 25.0, vertical: 12.0),
type: .side(offset: 5, width: 4)))
}
private var viewModel: ComposerViewModelProtocol!
private var isLandscapePhone: Bool {
let device = UIDevice.current
return device.isPhone && device.orientation.isLandscape
}
// MARK: Public
override var delegate: MXKRoomInputToolbarViewDelegate! {
didSet {
setupComposerIfNeeded()
}
}
override var placeholder: String! {
get {
viewModel.placeholder
}
set {
viewModel.placeholder = newValue
}
}
override var isFocused: Bool {
viewModel.isFocused
}
override var attributedTextMessage: NSAttributedString? {
// Note: this is only interactive in plain text mode. If RTE is enabled,
// APIs from the composer view model should be used.
get {
guard !self.textFormattingEnabled else {
MXLog.failure("[WysiwygInputToolbarView] Trying to get attributedTextMessage in RTE mode")
return nil
}
return self.wysiwygViewModel.textView.attributedText
}
set {
guard !self.textFormattingEnabled else {
MXLog.failure("[WysiwygInputToolbarView] Trying to set attributedTextMessage in RTE mode")
return
}
self.wysiwygViewModel.textView.attributedText = newValue
}
}
override var defaultFont: UIFont {
return UIFont.preferredFont(forTextStyle: .body)
}
var isMaximised: Bool {
wysiwygViewModel.maximised
}
var idealHeight: CGFloat {
get {
wysiwygViewModel.idealHeight
}
set {
wysiwygViewModel.idealHeight = newValue
}
}
var compressedHeight: CGFloat {
wysiwygViewModel.compressedHeight
}
var maxExpandedHeight: CGFloat {
wysiwygViewModel.maxExpandedHeight
}
var maxCompressedHeight: CGFloat {
wysiwygViewModel.maxCompressedHeight
}
override func paste(_ sender: Any?) {
let pasteboard = MXKPasteboardManager.shared.pasteboard
let types = pasteboard.types.map { UTI(rawValue: $0) }
// Minimise the composer and dismiss the keyboard if it's an image, a video or a file
if types.contains(where: { $0.conforms(to: .image) || $0.conforms(to: .movie) || $0.conforms(to: .video) || $0.conforms(to: .application) }) {
wysiwygViewModel.maximised = false
DispatchQueue.main.async {
self.viewModel.dismissKeyboard()
}
}
super.paste(sender)
}
// MARK: - Setup
override class func instantiate() -> MXKRoomInputToolbarView! {
return loadFromNib()
}
private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
return (delegate as? RoomInputToolbarViewDelegate) ?? nil
}
private var permalinkReplacer: MentionReplacer? {
return (delegate as? MentionReplacer)
}
override func awakeFromNib() {
super.awakeFromNib()
setupComposerIfNeeded()
}
override func customizeRendering() {
super.customizeRendering()
self.backgroundColor = .clear
}
override func dismissKeyboard() {
self.viewModel.dismissKeyboard()
}
@discardableResult
override func becomeFirstResponder() -> Bool {
self.wysiwygViewModel.textView.becomeFirstResponder()
}
override func dismissValidationView(_ validationView: MXKImageView!) {
super.dismissValidationView(validationView)
if isMaximised {
showKeyboard()
}
}
override func setPartialContent(_ attributedTextMessage: NSAttributedString) {
let content: String
if #available(iOS 15.0, *) {
content = PillsFormatter.stringByReplacingPills(in: attributedTextMessage, mode: .markdown)
} else {
content = attributedTextMessage.string
}
self.wysiwygViewModel.setMarkdownContent(content)
}
func showKeyboard() {
self.wysiwygViewModel.textView.becomeFirstResponder()
self.viewModel.showKeyboard()
}
func minimise() {
wysiwygViewModel.maximised = false
}
func performLinkOperation(_ linkOperation: WysiwygLinkOperation) {
if let selectionToRestore = viewModel.selectionToRestore {
wysiwygViewModel.select(range: selectionToRestore)
}
wysiwygViewModel.applyLinkOperation(linkOperation)
}
func mention(_ member: MXRoomMember) {
guard let userId = member.userId else {
return
}
let displayName = member.displayname ?? userId
self.wysiwygViewModel.setMention(url: MXTools.permalinkToUser(withUserId: userId),
name: displayName,
mentionType: .user)
}
func command(_ command: String) {
self.wysiwygViewModel.setCommand(name: command)
}
// MARK: - Private
private func setupComposerIfNeeded() {
guard hostingViewController == nil,
let toolbarViewDelegate,
let permalinkReplacer else { return }
viewModel = ComposerViewModel(
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting,
isLandscapePhone: isLandscapePhone,
bindings: ComposerBindings(focused: false)))
viewModel.callback = { [weak self] result in
self?.handleViewModelResult(result)
}
wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting
wysiwygViewModel.mentionReplacer = permalinkReplacer
inputAccessoryViewForKeyboard = UIView(frame: .zero)
let composer = Composer(
viewModel: viewModel.context,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionSharedContext: toolbarViewDelegate.completionSuggestionContext().context,
resizeAnimationDuration: Double(kResizeComposerAnimationDuration),
sendMessageAction: { [weak self] content in
guard let self = self else { return }
self.sendWysiwygMessage(content: content)
}, showSendMediaActions: { [weak self] in
guard let self = self else { return }
self.showSendMediaActions()
})
.introspectTextView { [weak self] textView in
guard let self = self else { return }
textView.inputAccessoryView = self.inputAccessoryViewForKeyboard
}
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: toolbarViewDelegate.mediaManager())))
hostingViewController = VectorHostingController(rootView: composer)
hostingViewController.publishHeightChanges = true
let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height
let subView: UIView = hostingViewController.view
self.addSubview(subView)
self.translatesAutoresizingMaskIntoConstraints = false
subView.translatesAutoresizingMaskIntoConstraints = false
heightConstraint = subView.heightAnchor.constraint(equalToConstant: height)
NSLayoutConstraint.activate([
heightConstraint,
subView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
subView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
subView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
cancellables = [
hostingViewController.heightPublisher
.removeDuplicates()
.sink(receiveValue: { [weak self] idealHeight in
guard let self = self else { return }
self.updateToolbarHeight(wysiwygHeight: idealHeight)
}),
// Required to update the view constraints after minimise/maximise is tapped
wysiwygViewModel.$idealHeight
.removeDuplicates()
.sink { [weak hostingViewController] _ in
hostingViewController?.view.setNeedsLayout()
},
wysiwygViewModel.$maximised
.dropFirst()
.removeDuplicates()
.sink { [weak self] value in
guard let self = self else { return }
self.toolbarViewDelegate?.didChangeMaximisedState(value)
self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0
if !value {
self.voiceMessageBottomConstraint?.constant = 2
}
},
wysiwygViewModel.$plainTextContent
.removeDuplicates()
.dropFirst()
.sink { [weak self] attributed in
// Note: filter out `plainTextMode` being off, as switching to RTE will trigger this
// publisher with empty content. This avoids saving the partial text message
// or trying to compute suggestion from this empty content.
guard let self, self.wysiwygViewModel.plainTextMode else { return }
self.textMessage = attributed.string
self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self)
self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed)
},
wysiwygViewModel.$attributedContent
.removeDuplicates(by: {
$0.text == $1.text
})
.dropFirst()
.sink { [weak self] _ in
// Note: filter out `plainTextMode` being on, as switching to plain text mode will trigger this
// publisher with empty content. This avoids saving the partial text message
// or trying to compute suggestion from this empty content.
guard let self, !self.wysiwygViewModel.plainTextMode else { return }
let markdown = self.wysiwygViewModel.content.markdown
let attributed = NSAttributedString(string: markdown, attributes: [.font: self.defaultFont])
self.toolbarViewDelegate?.roomInputToolbarView?(self, shouldStorePartialContent: attributed)
}
]
update(theme: ThemeService.shared().theme)
registerThemeServiceDidChangeThemeNotification()
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil)
}
@objc private func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
let keyboardRectangle = keyboardFrame.cgRectValue
keyboardHeight = keyboardRectangle.height
if self.isMaximised {
self.voiceMessageBottomConstraint?.constant = keyboardHeight - (window?.safeAreaInsets.bottom ?? 0) + 2
} else {
self.voiceMessageBottomConstraint?.constant = 2
}
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
if self.isMaximised {
self.voiceMessageBottomConstraint?.constant = 2
}
}
@objc private func deviceDidRotate(_ notification: Notification) {
viewModel.isLandscapePhone = isLandscapePhone
DispatchQueue.main.async {
self.updateTextViewHeight()
}
}
private func updateToolbarHeight(wysiwygHeight: CGFloat) {
self.heightConstraint.constant = wysiwygHeight
toolbarViewDelegate?.roomInputToolbarView?(self, heightDidChanged: wysiwygHeight, completion: nil)
}
private func sendWysiwygMessage(content: WysiwygComposerContent) {
// bwi: #4955 disable WYSIWYG commands
if BWIBuildSettings.shared.enableWYSIWYGCommands && content.markdown.prefix(while: { $0 == "/" }).count == 1 {
let commandText: String
if content.markdown.hasPrefix(MXKSlashCommand.emote.cmd) {
// `/me` command works with markdown content
commandText = content.markdown
} else if #available(iOS 15.0, *) {
// Other commands should see pills replaced by matrix identifiers
commandText = PillsFormatter.stringByReplacingPills(in: self.wysiwygViewModel.textView.attributedText, mode: .identifier)
} else {
// Without Pills support, just use the raw text for command
commandText = self.wysiwygViewModel.textView.text
}
// Fix potential command failures due to trailing characters
// or NBSP that are not properly handled by the command interpreter
let sanitizedCommand = commandText
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: String.nbsp, with: " ")
delegate?.roomInputToolbarView?(self, sendCommand: sanitizedCommand)
} else {
delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.markdown)
}
if isMaximised {
minimise()
}
}
private func showSendMediaActions() {
delegate?.roomInputToolbarViewShowSendMediaActions?(self)
}
private func handleViewModelResult(_ result: ComposerViewModelResult) {
switch result {
case .cancel:
toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self)
case let .contentDidChange(isEmpty):
setVoiceMessageToolbarIsHidden(!isEmpty)
case let .linkTapped(linkAction):
toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction))
case let .suggestion(pattern):
toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern))
}
}
private func setVoiceMessageToolbarIsHidden(_ isHidden: Bool) {
guard let voiceMessageToolbarView = voiceMessageToolbarView else { return }
UIView.transition(
with: voiceMessageToolbarView, duration: 0.15,
options: .transitionCrossDissolve,
animations: {
voiceMessageToolbarView.isHidden = isHidden
}
)
}
private func registerThemeServiceDidChangeThemeNotification() {
NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
}
@objc private func themeDidChange() {
self.update(theme: ThemeService.shared().theme)
}
private func update(theme: Theme) {
hostingViewController.view.backgroundColor = theme.colors.background
wysiwygViewModel.parserStyle = WysiwygInputToolbarView.parserStyle
}
private func updateTextViewHeight() {
let height = UIScreen.main.bounds.height
let barOffset: CGFloat = 68
let toolbarHeight: CGFloat = sendMode == .send ? 96 : 110
let finalHeight = height - keyboardHeight - toolbarHeight - barOffset
wysiwygViewModel.maxExpandedHeight = finalHeight
if finalHeight < 200 {
wysiwygViewModel.maxCompressedHeight = finalHeight > wysiwygViewModel.minHeight ? finalHeight : wysiwygViewModel.minHeight
} else {
wysiwygViewModel.maxCompressedHeight = 200
}
}
// MARK: - HtmlRoomInputToolbarViewProtocol
var isEncryptionEnabled = false {
didSet {
updatePlaceholderText()
}
}
/// The current html content of the composer
var htmlContent: String {
get {
wysiwygViewModel.content.html
}
set {
wysiwygViewModel.setHtmlContent(newValue)
}
}
/// The display name to show when in edit/reply
var eventSenderDisplayName: String! {
get {
viewModel.eventSenderDisplayName
}
set {
viewModel.eventSenderDisplayName = newValue
}
}
/// Whether the composer is in send, reply or edit mode.
var sendMode: RoomInputToolbarViewSendMode {
get {
viewModel.sendMode.legacySendMode
}
set {
viewModel.sendMode = ComposerSendMode(from: newValue)
updatePlaceholderText()
updateTextViewHeight()
}
}
/// Whether text formatting is currently enabled in the composer.
var textFormattingEnabled: Bool {
get {
self.viewModel.textFormattingEnabled
}
set {
self.viewModel.textFormattingEnabled = newValue
self.wysiwygViewModel.plainTextMode = !newValue
}
}
/// Add the voice message toolbar to the composer
/// - Parameter voiceMessageToolbarView: the voice message toolbar UIView
func setVoiceMessageToolbarView(_ voiceMessageToolbarView: UIView!) {
if let voiceMessageToolbarView = voiceMessageToolbarView as? VoiceMessageToolbarView {
self.voiceMessageToolbarView = voiceMessageToolbarView
voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.deactivate(voiceMessageToolbarView.containersTopConstraints)
addSubview(voiceMessageToolbarView)
let bottomConstraint = hostingViewController.view.bottomAnchor.constraint(equalTo: voiceMessageToolbarView.bottomAnchor, constant: 2)
voiceMessageBottomConstraint = bottomConstraint
NSLayoutConstraint.activate(
[
hostingViewController.view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: voiceMessageToolbarView.topAnchor),
hostingViewController.view.safeAreaLayoutGuide.leftAnchor.constraint(equalTo: voiceMessageToolbarView.leftAnchor),
bottomConstraint,
hostingViewController.view.safeAreaLayoutGuide.rightAnchor.constraint(equalTo: voiceMessageToolbarView.rightAnchor)
]
)
} else {
self.voiceMessageToolbarView?.removeFromSuperview()
self.voiceMessageToolbarView = nil
self.voiceMessageBottomConstraint?.isActive = false
self.voiceMessageBottomConstraint = nil
}
}
func toolbarHeight() -> CGFloat {
return heightConstraint.constant
}
}
// MARK: - LegacySendModeAdapter
fileprivate extension ComposerSendMode {
init(from sendMode: RoomInputToolbarViewSendMode) {
switch sendMode {
case .reply: self = .reply
case .edit: self = .edit
case .createDM: self = .createDM
default: self = .send
}
}
var legacySendMode: RoomInputToolbarViewSendMode {
switch self {
case .createDM: return .createDM
case .reply: return .reply
case .edit: return .edit
case .send: return .send
}
}
}