// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import UIKit import SessionUIKit protocol AttachmentCaptionDelegate: class { func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) func captionViewDidCancel() } // MARK: - class AttachmentCaptionViewController: OWSViewController { weak var delegate: AttachmentCaptionDelegate? private let attachmentItem: SignalAttachmentItem private let originalCaptionText: String? private let textView = UITextView() private var textViewHeightConstraint: NSLayoutConstraint? private let kMaxCaptionCharacterCount = 240 init(delegate: AttachmentCaptionDelegate, attachmentItem: SignalAttachmentItem) { self.delegate = delegate self.attachmentItem = attachmentItem self.originalCaptionText = attachmentItem.captionText super.init(nibName: nil, bundle: nil) self.addObserver(textView, forKeyPath: "contentSize", options: .new, context: nil) } @available(*, unavailable, message: "use other init() instead.") required public init?(coder aDecoder: NSCoder) { notImplemented() } deinit { self.removeObserver(textView, forKeyPath: "contentSize") } open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { updateTextView() } // MARK: - View Lifecycle public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) textView.becomeFirstResponder() updateTextView() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) textView.becomeFirstResponder() updateTextView() } public override func loadView() { self.view = UIView() self.view.backgroundColor = UIColor(white: 0, alpha: 0.25) self.view.isOpaque = false self.view.isUserInteractionEnabled = true self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(backgroundTapped))) configureTextView() let doneIcon = UIImage(named: "image_editor_checkmark_full")?.withRenderingMode(.alwaysTemplate) let doneButton = UIBarButtonItem(image: doneIcon, style: .plain, target: self, action: #selector(didTapDone)) doneButton.tintColor = .white navigationItem.rightBarButtonItem = doneButton self.view.layoutMargins = .zero lengthLimitLabel.setContentHuggingHigh() lengthLimitLabel.setCompressionResistanceHigh() let stackView = UIStackView(arrangedSubviews: [lengthLimitLabel, textView]) stackView.axis = .vertical stackView.spacing = 20 stackView.alignment = .fill stackView.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) stackView.isLayoutMarginsRelativeArrangement = true self.view.addSubview(stackView) stackView.autoPinEdge(toSuperviewEdge: .leading) stackView.autoPinEdge(toSuperviewEdge: .trailing) self.autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true) let backgroundView = UIView() backgroundView.backgroundColor = UIColor(white: 0, alpha: 0.5) view.addSubview(backgroundView) view.sendSubviewToBack(backgroundView) backgroundView.autoPinEdge(toSuperviewEdge: .leading) backgroundView.autoPinEdge(toSuperviewEdge: .trailing) backgroundView.autoPinEdge(toSuperviewEdge: .bottom) backgroundView.autoPinEdge(.top, to: .top, of: stackView) let minTextHeight: CGFloat = textView.font?.lineHeight ?? 0 textViewHeightConstraint = textView.autoSetDimension(.height, toSize: minTextHeight) view.addSubview(placeholderTextView) placeholderTextView.autoAlignAxis(.horizontal, toSameAxisOf: textView) placeholderTextView.autoPinEdge(.leading, to: .leading, of: textView) placeholderTextView.autoPinEdge(.trailing, to: .trailing, of: textView) } private func configureTextView() { textView.delegate = self textView.text = attachmentItem.captionText textView.font = UIFont.ows_dynamicTypeBody textView.textColor = .white textView.isEditable = true textView.backgroundColor = .clear textView.isOpaque = false // We use a white cursor since we use a dark background. textView.tintColor = .white textView.isScrollEnabled = true textView.scrollsToTop = false textView.isUserInteractionEnabled = true textView.textAlignment = .left textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 textView.contentInset = .zero } // MARK: - Events @objc func backgroundTapped(sender: UIGestureRecognizer) { AssertIsOnMainThread() completeAndDismiss(didCancel: false) } @objc public func didTapCancel() { completeAndDismiss(didCancel: true) } @objc public func didTapDone() { completeAndDismiss(didCancel: false) } private func completeAndDismiss(didCancel: Bool) { if didCancel { self.delegate?.captionViewDidCancel() } else { self.delegate?.captionView(self, didChangeCaptionText: self.textView.text, attachmentItem: attachmentItem) } self.dismiss(animated: true) { // Do nothing. } } // MARK: - Length Limit private lazy var lengthLimitLabel: UILabel = { let lengthLimitLabel = UILabel() // Length Limit Label shown when the user inputs too long of a message lengthLimitLabel.textColor = UIColor.ows_destructiveRed lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the attachment caption.") lengthLimitLabel.textAlignment = .center // Add shadow in case overlayed on white content lengthLimitLabel.layer.shadowColor = UIColor.black.cgColor lengthLimitLabel.layer.shadowOffset = .zero lengthLimitLabel.layer.shadowOpacity = 0.8 lengthLimitLabel.isHidden = true return lengthLimitLabel }() // MARK: - Text Height // TODO: We need to revisit this with Myles. func updatePlaceholderTextViewVisibility() { let isHidden: Bool = { guard !self.textView.isFirstResponder else { return true } guard let captionText = self.textView.text else { return false } guard captionText.count > 0 else { return false } return true }() placeholderTextView.isHidden = isHidden } private lazy var placeholderTextView: UIView = { let placeholderTextView = UITextView() placeholderTextView.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER", comment: "placeholder text for an empty captioning field") placeholderTextView.isEditable = false placeholderTextView.backgroundColor = .clear placeholderTextView.font = UIFont.ows_dynamicTypeBody placeholderTextView.textColor = Colors.text placeholderTextView.tintColor = Colors.text placeholderTextView.returnKeyType = .done return placeholderTextView }() // MARK: - Text Height private func updateTextView() { guard let textViewHeightConstraint = textViewHeightConstraint else { owsFailDebug("Missing textViewHeightConstraint.") return } let contentSize = textView.sizeThatFits(CGSize(width: textView.width(), height: CGFloat.greatestFiniteMagnitude)) // `textView.contentSize` isn't accurate when restoring a multiline draft, so we compute it here. textView.contentSize = contentSize let minHeight: CGFloat = textView.font?.lineHeight ?? 0 let maxHeight: CGFloat = 300 let newHeight = contentSize.height.clamp(minHeight, maxHeight) textViewHeightConstraint.constant = newHeight textView.invalidateIntrinsicContentSize() textView.superview?.invalidateIntrinsicContentSize() textView.isScrollEnabled = contentSize.height > maxHeight updatePlaceholderTextViewVisibility() } } extension AttachmentCaptionViewController: UITextViewDelegate { public func textViewDidChange(_ textView: UITextView) { updateTextView() } public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { let existingText: String = textView.text ?? "" let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) let kMaxCaptionByteCount = kOversizeTextMessageSizeThreshold / 4 guard proposedText.utf8.count <= kMaxCaptionByteCount else { Logger.debug("hit caption byte count limit") self.lengthLimitLabel.isHidden = false // `range` represents the section of the existing text we will replace. We can re-use that space. // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is // to just measure the utf8 encoded bytes of the replaced substring. let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count // Accept as much of the input as we can let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) } return false } // After verifying the byte-length is sufficiently small, verify the character count is within bounds. // Normally this character count should entail *much* less byte count. guard proposedText.count <= kMaxCaptionCharacterCount else { Logger.debug("hit caption character count limit") self.lengthLimitLabel.isHidden = false // `range` represents the section of the existing text we will replace. We can re-use that space. let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count // Accept as much of the input as we can let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete if charBudget >= 0 { let acceptableNewText = String(text.prefix(charBudget)) textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) } return false } self.lengthLimitLabel.isHidden = true return true } public func textViewDidBeginEditing(_ textView: UITextView) { updatePlaceholderTextViewVisibility() } public func textViewDidEndEditing(_ textView: UITextView) { updatePlaceholderTextViewVisibility() } }