// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import UIKit import SignalMessaging // Coincides with Android's max text message length let kMaxMessageBodyCharacterCount = 2000 protocol AttachmentTextToolbarDelegate: AnyObject { func attachmentTextToolbarWillBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolBarDidChangeHeight(_ attachmentTextToolbar: AttachmentTextToolbar) } // MARK: - class AttachmentTextToolbar: UIView { // Forward text editing-related events to AttachmentApprovalToolbar. weak var delegate: AttachmentTextToolbarDelegate? // Forward mention-related calls directly to the view controller. weak var mentionTextViewDelegate: BodyRangesTextViewDelegate? private var isViewOnceEnabled: Bool = false func setIsViewOnce(enabled: Bool, animated: Bool) { guard isViewOnceEnabled != enabled else { return } isViewOnceEnabled = enabled updateContent(animated: animated) } var isEditingText: Bool { textView.isFirstResponder } var messageBodyForSending: MessageBody? { // Ignore message text if "view-once" is enabled. guard !isViewOnceEnabled else { return nil } return textView.messageBodyForSending } func setMessageBody(_ messageBody: MessageBody?, txProvider: EditableMessageBodyTextStorage.ReadTxProvider) { textView.setMessageBody(messageBody, txProvider: txProvider) updateAppearance(animated: false) } // MARK: - Initializers override init(frame: CGRect) { super.init(frame: frame) // Specifying autoresizing mask and an intrinsic content size allows proper // sizing when used as an input accessory view. autoresizingMask = .flexibleHeight preservesSuperviewLayoutMargins = true translatesAutoresizingMaskIntoConstraints = false layoutMargins.top = 10 layoutMargins.bottom = 10 textView.mentionDelegate = self // Layout addSubview(textViewContainer) textViewContainer.autoPinEdgesToSuperviewMargins() // We pin edges explicitly rather than doing something like: // textView.autoPinEdges(toSuperviewMarginsExcludingEdge: .right) // because that method uses `leading` / `trailing` rather than `left` vs. `right`. // So it doesn't work as expected with RTL layouts when we explicitly want something // to be on the right side for both RTL and LTR layouts, like with the send button. // I believe this is a bug in PureLayout. Filed here: https://github.com/PureLayout/PureLayout/issues/209 textViewWrapperView.autoPinEdge(toSuperviewMargin: .top) textViewWrapperView.autoPinEdge(toSuperviewMargin: .bottom) addSubview(addMessageButton) addMessageButton.autoPinEdgesToSuperviewMargins() addConstraint({ let constraint = addMessageButton.heightAnchor.constraint(equalToConstant: kMinTextViewHeight) constraint.priority = UILayoutPriority.defaultLow return constraint }()) addSubview(viewOnceMediaLabel) viewOnceMediaLabel.autoPinEdgesToSuperviewMargins() addConstraint({ let constraint = viewOnceMediaLabel.heightAnchor.constraint(equalToConstant: kMinTextViewHeight) constraint.priority = UILayoutPriority.defaultLow return constraint }()) updateContent(animated: false) } @available(*, unavailable, message: "Use init(frame:) instead") required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - UIView Overrides // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. override var intrinsicContentSize: CGSize { .zero } public override var bounds: CGRect { didSet { guard oldValue.size.height != bounds.size.height else { return } // Compensate for autolayout frame/bounds changes when animating height change. // This logic ensures the input toolbar stays pinned to the keyboard visually. if isAnimatingHeightChange && textView.isFirstResponder { var frame = frame frame.origin.y = 0 // In this conditional, bounds change is captured in an animation block, which we don't want here. UIView.performWithoutAnimation { self.frame = frame } } } } // MARK: - Layout private var isAnimatingHeightChange = false private let kMinTextViewHeight: CGFloat = 36 private var maxTextViewHeight: CGFloat { // About ~4 lines in portrait and ~3 lines in landscape. // Otherwise we risk obscuring too much of the content. return UIDevice.current.orientation.isPortrait ? 160 : 100 } private lazy var textViewMinimumHeightConstraint: NSLayoutConstraint = { textView.heightAnchor.constraint(greaterThanOrEqualToConstant: kMinTextViewHeight) }() private lazy var textViewHeightConstraint: NSLayoutConstraint = { textView.heightAnchor.constraint(equalToConstant: kMinTextViewHeight) }() private func updateContent(animated: Bool) { AssertIsOnMainThread() updateAppearance(animated: animated) updateHeight(animated: animated) } private func updateAppearance(animated: Bool) { let hasText = !textView.isEmpty let isEditing = isEditingText addMessageButton.setIsHidden(hasText || isEditing || isViewOnceEnabled, animated: animated) viewOnceMediaLabel.setIsHidden(!isViewOnceEnabled, animated: animated) textViewContainer.setIsHidden((!hasText && !isEditing) || isViewOnceEnabled, animated: animated) placeholderTextView.setIsHidden(hasText, animated: animated) doneButton.setIsHidden(!isEditing, animated: animated) if let blueCircleView = doneButton.subviews.first(where: { $0 is CircleView }) { doneButton.sendSubviewToBack(blueCircleView) } } private func updateHeight(animated: Bool) { // Minimum text area size defines text field size when input field isn't active. let placeholderTextViewHeight = clampedHeight(for: placeholderTextView) textViewMinimumHeightConstraint.constant = placeholderTextViewHeight // Always keep height of the text field in expanded state current. textViewHeightConstraint.isActive = isEditingText let textViewHeight = clampedHeight(for: textView) guard textViewHeightConstraint.constant != textViewHeight else { return } if animated { isAnimatingHeightChange = true let animator = UIViewPropertyAnimator( duration: 0.25, springDamping: 1, springResponse: 0.25 ) animator.addAnimations { self.textViewHeightConstraint.constant = textViewHeight self.delegate?.attachmentTextToolBarDidChangeHeight(self) } animator.addCompletion { _ in self.isAnimatingHeightChange = false } animator.startAnimation() } else { textViewHeightConstraint.constant = textViewHeight } } private func clampedHeight(for textView: UITextView) -> CGFloat { let fixedWidth = textView.width let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude)) return CGFloatClamp(contentSize.height, kMinTextViewHeight, maxTextViewHeight) } // MARK: - Subviews lazy private(set) var textView: BodyRangesTextView = { let textView = buildTextView() textView.returnKeyType = .done textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3) textView.mentionDelegate = self return textView }() private let placeholderText = OWSLocalizedString("MEDIA_EDITOR_TEXT_FIELD_ADD_MESSAGE", comment: "Placeholder for message text input field in media editor.") private lazy var placeholderTextView: UITextView = { let placeholderTextView = buildTextView() placeholderTextView.setMessageBody(.init(text: placeholderText, ranges: .empty), txProvider: databaseStorage.readTxProvider) placeholderTextView.isEditable = false placeholderTextView.isUserInteractionEnabled = false placeholderTextView.textContainer.maximumNumberOfLines = 1 placeholderTextView.textContainer.lineBreakMode = .byTruncatingTail placeholderTextView.textColor = .ows_gray45 return placeholderTextView }() private lazy var addMessageButton: UIButton = { let button = UIButton(type: .custom) button.setTitle(placeholderText, for: .normal) button.setTitleColor(.ows_white, for: .normal) button.titleLabel?.lineBreakMode = .byTruncatingTail button.titleLabel?.textAlignment = .center button.titleLabel?.font = .dynamicTypeBodyClamped button.addTarget(self, action: #selector(didTapAddMessage), for: .touchDown) return button }() private lazy var viewOnceMediaLabel: UILabel = { let label = UILabel() label.text = OWSLocalizedString("MEDIA_EDITOR_TEXT_FIELD_VIEW_ONCE_MEDIA", comment: "Shown in place of message input text in media editor when 'View Once' is on.") label.numberOfLines = 1 label.lineBreakMode = .byTruncatingTail label.textAlignment = .center label.textColor = .ows_whiteAlpha50 label.font = .dynamicTypeBodyClamped return label }() private lazy var textViewContainer: UIView = { let hStackView = UIStackView(arrangedSubviews: [ textViewWrapperView, doneButton ]) hStackView.axis = .horizontal hStackView.alignment = .bottom hStackView.spacing = 4 return hStackView }() private lazy var doneButton: UIButton = { let doneButton = OWSButton(imageName: Theme.iconName(.checkmark), tintColor: .white) { [weak self] in guard let self = self else { return } self.didTapFinishEditing() } let visibleButtonSize = kMinTextViewHeight doneButton.layoutMargins = UIEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0) doneButton.contentEdgeInsets = doneButton.layoutMargins doneButton.accessibilityLabel = CommonStrings.doneButton let blueCircle = CircleView(diameter: visibleButtonSize) blueCircle.backgroundColor = .ows_accentBlue blueCircle.isUserInteractionEnabled = false doneButton.addSubview(blueCircle) doneButton.sendSubviewToBack(blueCircle) blueCircle.autoPinEdgesToSuperviewMargins() return doneButton }() private lazy var textViewWrapperView: UIView = { let backgroundView = UIView() backgroundView.backgroundColor = .ows_gray80 backgroundView.layer.cornerRadius = kMinTextViewHeight / 2 backgroundView.clipsToBounds = true let wrapperView = UIView() wrapperView.addSubview(backgroundView) backgroundView.autoPinEdgesToSuperviewEdges() wrapperView.addSubview(textView) textView.autoPinEdgesToSuperviewEdges() wrapperView.addConstraint(textViewHeightConstraint) wrapperView.addConstraint(textViewMinimumHeightConstraint) wrapperView.addSubview(placeholderTextView) placeholderTextView.autoPinEdges(toEdgesOf: textView) return wrapperView }() private func buildTextView() -> AttachmentTextView { let textView = AttachmentTextView() textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance textView.backgroundColor = .clear textView.tintColor = Theme.darkThemePrimaryColor textView.font = .dynamicTypeBodyClamped textView.textColor = Theme.darkThemePrimaryColor return textView } } // MARK: - Actions extension AttachmentTextToolbar { @objc private func didTapFinishEditing() { textView.acceptAutocorrectSuggestion() textView.resignFirstResponder() } @objc private func didTapAddMessage() { guard !isViewOnceEnabled else { return } textView.becomeFirstResponder() } } extension AttachmentTextToolbar: BodyRangesTextViewDelegate { func textViewDidBeginTypingMention(_ textView: BodyRangesTextView) { mentionTextViewDelegate?.textViewDidBeginTypingMention(textView) } func textViewDidEndTypingMention(_ textView: BodyRangesTextView) { mentionTextViewDelegate?.textViewDidEndTypingMention(textView) } func textViewMentionPickerParentView(_ textView: BodyRangesTextView) -> UIView? { return mentionTextViewDelegate?.textViewMentionPickerParentView(textView) } func textViewMentionPickerReferenceView(_ textView: BodyRangesTextView) -> UIView? { return mentionTextViewDelegate?.textViewMentionPickerReferenceView(textView) } func textViewMentionPickerPossibleAddresses(_ textView: BodyRangesTextView, tx: DBReadTransaction) -> [SignalServiceAddress] { return mentionTextViewDelegate?.textViewMentionPickerPossibleAddresses(textView, tx: tx) ?? [] } public func textViewDisplayConfiguration(_ textView: BodyRangesTextView) -> HydratedMessageBody.DisplayConfiguration { return .composingAttachment() } public func mentionPickerStyle(_ textView: BodyRangesTextView) -> MentionPickerStyle { return .composingAttachment } public func textViewMentionCacheInvalidationKey(_ textView: BodyRangesTextView) -> String { return mentionTextViewDelegate?.textViewMentionCacheInvalidationKey(textView) ?? UUID().uuidString } } extension AttachmentTextToolbar: UITextViewDelegate { public func textViewDidChange(_ textView: UITextView) { updateContent(animated: true) delegate?.attachmentTextToolbarDidChange(self) } public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { // Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button // allows the user to get the keyboard out of the way while in the attachment approval view. if text == "\n" { textView.resignFirstResponder() return false } else { return true } } public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { delegate?.attachmentTextToolbarWillBeginEditing(self) // Putting these lines in `textViewDidBeginEditing` doesn't work. textView.textContainer.lineBreakMode = .byWordWrapping textView.textContainer.maximumNumberOfLines = 0 return true } public func textViewDidBeginEditing(_ textView: UITextView) { // Making textView think its content has changed is necessary // in order to get correct textView size and expand it to multiple lines if necessary. textView.layoutManager.processEditing(for: textView.textStorage, edited: .editedCharacters, range: NSRange(location: 0, length: 0), changeInLength: 0, invalidatedRange: NSRange(location: 0, length: 0)) delegate?.attachmentTextToolbarDidBeginEditing(self) updateContent(animated: true) } public func textViewDidEndEditing(_ textView: UITextView) { textView.textContainer.lineBreakMode = .byTruncatingTail textView.textContainer.maximumNumberOfLines = 1 delegate?.attachmentTextToolbarDidEndEditing(self) updateContent(animated: true) } }