//
// 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 MediaPlayer
import YYImage
import NVActivityIndicatorView
import SessionUIKit
@objc
public enum MediaMessageViewMode : UInt {
case large
case small
case attachmentApproval
}
@objc
public class MediaMessageView : UIView , OWSAudioPlayerDelegate {
// MARK: P r o p e r t i e s
@objc
public let mode : MediaMessageViewMode
@objc
public let attachment : SignalAttachment
@objc
public var audioPlayer : OWSAudioPlayer ?
@objc
public var audioPlayButton : UIButton ?
@objc
public var videoPlayButton : UIImageView ?
@objc
public var playbackState = AudioPlaybackState . stopped {
didSet {
AssertIsOnMainThread ( )
ensureButtonState ( )
}
}
@objc
public var audioProgressSeconds : CGFloat = 0
@objc
public var audioDurationSeconds : CGFloat = 0
@objc
public var contentView : UIView ?
private var linkPreviewInfo : ( url : String , draft : OWSLinkPreviewDraft ? ) ?
// MARK: I n i t i a l i z e r s
@ available ( * , unavailable , message : " use other constructor instead. " )
required public init ? ( coder aDecoder : NSCoder ) {
notImplemented ( )
}
// C u r r e n t l y w e o n l y u s e o n e m o d e ( A t t a c h m e n t A p p r o v a l ) , s o w e c o u l d s i m p l i f y t h i s c l a s s , b u t i t ' s k i n d
// o f n i c e t h a t i t ' s w r i t t e n i n a f l e x i b l e w a y i n c a s e w e ' d w a n t t o u s e i t e l s e w h e r e a g a i n i n t h e f u t u r e .
@objc
public required init ( attachment : SignalAttachment , mode : MediaMessageViewMode ) {
if attachment . hasError {
owsFailDebug ( attachment . error . debugDescription )
}
self . attachment = attachment
self . mode = mode
super . init ( frame : CGRect . zero )
createViews ( )
}
deinit {
NotificationCenter . default . removeObserver ( self )
}
// MARK: - C r e a t e V i e w s
private func createViews ( ) {
if attachment . isAnimatedImage {
createAnimatedPreview ( )
} else if attachment . isImage {
createImagePreview ( )
} else if attachment . isVideo {
createVideoPreview ( )
} else if attachment . isAudio {
createAudioPreview ( )
} else if attachment . isUrl {
createUrlPreview ( )
} else if attachment . isText {
// D o n o t h i n g a s w e w i l l j u s t p u t t h e t e x t i n t h e ' m e s s a g e ' i n p u t
} else {
createGenericPreview ( )
}
}
private func wrapViewsInVerticalStack ( subviews : [ UIView ] ) -> UIView {
assert ( subviews . count > 0 )
let stackView = UIView ( )
var lastView : UIView ?
for subview in subviews {
stackView . addSubview ( subview )
subview . autoHCenterInSuperview ( )
if lastView = = nil {
subview . autoPinEdge ( toSuperviewEdge : . top )
} else {
subview . autoPinEdge ( . top , to : . bottom , of : lastView ! , withOffset : stackSpacing ( ) )
}
lastView = subview
}
lastView ? . autoPinEdge ( toSuperviewEdge : . bottom )
return stackView
}
private func wrapViewsInHorizontalStack ( subviews : [ UIView ] ) -> UIView {
assert ( subviews . count > 0 )
let stackView = UIView ( )
var lastView : UIView ?
for subview in subviews {
stackView . addSubview ( subview )
subview . autoVCenterInSuperview ( )
if lastView = = nil {
subview . autoPinEdge ( toSuperviewEdge : . left )
} else {
subview . autoPinEdge ( . left , to : . right , of : lastView ! , withOffset : stackSpacing ( ) )
}
lastView = subview
}
lastView ? . autoPinEdge ( toSuperviewEdge : . right )
return stackView
}
private func stackSpacing ( ) -> CGFloat {
switch mode {
case . large , . attachmentApproval :
return CGFloat ( 10 )
case . small :
return CGFloat ( 5 )
}
}
private func createAudioPreview ( ) {
guard let dataUrl = attachment . dataUrl else {
createGenericPreview ( )
return
}
audioPlayer = OWSAudioPlayer ( mediaUrl : dataUrl , audioBehavior : . playback , delegate : self )
var subviews = [ UIView ] ( )
let audioPlayButton = UIButton ( )
self . audioPlayButton = audioPlayButton
setAudioIconToPlay ( )
audioPlayButton . imageView ? . layer . minificationFilter = . trilinear
audioPlayButton . imageView ? . layer . magnificationFilter = . trilinear
audioPlayButton . addTarget ( self , action : #selector ( audioPlayButtonPressed ) , for : . touchUpInside )
let buttonSize = createHeroViewSize ( )
audioPlayButton . autoSetDimension ( . width , toSize : buttonSize )
audioPlayButton . autoSetDimension ( . height , toSize : buttonSize )
subviews . append ( audioPlayButton )
let fileNameLabel = createFileNameLabel ( )
if let fileNameLabel = fileNameLabel {
subviews . append ( fileNameLabel )
}
let fileSizeLabel = createFileSizeLabel ( )
subviews . append ( fileSizeLabel )
let stackView = wrapViewsInVerticalStack ( subviews : subviews )
self . addSubview ( stackView )
fileNameLabel ? . autoPinWidthToSuperview ( withMargin : 32 )
// W e w a n t t o c e n t e r t h e s t a c k V i e w i n i t ' s s u p e r v i e w w h i l e a l s o e n s u r i n g
// i t ' s s u p e r v i e w i s b i g e n o u g h t o c o n t a i n i t .
stackView . autoPinWidthToSuperview ( )
stackView . autoVCenterInSuperview ( )
NSLayoutConstraint . autoSetPriority ( UILayoutPriority . defaultLow ) {
stackView . autoPinHeightToSuperview ( )
}
stackView . autoPinEdge ( toSuperviewEdge : . top , withInset : 0 , relation : . greaterThanOrEqual )
stackView . autoPinEdge ( toSuperviewEdge : . bottom , withInset : 0 , relation : . greaterThanOrEqual )
}
private func createAnimatedPreview ( ) {
guard attachment . isValidImage else {
createGenericPreview ( )
return
}
guard let dataUrl = attachment . dataUrl else {
createGenericPreview ( )
return
}
guard let image = YYImage ( contentsOfFile : dataUrl . path ) else {
createGenericPreview ( )
return
}
guard image . size . width > 0 && image . size . height > 0 else {
createGenericPreview ( )
return
}
let animatedImageView = YYAnimatedImageView ( )
animatedImageView . image = image
let aspectRatio = image . size . width / image . size . height
addSubviewWithScaleAspectFitLayout ( view : animatedImageView , aspectRatio : aspectRatio )
contentView = animatedImageView
}
private func addSubviewWithScaleAspectFitLayout ( view : UIView , aspectRatio : CGFloat ) {
self . addSubview ( view )
// T h i s e m u l a t e s t h e b e h a v i o r o f c o n t e n t M o d e = . s c a l e A s p e c t F i t u s i n g
// i O S a u t o l a y o u t c o n s t r a i n t s .
//
// T h i s a l l o w s C o n v e r s a t i o n I n p u t T o o l b a r t o p l a c e t h e " c a n c e l " b u t t o n
// i n t h e u p p e r - r i g h t h a n d c o r n e r o f t h e p r e v i e w c o n t e n t .
view . autoCenterInSuperview ( )
view . autoPin ( toAspectRatio : aspectRatio )
view . autoMatch ( . width , to : . width , of : self , withMultiplier : 1.0 , relation : . lessThanOrEqual )
view . autoMatch ( . height , to : . height , of : self , withMultiplier : 1.0 , relation : . lessThanOrEqual )
}
private func createImagePreview ( ) {
guard attachment . isValidImage else {
createGenericPreview ( )
return
}
guard let image = attachment . image ( ) else {
createGenericPreview ( )
return
}
guard image . size . width > 0 && image . size . height > 0 else {
createGenericPreview ( )
return
}
let imageView = UIImageView ( image : image )
imageView . layer . minificationFilter = . trilinear
imageView . layer . magnificationFilter = . trilinear
let aspectRatio = image . size . width / image . size . height
addSubviewWithScaleAspectFitLayout ( view : imageView , aspectRatio : aspectRatio )
contentView = imageView
}
private func createVideoPreview ( ) {
guard attachment . isValidVideo else {
createGenericPreview ( )
return
}
guard let image = attachment . videoPreview ( ) else {
createGenericPreview ( )
return
}
guard image . size . width > 0 && image . size . height > 0 else {
createGenericPreview ( )
return
}
let imageView = UIImageView ( image : image )
imageView . layer . minificationFilter = . trilinear
imageView . layer . magnificationFilter = . trilinear
let aspectRatio = image . size . width / image . size . height
addSubviewWithScaleAspectFitLayout ( view : imageView , aspectRatio : aspectRatio )
contentView = imageView
// a t t a c h m e n t a p p r o v a l p r o v i d e s i t ' s o w n p l a y b u t t o n t o k e e p i t
// a t t h e p r o p e r z o o m s c a l e .
if mode != . attachmentApproval {
let videoPlayIcon = UIImage ( named : " CirclePlay " ) !
let videoPlayButton = UIImageView ( image : videoPlayIcon )
self . videoPlayButton = videoPlayButton
videoPlayButton . contentMode = . scaleAspectFit
self . addSubview ( videoPlayButton )
videoPlayButton . autoCenterInSuperview ( )
videoPlayButton . autoSetDimension ( . width , toSize : 72 )
videoPlayButton . autoSetDimension ( . height , toSize : 72 )
}
}
private func createUrlPreview ( ) {
// I f l i n k p r e v i e w s a r e n ' t e n a b l e d t h e n u s e a f a l l b a c k s t a t e
guard let linkPreviewURL : String = OWSLinkPreview . previewURL ( forRawBodyText : attachment . text ( ) ) else {
createGenericPreview ( )
return
}
linkPreviewInfo = ( url : linkPreviewURL , draft : nil )
var subviews = [ UIView ] ( )
let color : UIColor = isLightMode ? . black : . white
let loadingView = NVActivityIndicatorView ( frame : CGRect . zero , type : . circleStrokeSpin , color : color , padding : nil )
loadingView . set ( . width , to : 24 )
loadingView . set ( . height , to : 24 )
loadingView . startAnimating ( )
subviews . append ( loadingView )
let imageViewContainer = UIView ( )
imageViewContainer . clipsToBounds = true
imageViewContainer . contentMode = . center
imageViewContainer . alpha = 0
imageViewContainer . layer . cornerRadius = 8
subviews . append ( imageViewContainer )
let imageView = createHeroImageView ( imageName : " FileLarge " )
imageViewContainer . addSubview ( imageView )
imageView . pin ( to : imageViewContainer )
let titleLabel = UILabel ( )
titleLabel . text = linkPreviewURL
titleLabel . textColor = controlTintColor
titleLabel . font = labelFont ( )
titleLabel . textAlignment = . center
titleLabel . lineBreakMode = . byTruncatingMiddle
subviews . append ( titleLabel )
let stackView = wrapViewsInVerticalStack ( subviews : subviews )
self . addSubview ( stackView )
titleLabel . autoPinWidthToSuperview ( withMargin : 32 )
NSLayoutConstraint . activate ( [
imageView . widthAnchor . constraint ( equalToConstant : 80 ) ,
imageView . heightAnchor . constraint ( equalToConstant : 80 )
] )
// B u i l d t h e l i n k p r e v i e w
OWSLinkPreview . tryToBuildPreviewInfo ( previewUrl : linkPreviewURL ) . done { [ weak self ] draft in
// L o a d e r
loadingView . alpha = 0
loadingView . stopAnimating ( )
self ? . linkPreviewInfo = ( url : linkPreviewURL , draft : draft )
// TODO: L o o k a t r e f a c t o r i n g t h i s b e h a v i o u r t o c o n s o l i d a t e a t t a c h m e n t m u t a t i o n s
self ? . attachment . linkPreviewDraft = draft
let image : UIImage ?
if let jpegImageData : Data = draft . jpegImageData , let loadedImage : UIImage = UIImage ( data : jpegImageData ) {
image = loadedImage
imageView . contentMode = . scaleAspectFill
}
else {
image = UIImage ( named : " Link " ) ? . withTint ( isLightMode ? . black : . white )
imageView . contentMode = . center
}
// I m a g e v i e w
( imageView as ? UIImageView ) ? . image = image
imageViewContainer . alpha = 1
imageViewContainer . backgroundColor = isDarkMode ? . black : UIColor . black . withAlphaComponent ( 0.06 )
// T i t l e
if let title = draft . title {
titleLabel . font = . boldSystemFont ( ofSize : Values . smallFontSize )
titleLabel . text = title
titleLabel . textAlignment = . left
titleLabel . numberOfLines = 2
}
guard let hStackView = self ? . wrapViewsInHorizontalStack ( subviews : subviews ) else {
// TODO: F a l l b a c k
return
}
stackView . removeFromSuperview ( )
self ? . addSubview ( hStackView )
// W e w a n t t o c e n t e r t h e s t a c k V i e w i n i t ' s s u p e r v i e w w h i l e a l s o e n s u r i n g
// i t ' s s u p e r v i e w i s b i g e n o u g h t o c o n t a i n i t .
hStackView . autoPinWidthToSuperview ( withMargin : 32 )
hStackView . autoVCenterInSuperview ( )
NSLayoutConstraint . autoSetPriority ( UILayoutPriority . defaultLow ) {
hStackView . autoPinHeightToSuperview ( )
}
hStackView . autoPinEdge ( toSuperviewEdge : . top , withInset : 0 , relation : . greaterThanOrEqual )
hStackView . autoPinEdge ( toSuperviewEdge : . bottom , withInset : 0 , relation : . greaterThanOrEqual )
} . catch { _ in
// TODO: F a l l b a c k
loadingView . stopAnimating ( )
} . retainUntilComplete ( )
// W e w a n t t o c e n t e r t h e s t a c k V i e w i n i t ' s s u p e r v i e w w h i l e a l s o e n s u r i n g
// i t ' s s u p e r v i e w i s b i g e n o u g h t o c o n t a i n i t .
stackView . autoPinWidthToSuperview ( )
stackView . autoVCenterInSuperview ( )
NSLayoutConstraint . autoSetPriority ( UILayoutPriority . defaultLow ) {
stackView . autoPinHeightToSuperview ( )
}
stackView . autoPinEdge ( toSuperviewEdge : . top , withInset : 0 , relation : . greaterThanOrEqual )
stackView . autoPinEdge ( toSuperviewEdge : . bottom , withInset : 0 , relation : . greaterThanOrEqual )
}
private func createGenericPreview ( ) {
var subviews = [ UIView ] ( )
let imageView = createHeroImageView ( imageName : " FileLarge " )
imageView . contentMode = . center
subviews . append ( imageView )
let fileNameLabel = createFileNameLabel ( )
if let fileNameLabel = fileNameLabel {
subviews . append ( fileNameLabel )
}
let fileSizeLabel = createFileSizeLabel ( )
subviews . append ( fileSizeLabel )
let stackView = wrapViewsInVerticalStack ( subviews : subviews )
self . addSubview ( stackView )
fileNameLabel ? . autoPinWidthToSuperview ( withMargin : 32 )
// W e w a n t t o c e n t e r t h e s t a c k V i e w i n i t ' s s u p e r v i e w w h i l e a l s o e n s u r i n g
// i t ' s s u p e r v i e w i s b i g e n o u g h t o c o n t a i n i t .
stackView . autoPinWidthToSuperview ( )
stackView . autoVCenterInSuperview ( )
NSLayoutConstraint . autoSetPriority ( UILayoutPriority . defaultLow ) {
stackView . autoPinHeightToSuperview ( )
}
stackView . autoPinEdge ( toSuperviewEdge : . top , withInset : 0 , relation : . greaterThanOrEqual )
stackView . autoPinEdge ( toSuperviewEdge : . bottom , withInset : 0 , relation : . greaterThanOrEqual )
}
private func createHeroViewSize ( ) -> CGFloat {
switch mode {
case . large :
return ScaleFromIPhone5To7Plus ( 175 , 225 )
case . attachmentApproval :
return ScaleFromIPhone5 ( 100 )
case . small :
return ScaleFromIPhone5To7Plus ( 80 , 80 )
}
}
private func createHeroImageView ( imageName : String ) -> UIView {
let imageSize = createHeroViewSize ( )
let image = UIImage ( named : imageName )
assert ( image != nil )
let imageView = UIImageView ( image : image )
imageView . layer . minificationFilter = . trilinear
imageView . layer . magnificationFilter = . trilinear
imageView . layer . shadowColor = UIColor . black . cgColor
let shadowScaling = 5.0
imageView . layer . shadowRadius = CGFloat ( 2.0 * shadowScaling )
imageView . layer . shadowOpacity = 0.25
imageView . layer . shadowOffset = CGSize ( width : 0.75 * shadowScaling , height : 0.75 * shadowScaling )
imageView . autoSetDimension ( . width , toSize : imageSize )
imageView . autoSetDimension ( . height , toSize : imageSize )
return imageView
}
private func labelFont ( ) -> UIFont {
switch mode {
case . large , . attachmentApproval :
return UIFont . ows_regularFont ( withSize : ScaleFromIPhone5To7Plus ( 18 , 24 ) )
case . small :
return UIFont . ows_regularFont ( withSize : ScaleFromIPhone5To7Plus ( 14 , 14 ) )
}
}
private var controlTintColor : UIColor {
switch mode {
case . small , . large :
return Colors . accent
case . attachmentApproval :
return Colors . text
}
}
private func formattedFileExtension ( ) -> String ? {
guard let fileExtension = attachment . fileExtension else {
return nil
}
return String ( format : NSLocalizedString ( " ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT " ,
comment : " Format string for file extension label in call interstitial view " ) ,
fileExtension . uppercased ( ) )
}
public func formattedFileName ( ) -> String ? {
guard let sourceFilename = attachment . sourceFilename else {
return nil
}
let filename = sourceFilename . trimmingCharacters ( in : CharacterSet . whitespacesAndNewlines )
guard filename . count > 0 else {
return nil
}
return filename
}
private func createFileNameLabel ( ) -> UIView ? {
let filename = formattedFileName ( ) ? ? formattedFileExtension ( )
guard filename != nil else {
return nil
}
let label = UILabel ( )
label . text = filename
label . textColor = controlTintColor
label . font = labelFont ( )
label . textAlignment = . center
label . lineBreakMode = . byTruncatingMiddle
return label
}
private func createFileSizeLabel ( ) -> UIView {
let label = UILabel ( )
let fileSize = attachment . dataLength
label . text = String ( format : NSLocalizedString ( " ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT " ,
comment : " Format string for file size label in call interstitial view. Embeds: {{file size as 'N mb' or 'N kb'}}. " ) ,
OWSFormat . formatFileSize ( UInt ( fileSize ) ) )
label . textColor = controlTintColor
label . font = labelFont ( )
label . textAlignment = . center
return label
}
// MARK: - E v e n t H a n d l e r s
@objc
func audioPlayButtonPressed ( sender : UIButton ) {
audioPlayer ? . togglePlayState ( )
}
// MARK: - O W S A u d i o P l a y e r D e l e g a t e
public func audioPlaybackState ( ) -> AudioPlaybackState {
return playbackState
}
public func setAudioPlaybackState ( _ value : AudioPlaybackState ) {
playbackState = value
}
public func showInvalidAudioFileAlert ( ) {
OWSAlerts . showErrorAlert ( message : NSLocalizedString ( " INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE " , comment : " Message for the alert indicating that an audio file is invalid. " ) )
}
public func audioPlayerDidFinishPlaying ( _ player : OWSAudioPlayer , successfully flag : Bool ) {
// D o n o t h i n g
}
private func ensureButtonState ( ) {
if playbackState = = . playing {
setAudioIconToPause ( )
} else {
setAudioIconToPlay ( )
}
}
public func setAudioProgress ( _ progress : CGFloat , duration : CGFloat ) {
audioProgressSeconds = progress
audioDurationSeconds = duration
}
private func setAudioIconToPlay ( ) {
let image = UIImage ( named : " audio_play_black_large " ) ? . withRenderingMode ( . alwaysTemplate )
assert ( image != nil )
audioPlayButton ? . setImage ( image , for : . normal )
audioPlayButton ? . imageView ? . tintColor = controlTintColor
}
private func setAudioIconToPause ( ) {
let image = UIImage ( named : " audio_pause_black_large " ) ? . withRenderingMode ( . alwaysTemplate )
assert ( image != nil )
audioPlayButton ? . setImage ( image , for : . normal )
audioPlayButton ? . imageView ? . tintColor = controlTintColor
}
}