//
// 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 Foundation
import UIKit
import SessionUIKit
// C o i n c i d e s w i t h A n d r o i d ' s m a x t e x t m e s s a g e l e n g t h
let kMaxMessageBodyCharacterCount = 2000
protocol AttachmentTextToolbarDelegate : class {
func attachmentTextToolbarDidTapSend ( _ attachmentTextToolbar : AttachmentTextToolbar )
func attachmentTextToolbarDidBeginEditing ( _ attachmentTextToolbar : AttachmentTextToolbar )
func attachmentTextToolbarDidEndEditing ( _ attachmentTextToolbar : AttachmentTextToolbar )
func attachmentTextToolbarDidChange ( _ attachmentTextToolbar : AttachmentTextToolbar )
}
// MARK: -
class AttachmentTextToolbar : UIView , UITextViewDelegate {
weak var attachmentTextToolbarDelegate : AttachmentTextToolbarDelegate ?
var messageText : String ? {
get { return textView . text }
set {
textView . text = newValue
updatePlaceholderTextViewVisibility ( )
}
}
// L a y o u t C o n s t a n t s
static let kToolbarMargin : CGFloat = 8
static let kMinTextViewHeight : CGFloat = 40
var maxTextViewHeight : CGFloat {
// A b o u t ~ 4 l i n e s i n p o r t r a i t a n d ~ 3 l i n e s i n l a n d s c a p e .
// O t h e r w i s e w e r i s k o b s c u r i n g t o o m u c h o f t h e c o n t e n t .
return UIDevice . current . orientation . isPortrait ? 160 : 100
}
var textViewHeightConstraint : NSLayoutConstraint !
var textViewHeight : CGFloat
// MARK: - I n i t i a l i z e r s
init ( ) {
self . sendButton = UIButton ( type : . system )
self . textViewHeight = AttachmentTextToolbar . kMinTextViewHeight
super . init ( frame : CGRect . zero )
// S p e c i f y i n g a u t o r s i z i n g m a s k a n d a n i n t r i n s i c c o n t e n t s i z e a l l o w s p r o p e r
// s i z i n g w h e n u s e d a s a n i n p u t a c c e s s o r y v i e w .
self . autoresizingMask = . flexibleHeight
self . translatesAutoresizingMaskIntoConstraints = false
self . backgroundColor = UIColor . clear
textView . delegate = self
let sendTitle = NSLocalizedString ( " ATTACHMENT_APPROVAL_SEND_BUTTON " , comment : " Label for 'send' button in the 'attachment approval' dialog. " )
sendButton . setTitle ( sendTitle , for : . normal )
sendButton . addTarget ( self , action : #selector ( didTapSend ) , for : . touchUpInside )
sendButton . titleLabel ? . font = . boldSystemFont ( ofSize : Values . mediumFontSize )
sendButton . titleLabel ? . textAlignment = . center
sendButton . tintColor = Colors . accent
// I n c r e a s e h i t a r e a o f s e n d b u t t o n
sendButton . contentEdgeInsets = UIEdgeInsets ( top : 6 , left : 8 , bottom : 6 , right : 8 )
let contentView = UIView ( )
contentView . addSubview ( sendButton )
contentView . addSubview ( textContainer )
contentView . addSubview ( lengthLimitLabel )
addSubview ( contentView )
contentView . autoPinEdgesToSuperviewEdges ( )
// L a y o u t
// W e h a v e t o w r a p t h e t o o l b a r i t e m s i n a c o n t e n t v i e w b e c a u s e i O S ( a t l e a s t o n i O S 1 0 . 3 ) a s s i g n s t h e i n p u t A c c e s s o r y V i e w . l a y o u t M a r g i n s
// w h e n r e s i g n i n g f i r s t r e s p o n d e r ( v e r i f i e d b y a u d i t i n g w i t h ` l a y o u t M a r g i n s D i d C h a n g e ` ) .
// T h e e f f e c t o f t h i s i s t h a t i f w e w e r e t o a s s i g n t h e s e m a r g i n s t o s e l f . l a y o u t M a r g i n s , t h e y ' d b e b l o w n a w a y i f t h e
// u s e r d i s m i s s e s t h e k e y b o a r d , g i v i n g t h e i n p u t a c c e s s o r y v i e w a w o n k y l a y o u t .
contentView . layoutMargins = UIEdgeInsets (
top : AttachmentTextToolbar . kToolbarMargin ,
left : AttachmentTextToolbar . kToolbarMargin ,
bottom : AttachmentTextToolbar . kToolbarMargin ,
right : AttachmentTextToolbar . kToolbarMargin
)
self . textViewHeightConstraint = textView . autoSetDimension ( . height , toSize : AttachmentTextToolbar . kMinTextViewHeight )
// W e p i n a l l t h r e e e d g e s e x p l i c i t l y r a t h e r t h a n d o i n g s o m e t h i n g l i k e :
// t e x t V i e w . a u t o P i n E d g e s ( t o S u p e r v i e w M a r g i n s E x c l u d i n g E d g e : . r i g h t )
// b e c a u s e t h a t m e t h o d u s e s ` l e a d i n g ` / ` t r a i l i n g ` r a t h e r t h a n ` l e f t ` v s . ` r i g h t ` .
// S o i t d o e s n ' t w o r k a s e x p e c t e d w i t h R T L l a y o u t s w h e n w e e x p l i c i t l y w a n t s o m e t h i n g
// t o b e o n t h e r i g h t s i d e f o r b o t h R T L a n d L T R l a y o u t s , l i k e w i t h t h e s e n d b u t t o n .
// I b e l i e v e t h i s i s a b u g i n P u r e L a y o u t . F i l e d h e r e : h t t p s : / / g i t h u b . c o m / P u r e L a y o u t / P u r e L a y o u t / i s s u e s / 2 0 9
textContainer . autoPinEdge ( toSuperviewMargin : . top )
textContainer . autoPinEdge ( toSuperviewMargin : . bottom )
textContainer . autoPinEdge ( toSuperviewMargin : . left )
sendButton . autoPinEdge ( . left , to : . right , of : textContainer , withOffset : AttachmentTextToolbar . kToolbarMargin )
sendButton . autoPinEdge ( . bottom , to : . bottom , of : textContainer , withOffset : - 3 )
sendButton . autoPinEdge ( toSuperviewMargin : . right )
sendButton . setContentHuggingHigh ( )
sendButton . setCompressionResistanceHigh ( )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . left )
lengthLimitLabel . autoPinEdge ( toSuperviewMargin : . right )
lengthLimitLabel . autoPinEdge ( . bottom , to : . top , of : textContainer , withOffset : - 6 )
lengthLimitLabel . setContentHuggingHigh ( )
lengthLimitLabel . setCompressionResistanceHigh ( )
}
required init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
// MARK: - U I V i e w O v e r r i d e s
override var intrinsicContentSize : CGSize {
get {
// S i n c e w e h a v e ` s e l f . a u t o r e s i z i n g M a s k = U I V i e w A u t o r e s i z i n g F l e x i b l e H e i g h t ` , w e m u s t s p e c i f y
// a n i n t r i n s i c C o n t e n t S i z e . S p e c i f y i n g C G S i z e . z e r o c a u s e s t h e h e i g h t t o b e d e t e r m i n e d b y a u t o l a y o u t .
return CGSize . zero
}
}
// MARK: - S u b v i e w s
private let sendButton : UIButton
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 = . white
lengthLimitLabel . text = NSLocalizedString ( " ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED " , comment : " One-line label indicating the user can add no more text to the media message field. " )
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 . layer . shadowRadius = 2.0
lengthLimitLabel . isHidden = true
return lengthLimitLabel
} ( )
lazy var textView : UITextView = {
let textView = buildTextView ( )
textView . returnKeyType = . done
textView . scrollIndicatorInsets = UIEdgeInsets ( top : 5 , left : 0 , bottom : 5 , right : 3 )
return textView
} ( )
private lazy var placeholderTextView : UITextView = {
let placeholderTextView = buildTextView ( )
placeholderTextView . text = NSLocalizedString ( " Message " , comment : " " )
placeholderTextView . isEditable = false
return placeholderTextView
} ( )
private lazy var textContainer : UIView = {
let textContainer = UIView ( )
textContainer . layer . borderColor = UIColor . white . cgColor
textContainer . layer . borderWidth = Values . separatorThickness
textContainer . layer . cornerRadius = ( AttachmentTextToolbar . kMinTextViewHeight / 2 )
textContainer . clipsToBounds = true
textContainer . addSubview ( placeholderTextView )
placeholderTextView . autoPinEdgesToSuperviewEdges ( )
textContainer . addSubview ( textView )
textView . autoPinEdgesToSuperviewEdges ( )
return textContainer
} ( )
private func buildTextView ( ) -> UITextView {
let textView = AttachmentTextView ( )
textView . keyboardAppearance = isLightMode ? . default : . dark
textView . backgroundColor = . clear
textView . tintColor = . white
textView . font = . systemFont ( ofSize : Values . mediumFontSize )
textView . textColor = . white
textView . showsVerticalScrollIndicator = false
textView . textContainerInset = UIEdgeInsets ( top : 10 , left : 10 , bottom : 10 , right : 10 )
return textView
}
// MARK: - A c t i o n s
@objc func didTapSend ( ) {
attachmentTextToolbarDelegate ? . attachmentTextToolbarDidTapSend ( self )
}
// MARK: - U I T e x t V i e w D e l e g a t e
public func textViewDidChange ( _ textView : UITextView ) {
updateHeight ( textView : textView )
attachmentTextToolbarDelegate ? . attachmentTextToolbarDidChange ( self )
}
public func textView ( _ textView : UITextView , shouldChangeTextIn range : NSRange , replacementText text : String ) -> Bool {
if ! FeatureFlags . sendingMediaWithOversizeText {
let existingText : String = textView . text ? ? " "
let proposedText : String = ( existingText as NSString ) . replacingCharacters ( in : range , with : text )
// D o n ' t c o m p l i c a t e t h i n g s b y m i x i n g m e d i a a t t a c h m e n t s w i t h o v e r s i z e t e x t a t t a c h m e n t s
guard proposedText . utf8 . count < kOversizeTextMessageSizeThreshold else {
Logger . debug ( " long text was truncated " )
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
}
self . lengthLimitLabel . isHidden = true
// 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 .
guard proposedText . count < kMaxMessageBodyCharacterCount else {
Logger . debug ( " hit attachment message body 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 ( kMaxMessageBodyCharacterCount ) - charsAfterDelete
if charBudget >= 0 {
let acceptableNewText = String ( text . prefix ( charBudget ) )
textView . text = ( existingText as NSString ) . replacingCharacters ( in : range , with : acceptableNewText )
}
return false
}
}
// T h o u g h w e c a n w r a p t h e t e x t , w e d o n ' t w a n t t o e n c o u r a g e m u l t l i n e c a p t i o n s , p l u s a " d o n e " b u t t o n
// a l l o w s t h e u s e r t o g e t t h e k e y b o a r d o u t o f t h e w a y w h i l e i n t h e a t t a c h m e n t a p p r o v a l v i e w .
if text = = " \n " {
textView . resignFirstResponder ( )
return false
} else {
return true
}
}
public func textViewDidBeginEditing ( _ textView : UITextView ) {
attachmentTextToolbarDelegate ? . attachmentTextToolbarDidBeginEditing ( self )
updatePlaceholderTextViewVisibility ( )
}
public func textViewDidEndEditing ( _ textView : UITextView ) {
attachmentTextToolbarDelegate ? . attachmentTextToolbarDidEndEditing ( self )
updatePlaceholderTextViewVisibility ( )
}
// MARK: - H e l p e r s
func updatePlaceholderTextViewVisibility ( ) {
let isHidden : Bool = {
guard ! self . textView . isFirstResponder else {
return true
}
guard let text = self . textView . text else {
return false
}
guard text . count > 0 else {
return false
}
return true
} ( )
placeholderTextView . isHidden = isHidden
}
private func updateHeight ( textView : UITextView ) {
// c o m p u t e n e w h e i g h t a s s u m i n g w i d t h i s u n c h a n g e d
let currentSize = textView . frame . size
let newHeight = clampedTextViewHeight ( fixedWidth : currentSize . width )
if newHeight != textViewHeight {
Logger . debug ( " TextView height changed: \( textViewHeight ) -> \( newHeight ) " )
textViewHeight = newHeight
textViewHeightConstraint ? . constant = textViewHeight
invalidateIntrinsicContentSize ( )
}
}
private func clampedTextViewHeight ( fixedWidth : CGFloat ) -> CGFloat {
let contentSize = textView . sizeThatFits ( CGSize ( width : fixedWidth , height : CGFloat . greatestFiniteMagnitude ) )
return CGFloatClamp ( contentSize . height , AttachmentTextToolbar . kMinTextViewHeight , maxTextViewHeight )
}
}