mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			317 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			317 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
| //
 | |
| //  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()
 | |
|     }
 | |
| }
 |