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.
		
		
		
		
		
			
		
			
	
	
		
			316 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
		
		
			
		
	
	
			316 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
| 
											7 years ago
										 | // | ||
|  | //  Copyright (c) 2019 Open Whisper Systems. All rights reserved. | ||
|  | // | ||
|  | 
 | ||
|  | import UIKit | ||
|  | 
 | ||
|  | protocol AttachmentCaptionDelegate: class { | ||
|  |     func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) | ||
| 
											7 years ago
										 |     func captionViewDidCancel() | ||
| 
											7 years ago
										 | } | ||
|  | 
 | ||
| 
											7 years ago
										 | // MARK: - | ||
|  | 
 | ||
| 
											7 years ago
										 | 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) | ||
|  | 
 | ||
| 
											7 years ago
										 |         let backgroundView = UIView() | ||
|  |         backgroundView.backgroundColor = UIColor(white: 0, alpha: 0.5) | ||
|  |         view.addSubview(backgroundView) | ||
| 
											7 years ago
										 |         view.sendSubviewToBack(backgroundView) | ||
| 
											7 years ago
										 |         backgroundView.autoPinEdge(toSuperviewEdge: .leading) | ||
|  |         backgroundView.autoPinEdge(toSuperviewEdge: .trailing) | ||
|  |         backgroundView.autoPinEdge(toSuperviewEdge: .bottom) | ||
|  |         backgroundView.autoPinEdge(.top, to: .top, of: stackView) | ||
|  | 
 | ||
| 
											7 years ago
										 |         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 { | ||
| 
											7 years ago
										 |             self.delegate?.captionViewDidCancel() | ||
| 
											7 years ago
										 |         } 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 | ||
| 
											7 years ago
										 |         lengthLimitLabel.layer.shadowOffset = .zero | ||
| 
											7 years ago
										 |         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 = Theme.darkThemePrimaryColor | ||
|  |         placeholderTextView.tintColor = Theme.darkThemePrimaryColor | ||
|  |         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)) | ||
|  | 
 | ||
| 
											7 years ago
										 |         // `textView.contentSize` isn't accurate when restoring a multiline draft, so we compute it here. | ||
|  |         textView.contentSize = contentSize | ||
| 
											7 years ago
										 | 
 | ||
|  |         let minHeight: CGFloat = textView.font?.lineHeight ?? 0 | ||
| 
											7 years ago
										 |         let maxHeight: CGFloat = 300 | ||
| 
											7 years ago
										 |         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() | ||
|  |     } | ||
|  | } |