// C o p y r i g h t © 2 0 2 2 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
import UIKit
import GRDB
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
class MediaPageViewController : UIPageViewController , UIPageViewControllerDataSource , UIPageViewControllerDelegate , MediaDetailViewControllerDelegate , InteractivelyDismissableViewController {
class DynamicallySizedView : UIView {
override var intrinsicContentSize : CGSize { CGSize . zero }
}
fileprivate var mediaInteractiveDismiss : MediaInteractiveDismiss ?
public let viewModel : MediaGalleryViewModel
private var dataChangeObservable : DatabaseCancellable ?
private var initialPage : MediaDetailViewController
private var cachedPages : [ Int64 : [ MediaGalleryViewModel . Item : MediaDetailViewController ] ] = [ : ]
public var currentViewController : MediaDetailViewController {
return viewControllers ! . first as ! MediaDetailViewController
}
public var currentItem : MediaGalleryViewModel . Item {
return currentViewController . galleryItem
}
public func setCurrentItem ( _ item : MediaGalleryViewModel . Item , direction : UIPageViewController . NavigationDirection , animated isAnimated : Bool ) {
guard let galleryPage = self . buildGalleryPage ( galleryItem : item ) else {
owsFailDebug ( " unexpectedly unable to build new gallery page " )
return
}
// C a c h e a n d r e t r i e v e t h e n e w a l b u m i t e m s
viewModel . loadAndCacheAlbumData (
for : item . interactionId ,
in : self . viewModel . threadId
)
// S w a p o u t t h e d a t a b a s e o b s e r v e r
dataChangeObservable ? . cancel ( )
viewModel . replaceAlbumObservation ( toObservationFor : item . interactionId )
startObservingChanges ( )
updateTitle ( item : item )
updateCaption ( item : item )
setViewControllers ( [ galleryPage ] , direction : direction , animated : isAnimated )
updateFooterBarButtonItems ( isPlayingVideo : false )
updateMediaRail ( item : item )
}
private let showAllMediaButton : Bool
private let sliderEnabled : Bool
init (
viewModel : MediaGalleryViewModel ,
initialItem : MediaGalleryViewModel . Item ,
options : [ MediaGalleryOption ]
) {
self . viewModel = viewModel
self . showAllMediaButton = options . contains ( . showAllMediaButton )
self . sliderEnabled = options . contains ( . sliderEnabled )
self . initialPage = MediaDetailViewController ( galleryItem : initialItem )
super . init (
transitionStyle : . scroll ,
navigationOrientation : . horizontal ,
options : [ . interPageSpacing : 20 ]
)
self . cachedPages [ initialItem . interactionId ] = [ initialItem : self . initialPage ]
self . initialPage . delegate = self
self . dataSource = self
self . delegate = self
self . modalPresentationStyle = . overFullScreen
self . transitioningDelegate = self
self . setViewControllers ( [ initialPage ] , direction : . forward , animated : false , completion : nil )
}
@ available ( * , unavailable , message : " Unimplemented " )
required init ? ( coder : NSCoder ) {
notImplemented ( )
}
deinit {
Logger . debug ( " deinit " )
}
// MARK: - S u b v i e w
private var hasAppeared : Bool = false
override var canBecomeFirstResponder : Bool { hasAppeared }
override var inputAccessoryView : UIView ? {
return bottomContainer
}
// MARK: - B o t t o m B a r
var bottomContainer : UIView !
var footerBar : UIToolbar = {
let result : UIToolbar = UIToolbar ( )
result . clipsToBounds = true // h i d e 1 p x t o p - b o r d e r
result . themeTintColor = . textPrimary
result . themeBarTintColor = . backgroundPrimary
result . themeBackgroundColor = . backgroundPrimary
result . setBackgroundImage ( UIImage ( ) , forToolbarPosition : . any , barMetrics : UIBarMetrics . default )
result . setShadowImage ( UIImage ( ) , forToolbarPosition : . any )
result . isTranslucent = false
return result
} ( )
let captionContainerView : CaptionContainerView = CaptionContainerView ( )
var galleryRailView : GalleryRailView = GalleryRailView ( )
var pagerScrollView : UIScrollView !
// MARK: U I V i e w C o n t r o l l e r o v e r r i d e s
override func viewDidLoad ( ) {
super . viewDidLoad ( )
// N a v i g a t i o n
let backButton = UIViewController . createOWSBackButton ( target : self , selector : #selector ( didPressDismissButton ) )
self . navigationItem . leftBarButtonItem = backButton
self . navigationItem . titleView = portraitHeaderView
if showAllMediaButton {
self . navigationItem . rightBarButtonItem = UIBarButtonItem ( title : MediaStrings . allMedia , style : . plain , target : self , action : #selector ( didPressAllMediaButton ) )
}
// E v e n t h o u g h b a r s a r e o p a q u e , w e w a n t c o n t e n t t o b e l a y e d o u t b e h i n d t h e m .
// T h e b a r s m i g h t o b s c u r e p a r t o f t h e c o n t e n t , b u t t h e y c a n e a s i l y b e h i d d e n b y t a p p i n g
// T h e a l t e r n a t i v e w o u l d b e t h a t c o n t e n t w o u l d s h i f t w h e n t h e n a v b a r s h i d e .
self . extendedLayoutIncludesOpaqueBars = true
self . automaticallyAdjustsScrollViewInsets = false
// D i s a b l e t h e i n t e r a c t i v e P o p G e s t u r e R e c o g n i z e r a s w e w a n t t o b e a b l e t o s w i p e b e t w e e n
// d i f f e r e n t p a g e s
self . navigationController ? . interactivePopGestureRecognizer ? . isEnabled = false
self . mediaInteractiveDismiss = MediaInteractiveDismiss ( targetViewController : self )
self . mediaInteractiveDismiss ? . addGestureRecognizer ( to : view )
// G e t r e f e r e n c e t o p a g e d c o n t e n t w h i c h l i v e s i n a s c r o l l V i e w c r e a t e d b y t h e s u p e r c l a s s
// W e s h o w / h i d e t h i s c o n t e n t d u r i n g p r e s e n t a t i o n
for view in self . view . subviews {
if let pagerScrollView = view as ? UIScrollView {
pagerScrollView . contentInsetAdjustmentBehavior = . never
self . pagerScrollView = pagerScrollView
}
}
// H a c k t o a v o i d " p a g e " b o u n c i n g w h e n n o t i n g a l l e r y v i e w .
// e . g . w h e n g e t t i n g t o m e d i a d e t a i l s v i a m e s s a g e d e t a i l s s c r e e n , t h e r e ' s o n l y
// o n e " P a g e " s o t h e b o u n c e d o e s n ' t m a k e s e n s e .
pagerScrollView . isScrollEnabled = sliderEnabled
pagerScrollViewContentOffsetObservation = pagerScrollView . observe ( \ . contentOffset , options : [ . new ] ) { [ weak self ] _ , change in
guard let strongSelf = self else { return }
strongSelf . pagerScrollView ( strongSelf . pagerScrollView , contentOffsetDidChange : change )
}
// V i e w s
pagerScrollView . themeBackgroundColor = . newConversation_background
view . themeBackgroundColor = . newConversation_background
captionContainerView . delegate = self
updateCaptionContainerVisibility ( )
galleryRailView . isHidden = true
galleryRailView . delegate = self
galleryRailView . autoSetDimension ( . height , toSize : 72 )
footerBar . autoSetDimension ( . height , toSize : 44 )
let bottomContainer : DynamicallySizedView = DynamicallySizedView ( )
bottomContainer . clipsToBounds = true
bottomContainer . autoresizingMask = . flexibleHeight
bottomContainer . themeBackgroundColor = . backgroundPrimary
self . bottomContainer = bottomContainer
let bottomStack = UIStackView ( arrangedSubviews : [ captionContainerView , galleryRailView , footerBar ] )
bottomStack . axis = . vertical
bottomStack . isLayoutMarginsRelativeArrangement = true
bottomContainer . addSubview ( bottomStack )
bottomStack . autoPinEdgesToSuperviewEdges ( )
let galleryRailBlockingView : UIView = UIView ( )
galleryRailBlockingView . themeBackgroundColor = . backgroundPrimary
bottomStack . addSubview ( galleryRailBlockingView )
galleryRailBlockingView . pin ( . top , to : . bottom , of : footerBar )
galleryRailBlockingView . pin ( . left , to : . left , of : bottomStack )
galleryRailBlockingView . pin ( . right , to : . right , of : bottomStack )
galleryRailBlockingView . pin ( . bottom , to : . bottom , of : bottomStack )
updateTitle ( item : currentItem )
updateCaption ( item : currentItem )
updateMediaRail ( item : currentItem )
updateFooterBarButtonItems ( isPlayingVideo : false )
// G e s t u r e s
let verticalSwipe = UISwipeGestureRecognizer ( target : self , action : #selector ( didSwipeView ) )
verticalSwipe . direction = [ . up , . down ]
view . addGestureRecognizer ( verticalSwipe )
// N o t i f i c a t i o n s
NotificationCenter . default . addObserver (
self ,
selector : #selector ( applicationDidBecomeActive ( _ : ) ) ,
name : UIApplication . didBecomeActiveNotification ,
object : nil
)
NotificationCenter . default . addObserver (
self ,
selector : #selector ( applicationDidResignActive ( _ : ) ) ,
name : UIApplication . didEnterBackgroundNotification , object : nil
)
}
public override func viewWillAppear ( _ animated : Bool ) {
super . viewWillAppear ( animated )
startObservingChanges ( )
}
override func viewDidAppear ( _ animated : Bool ) {
super . viewDidAppear ( animated )
hasAppeared = true
becomeFirstResponder ( )
}
public override func viewWillDisappear ( _ animated : Bool ) {
super . viewWillDisappear ( animated )
// S t o p o b s e r v i n g d a t a b a s e c h a n g e s
dataChangeObservable ? . cancel ( )
resignFirstResponder ( )
}
@objc func applicationDidBecomeActive ( _ notification : Notification ) {
startObservingChanges ( )
}
@objc func applicationDidResignActive ( _ notification : Notification ) {
// S t o p o b s e r v i n g d a t a b a s e c h a n g e s
dataChangeObservable ? . cancel ( )
}
override func viewWillTransition ( to size : CGSize , with coordinator : UIViewControllerTransitionCoordinator ) {
super . viewWillTransition ( to : size , with : coordinator )
let isLandscape = size . width > size . height
self . navigationItem . titleView = isLandscape ? nil : self . portraitHeaderView
}
override func didReceiveMemoryWarning ( ) {
Logger . info ( " " )
super . didReceiveMemoryWarning ( )
self . cachedPages = [ : ]
}
// MARK: K V O
var pagerScrollViewContentOffsetObservation : NSKeyValueObservation ?
func pagerScrollView ( _ pagerScrollView : UIScrollView , contentOffsetDidChange change : NSKeyValueObservedChange < CGPoint > ) {
guard let newValue = change . newValue else {
owsFailDebug ( " newValue was unexpectedly nil " )
return
}
let width = pagerScrollView . frame . size . width
guard width > 0 else {
return
}
let ratioComplete = abs ( ( newValue . x - width ) / width )
captionContainerView . updatePagerTransition ( ratioComplete : ratioComplete )
}
// MARK: V i e w H e l p e r s
public func willBePresentedAgain ( ) {
updateFooterBarButtonItems ( isPlayingVideo : false )
}
public func wasPresented ( ) {
let currentViewController = self . currentViewController
if currentViewController . galleryItem . isVideo {
currentViewController . playVideo ( )
}
}
private var shouldHideToolbars : Bool = false {
didSet {
guard oldValue != shouldHideToolbars else { return }
self . navigationController ? . setNavigationBarHidden ( shouldHideToolbars , animated : false )
UIView . animate ( withDuration : 0.1 ) {
self . currentViewController . setShouldHideToolbars ( self . shouldHideToolbars )
self . bottomContainer . isHidden = self . shouldHideToolbars
}
}
}
// MARK: B a r B u t t o n s
lazy var shareBarButton : UIBarButtonItem = {
let shareBarButton = UIBarButtonItem (
barButtonSystemItem : . action ,
target : self ,
action : #selector ( didPressShare )
)
shareBarButton . themeTintColor = . textPrimary
return shareBarButton
} ( )
lazy var deleteBarButton : UIBarButtonItem = {
let deleteBarButton = UIBarButtonItem (
barButtonSystemItem : . trash ,
target : self ,
action : #selector ( didPressDelete )
)
deleteBarButton . themeTintColor = . textPrimary
return deleteBarButton
} ( )
func buildFlexibleSpace ( ) -> UIBarButtonItem {
return UIBarButtonItem ( barButtonSystemItem : . flexibleSpace , target : nil , action : nil )
}
lazy var videoPlayBarButton : UIBarButtonItem = {
let videoPlayBarButton = UIBarButtonItem (
barButtonSystemItem : . play ,
target : self ,
action : #selector ( didPressPlayBarButton )
)
videoPlayBarButton . themeTintColor = . textPrimary
return videoPlayBarButton
} ( )
lazy var videoPauseBarButton : UIBarButtonItem = {
let videoPauseBarButton = UIBarButtonItem (
barButtonSystemItem : . pause ,
target : self ,
action : #selector ( didPressPauseBarButton )
)
videoPauseBarButton . themeTintColor = . textPrimary
return videoPauseBarButton
} ( )
private func updateFooterBarButtonItems ( isPlayingVideo : Bool ) {
self . footerBar . setItems (
[
shareBarButton ,
buildFlexibleSpace ( ) ,
( self . currentItem . isVideo && isPlayingVideo ? self . videoPauseBarButton : nil ) ,
( self . currentItem . isVideo && ! isPlayingVideo ? self . videoPlayBarButton : nil ) ,
( self . currentItem . isVideo ? buildFlexibleSpace ( ) : nil ) ,
deleteBarButton
] . compactMap { $0 } ,
animated : false
)
}
func updateMediaRail ( item : MediaGalleryViewModel . Item ) {
galleryRailView . configureCellViews (
album : ( self . viewModel . albumData [ item . interactionId ] ? ? [ ] ) ,
focusedItem : currentItem ,
cellViewBuilder : { _ in return GalleryRailCellView ( ) }
)
}
// MARK: - U p d a t i n g
private func startObservingChanges ( ) {
// S t a r t o b s e r v i n g f o r d a t a c h a n g e s
dataChangeObservable = Storage . shared . start (
viewModel . observableAlbumData ,
onError : { _ in } ,
onChange : { [ weak self ] albumData in
// T h e d e f a u l s c h e d u l e r e m i t s c h a n g e s o n t h e m a i n t h r e a d
self ? . handleUpdates ( albumData )
}
)
}
private func handleUpdates ( _ updatedViewData : [ MediaGalleryViewModel . Item ] ) {
// D e t e r m i n e i f w e s w a p p e d a l b u m s ( i f s o w e d o n ' t n e e d t o d o a n y t h i n g e l s e )
guard updatedViewData . contains ( where : { $0 . interactionId = = currentItem . interactionId } ) else {
if let updatedInteractionId : Int64 = updatedViewData . first ? . interactionId {
self . viewModel . updateAlbumData ( updatedViewData , for : updatedInteractionId )
}
return
}
// C l e a r t h e c a c h e d p a g e s t h a t n o l o n g e r m a t c h
let interactionId : Int64 = currentItem . interactionId
let updatedCachedPages : [ MediaGalleryViewModel . Item : MediaDetailViewController ] = cachedPages [ interactionId ]
. defaulting ( to : [ : ] )
. filter { key , _ -> Bool in updatedViewData . contains ( key ) }
// I f t h e r e a r e n o m o r e i t e m s i n t h e a l b u m t h e n d i s m i s s t h e s c r e e n
guard
! updatedViewData . isEmpty ,
let oldIndex : Int = self . viewModel . albumData [ interactionId ] ? . firstIndex ( of : currentItem )
else {
self . dismissSelf ( animated : true )
return
}
// U p d a t e t h e c a c h e s
self . viewModel . updateAlbumData ( updatedViewData , for : interactionId )
self . cachedPages [ interactionId ] = updatedCachedPages
// I f t h e c u r r e n t i t e m i s s t i l l a v a i l a b l e t h e n d o n o t h i n g e l s e
guard updatedCachedPages [ currentItem ] = = nil else { return }
// I f t h e c u r r e n t i t e m w a s m o d i f i e d w i t h i n t h e c u r r e n t u p d a t e t h e n r e l o a d i t ( j u s t i n c a s e )
if let updatedCurrentItem : MediaGalleryViewModel . Item = updatedViewData . first ( where : { item in item . attachment . id = = currentItem . attachment . id } ) {
setCurrentItem ( updatedCurrentItem , direction : . forward , animated : false )
return
}
// D e t e r m i n e t h e n e x t i n d e x ( i f i t ' s l e s s t h a n 0 t h e n p o p t h e s c r e e n )
let nextIndex : Int = min ( oldIndex , ( updatedViewData . count - 1 ) )
guard nextIndex >= 0 else {
self . dismissSelf ( animated : true )
return
}
self . setCurrentItem (
updatedViewData [ nextIndex ] ,
direction : ( nextIndex < oldIndex ?
. reverse :
. forward
) ,
animated : true
)
}
// MARK: - A c t i o n s
@objc public func didPressAllMediaButton ( sender : Any ) {
currentViewController . stopAnyVideo ( )
// I f t h e s c r e e n w a s n ' t p r e s e n t e d o r i t w a s p r e s e n t e d f r o m a l o c a t i o n w h i c h i s n ' t t h e
// M e d i a T i l e V i e w C o n t r o l l e r t h e n j u s t p o p / d i s m i s s t h e s c r e e n
guard
let presentingNavController : UINavigationController = ( self . presentingViewController as ? UINavigationController ) ,
! ( presentingNavController . viewControllers . last is AllMediaViewController )
else {
guard self . navigationController ? . viewControllers . count = = 1 else {
self . navigationController ? . popViewController ( animated : true )
return
}
self . dismiss ( animated : true )
return
}
// O t h e r w i s e i f w e c a m e v i a t h e c o n v e r s a t i o n s c r e e n w e n e e d t o p u s h a n e w
// i n s t a n c e o f M e d i a T i l e V i e w C o n t r o l l e r
let allMediaViewController : AllMediaViewController = MediaGalleryViewModel . createAllMediaViewController (
threadId : self . viewModel . threadId ,
threadVariant : self . viewModel . threadVariant ,
focusedAttachmentId : currentItem . attachment . id ,
performInitialQuerySync : true
)
let navController : MediaGalleryNavigationController = MediaGalleryNavigationController ( )
navController . viewControllers = [ allMediaViewController ]
navController . modalPresentationStyle = . overFullScreen
navController . transitioningDelegate = allMediaViewController
self . navigationController ? . present ( navController , animated : true )
}
@objc public func didSwipeView ( sender : Any ) {
self . dismissSelf ( animated : true )
}
@objc public func didPressDismissButton ( _ sender : Any ) {
dismissSelf ( animated : true )
}
@objc
public func didPressShare ( _ sender : Any ) {
guard let currentViewController = self . viewControllers ? [ 0 ] as ? MediaDetailViewController else {
owsFailDebug ( " currentViewController was unexpectedly nil " )
return
}
guard let originalFilePath : String = currentViewController . galleryItem . attachment . originalFilePath else {
return
}
let shareVC = UIActivityViewController ( activityItems : [ URL ( fileURLWithPath : originalFilePath ) ] , applicationActivities : nil )
if UIDevice . current . isIPad {
shareVC . excludedActivityTypes = [ ]
shareVC . popoverPresentationController ? . permittedArrowDirections = [ ]
shareVC . popoverPresentationController ? . sourceView = self . view
shareVC . popoverPresentationController ? . sourceRect = self . view . bounds
}
shareVC . completionWithItemsHandler = { activityType , completed , returnedItems , activityError in
if let activityError = activityError {
SNLog ( " Failed to share with activityError: \( activityError ) " )
}
else if completed {
SNLog ( " Did share with activityType: \( activityType . debugDescription ) " )
}
guard
let activityType = activityType ,
activityType = = . saveToCameraRoll ,
currentViewController . galleryItem . interactionVariant = = . standardIncoming ,
self . viewModel . threadVariant = = . contact
else { return }
Storage . shared . write { db in
guard let thread : SessionThread = try SessionThread . fetchOne ( db , id : self . viewModel . threadId ) else {
return
}
try MessageSender . send (
db ,
message : DataExtractionNotification (
kind : . mediaSaved (
timestamp : UInt64 ( currentViewController . galleryItem . interactionTimestampMs )
)
) ,
interactionId : nil , // S h o w n o i n t e r a c t i o n f o r t h e c u r r e n t u s e r
in : thread
)
}
}
self . present ( shareVC , animated : true , completion : nil )
}
@objc public func didPressDelete ( _ sender : Any ) {
let itemToDelete : MediaGalleryViewModel . Item = self . currentItem
let actionSheet : UIAlertController = UIAlertController ( title : nil , message : nil , preferredStyle : . actionSheet )
let deleteAction = UIAlertAction (
title : " delete_message_for_me " . localized ( ) ,
style : . destructive
) { _ in
Storage . shared . writeAsync { db in
_ = try Attachment
. filter ( id : itemToDelete . attachment . id )
. deleteAll ( db )
// A d d t h e g a r b a g e c o l l e c t i o n j o b t o d e l e t e o r p h a n e d a t t a c h m e n t f i l e s
JobRunner . add (
db ,
job : Job (
variant : . garbageCollection ,
behaviour : . runOnce ,
details : GarbageCollectionJob . Details (
typesToCollect : [ . orphanedAttachmentFiles ]
)
)
)
// D e l e t e a n y i n t e r a c t i o n s w h i c h h a d a l l o f t h e i r a t t a c h m e n t s r e m o v e d
_ = try Interaction
. filter ( id : itemToDelete . interactionId )
. having ( Interaction . interactionAttachments . isEmpty )
. deleteAll ( db )
}
}
actionSheet . addAction ( UIAlertAction ( title : " TXT_CANCEL_TITLE " . localized ( ) , style : . cancel ) )
actionSheet . addAction ( deleteAction )
Modal . setupForIPadIfNeeded ( actionSheet , targetView : self . view )
self . present ( actionSheet , animated : true )
}
// MARK: - V i d e o i n t e r a c t i o n
@objc public func didPressPlayBarButton ( ) {
guard let currentViewController = self . viewControllers ? . first as ? MediaDetailViewController else {
SNLog ( " currentViewController was unexpectedly nil " )
return
}
currentViewController . didPressPlayBarButton ( )
}
@objc public func didPressPauseBarButton ( ) {
guard let currentViewController = self . viewControllers ? . first as ? MediaDetailViewController else {
SNLog ( " currentViewController was unexpectedly nil " )
return
}
currentViewController . didPressPauseBarButton ( )
}
// 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
var pendingViewController : MediaDetailViewController ?
public func pageViewController ( _ pageViewController : UIPageViewController , willTransitionTo pendingViewControllers : [ UIViewController ] ) {
Logger . debug ( " " )
assert ( pendingViewControllers . count = = 1 )
pendingViewControllers . forEach { viewController in
guard let pendingViewController = viewController as ? MediaDetailViewController else {
owsFailDebug ( " unexpected mediaDetailViewController: \( viewController ) " )
return
}
self . pendingViewController = pendingViewController
if let pendingCaptionText = pendingViewController . galleryItem . captionForDisplay , pendingCaptionText . count > 0 {
self . captionContainerView . pendingText = pendingCaptionText
} else {
self . captionContainerView . pendingText = nil
}
// E n s u r e u p c o m i n g p a g e r e s p e c t s c u r r e n t t o o l b a r s t a t u s
pendingViewController . setShouldHideToolbars ( self . shouldHideToolbars )
}
}
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 ? MediaDetailViewController else {
owsFailDebug ( " unexpected mediaDetailViewController: \( viewController ) " )
return
}
// D o a n y c l e a n u p f o r t h e n o - l o n g e r v i s i b l e v i e w c o n t r o l l e r
if transitionCompleted {
pendingViewController = nil
// T h i s c a n h a p p e n w h e n t r y i n g t o p a g e p a s t t h e l a s t ( o r f i r s t ) v i e w c o n t r o l l e r
// I n t h a t c a s e , w e d o n ' t w a n t t o c h a n g e t h e c a p t i o n V i e w .
if ( previousPage != currentViewController ) {
captionContainerView . completePagerTransition ( )
}
updateTitle ( item : currentItem )
updateMediaRail ( item : currentItem )
previousPage . zoomOut ( animated : false )
previousPage . stopAnyVideo ( )
updateFooterBarButtonItems ( isPlayingVideo : false )
} else {
captionContainerView . pendingText = nil
}
}
}
// 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 mediaViewController : MediaDetailViewController = viewController as ? MediaDetailViewController else {
return nil
}
// F i r s t c h e c k i f t h e r e i s a n o t h e r i t e m i n t h e c u r r e n t a l b u m
let interactionId : Int64 = mediaViewController . galleryItem . interactionId
if
let currentAlbum : [ MediaGalleryViewModel . Item ] = self . viewModel . albumData [ interactionId ] ,
let index : Int = currentAlbum . firstIndex ( of : mediaViewController . galleryItem ) ,
index > 0 ,
let previousPage : MediaDetailViewController = buildGalleryPage ( galleryItem : currentAlbum [ index - 1 ] )
{
return previousPage
}
// T h e n c h e c k i f t h e r e i s a n i n t e r a c t i o n b e f o r e t h e c u r r e n t a l b u m i n t e r a c t i o n
guard let interactionIdAfter : Int64 = self . viewModel . interactionIdAfter [ interactionId ] else {
return nil
}
// C a c h e a n d r e t r i e v e t h e n e w a l b u m i t e m s
let newAlbumItems : [ MediaGalleryViewModel . Item ] = viewModel . loadAndCacheAlbumData (
for : interactionIdAfter ,
in : self . viewModel . threadId
)
guard
! newAlbumItems . isEmpty ,
let previousPage : MediaDetailViewController = buildGalleryPage (
galleryItem : newAlbumItems [ newAlbumItems . count - 1 ]
)
else {
// I n v a l i d s t a t e , r e s t a r t t h e o b s e r v e r
startObservingChanges ( )
return nil
}
// S w a p o u t t h e d a t a b a s e o b s e r v e r
dataChangeObservable ? . cancel ( )
viewModel . replaceAlbumObservation ( toObservationFor : interactionIdAfter )
startObservingChanges ( )
return previousPage
}
public func pageViewController ( _ pageViewController : UIPageViewController , viewControllerAfter viewController : UIViewController ) -> UIViewController ? {
guard let mediaViewController : MediaDetailViewController = viewController as ? MediaDetailViewController else {
return nil
}
// F i r s t c h e c k i f t h e r e i s a n o t h e r i t e m i n t h e c u r r e n t a l b u m
let interactionId : Int64 = mediaViewController . galleryItem . interactionId
if
let currentAlbum : [ MediaGalleryViewModel . Item ] = self . viewModel . albumData [ interactionId ] ,
let index : Int = currentAlbum . firstIndex ( of : mediaViewController . galleryItem ) ,
index < ( currentAlbum . count - 1 ) ,
let nextPage : MediaDetailViewController = buildGalleryPage ( galleryItem : currentAlbum [ index + 1 ] )
{
return nextPage
}
// T h e n c h e c k i f t h e r e i s a n i n t e r a c t i o n b e f o r e t h e c u r r e n t a l b u m i n t e r a c t i o n
guard let interactionIdBefore : Int64 = self . viewModel . interactionIdBefore [ interactionId ] else {
return nil
}
// C a c h e a n d r e t r i e v e t h e n e w a l b u m i t e m s
let newAlbumItems : [ MediaGalleryViewModel . Item ] = viewModel . loadAndCacheAlbumData (
for : interactionIdBefore ,
in : self . viewModel . threadId
)
guard
! newAlbumItems . isEmpty ,
let nextPage : MediaDetailViewController = buildGalleryPage ( galleryItem : newAlbumItems [ 0 ] )
else {
// I n v a l i d s t a t e , r e s t a r t t h e o b s e r v e r
startObservingChanges ( )
return nil
}
// S w a p o u t t h e d a t a b a s e o b s e r v e r
dataChangeObservable ? . cancel ( )
viewModel . replaceAlbumObservation ( toObservationFor : interactionIdBefore )
startObservingChanges ( )
return nextPage
}
private func buildGalleryPage ( galleryItem : MediaGalleryViewModel . Item ) -> MediaDetailViewController ? {
if let cachedPage : MediaDetailViewController = cachedPages [ galleryItem . interactionId ] ? [ galleryItem ] {
return cachedPage
}
cachedPages [ galleryItem . interactionId ] = ( cachedPages [ galleryItem . interactionId ] ? ? [ : ] )
. setting ( galleryItem , MediaDetailViewController ( galleryItem : galleryItem , delegate : self ) )
return cachedPages [ galleryItem . interactionId ] ? [ galleryItem ]
}
public func dismissSelf ( animated isAnimated : Bool , completion : ( ( ) -> Void ) ? = nil ) {
// I f w e h a v e p r e s e n t e d a M e d i a T i l e V i e w C o n t r o l l e r f r o m t h i s s c r e e n t h e n i t w i l l c o n t i n u e
// t o o b s e r v e m e d i a c h a n g e s a n d i f a l l t h e i t e m s i n t h e a l b u m t h i s s c r e e n i s s h o w i n g a r e
// d e l e t e d i t w i l l a t t e m p t t o a u t o - d i s m i s s
guard self . presentedViewController = = nil else { return }
// S w a p p i n g m e d i a V i e w f o r p r e s e n t a t i o n V i e w w i l l b e p e r c e p t i b l e i f w e ' r e n o t z o o m e d o u t a l l t h e w a y .
// c u r r e n t V C
currentViewController . zoomOut ( animated : true )
currentViewController . stopAnyVideo ( )
self . navigationController ? . view . isUserInteractionEnabled = false
self . navigationController ? . dismiss ( animated : true , completion : { [ weak self ] in
if ! IsLandscapeOrientationEnabled ( ) {
UIDevice . current . ows_setOrientation ( . portrait )
}
UIApplication . shared . isStatusBarHidden = false
self ? . navigationController ? . presentingViewController ? . setNeedsStatusBarAppearanceUpdate ( )
completion ? ( )
} )
}
// MARK: M e d i a D e t a i l V i e w C o n t r o l l e r D e l e g a t e
public func mediaDetailViewControllerDidTapMedia ( _ mediaDetailViewController : MediaDetailViewController ) {
Logger . debug ( " " )
self . shouldHideToolbars = ! self . shouldHideToolbars
}
public func mediaDetailViewController ( _ mediaDetailViewController : MediaDetailViewController , isPlayingVideo : Bool ) {
guard mediaDetailViewController = = currentViewController else {
Logger . verbose ( " ignoring stale delegate. " )
return
}
self . shouldHideToolbars = isPlayingVideo
self . updateFooterBarButtonItems ( isPlayingVideo : isPlayingVideo )
}
// MARK: - D y n a m i c H e a d e r
private lazy var dateFormatter : DateFormatter = {
let formatter = DateFormatter ( )
formatter . dateStyle = . short
formatter . timeStyle = . short
return formatter
} ( )
lazy private var portraitHeaderNameLabel : UILabel = {
let label : UILabel = UILabel ( )
label . font = . systemFont ( ofSize : Values . mediumFontSize )
label . themeTextColor = . textPrimary
label . textAlignment = . center
label . adjustsFontSizeToFitWidth = true
label . minimumScaleFactor = 0.8
return label
} ( )
lazy private var portraitHeaderDateLabel : UILabel = {
let label : UILabel = UILabel ( )
label . font = . systemFont ( ofSize : Values . verySmallFontSize )
label . themeTextColor = . textPrimary
label . textAlignment = . center
label . adjustsFontSizeToFitWidth = true
label . minimumScaleFactor = 0.8
return label
} ( )
private lazy var portraitHeaderView : UIView = {
let stackView : UIStackView = UIStackView ( )
stackView . axis = . vertical
stackView . alignment = . center
stackView . spacing = 0
stackView . distribution = . fillProportionally
stackView . addArrangedSubview ( portraitHeaderNameLabel )
stackView . addArrangedSubview ( portraitHeaderDateLabel )
let containerView = UIView ( )
containerView . layoutMargins = UIEdgeInsets ( top : 2 , left : 8 , bottom : 4 , right : 8 )
containerView . addSubview ( stackView )
stackView . autoPinEdge ( toSuperviewMargin : . top , relation : . greaterThanOrEqual )
stackView . autoPinEdge ( toSuperviewMargin : . trailing , relation : . greaterThanOrEqual )
stackView . autoPinEdge ( toSuperviewMargin : . bottom , relation : . greaterThanOrEqual )
stackView . autoPinEdge ( toSuperviewMargin : . leading , relation : . greaterThanOrEqual )
stackView . setContentHuggingHigh ( )
stackView . autoCenterInSuperview ( )
return containerView
} ( )
private func updateCaption ( item : MediaGalleryViewModel . Item ) {
captionContainerView . currentText = item . captionForDisplay
}
private func updateTitle ( item : MediaGalleryViewModel . Item ) {
let targetItem : MediaGalleryViewModel . Item = item
let threadVariant : SessionThread . Variant = self . viewModel . threadVariant
let name : String = {
switch targetItem . interactionVariant {
case . standardIncoming :
return Storage . shared
. read { db in
Profile . displayName (
db ,
id : targetItem . interactionAuthorId ,
threadVariant : threadVariant
)
}
. defaulting ( to : Profile . truncated ( id : targetItem . interactionAuthorId , truncating : . middle ) )
case . standardOutgoing :
return " MEDIA_GALLERY_SENDER_NAME_YOU " . localized ( ) // " S h o r t s e n d e r l a b e l f o r m e d i a s e n t b y y o u "
default :
owsFailDebug ( " Unsupported message variant: \( targetItem . interactionVariant ) " )
return " "
}
} ( )
portraitHeaderNameLabel . text = name
// u s e s e n t d a t e
let date = Date ( timeIntervalSince1970 : ( Double ( targetItem . interactionTimestampMs ) / 1000 ) )
let formattedDate = dateFormatter . string ( from : date )
portraitHeaderDateLabel . text = formattedDate
let landscapeHeaderFormat = NSLocalizedString ( " MEDIA_GALLERY_LANDSCAPE_TITLE_FORMAT " , comment : " embeds {{sender name}} and {{sent datetime}}, e.g. 'Sarah on 10/30/18, 3:29' " )
let landscapeHeaderText = String ( format : landscapeHeaderFormat , name , formattedDate )
self . title = landscapeHeaderText
self . navigationItem . title = landscapeHeaderText
}
// MARK: - I n t e r a c t i v e l y D i s m i s s a b l e V i e w C o n t r o l l e r
func performInteractiveDismissal ( animated : Bool ) {
dismissSelf ( animated : true )
}
}
extension MediaGalleryViewModel . Item : GalleryRailItem {
public func buildRailItemView ( ) -> UIView {
let imageView : UIImageView = UIImageView ( )
imageView . contentMode = . scaleAspectFill
getRailImage ( )
. map { [ weak imageView ] image in
guard let imageView = imageView else { return }
imageView . image = image
}
. retainUntilComplete ( )
return imageView
}
public func getRailImage ( ) -> Guarantee < UIImage > {
return Guarantee < UIImage > { fulfill in
self . thumbnailImage ( async : { image in fulfill ( image ) } )
}
}
public func isEqual ( to other : GalleryRailItem ? ) -> Bool {
guard let otherItem : MediaGalleryViewModel . Item = other as ? MediaGalleryViewModel . Item else { return false }
return ( self = = otherItem )
}
}
extension MediaPageViewController : GalleryRailViewDelegate {
func galleryRailView ( _ galleryRailView : GalleryRailView , didTapItem imageRailItem : GalleryRailItem ) {
guard let targetItem = imageRailItem as ? MediaGalleryViewModel . Item else {
owsFailDebug ( " unexpected imageRailItem: \( imageRailItem ) " )
return
}
self . setCurrentItem (
targetItem ,
direction : ( currentItem . attachmentAlbumIndex < targetItem . attachmentAlbumIndex ?
. forward :
. reverse
) ,
animated : true
)
}
}
extension MediaPageViewController : CaptionContainerViewDelegate {
func captionContainerViewDidUpdateText ( _ captionContainerView : CaptionContainerView ) {
updateCaptionContainerVisibility ( )
}
// MARK: H e l p e r s
func updateCaptionContainerVisibility ( ) {
if let currentText = captionContainerView . currentText , currentText . count > 0 {
captionContainerView . isHidden = false
return
}
if let pendingText = captionContainerView . pendingText , pendingText . count > 0 {
captionContainerView . isHidden = false
return
}
captionContainerView . isHidden = true
}
}
// MARK: - U I V i e w C o n t r o l l e r T r a n s i t i o n i n g D e l e g a t e
extension MediaPageViewController : UIViewControllerTransitioningDelegate {
public func animationController ( forPresented presented : UIViewController , presenting : UIViewController , source : UIViewController ) -> UIViewControllerAnimatedTransitioning ? {
guard self = = presented || self . navigationController = = presented else { return nil }
return MediaZoomAnimationController ( galleryItem : currentItem )
}
public func animationController ( forDismissed dismissed : UIViewController ) -> UIViewControllerAnimatedTransitioning ? {
guard self = = dismissed || self . navigationController = = dismissed else { return nil }
guard ! self . viewModel . albumData . isEmpty else { return nil }
let animationController = MediaDismissAnimationController ( galleryItem : currentItem , interactionController : mediaInteractiveDismiss )
mediaInteractiveDismiss ? . interactiveDismissDelegate = animationController
return animationController
}
public func interactionControllerForDismissal ( using animator : UIViewControllerAnimatedTransitioning ) -> UIViewControllerInteractiveTransitioning ? {
guard let animator = animator as ? MediaDismissAnimationController ,
let interactionController = animator . interactionController ,
interactionController . interactionInProgress
else {
return nil
}
return interactionController
}
}
// MARK: - M e d i a P r e s e n t a t i o n C o n t e x t P r o v i d e r
extension MediaPageViewController : MediaPresentationContextProvider {
func mediaPresentationContext ( mediaItem : Media , in coordinateSpace : UICoordinateSpace ) -> MediaPresentationContext ? {
let mediaView = currentViewController . mediaView
guard let mediaSuperview : UIView = mediaView . superview else { return nil }
let presentationFrame = coordinateSpace . convert ( mediaView . frame , from : mediaSuperview )
return MediaPresentationContext (
mediaView : mediaView ,
presentationFrame : presentationFrame ,
cornerRadius : 0 ,
cornerMask : CACornerMask ( )
)
}
func snapshotOverlayView ( in coordinateSpace : UICoordinateSpace ) -> ( UIView , CGRect ) ? {
return self . navigationController ? . navigationBar . generateSnapshot ( in : coordinateSpace )
}
}