Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/AttachmentApproval/AttachmentTextToolbar.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient
import SignalServiceKit
import UIKit

// 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.bodyRangesDelegate = 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 }

    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 CGFloat.clamp(contentSize.height, min: kMinTextViewHeight, max: maxTextViewHeight)
    }

    // MARK: - Subviews

    private(set) lazy var textView: BodyRangesTextView = {
        let textView = buildTextView()
        textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3)
        textView.bodyRangesDelegate = 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: SSKEnvironment.shared.databaseStorageRef.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 else { return }
            self.didTapFinishEditing()
        }
        let visibleButtonSize = kMinTextViewHeight
        doneButton.layoutMargins = UIEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0)
        doneButton.ows_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 textViewMentionPickerPossibleAcis(_ textView: BodyRangesTextView, tx: DBReadTransaction) -> [Aci] {
        return mentionTextViewDelegate?.textViewMentionPickerPossibleAcis(textView, tx: tx) ?? []
    }

    func textViewDisplayConfiguration(_ textView: BodyRangesTextView) -> HydratedMessageBody.DisplayConfiguration {
        return .composingAttachment()
    }

    func mentionPickerStyle(_ textView: BodyRangesTextView) -> MentionPickerStyle {
        return .composingAttachment
    }

    func textViewMentionCacheInvalidationKey(_ textView: BodyRangesTextView) -> String {
        return mentionTextViewDelegate?.textViewMentionCacheInvalidationKey(textView) ?? UUID().uuidString
    }
}

extension AttachmentTextToolbar: UITextViewDelegate {

    func textViewDidChange(_ textView: UITextView) {
        updateContent(animated: true)
        delegate?.attachmentTextToolbarDidChange(self)
    }

    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
    }

    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)
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        // We want to collapse the no-longer-editing text view to one line. If
        // it has multiple lines, and we're focused anywhere other than the
        // first line, this will make the text view appear blank; instead, put
        // the cursor at the front.
        let startTextPosition = textView.beginningOfDocument
        textView.textContainer.lineBreakMode = .byTruncatingTail
        textView.textContainer.maximumNumberOfLines = 1
        textView.selectedTextRange = textView.textRange(from: startTextPosition, to: startTextPosition)

        delegate?.attachmentTextToolbarDidEndEditing(self)
        updateContent(animated: true)
    }
}