element-ios/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift

573 lines
22 KiB
Swift

//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
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) {
if 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
}
}
}