// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI public import UIKit public protocol ConversationInputTextViewDelegate: AnyObject { func didAttemptAttachmentPaste() func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void) func inputTextViewSendMessagePressed() func textViewDidChange(_ textView: UITextView) } // MARK: - protocol ConversationTextViewToolbarDelegate: AnyObject { func textViewDidChange(_ textView: UITextView) func textViewDidChangeSelection(_ textView: UITextView) } // MARK: - class ConversationInputTextView: BodyRangesTextView { private lazy var placeholderView = UILabel() private var placeholderConstraints: [NSLayoutConstraint]? weak var inputTextViewDelegate: ConversationInputTextViewDelegate? weak var textViewToolbarDelegate: ConversationTextViewToolbarDelegate? var trimmedText: String { textStorage.string.ows_stripped() } var untrimmedText: String { textStorage.string } private var textIsChanging = false var inFieldButtonsAreaWidth: CGFloat = 0 { didSet { ensurePlaceholderConstraints() } } override init() { super.init() backgroundColor = nil scrollIndicatorInsets = UIEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4) isScrollEnabled = true scrollsToTop = false isUserInteractionEnabled = true contentMode = .redraw dataDetectorTypes = [] // Fixes https://github.com/signalapp/Signal-iOS/issues/5954 // Weird iOS undocumented behavior where it force enables stickers/memojis // For reference https://stackoverflow.com/questions/79699798/uitextview-enabling-paste-force-enables-memojis-stickers/79700557#79700557 if #available(iOS 18.0, *) { supportsAdaptiveImageGlyph = false } placeholderView.text = OWSLocalizedString( "INPUT_TOOLBAR_MESSAGE_PLACEHOLDER", comment: "Placeholder text displayed in empty input box in chat screen.", ) placeholderView.textColor = UIColor.Signal.secondaryLabel placeholderView.isUserInteractionEnabled = false addSubview(placeholderView) // We need to do these steps _after_ placeholderView is configured. font = .dynamicTypeBody textColor = UIColor.Signal.label textAlignment = .natural textContainer.lineFragmentPadding = 0 contentInset = .zero setMessageBody(nil, txProvider: SSKEnvironment.shared.databaseStorageRef.readTxProvider) ensurePlaceholderConstraints() updatePlaceholderVisibility() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - var placeholderTextColor: UIColor? { get { placeholderView.textColor } set { placeholderView.textColor = newValue } } override var defaultTextContainerInset: UIEdgeInsets { var textContainerInset = super.defaultTextContainerInset textContainerInset.left = 12 textContainerInset.right = 12 // If the placeholder view is visible, we need to offset // the input container to accommodate for the sticker button. if !placeholderView.isHidden { textContainerInset.right += inFieldButtonsAreaWidth } return textContainerInset } private func ensurePlaceholderConstraints() { // Don't update constraints when MentionInputView sets textContainerInset in its initializer // because placeholderView wasn't added yet. guard placeholderView.superview != nil else { return } if let placeholderConstraints { NSLayoutConstraint.deactivate(placeholderConstraints) } let topInset = textContainerInset.top let leftInset = textContainerInset.left let rightInset = textContainerInset.right placeholderConstraints = [ placeholderView.autoMatch(.width, to: .width, of: self, withOffset: -(leftInset + rightInset)), placeholderView.autoPinEdge(toSuperviewEdge: .left, withInset: leftInset), placeholderView.autoPinEdge(toSuperviewEdge: .top, withInset: topInset), ] } private func updatePlaceholderVisibility() { placeholderView.isHidden = !textStorage.string.isEmpty } override var font: UIFont? { didSet { placeholderView.font = font } } override var contentInset: UIEdgeInsets { didSet { ensurePlaceholderConstraints() } } override var textContainerInset: UIEdgeInsets { didSet { ensurePlaceholderConstraints() } } override func setMessageBody(_ messageBody: MessageBody?, txProvider: ((DBReadTransaction) -> Void) -> Void) { super.setMessageBody(messageBody, txProvider: txProvider) updatePlaceholderVisibility() updateTextContainerInset() } var pasteboardHasPossibleAttachment: Bool { // We don't want to load/convert images more than once so we // only do a cursory validation pass at this time. return PasteboardAttachment.mayHaveAttachments() && !PasteboardAttachment.hasText() } override var inputView: UIView? { didSet { reloadCaret() } } // Force UITextView to redraw to make sure the caret is shown/hidden as necessary. private func reloadCaret() { let fullRange = NSRange(location: 0, length: textStorage.length) layoutManager.invalidateLayout(forCharacterRange: fullRange, actualCharacterRange: nil) layoutManager.invalidateDisplay(forCharacterRange: fullRange) layoutManager.ensureLayout(for: textContainer) } private var isTextInputMode: Bool { return inputView == nil } override func canPerformPasteAction() -> Bool { return pasteboardHasPossibleAttachment } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { guard isTextInputMode else { return false } return super.canPerformAction(action, withSender: sender) } override func caretRect(for position: UITextPosition) -> CGRect { guard isTextInputMode else { return .zero } return super.caretRect(for: position) } override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { guard isTextInputMode else { return [] } return super.selectionRects(for: range) } override func paste(_ sender: Any?) { if pasteboardHasPossibleAttachment { inputTextViewDelegate?.didAttemptAttachmentPaste() return } if handleAttemptedAccountEntropyPoolPaste() { return } super.paste(sender) } private func handleAttemptedAccountEntropyPoolPaste() -> Bool { let accountKeyStore = DependenciesBridge.shared.accountKeyStore let db = DependenciesBridge.shared.db guard let pasteboardString = UIPasteboard.general.strings?.first else { return false } let filteredPasteboardString = pasteboardString.filter { !$0.isWhitespace } guard let pastedAEP = try? DisplayableAccountEntropyPool(displayString: filteredPasteboardString), let localAEP = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }), pastedAEP.rawValue == localAEP else { return false } inputTextViewDelegate?.didAttemptAccountEntropyPoolPaste( completePaste: { [weak self] in guard let self else { return } let pasteRange: UITextRange if let selectedTextRange { pasteRange = selectedTextRange } else if let endRange = textRange(from: endOfDocument, to: endOfDocument) { pasteRange = endRange } else { return } replace(pasteRange, withText: filteredPasteboardString) }, ) return true } // MARK: - UITextViewDelegate override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { textIsChanging = true return super.textView(self, shouldChangeTextIn: range, replacementText: text) } override func textViewDidChange(_ textView: UITextView) { super.textViewDidChange(textView) textIsChanging = false updatePlaceholderVisibility() updateTextContainerInset() inputTextViewDelegate?.textViewDidChange(self) textViewToolbarDelegate?.textViewDidChange(self) } override func textViewDidChangeSelection(_ textView: UITextView) { super.textViewDidChangeSelection(textView) textViewToolbarDelegate?.textViewDidChangeSelection(self) } // MARK: - Key Commands override var keyCommands: [UIKeyCommand]? { let keyCommands = super.keyCommands ?? [] // We don't define discoverability title for these key commands as they're // considered "default" functionality and shouldn't clutter the shortcut // list that is rendered when you hold down the command key. return keyCommands + [ // An unmodified return can only be sent by a hardware keyboard, // return on the software keyboard will not trigger this command. // Return, send message UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(unmodifiedReturnPressed(_:))), // Alt + Return, inserts a new line UIKeyCommand(input: "\r", modifierFlags: .alternate, action: #selector(modifiedReturnPressed(_:))), // Shift + Return, inserts a new line UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(modifiedReturnPressed(_:))), ] } @objc private func unmodifiedReturnPressed(_ sender: UIKeyCommand) { inputTextViewDelegate?.inputTextViewSendMessagePressed() } @objc private func modifiedReturnPressed(_ sender: UIKeyCommand) { replace(selectedTextRange ?? UITextRange(), withText: "\n") inputTextViewDelegate?.textViewDidChange(self) textViewToolbarDelegate?.textViewDidChange(self) } }