//
// C o p y r i g h t ( c ) 2 0 1 9 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
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: - V i e w L i f e c y c l e
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
// W e u s e a w h i t e c u r s o r s i n c e w e u s e a d a r k b a c k g r o u n d .
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: - E v e n t s
@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 ) {
// D o n o t h i n g .
}
}
// MARK: - L e n g t h L i m i t
private lazy var lengthLimitLabel : UILabel = {
let lengthLimitLabel = UILabel ( )
// L e n g t h L i m i t L a b e l s h o w n w h e n t h e u s e r i n p u t s t o o l o n g o f a m e s s a g e
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
// A d d s h a d o w i n c a s e o v e r l a y e d o n w h i t e c o n t e n t
lengthLimitLabel . layer . shadowColor = UIColor . black . cgColor
lengthLimitLabel . layer . shadowOffset = . zero
lengthLimitLabel . layer . shadowOpacity = 0.8
lengthLimitLabel . isHidden = true
return lengthLimitLabel
} ( )
// MARK: - T e x t H e i g h t
// TODO: W e n e e d t o r e v i s i t t h i s w i t h M y l e s .
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: - T e x t H e i g h t
private func updateTextView ( ) {
guard let textViewHeightConstraint = textViewHeightConstraint else {
owsFailDebug ( " Missing textViewHeightConstraint. " )
return
}
let contentSize = textView . sizeThatFits ( CGSize ( width : textView . width ( ) , height : CGFloat . greatestFiniteMagnitude ) )
// ` t e x t V i e w . c o n t e n t S i z e ` i s n ' t a c c u r a t e w h e n r e s t o r i n g a m u l t i l i n e d r a f t , s o w e c o m p u t e i t h e r e .
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
// ` r a n g e ` r e p r e s e n t s t h e s e c t i o n o f t h e e x i s t i n g t e x t w e w i l l r e p l a c e . W e c a n r e - u s e t h a t s p a c e .
// R a n g e i s i n u n i t s o f N S S t r i n g s ' s s t a n d a r d U T F - 1 6 c h a r a c t e r s . S i n c e s o m e o f t h o s e c h a r s c o u l d b e
// r e p r e s e n t e d a s s i n g l e b y t e s i n u t f - 8 , w h i l e o t h e r s m a y b e 8 o r m o r e , t h e o n l y w a y t o b e s u r e i s
// t o j u s t m e a s u r e t h e u t f 8 e n c o d e d b y t e s o f t h e r e p l a c e d s u b s t r i n g .
let bytesAfterDelete : Int = ( existingText as NSString ) . replacingCharacters ( in : range , with : " " ) . utf8 . count
// A c c e p t a s m u c h o f t h e i n p u t a s w e c a n
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
}
// A f t e r v e r i f y i n g t h e b y t e - l e n g t h i s s u f f i c i e n t l y s m a l l , v e r i f y t h e c h a r a c t e r c o u n t i s w i t h i n b o u n d s .
// N o r m a l l y t h i s c h a r a c t e r c o u n t s h o u l d e n t a i l * m u c h * l e s s b y t e c o u n t .
guard proposedText . count <= kMaxCaptionCharacterCount else {
Logger . debug ( " hit caption character count limit " )
self . lengthLimitLabel . isHidden = false
// ` r a n g e ` r e p r e s e n t s t h e s e c t i o n o f t h e e x i s t i n g t e x t w e w i l l r e p l a c e . W e c a n r e - u s e t h a t s p a c e .
let charsAfterDelete : Int = ( existingText as NSString ) . replacingCharacters ( in : range , with : " " ) . count
// A c c e p t a s m u c h o f t h e i n p u t a s w e c a n
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 ( )
}
}