//
// 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 AVFoundation
import MediaPlayer
import PromiseKit
@objc
public protocol AttachmentApprovalViewControllerDelegate : class {
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didApproveAttachments attachments : [ SignalAttachment ] , messageText : String ? )
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didCancelAttachments attachments : [ SignalAttachment ] )
@objc optional func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , addMoreToAttachments attachments : [ SignalAttachment ] )
@objc optional func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , changedCaptionOfAttachment attachment : SignalAttachment )
}
// MARK: -
class AttachmentItemCollection {
private ( set ) var attachmentItems : [ SignalAttachmentItem ]
init ( attachmentItems : [ SignalAttachmentItem ] ) {
self . attachmentItems = attachmentItems
}
func itemAfter ( item : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : item ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let nextIndex = attachmentItems . index ( after : currentIndex )
return attachmentItems [ safe : nextIndex ]
}
func itemBefore ( item : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : item ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let prevIndex = attachmentItems . index ( before : currentIndex )
return attachmentItems [ safe : prevIndex ]
}
func remove ( item : SignalAttachmentItem ) {
attachmentItems = attachmentItems . filter { $0 != item }
}
var count : Int {
return attachmentItems . count
}
}
// MARK: -
class SignalAttachmentItem : Hashable {
enum SignalAttachmentItemError : Error {
case noThumbnail
}
let attachment : SignalAttachment
// T h i s m i g h t b e n i l i f t h e a t t a c h m e n t i s n o t a v a l i d i m a g e .
var imageEditorModel : ImageEditorModel ?
init ( attachment : SignalAttachment ) {
self . attachment = attachment
// T r y a n d m a k e a I m a g e E d i t o r M o d e l .
// T h i s w i l l o n l y a p p l y f o r v a l i d i m a g e s .
if ImageEditorModel . isFeatureEnabled ,
let dataUrl : URL = attachment . dataUrl ,
dataUrl . isFileURL {
let path = dataUrl . path
do {
imageEditorModel = try ImageEditorModel ( srcImagePath : path )
} catch {
// U s u a l l y n o t a n e r r o r ; t h i s u s u a l l y i n d i c a t e s i n v a l i d i n p u t .
Logger . warn ( " Could not create image editor: \( error ) " )
}
}
}
// MARK:
var captionText : String ? {
return attachment . captionText
}
var imageSize : CGSize = . zero
func getThumbnailImage ( ) -> Promise < UIImage > {
return DispatchQueue . global ( ) . async ( . promise ) { ( ) -> UIImage in
guard let image = self . attachment . staticThumbnail ( ) else {
throw SignalAttachmentItemError . noThumbnail
}
return image
} . tap { result in
switch result {
case . fulfilled ( let image ) :
self . imageSize = image . size
case . rejected ( let error ) :
owsFailDebug ( " failed with error: \( error ) " )
}
}
}
// MARK: H a s h a b l e
public var hashValue : Int {
return attachment . hashValue
}
// MARK: E q u a t a b l e
static func = = ( lhs : SignalAttachmentItem , rhs : SignalAttachmentItem ) -> Bool {
return lhs . attachment = = rhs . attachment
}
}
// MARK: -
@objc
public enum AttachmentApprovalViewControllerMode : UInt {
case modal
case sharedNavigation
}
// MARK: -
@objc
public class AttachmentApprovalViewController : UIPageViewController , UIPageViewControllerDataSource , UIPageViewControllerDelegate {
// MARK: - P r o p e r t i e s
private let mode : AttachmentApprovalViewControllerMode
public weak var approvalDelegate : AttachmentApprovalViewControllerDelegate ?
// MARK: - I n i t i a l i z e r s
@ available ( * , unavailable , message : " use attachment: constructor instead. " )
required public init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
let kSpacingBetweenItems : CGFloat = 20
@objc
required public init ( mode : AttachmentApprovalViewControllerMode ,
attachments : [ SignalAttachment ] ) {
assert ( attachments . count > 0 )
self . mode = mode
let attachmentItems = attachments . map { SignalAttachmentItem ( attachment : $0 ) }
self . attachmentItemCollection = AttachmentItemCollection ( attachmentItems : attachmentItems )
super . init ( transitionStyle : . scroll ,
navigationOrientation : . horizontal ,
options : [ UIPageViewControllerOptionInterPageSpacingKey : kSpacingBetweenItems ] )
self . dataSource = self
self . delegate = self
}
@objc
public class func wrappedInNavController ( attachments : [ SignalAttachment ] , approvalDelegate : AttachmentApprovalViewControllerDelegate ) -> OWSNavigationController {
let vc = AttachmentApprovalViewController ( mode : . modal , attachments : attachments )
vc . approvalDelegate = approvalDelegate
let navController = OWSNavigationController ( rootViewController : vc )
navController . ows_prefersStatusBarHidden = true
guard let navigationBar = navController . navigationBar as ? OWSNavigationBar else {
owsFailDebug ( " navigationBar was nil or unexpected class " )
return navController
}
navigationBar . overrideTheme ( type : . clear )
return navController
}
// MARK: - S u b v i e w s
var galleryRailView : GalleryRailView {
return bottomToolView . galleryRailView
}
var mediaMessageTextToolbar : MediaMessageTextToolbar {
return bottomToolView . mediaMessageTextToolbar
}
lazy var bottomToolView : BottomToolView = {
let isAddMoreVisible = mode = = . sharedNavigation
let bottomToolView = BottomToolView ( isAddMoreVisible : isAddMoreVisible )
return bottomToolView
} ( )
// MARK: - V i e w L i f e c y c l e
public override var prefersStatusBarHidden : Bool {
return true
}
override public func viewDidLoad ( ) {
super . viewDidLoad ( )
self . view . backgroundColor = . black
// a v o i d a n u n p l e a s a n t " b o u n c e " w h i c h d o e s n ' t m a k e s e n s e i n t h e c o n t e x t o f a s i n g l e i t e m .
pagerScrollView ? . isScrollEnabled = attachmentItems . count > 1
// B o t t o m T o o l b a r
galleryRailView . delegate = self
mediaMessageTextToolbar . mediaMessageTextToolbarDelegate = self
// N a v i g a t i o n
self . navigationItem . title = nil
guard let firstItem = attachmentItems . first else {
owsFailDebug ( " firstItem was unexpectedly nil " )
return
}
self . setCurrentItem ( firstItem , direction : . forward , animated : false )
// l a y o u t i m m e d i a t e l y t o a v o i d a n i m a t i n g t h e l a y o u t p r o c e s s d u r i n g t h e t r a n s i t i o n
self . currentPageViewController . view . layoutIfNeeded ( )
}
override public func viewWillAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewWillAppear ( animated )
guard let navigationBar = navigationController ? . navigationBar as ? OWSNavigationBar else {
owsFailDebug ( " navigationBar was nil or unexpected class " )
return
}
navigationBar . overrideTheme ( type : . clear )
updateNavigationBar ( )
updateControlVisibility ( )
}
override public func viewDidAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewDidAppear ( animated )
updateNavigationBar ( )
updateControlVisibility ( )
}
override public func viewWillDisappear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewWillDisappear ( animated )
}
override public var inputAccessoryView : UIView ? {
bottomToolView . layoutIfNeeded ( )
return bottomToolView
}
override public var canBecomeFirstResponder : Bool {
return ! shouldHideControls
}
// MARK: - N a v i g a t i o n B a r
public func updateNavigationBar ( ) {
guard ! shouldHideControls else {
self . navigationItem . leftBarButtonItem = nil
self . navigationItem . rightBarButtonItem = nil
return
}
var navigationBarItems = [ UIView ] ( )
var isShowingCaptionView = false
if let viewControllers = viewControllers ,
viewControllers . count = = 1 ,
let firstViewController = viewControllers . first as ? AttachmentPrepViewController {
navigationBarItems = firstViewController . navigationBarItems ( )
isShowingCaptionView = firstViewController . isShowingCaptionView
}
guard ! isShowingCaptionView else {
// H i d e a l l n a v i g a t i o n b a r i t e m s w h i l e t h e c a p t i o n v i e w i s o p e n .
self . navigationItem . leftBarButtonItem = nil
self . navigationItem . rightBarButtonItem = nil
return
}
updateNavigationBar ( navigationBarItems : navigationBarItems )
let hasCancel = ( mode != . sharedNavigation )
if hasCancel {
let cancelButton = UIBarButtonItem ( barButtonSystemItem : . cancel ,
target : self , action : #selector ( cancelPressed ) )
cancelButton . tintColor = . white
self . navigationItem . leftBarButtonItem = cancelButton
} else {
// N o t e : u s i n g a c u s t o m l e f t B a r B u t t o n I t e m b r e a k s t h e i n t e r a c t i v e p o p g e s t u r e .
self . navigationItem . leftBarButtonItem = self . createOWSBackButton ( )
}
}
// MARK: - C o n t r o l V i s i b i l i t y
public var shouldHideControls : Bool {
guard let pageViewController = pageViewControllers . first else {
return false
}
return pageViewController . shouldHideControls
}
private func updateControlVisibility ( ) {
if shouldHideControls {
if isFirstResponder {
resignFirstResponder ( )
}
} else {
if ! isFirstResponder {
becomeFirstResponder ( )
}
}
}
// MARK: - V i e w H e l p e r s
func remove ( attachmentItem : SignalAttachmentItem ) {
if attachmentItem = = currentItem {
if let nextItem = attachmentItemCollection . itemAfter ( item : attachmentItem ) {
setCurrentItem ( nextItem , direction : . forward , animated : true )
} else if let prevItem = attachmentItemCollection . itemBefore ( item : attachmentItem ) {
setCurrentItem ( prevItem , direction : . reverse , animated : true )
} else {
owsFailDebug ( " removing last item shouldn't be possible because rail should not be visible " )
return
}
}
guard let cell = galleryRailView . cellViews . first ( where : { $0 . item = = = attachmentItem } ) else {
owsFailDebug ( " cell was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.2 ,
animations : {
// s h r i n k s t a c k v i e w i t e m u n t i l i t d i s a p p e a r s
cell . isHidden = true
// s i m u l t a n e o u s l y f a d e o u t
cell . alpha = 0
} ,
completion : { _ in
self . attachmentItemCollection . remove ( item : attachmentItem )
self . updateMediaRail ( )
} )
}
lazy var pagerScrollView : UIScrollView ? = {
// T h i s i s k i n d o f a h a c k . S i n c e w e d o n ' t h a v e f i r s t c l a s s a c c e s s t o t h e s u p e r v i e w ' s ` s c r o l l V i e w `
// w e t r a v e r s e t h e v i e w h i e r a r c h y u n t i l w e f i n d i t .
let pagerScrollView = view . subviews . first { $0 is UIScrollView } as ? UIScrollView
assert ( pagerScrollView != nil )
return pagerScrollView
} ( )
// MARK: - U I P a g e V i e w C o n t r o l l e r D e l e g a t e
public func pageViewController ( _ pageViewController : UIPageViewController , willTransitionTo pendingViewControllers : [ UIViewController ] ) {
Logger . debug ( " " )
assert ( pendingViewControllers . count = = 1 )
pendingViewControllers . forEach { viewController in
guard let pendingPage = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return
}
// u s e c o m p a c t s c a l e w h e n k e y b o a r d i s p o p p e d .
let scale : AttachmentPrepViewController . AttachmentViewScale = self . isFirstResponder ? . fullsize : . compact
pendingPage . setAttachmentViewScale ( scale , animated : false )
}
}
public func pageViewController ( _ pageViewController : UIPageViewController , didFinishAnimating finished : Bool , previousViewControllers : [ UIViewController ] , transitionCompleted : Bool ) {
Logger . debug ( " " )
assert ( previousViewControllers . count = = 1 )
previousViewControllers . forEach { viewController in
guard let previousPage = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return
}
if transitionCompleted {
previousPage . zoomOut ( animated : false )
updateMediaRail ( )
}
}
updateNavigationBar ( )
updateControlVisibility ( )
}
// MARK: - U I P a g e V i e w C o n t r o l l e r D a t a S o u r c e
public func pageViewController ( _ pageViewController : UIPageViewController , viewControllerBefore viewController : UIViewController ) -> UIViewController ? {
guard let currentViewController = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return nil
}
let currentItem = currentViewController . attachmentItem
guard let previousItem = attachmentItem ( before : currentItem ) else {
return nil
}
guard let previousPage : AttachmentPrepViewController = buildPage ( item : previousItem ) else {
return nil
}
return previousPage
}
public func pageViewController ( _ pageViewController : UIPageViewController , viewControllerAfter viewController : UIViewController ) -> UIViewController ? {
Logger . debug ( " " )
guard let currentViewController = viewController as ? AttachmentPrepViewController else {
owsFailDebug ( " unexpected viewController: \( viewController ) " )
return nil
}
let currentItem = currentViewController . attachmentItem
guard let nextItem = attachmentItem ( after : currentItem ) else {
return nil
}
guard let nextPage : AttachmentPrepViewController = buildPage ( item : nextItem ) else {
return nil
}
return nextPage
}
public var currentPageViewController : AttachmentPrepViewController {
return pageViewControllers . first !
}
public var pageViewControllers : [ AttachmentPrepViewController ] {
return super . viewControllers ! . map { $0 as ! AttachmentPrepViewController }
}
var currentItem : SignalAttachmentItem ! {
get {
return currentPageViewController . attachmentItem
}
set {
setCurrentItem ( newValue , direction : . forward , animated : false )
}
}
private var cachedPages : [ SignalAttachmentItem : AttachmentPrepViewController ] = [ : ]
private func buildPage ( item : SignalAttachmentItem ) -> AttachmentPrepViewController ? {
if let cachedPage = cachedPages [ item ] {
Logger . debug ( " cache hit. " )
return cachedPage
}
Logger . debug ( " cache miss. " )
let viewController = AttachmentPrepViewController ( attachmentItem : item )
viewController . prepDelegate = self
cachedPages [ item ] = viewController
return viewController
}
private func setCurrentItem ( _ item : SignalAttachmentItem , direction : UIPageViewControllerNavigationDirection , animated isAnimated : Bool ) {
guard let page = self . buildPage ( item : item ) else {
owsFailDebug ( " unexpectedly unable to build new page " )
return
}
page . loadViewIfNeeded ( )
self . setViewControllers ( [ page ] , direction : direction , animated : isAnimated , completion : nil )
updateMediaRail ( )
}
func updateMediaRail ( ) {
guard let currentItem = self . currentItem else {
owsFailDebug ( " currentItem was unexpectedly nil " )
return
}
let cellViewBuilder : ( ) -> ApprovalRailCellView = { [ weak self ] in
let cell = ApprovalRailCellView ( )
cell . approvalRailCellDelegate = self
return cell
}
galleryRailView . configureCellViews ( itemProvider : attachmentItemCollection ,
focusedItem : currentItem ,
cellViewBuilder : cellViewBuilder )
galleryRailView . isHidden = attachmentItemCollection . attachmentItems . count < 2
}
let attachmentItemCollection : AttachmentItemCollection
var attachmentItems : [ SignalAttachmentItem ] {
return attachmentItemCollection . attachmentItems
}
var attachments : [ SignalAttachment ] {
return attachmentItems . map { ( attachmentItem ) in
autoreleasepool {
return self . processedAttachment ( forAttachmentItem : attachmentItem )
}
}
}
// F o r a n y a t t a c h m e n t s e d i t e d w i t h t h e i m a g e e d i t o r , r e t u r n s a
// n e w S i g n a l A t t a c h m e n t t h a t r e f l e c t s t h o s e c h a n g e s . O t h e r w i s e ,
// r e t u r n s t h e o r i g i n a l a t t a c h m e n t .
//
// I f a n y e r r o r s o c c u r s i n t h e e x p o r t p r o c e s s , w e f a i l o v e r t o
// s e n d i n g t h e o r i g i n a l a t t a c h m e n t . T h i s s e e m s b e t t e r t h a n t r y i n g
// t o i n v o l v e t h e u s e r i n r e s o l v i n g t h e i s s u e .
func processedAttachment ( forAttachmentItem attachmentItem : SignalAttachmentItem ) -> SignalAttachment {
guard let imageEditorModel = attachmentItem . imageEditorModel else {
// I m a g e w a s n o t e d i t e d .
return attachmentItem . attachment
}
guard imageEditorModel . isDirty ( ) else {
// I m a g e e d i t o r h a s n o c h a n g e s .
return attachmentItem . attachment
}
guard let dstImage = ImageEditorCanvasView . renderForOutput ( model : imageEditorModel , transform : imageEditorModel . currentTransform ( ) ) else {
owsFailDebug ( " Could not render for output. " )
return attachmentItem . attachment
}
var dataUTI = kUTTypeImage as String
guard let dstData : Data = {
let isLossy : Bool = attachmentItem . attachment . mimeType . caseInsensitiveCompare ( OWSMimeTypeImageJpeg ) = = . orderedSame
if isLossy {
dataUTI = kUTTypeJPEG as String
return UIImageJPEGRepresentation ( dstImage , 0.9 )
} else {
dataUTI = kUTTypePNG as String
return UIImagePNGRepresentation ( dstImage )
}
} ( ) else {
owsFailDebug ( " Could not export for output. " )
return attachmentItem . attachment
}
guard let dataSource = DataSourceValue . dataSource ( with : dstData , utiType : dataUTI ) else {
owsFailDebug ( " Could not prepare data source for output. " )
return attachmentItem . attachment
}
// R e w r i t e t h e f i l e n a m e ' s e x t e n s i o n t o r e f l e c t t h e o u t p u t f i l e f o r m a t .
var filename : String ? = attachmentItem . attachment . sourceFilename
if let sourceFilename = attachmentItem . attachment . sourceFilename {
if let fileExtension : String = MIMETypeUtil . fileExtension ( forUTIType : dataUTI ) {
filename = ( sourceFilename as NSString ) . deletingPathExtension . appendingFileExtension ( fileExtension )
}
}
dataSource . sourceFilename = filename
let dstAttachment = SignalAttachment . attachment ( dataSource : dataSource , dataUTI : dataUTI , imageQuality : . original )
if let attachmentError = dstAttachment . error {
owsFailDebug ( " Could not prepare attachment for output: \( attachmentError ) . " )
return attachmentItem . attachment
}
return dstAttachment
}
func attachmentItem ( before currentItem : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : currentItem ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let index : Int = attachmentItems . index ( before : currentIndex )
guard let previousItem = attachmentItems [ safe : index ] else {
// a l r e a d y a t f i r s t i t e m
return nil
}
return previousItem
}
func attachmentItem ( after currentItem : SignalAttachmentItem ) -> SignalAttachmentItem ? {
guard let currentIndex = attachmentItems . index ( of : currentItem ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return nil
}
let index : Int = attachmentItems . index ( after : currentIndex )
guard let nextItem = attachmentItems [ safe : index ] else {
// a l r e a d y a t l a s t i t e m
return nil
}
return nextItem
}
// MARK: - E v e n t H a n d l e r s
@objc func cancelPressed ( sender : UIButton ) {
self . approvalDelegate ? . attachmentApproval ( self , didCancelAttachments : attachments )
}
}
extension AttachmentApprovalViewController : MediaMessageTextToolbarDelegate {
func mediaMessageTextToolbarDidBeginEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
currentPageViewController . setAttachmentViewScale ( . compact , animated : true )
}
func mediaMessageTextToolbarDidEndEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
currentPageViewController . setAttachmentViewScale ( . fullsize , animated : true )
}
func mediaMessageTextToolbarDidTapSend ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
// T o o l b a r f l i c k e r s i n a n d o u t i f t h e r e a r e e r r o r s
// a n d r e m a i n s v i s i b l e m o m e n t a r i l y a f t e r s h a r e e x t e n s i o n i s d i s m i s s e d .
// I t ' s e a s i e s t t o j u s t h i d e i t a t t h i s p o i n t s i n c e w e ' r e d o n e w i t h i t .
currentPageViewController . shouldAllowAttachmentViewResizing = false
mediaMessageTextToolbar . isUserInteractionEnabled = false
mediaMessageTextToolbar . isHidden = true
approvalDelegate ? . attachmentApproval ( self , didApproveAttachments : attachments , messageText : mediaMessageTextToolbar . messageText )
}
func mediaMessageTextToolbarDidAddMore ( _ mediaMessageTextToolbar : MediaMessageTextToolbar ) {
self . approvalDelegate ? . attachmentApproval ? ( self , addMoreToAttachments : attachments )
}
}
extension AttachmentApprovalViewController : AttachmentPrepViewControllerDelegate {
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didUpdateCaptionForAttachmentItem attachmentItem : SignalAttachmentItem ) {
self . approvalDelegate ? . attachmentApproval ? ( self , changedCaptionOfAttachment : attachmentItem . attachment )
updateMediaRail ( )
}
func prepViewControllerUpdateNavigationBar ( ) {
updateNavigationBar ( )
}
func prepViewControllerUpdateControls ( ) {
updateControlVisibility ( )
}
func prepViewControllerAttachmentCount ( ) -> Int {
return attachmentItemCollection . count
}
}
// MARK: G a l l e r y R a i l
extension SignalAttachmentItem : GalleryRailItem {
var aspectRatio : CGFloat {
return self . imageSize . aspectRatio
}
func getRailImage ( ) -> Promise < UIImage > {
return self . getThumbnailImage ( )
}
}
extension AttachmentItemCollection : GalleryRailItemProvider {
var railItems : [ GalleryRailItem ] {
return self . attachmentItems
}
}
extension AttachmentApprovalViewController : GalleryRailViewDelegate {
public func galleryRailView ( _ galleryRailView : GalleryRailView , didTapItem imageRailItem : GalleryRailItem ) {
guard let targetItem = imageRailItem as ? SignalAttachmentItem else {
owsFailDebug ( " unexpected imageRailItem: \( imageRailItem ) " )
return
}
guard let currentIndex = attachmentItems . index ( of : currentItem ) else {
owsFailDebug ( " currentIndex was unexpectedly nil " )
return
}
guard let targetIndex = attachmentItems . index ( of : targetItem ) else {
owsFailDebug ( " targetIndex was unexpectedly nil " )
return
}
let direction : UIPageViewControllerNavigationDirection = currentIndex < targetIndex ? . forward : . reverse
self . setCurrentItem ( targetItem , direction : direction , animated : true )
}
}
// MARK: - I n d i v i d u a l P a g e
enum KeyboardScenario {
case hidden , editingMessage , editingCaption
}
protocol AttachmentPrepViewControllerDelegate : class {
func prepViewController ( _ prepViewController : AttachmentPrepViewController , didUpdateCaptionForAttachmentItem attachmentItem : SignalAttachmentItem )
func prepViewControllerUpdateNavigationBar ( )
func prepViewControllerUpdateControls ( )
func prepViewControllerAttachmentCount ( ) -> Int
}
public class AttachmentPrepViewController : OWSViewController , PlayerProgressBarDelegate , OWSVideoPlayerDelegate {
// W e s o m e t i m e s s h r i n k t h e a t t a c h m e n t v i e w s o t h a t i t r e m a i n s s o m e w h a t v i s i b l e
// w h e n t h e k e y b o a r d i s p r e s e n t e d .
enum AttachmentViewScale {
case fullsize , compact
}
// MARK: - P r o p e r t i e s
weak var prepDelegate : AttachmentPrepViewControllerDelegate ?
let attachmentItem : SignalAttachmentItem
var attachment : SignalAttachment {
return attachmentItem . attachment
}
private var videoPlayer : OWSVideoPlayer ?
private ( set ) var mediaMessageView : MediaMessageView !
private ( set ) var scrollView : UIScrollView !
private ( set ) var contentContainer : UIView !
private ( set ) var playVideoButton : UIView ?
private var imageEditorView : ImageEditorView ?
fileprivate var isShowingCaptionView = false {
didSet {
prepDelegate ? . prepViewControllerUpdateNavigationBar ( )
prepDelegate ? . prepViewControllerUpdateControls ( )
}
}
public var shouldHideControls : Bool {
guard let imageEditorView = imageEditorView else {
return false
}
return imageEditorView . shouldHideControls
}
// MARK: - I n i t i a l i z e r s
init ( attachmentItem : SignalAttachmentItem ) {
self . attachmentItem = attachmentItem
super . init ( nibName : nil , bundle : nil )
assert ( ! attachment . hasError )
}
public required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
// MARK: - S u b v i e w s
// TODO: D o w e s t i l l n e e d t h i s ?
lazy var touchInterceptorView : UIView = {
let touchInterceptorView = UIView ( )
let tapGesture = UITapGestureRecognizer ( target : self , action : #selector ( didTapTouchInterceptorView ( gesture : ) ) )
touchInterceptorView . addGestureRecognizer ( tapGesture )
return touchInterceptorView
} ( )
// MARK: - V i e w L i f e c y c l e
override public func loadView ( ) {
self . view = UIView ( )
self . mediaMessageView = MediaMessageView ( attachment : attachment , mode : . attachmentApproval )
// A n y t h i n g t h a t s h o u l d b e s h r u n k w h e n u s e r p o p s k e y b o a r d l i v e s i n t h e c o n t e n t C o n t a i n e r .
let contentContainer = UIView ( )
self . contentContainer = contentContainer
view . addSubview ( contentContainer )
contentContainer . autoPinEdgesToSuperviewEdges ( )
// S c r o l l V i e w - u s e d t o z o o m / p a n o n i m a g e s a n d v i d e o
scrollView = UIScrollView ( )
contentContainer . addSubview ( scrollView )
scrollView . delegate = self
scrollView . showsHorizontalScrollIndicator = false
scrollView . showsVerticalScrollIndicator = false
// P a n n i n g s h o u l d s t o p p r e t t y s o o n a f t e r t h e u s e r s t o p s s c r o l l i n g
scrollView . decelerationRate = UIScrollViewDecelerationRateFast
// W e w a n t s c r o l l v i e w c o n t e n t u p a n d b e h i n d t h e s y s t e m s t a t u s b a r c o n t e n t
// b u t w e w a n t o t h e r c o n t e n t ( e . g . b a r b u t t o n s ) t o r e s p e c t t h e t o p l a y o u t g u i d e .
self . automaticallyAdjustsScrollViewInsets = false
scrollView . autoPinEdgesToSuperviewEdges ( )
let backgroundColor = UIColor . black
self . view . backgroundColor = backgroundColor
// C r e a t e f u l l s c r e e n c o n t a i n e r v i e w s o t h e s c r o l l V i e w
// c a n c o m p u t e a n a p p r o p r i a t e c o n t e n t s i z e i n w h i c h t o c e n t e r
// o u r m e d i a v i e w .
let containerView = UIView . container ( )
scrollView . addSubview ( containerView )
containerView . autoPinEdgesToSuperviewEdges ( )
containerView . autoMatch ( . height , to : . height , of : self . view )
containerView . autoMatch ( . width , to : . width , of : self . view )
containerView . addSubview ( mediaMessageView )
mediaMessageView . autoPinEdgesToSuperviewEdges ( )
#if DEBUG
if let imageEditorModel = attachmentItem . imageEditorModel {
let imageEditorView = ImageEditorView ( model : imageEditorModel , delegate : self )
if imageEditorView . configureSubviews ( ) {
self . imageEditorView = imageEditorView
mediaMessageView . isHidden = true
view . addSubview ( imageEditorView )
imageEditorView . autoPinEdgesToSuperviewEdges ( )
imageEditorUpdateNavigationBar ( )
}
}
#endif
if isZoomable {
// A d d t o p a n d b o t t o m g r a d i e n t s t o e n s u r e t o o l b a r c o n t r o l s a r e l e g i b l e
// w h e n p l a c e d o v e r i m a g e / v i d e o p r e v i e w w h i c h m a y b e a c l a s h i n g c o l o r .
let topGradient = GradientView ( from : backgroundColor , to : UIColor . clear )
self . view . addSubview ( topGradient )
topGradient . autoPinWidthToSuperview ( )
topGradient . autoPinEdge ( toSuperviewEdge : . top )
topGradient . autoSetDimension ( . height , toSize : ScaleFromIPhone5 ( 60 ) )
}
// H i d e t h e p l a y b u t t o n e m b e d d e d i n t h e M e d i a V i e w a n d r e p l a c e i t w i t h o u r o w n .
// T h i s a l l o w s u s t o z o o m i n o n t h e m e d i a v i e w w i t h o u t z o o m i n g i n o n t h e b u t t o n
if attachment . isVideo {
guard let videoURL = attachment . dataUrl else {
owsFailDebug ( " Missing videoURL " )
return
}
let player = OWSVideoPlayer ( url : videoURL )
self . videoPlayer = player
player . delegate = self
let playerView = VideoPlayerView ( )
playerView . player = player . avPlayer
self . mediaMessageView . addSubview ( playerView )
playerView . autoPinEdgesToSuperviewEdges ( )
let pauseGesture = UITapGestureRecognizer ( target : self , action : #selector ( didTapPlayerView ( _ : ) ) )
playerView . addGestureRecognizer ( pauseGesture )
let progressBar = PlayerProgressBar ( )
progressBar . player = player . avPlayer
progressBar . delegate = self
// w e d o n ' t w a n t t h e p r o g r e s s b a r t o z o o m d u r i n g " p i n c h - t o - z o o m "
// b u t w e d o w a n t i t t o s h r i n k w i t h t h e m e d i a c o n t e n t w h e n t h e u s e r
// p o p s t h e k e y b o a r d .
contentContainer . addSubview ( progressBar )
progressBar . autoPin ( toTopLayoutGuideOf : self , withInset : 0 )
progressBar . autoPinWidthToSuperview ( )
progressBar . autoSetDimension ( . height , toSize : 44 )
self . mediaMessageView . videoPlayButton ? . isHidden = true
let playButton = UIButton ( )
self . playVideoButton = playButton
playButton . accessibilityLabel = NSLocalizedString ( " PLAY_BUTTON_ACCESSABILITY_LABEL " , comment : " Accessibility label for button to start media playback " )
playButton . setBackgroundImage ( # imageLiteral ( resourceName : " play_button " ) , for : . normal )
playButton . contentMode = . scaleAspectFit
let playButtonWidth = ScaleFromIPhone5 ( 70 )
playButton . autoSetDimensions ( to : CGSize ( width : playButtonWidth , height : playButtonWidth ) )
self . contentContainer . addSubview ( playButton )
playButton . addTarget ( self , action : #selector ( playButtonTapped ) , for : . touchUpInside )
playButton . autoCenterInSuperview ( )
}
// C a p t i o n
view . addSubview ( touchInterceptorView )
touchInterceptorView . autoPinEdgesToSuperviewEdges ( )
touchInterceptorView . isHidden = true
}
override public func viewWillAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewWillAppear ( animated )
prepDelegate ? . prepViewControllerUpdateNavigationBar ( )
prepDelegate ? . prepViewControllerUpdateControls ( )
}
override public func viewDidAppear ( _ animated : Bool ) {
Logger . debug ( " " )
super . viewDidAppear ( animated )
prepDelegate ? . prepViewControllerUpdateNavigationBar ( )
prepDelegate ? . prepViewControllerUpdateControls ( )
}
override public func viewWillLayoutSubviews ( ) {
Logger . debug ( " " )
super . viewWillLayoutSubviews ( )
// e . g . i f f l i p p i n g t o / f r o m l a n d s c a p e
updateMinZoomScaleForSize ( view . bounds . size )
ensureAttachmentViewScale ( animated : false )
}
// MARK: - N a v i g a t i o n B a r
public func navigationBarItems ( ) -> [ UIView ] {
let captionButton = navigationBarButton ( imageName : " image_editor_caption " ,
selector : #selector ( didTapCaption ( sender : ) ) )
guard let imageEditorView = imageEditorView else {
// S h o w t h e " a d d c a p t i o n " b u t t o n f o r n o n - i m a g e a t t a c h m e n t s i f
// t h e r e i s m o r e t h a n o n e a t t a c h m e n t .
if let prepDelegate = prepDelegate ,
prepDelegate . prepViewControllerAttachmentCount ( ) > 1 {
return [ captionButton ]
}
return [ ]
}
var navigationBarItems = imageEditorView . navigationBarItems ( )
// S h o w t h e c a p t i o n U I i f t h e r e ' s m o r e t h a n o n e a t t a c h m e n t
// O R i f t h e a t t a c h m e n t a l r e a d y h a s a c a p t i o n .
var shouldShowCaptionUI = attachmentCount ( ) > 0
if let captionText = attachmentItem . captionText , captionText . count > 0 {
shouldShowCaptionUI = true
}
if shouldShowCaptionUI {
navigationBarItems . append ( captionButton )
}
return navigationBarItems
}
private func attachmentCount ( ) -> Int {
guard let prepDelegate = prepDelegate else {
owsFailDebug ( " Missing prepDelegate. " )
return 0
}
return prepDelegate . prepViewControllerAttachmentCount ( )
}
@objc func didTapCaption ( sender : UIButton ) {
Logger . verbose ( " " )
presentCaptionView ( )
}
private func presentCaptionView ( ) {
let view = AttachmentCaptionViewController ( delegate : self , attachmentItem : attachmentItem )
self . imageEditor ( presentFullScreenView : view , isTransparent : true )
isShowingCaptionView = true
}
// MARK: - E v e n t H a n d l e r s
@objc
func didTapTouchInterceptorView ( gesture : UITapGestureRecognizer ) {
Logger . info ( " " )
touchInterceptorView . isHidden = true
}
@objc
public func didTapPlayerView ( _ gestureRecognizer : UIGestureRecognizer ) {
assert ( self . videoPlayer != nil )
self . pauseVideo ( )
}
@objc
public func playButtonTapped ( ) {
self . playVideo ( )
}
// MARK: - V i d e o
private func playVideo ( ) {
Logger . info ( " " )
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
guard let playVideoButton = self . playVideoButton else {
owsFailDebug ( " playVideoButton was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.1 ) {
playVideoButton . alpha = 0.0
}
videoPlayer . play ( )
}
private func pauseVideo ( ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . pause ( )
guard let playVideoButton = self . playVideoButton else {
owsFailDebug ( " playVideoButton was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.1 ) {
playVideoButton . alpha = 1.0
}
}
@objc
public func videoPlayerDidPlayToCompletion ( _ videoPlayer : OWSVideoPlayer ) {
guard let playVideoButton = self . playVideoButton else {
owsFailDebug ( " playVideoButton was unexpectedly nil " )
return
}
UIView . animate ( withDuration : 0.1 ) {
playVideoButton . alpha = 1.0
}
}
public func playerProgressBarDidStartScrubbing ( _ playerProgressBar : PlayerProgressBar ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . pause ( )
}
public func playerProgressBar ( _ playerProgressBar : PlayerProgressBar , scrubbedToTime time : CMTime ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . seek ( to : time )
}
public func playerProgressBar ( _ playerProgressBar : PlayerProgressBar , didFinishScrubbingAtTime time : CMTime , shouldResumePlayback : Bool ) {
guard let videoPlayer = self . videoPlayer else {
owsFailDebug ( " video player was unexpectedly nil " )
return
}
videoPlayer . seek ( to : time )
if ( shouldResumePlayback ) {
videoPlayer . play ( )
}
}
// MARK: - H e l p e r s
var isZoomable : Bool {
return attachment . isImage || attachment . isVideo
}
func zoomOut ( animated : Bool ) {
if self . scrollView . zoomScale != self . scrollView . minimumZoomScale {
self . scrollView . setZoomScale ( self . scrollView . minimumZoomScale , animated : animated )
}
}
// W h e n t h e k e y b o a r d i s p o p p e d , i t c a n o b s c u r e t h e a t t a c h m e n t v i e w .
// s o w e s o m e t i m e s a l l o w r e s i z i n g t h e a t t a c h m e n t .
var shouldAllowAttachmentViewResizing : Bool = true
var attachmentViewScale : AttachmentViewScale = . fullsize
fileprivate func setAttachmentViewScale ( _ attachmentViewScale : AttachmentViewScale , animated : Bool ) {
self . attachmentViewScale = attachmentViewScale
ensureAttachmentViewScale ( animated : animated )
}
func ensureAttachmentViewScale ( animated : Bool ) {
let animationDuration = animated ? 0.2 : 0
guard shouldAllowAttachmentViewResizing else {
if self . contentContainer . transform != CGAffineTransform . identity {
UIView . animate ( withDuration : animationDuration ) {
self . contentContainer . transform = CGAffineTransform . identity
}
}
return
}
switch attachmentViewScale {
case . fullsize :
guard self . contentContainer . transform != . identity else {
return
}
UIView . animate ( withDuration : animationDuration ) {
self . contentContainer . transform = CGAffineTransform . identity
}
case . compact :
guard self . contentContainer . transform = = . identity else {
return
}
UIView . animate ( withDuration : animationDuration ) {
let kScaleFactor : CGFloat = 0.7
let scale = CGAffineTransform ( scaleX : kScaleFactor , y : kScaleFactor )
let originalHeight = self . scrollView . bounds . size . height
// P o s i t i o n t h e n e w s c a l e d i t e m t o b e c e n t e r e d w i t h r e s p e c t
// t o i t ' s n e w s i z e .
let heightDelta = originalHeight * ( 1 - kScaleFactor )
let translate = CGAffineTransform ( translationX : 0 , y : - heightDelta / 2 )
self . contentContainer . transform = scale . concatenating ( translate )
}
}
}
}
extension AttachmentPrepViewController : AttachmentCaptionDelegate {
func captionView ( _ captionView : AttachmentCaptionViewController , didChangeCaptionText captionText : String ? , attachmentItem : SignalAttachmentItem ) {
let attachment = attachmentItem . attachment
attachment . captionText = captionText
prepDelegate ? . prepViewController ( self , didUpdateCaptionForAttachmentItem : attachmentItem )
isShowingCaptionView = false
}
func captionViewDidCancel ( ) {
isShowingCaptionView = false
}
}
extension AttachmentPrepViewController : UIScrollViewDelegate {
public func viewForZooming ( in scrollView : UIScrollView ) -> UIView ? {
if isZoomable {
return mediaMessageView
} else {
// d o n ' t z o o m f o r a u d i o o r g e n e r i c a t t a c h m e n t s .
return nil
}
}
fileprivate func updateMinZoomScaleForSize ( _ size : CGSize ) {
Logger . debug ( " " )
// E n s u r e b o u n d s h a v e b e e n c o m p u t e d
mediaMessageView . layoutIfNeeded ( )
guard mediaMessageView . bounds . width > 0 , mediaMessageView . bounds . height > 0 else {
Logger . warn ( " bad bounds " )
return
}
let widthScale = size . width / mediaMessageView . bounds . width
let heightScale = size . height / mediaMessageView . bounds . height
let minScale = min ( widthScale , heightScale )
scrollView . maximumZoomScale = minScale * 5.0
scrollView . minimumZoomScale = minScale
scrollView . zoomScale = minScale
}
// K e e p t h e m e d i a v i e w c e n t e r e d w i t h i n t h e s c r o l l v i e w a s y o u z o o m
public func scrollViewDidZoom ( _ scrollView : UIScrollView ) {
// T h e s c r o l l v i e w h a s z o o m e d , s o y o u n e e d t o r e - c e n t e r t h e c o n t e n t s
let scrollViewSize = self . scrollViewVisibleSize
// F i r s t a s s u m e t h a t m e d i a M e s s a g e V i e w c e n t e r c o i n c i d e s w i t h t h e c o n t e n t s c e n t e r
// T h i s i s c o r r e c t w h e n t h e m e d i a M e s s a g e V i e w i s b i g g e r t h a n s c r o l l V i e w d u e t o z o o m
var contentCenter = CGPoint ( x : ( scrollView . contentSize . width / 2 ) , y : ( scrollView . contentSize . height / 2 ) )
let scrollViewCenter = self . scrollViewCenter
// i f m e d i a M e s s a g e V i e w i s s m a l l e r t h a n t h e s c r o l l V i e w v i s i b l e s i z e - f i x t h e c o n t e n t c e n t e r a c c o r d i n g l y
if self . scrollView . contentSize . width < scrollViewSize . width {
contentCenter . x = scrollViewCenter . x
}
if self . scrollView . contentSize . height < scrollViewSize . height {
contentCenter . y = scrollViewCenter . y
}
self . mediaMessageView . center = contentCenter
}
// r e t u r n t h e s c r o l l v i e w c e n t e r
private var scrollViewCenter : CGPoint {
let size = scrollViewVisibleSize
return CGPoint ( x : ( size . width / 2 ) , y : ( size . height / 2 ) )
}
// R e t u r n s c r o l l v i e w s i z e w i t h o u t t h e a r e a o v e r l a p p i n g w i t h t a b a n d n a v b a r .
private var scrollViewVisibleSize : CGSize {
let contentInset = scrollView . contentInset
let scrollViewSize = scrollView . bounds . standardized . size
let width = scrollViewSize . width - ( contentInset . left + contentInset . right )
let height = scrollViewSize . height - ( contentInset . top + contentInset . bottom )
return CGSize ( width : width , height : height )
}
}
// MARK: -
extension AttachmentPrepViewController : ImageEditorViewDelegate {
public func imageEditor ( presentFullScreenView viewController : UIViewController ,
isTransparent : Bool ) {
let navigationController = OWSNavigationController ( rootViewController : viewController )
navigationController . modalPresentationStyle = ( isTransparent
? . overFullScreen
: . fullScreen )
if let navigationBar = navigationController . navigationBar as ? OWSNavigationBar {
navigationBar . overrideTheme ( type : . clear )
} else {
owsFailDebug ( " navigationBar was nil or unexpected class " )
}
self . present ( navigationController , animated : false ) {
// D o n o t h i n g .
}
}
public func imageEditorUpdateNavigationBar ( ) {
prepDelegate ? . prepViewControllerUpdateNavigationBar ( )
}
public func imageEditorUpdateControls ( ) {
prepDelegate ? . prepViewControllerUpdateControls ( )
}
}
// MARK: -
class BottomToolView : UIView {
let mediaMessageTextToolbar : MediaMessageTextToolbar
let galleryRailView : GalleryRailView
var isEditingMediaMessage : Bool {
return mediaMessageTextToolbar . textView . isFirstResponder
}
let kGalleryRailViewHeight : CGFloat = 72
required init ( isAddMoreVisible : Bool ) {
mediaMessageTextToolbar = MediaMessageTextToolbar ( isAddMoreVisible : isAddMoreVisible )
galleryRailView = GalleryRailView ( )
galleryRailView . scrollFocusMode = . keepWithinBounds
galleryRailView . autoSetDimension ( . height , toSize : kGalleryRailViewHeight )
super . init ( frame : . zero )
// S p e c i f y i n g a u t o - r e 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
backgroundColor = UIColor . black . withAlphaComponent ( 0.6 )
preservesSuperviewLayoutMargins = true
let stackView = UIStackView ( arrangedSubviews : [ self . galleryRailView , self . mediaMessageTextToolbar ] )
stackView . axis = . vertical
addSubview ( stackView )
stackView . autoPinEdgesToSuperviewEdges ( )
}
required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
// MARK:
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
}
}
}
// 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 MediaMessageTextToolbarDelegate : class {
func mediaMessageTextToolbarDidTapSend ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
func mediaMessageTextToolbarDidBeginEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
func mediaMessageTextToolbarDidEndEditing ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
func mediaMessageTextToolbarDidAddMore ( _ mediaMessageTextToolbar : MediaMessageTextToolbar )
}
class MediaMessageTextToolbar : UIView , UITextViewDelegate {
weak var mediaMessageTextToolbarDelegate : MediaMessageTextToolbarDelegate ?
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
let kMinTextViewHeight : CGFloat = 38
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 ( isAddMoreVisible : Bool ) {
self . addMoreButton = UIButton ( type : . custom )
self . sendButton = UIButton ( type : . system )
self . textViewHeight = 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 addMoreIcon = # imageLiteral ( resourceName : " album_add_more " ) . withRenderingMode ( . alwaysTemplate )
addMoreButton . setImage ( addMoreIcon , for : . normal )
addMoreButton . tintColor = Theme . darkThemePrimaryColor
addMoreButton . addTarget ( self , action : #selector ( didTapAddMore ) , for : . touchUpInside )
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 = UIFont . ows_mediumFont ( withSize : 16 )
sendButton . titleLabel ? . textAlignment = . center
sendButton . tintColor = Theme . galleryHighlightColor
// 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 )
if isAddMoreVisible {
contentView . addSubview ( addMoreButton )
}
addSubview ( contentView )
contentView . autoPinEdgesToSuperviewEdges ( )
// L a y o u t
let kToolbarMargin : CGFloat = 8
// 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 : kToolbarMargin , left : kToolbarMargin , bottom : kToolbarMargin , right : kToolbarMargin )
self . textViewHeightConstraint = textView . autoSetDimension ( . height , toSize : 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 )
if isAddMoreVisible {
addMoreButton . autoPinEdge ( toSuperviewMargin : . left )
textContainer . autoPinEdge ( . left , to : . right , of : addMoreButton , withOffset : kToolbarMargin )
addMoreButton . autoAlignAxis ( . horizontal , toSameAxisOf : sendButton )
addMoreButton . setContentHuggingHigh ( )
addMoreButton . setCompressionResistanceHigh ( )
} else {
textContainer . autoPinEdge ( toSuperviewMargin : . left )
}
sendButton . autoPinEdge ( . left , to : . right , of : textContainer , withOffset : 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 addMoreButton : UIButton
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 . 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_TEXT_FIELD_PLACEHOLDER " , comment : " placeholder text for the editable message field " )
placeholderTextView . isEditable = false
return placeholderTextView
} ( )
private lazy var textContainer : UIView = {
let textContainer = UIView ( )
textContainer . layer . borderColor = Theme . darkThemePrimaryColor . cgColor
textContainer . layer . borderWidth = 0.5
textContainer . layer . cornerRadius = kMinTextViewHeight / 2
textContainer . clipsToBounds = true
textContainer . addSubview ( placeholderTextView )
placeholderTextView . autoPinEdgesToSuperviewEdges ( )
textContainer . addSubview ( textView )
textView . autoPinEdgesToSuperviewEdges ( )
return textContainer
} ( )
private func buildTextView ( ) -> UITextView {
let textView = MessageTextView ( )
textView . keyboardAppearance = Theme . darkThemeKeyboardAppearance
textView . backgroundColor = . clear
textView . tintColor = Theme . darkThemePrimaryColor
textView . font = UIFont . ows_dynamicTypeBody
textView . textColor = Theme . darkThemePrimaryColor
textView . textContainerInset = UIEdgeInsets ( top : 7 , left : 7 , bottom : 7 , right : 7 )
return textView
}
class MessageTextView : UITextView {
// W h e n c r e a t i n g n e w l i n e s , c o n t e n t O f f s e t i s a n i m a t e d , b u t b e c a u s e
// w e a r e s i m u l t a n e o u s l y r e s i z i n g t h e t e x t v i e w , t h i s c a n c a u s e t h e
// t e x t i n t h e t e x t v i e w t o b e " t o o h i g h " i n t h e t e x t v i e w .
// S o l u t i o n i s t o d i s a b l e a n i m a t i o n f o r s e t t i n g c o n t e n t o f f s e t .
override func setContentOffset ( _ contentOffset : CGPoint , animated : Bool ) {
super . setContentOffset ( contentOffset , animated : false )
}
}
// MARK: - A c t i o n s
@objc func didTapSend ( ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidTapSend ( self )
}
@objc func didTapAddMore ( ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidAddMore ( 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 )
}
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 ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidBeginEditing ( self )
updatePlaceholderTextViewVisibility ( )
}
public func textViewDidEndEditing ( _ textView : UITextView ) {
mediaMessageTextToolbarDelegate ? . mediaMessageTextToolbarDidEndEditing ( 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 , kMinTextViewHeight , maxTextViewHeight )
}
}
extension AttachmentApprovalViewController : ApprovalRailCellViewDelegate {
func approvalRailCellView ( _ approvalRailCellView : ApprovalRailCellView , didRemoveItem attachmentItem : SignalAttachmentItem ) {
remove ( attachmentItem : attachmentItem )
}
}
protocol ApprovalRailCellViewDelegate : class {
func approvalRailCellView ( _ approvalRailCellView : ApprovalRailCellView , didRemoveItem attachmentItem : SignalAttachmentItem )
}
public class ApprovalRailCellView : GalleryRailCellView {
weak var approvalRailCellDelegate : ApprovalRailCellViewDelegate ?
lazy var deleteButton : UIButton = {
let button = OWSButton { [ weak self ] in
guard let strongSelf = self else { return }
guard let attachmentItem = strongSelf . item as ? SignalAttachmentItem else {
owsFailDebug ( " attachmentItem was unexpectedly nil " )
return
}
strongSelf . approvalRailCellDelegate ? . approvalRailCellView ( strongSelf , didRemoveItem : attachmentItem )
}
button . setImage ( UIImage ( named : " x-24 " ) ? . withRenderingMode ( . alwaysTemplate ) , for : . normal )
button . tintColor = . white
button . layer . shadowColor = UIColor . black . cgColor
button . layer . shadowRadius = 2
button . layer . shadowOpacity = 0.66
button . layer . shadowOffset = . zero
let kButtonWidth : CGFloat = 24
button . autoSetDimensions ( to : CGSize ( width : kButtonWidth , height : kButtonWidth ) )
return button
} ( )
lazy var captionIndicator : UIView = {
let image = UIImage ( named : " image_editor_caption " ) ? . withRenderingMode ( . alwaysTemplate )
let imageView = UIImageView ( image : image )
imageView . tintColor = . white
imageView . layer . shadowColor = UIColor . black . cgColor
imageView . layer . shadowRadius = 2
imageView . layer . shadowOpacity = 0.66
imageView . layer . shadowOffset = . zero
return imageView
} ( )
override func setIsSelected ( _ isSelected : Bool ) {
super . setIsSelected ( isSelected )
if isSelected {
addSubview ( deleteButton )
deleteButton . autoPinEdge ( toSuperviewEdge : . top , withInset : cellBorderWidth )
deleteButton . autoPinEdge ( toSuperviewEdge : . trailing , withInset : cellBorderWidth + 4 )
} else {
deleteButton . removeFromSuperview ( )
}
}
override func configure ( item : GalleryRailItem , delegate : GalleryRailCellViewDelegate ) {
super . configure ( item : item , delegate : delegate )
var hasCaption = false
if let attachmentItem = item as ? SignalAttachmentItem {
if let captionText = attachmentItem . captionText {
hasCaption = captionText . count > 0
}
} else {
owsFailDebug ( " Invalid item. " )
}
if hasCaption {
addSubview ( captionIndicator )
captionIndicator . autoPinEdge ( toSuperviewEdge : . top , withInset : cellBorderWidth )
captionIndicator . autoPinEdge ( toSuperviewEdge : . leading , withInset : cellBorderWidth + 4 )
} else {
captionIndicator . removeFromSuperview ( )
}
}
}