mirror of https://github.com/oxen-io/session-ios
				
				
				
			Media page view controller
First pass at a swipe-through media view for conversations. Future work could include - title label per item - sender name - date/time - photo rail - include caption // FREEBIEpull/1/head
							parent
							
								
									88e1386720
								
							
						
					
					
						commit
						4ac9a1019b
					
				| @ -1 +1 @@ | ||||
| Subproject commit 93e79025cf285042cb397f3f4d1e0d52c68b9ecc | ||||
| Subproject commit 594b44bf169e0ee2a690507ad09ff396888e81f9 | ||||
| @ -0,0 +1,688 @@ | ||||
| // | ||||
| //  Copyright (c) 2018 Open Whisper Systems. All rights reserved. | ||||
| // | ||||
| 
 | ||||
| import UIKit | ||||
| 
 | ||||
| class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate { | ||||
| 
 | ||||
|     private struct MediaGalleryItem: Equatable { | ||||
|         let message: TSMessage | ||||
|         let attachmentStream: TSAttachmentStream | ||||
|         let viewController: MediaDetailViewController | ||||
| 
 | ||||
|         var isVideo: Bool { | ||||
|             return attachmentStream.isVideo() | ||||
|         } | ||||
| 
 | ||||
|         var image: UIImage { | ||||
|             guard let image = attachmentStream.image() else { | ||||
|                 owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment image") | ||||
|                 return UIImage() | ||||
|             } | ||||
| 
 | ||||
|             return image | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private var cachedItems: [MediaGalleryItem] = [] | ||||
|     private var initialItem: MediaGalleryItem! | ||||
|     private var currentItem: MediaGalleryItem! { | ||||
|         return cachedItems.first { $0.viewController == viewControllers?.first } | ||||
|     } | ||||
| 
 | ||||
|     private let includeGallery: Bool | ||||
|     private let thread: TSThread | ||||
| 
 | ||||
|     private let mediaGalleryFinder: OWSMediaGalleryFinder | ||||
|     private let uiDatabaseConnection: YapDatabaseConnection | ||||
| 
 | ||||
|     private var mediaMessages: [TSMessage] = [] | ||||
| 
 | ||||
|     convenience init(thread: TSThread, mediaMessage: TSMessage) { | ||||
|         self.init(thread: thread, mediaMessage: mediaMessage, includeGallery: true) | ||||
|     } | ||||
| 
 | ||||
|     init(thread: TSThread, mediaMessage: TSMessage, includeGallery: Bool) { | ||||
|         self.thread = thread | ||||
|         self.uiDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() | ||||
|         self.mediaGalleryFinder = OWSMediaGalleryFinder() | ||||
|         self.includeGallery = includeGallery | ||||
| 
 | ||||
|         let kSpacingBetweenItems: CGFloat = 20 | ||||
| 
 | ||||
|         super.init(transitionStyle: .scroll, | ||||
|                    navigationOrientation: .horizontal, | ||||
|                    options: [UIPageViewControllerOptionInterPageSpacingKey: kSpacingBetweenItems]) | ||||
| 
 | ||||
|         self.dataSource = self | ||||
|         self.delegate = self | ||||
| 
 | ||||
|         uiDatabaseConnection.beginLongLivedReadTransaction() | ||||
| 
 | ||||
|         if includeGallery { | ||||
|             uiDatabaseConnection.read { transaction in | ||||
|                 // TODO don't read all media messages in at once. Use Mapping? | ||||
|                 self.mediaGalleryFinder.enumerateMediaMessages(with: thread, transaction: transaction) { message in | ||||
|                     self.mediaMessages.append(message) | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
|             self.mediaMessages = [mediaMessage] | ||||
|         } | ||||
| 
 | ||||
|         guard let initialItem = self.buildGalleryItem(mediaMessage: mediaMessage, thread: thread) else { | ||||
|             owsFail("unexpetedly unable to build initial gallery item") | ||||
|             return | ||||
|         } | ||||
|         self.initialItem = initialItem | ||||
|         cachedItems.insert(initialItem, at: 0) | ||||
| 
 | ||||
|         self.setViewControllers([initialItem.viewController], direction: .forward, animated: false, completion: nil) | ||||
|     } | ||||
| 
 | ||||
|     @available(*, unavailable, message: "Unimplemented") | ||||
|     required init?(coder: NSCoder) { | ||||
|         fatalError("init(coder:) has not been implemented") | ||||
|     } | ||||
| 
 | ||||
|     deinit { | ||||
|         Logger.debug("\(logTag) deinit") | ||||
|     } | ||||
| 
 | ||||
|     var presentationView: UIImageView! | ||||
|     var footerBar: UIToolbar! | ||||
|     var videoPlayBarButton: UIBarButtonItem! | ||||
|     var videoPauseBarButton: UIBarButtonItem! | ||||
|     var pagerScrollView: UIScrollView! | ||||
| 
 | ||||
|     override func viewDidLoad() { | ||||
|         super.viewDidLoad() | ||||
| 
 | ||||
|         // Navigation | ||||
| 
 | ||||
|         self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(didPressDismissButton)) | ||||
| 
 | ||||
|         // Even though bars are opaque, we want content to be layed out behind them. | ||||
|         // The bars might obscure part of the content, but they can easily be hidden by tapping | ||||
|         // The alternative would be that content would shift when the navbars hide. | ||||
|         self.extendedLayoutIncludesOpaqueBars = true | ||||
|         self.automaticallyAdjustsScrollViewInsets = false | ||||
| 
 | ||||
|         // Get reference to paged content which lives in a scrollView created by the superclass | ||||
|         // We show/hide this content during presentation | ||||
|         for view in self.view.subviews { | ||||
|             if let pagerScrollView = view as? UIScrollView { | ||||
|                 self.pagerScrollView = pagerScrollView | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Hack to avoid "page" bouncing when not in gallery view. | ||||
|         // e.g. when getting to media details via message details screen, there's only | ||||
|         // one "Page" so the bounce doesn't make sense. | ||||
|         if !self.includeGallery { | ||||
|             pagerScrollView.isScrollEnabled = false | ||||
|         } | ||||
| 
 | ||||
|         // FIXME dynamic title with sender/date | ||||
|         self.title = "Attachment" | ||||
| 
 | ||||
|         // Views | ||||
| 
 | ||||
|         let kFooterHeight: CGFloat = 44 | ||||
| 
 | ||||
|         view.backgroundColor = UIColor.white | ||||
| 
 | ||||
|         let footerBar = UIToolbar() | ||||
|         self.footerBar = footerBar | ||||
|         footerBar.barTintColor = UIColor.ows_signalBrandBlue | ||||
| 
 | ||||
|         self.videoPlayBarButton = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didPressPlayBarButton)) | ||||
|         self.videoPauseBarButton = UIBarButtonItem(barButtonSystemItem: .pause, target: self, action: #selector(didPressPauseBarButton)) | ||||
| 
 | ||||
|         self.updateFooterBarButtonItems(isPlayingVideo: true) | ||||
|         self.view.addSubview(footerBar) | ||||
|         footerBar.autoPinWidthToSuperview() | ||||
|         footerBar.autoPin(toBottomLayoutGuideOf: self, withInset: 0) | ||||
|         footerBar.autoSetDimension(.height, toSize:kFooterHeight) | ||||
| 
 | ||||
|         // The presentationView is only used during present/dismiss animations. | ||||
|         // It's a static image of the media content. | ||||
|         let presentationView = UIImageView(image: currentItem.image) | ||||
|         self.presentationView = presentationView | ||||
|         self.view.addSubview(presentationView) | ||||
|         presentationView.isHidden = true | ||||
|         presentationView.clipsToBounds = true | ||||
|         presentationView.layer.allowsEdgeAntialiasing = true | ||||
|         presentationView.layer.minificationFilter = kCAFilterTrilinear | ||||
|         presentationView.layer.magnificationFilter = kCAFilterTrilinear | ||||
|         presentationView.contentMode = .scaleAspectFit | ||||
| 
 | ||||
|         // Gestures | ||||
| 
 | ||||
|         let doubleTap = UITapGestureRecognizer(target: nil, action: nil) | ||||
|         doubleTap.numberOfTapsRequired = 2 | ||||
|         view.addGestureRecognizer(doubleTap) | ||||
| 
 | ||||
|         let singleTap = UITapGestureRecognizer(target: self, action: #selector(didTapView)) | ||||
|         singleTap.require(toFail: doubleTap) | ||||
|         view.addGestureRecognizer(singleTap) | ||||
| 
 | ||||
|         let verticalSwipe = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeView)) | ||||
|         verticalSwipe.direction = [.up, .down] | ||||
|         view.addGestureRecognizer(verticalSwipe) | ||||
|     } | ||||
| 
 | ||||
|     // MARK: View Helpers | ||||
| 
 | ||||
|     @objc | ||||
|     public func didSwipeView(sender: Any) { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         self.dismissSelf(animated: true) | ||||
|     } | ||||
| 
 | ||||
|     @objc | ||||
|     public func didTapView(sender: Any) { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         self.shouldHideToolbars = !self.shouldHideToolbars | ||||
|     } | ||||
| 
 | ||||
|     private var shouldHideToolbars: Bool = false { | ||||
|         didSet { | ||||
|             if (oldValue == shouldHideToolbars) { | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             // Hiding the status bar affects the positioning of the navbar. We don't want to show that in an animation, it's | ||||
|             // better to just have everythign "flit" in/out. | ||||
|             UIApplication.shared.setStatusBarHidden(shouldHideToolbars, with:.none) | ||||
|             self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false) | ||||
| 
 | ||||
|             // We don't animate the background color change because the old color shows through momentarily | ||||
|             // behind where the status bar "used to be". | ||||
|             self.view.backgroundColor = shouldHideToolbars ? UIColor.black : UIColor.white | ||||
| 
 | ||||
|             UIView.animate(withDuration: 0.1) { | ||||
|                 self.currentItem.viewController.setShouldHideToolbars(self.shouldHideToolbars) | ||||
|                 self.footerBar.alpha = self.shouldHideToolbars ? 0 : 1 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private func updateFooterBarButtonItems(isPlayingVideo: Bool) { | ||||
|         // TODO do we still need this? seems like a vestige | ||||
|         // from when media detail view was used for attachment approval | ||||
|         if (self.footerBar == nil) { | ||||
|             owsFail("\(logTag) No footer bar visible.") | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         var toolbarItems: [UIBarButtonItem] = [ | ||||
|             UIBarButtonItem(barButtonSystemItem: .action, target:self, action: #selector(didPressShare)), | ||||
|             UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil) | ||||
|         ] | ||||
| 
 | ||||
|         if (self.currentItem.isVideo) { | ||||
|             toolbarItems += [ | ||||
|                 isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton, | ||||
|                 UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil) | ||||
|             ] | ||||
|         } | ||||
| 
 | ||||
|         toolbarItems.append(UIBarButtonItem(barButtonSystemItem: .trash, | ||||
|                                             target:self, | ||||
|                                             action:#selector(didPressDelete))) | ||||
| 
 | ||||
|         self.footerBar.setItems(toolbarItems, animated: false) | ||||
|     } | ||||
| 
 | ||||
|     var replacingView: UIView? | ||||
| 
 | ||||
|     // TODO Default to bottom of screen? | ||||
|     // TODO rename to replacingOriginRect | ||||
|     var originRect: CGRect? | ||||
| 
 | ||||
|     func present(fromViewController: UIViewController, replacingView: UIView) { | ||||
| 
 | ||||
|         self.replacingView = replacingView | ||||
| 
 | ||||
|         let convertedRect: CGRect = replacingView.convert(replacingView.bounds, to: UIApplication.shared.keyWindow) | ||||
|         self.originRect = convertedRect | ||||
| 
 | ||||
|         // loadView hasn't necessarily been called yet. | ||||
|         self.loadViewIfNeeded() | ||||
|         self.applyInitialMediaViewConstraints() | ||||
| 
 | ||||
|         let navController = UINavigationController(rootViewController: self) | ||||
| 
 | ||||
|         // UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually | ||||
|         // animate in our view, over the existing context, similar to a cross disolve, but allowing us to have | ||||
|         // more fine grained control | ||||
|         navController.modalPresentationStyle = .custom | ||||
|         navController.navigationBar.barTintColor = UIColor.ows_materialBlue | ||||
|         navController.navigationBar.isTranslucent = false | ||||
|         navController.navigationBar.isOpaque = true | ||||
| 
 | ||||
|         // We want to animate the tapped media from it's position in the previous VC | ||||
|         // to it's resting place in the center of this view controller. | ||||
|         // | ||||
|         // Rather than animating the actual media view in place, we animate the presentationView, which is a static | ||||
|         // image of the media content. Animating the actual media view is problematic for a couple reasons: | ||||
|         // 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning | ||||
|         //    correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement, | ||||
|         //    especially noticeable on high resolution images. | ||||
|         // 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale | ||||
|         //    video, wherein only the cropping is animated. | ||||
|         // Using a simple image view allows us to address both these problems relatively easily. | ||||
|         self.view.alpha = 0.0 | ||||
| 
 | ||||
|         self.pagerScrollView.isHidden = true | ||||
|         self.presentationView.isHidden = false | ||||
|         self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius | ||||
| 
 | ||||
|         fromViewController.present(navController, animated: false) { | ||||
| 
 | ||||
|             // 1. Fade in the entire view. | ||||
|             UIView.animate(withDuration: 0.1) { | ||||
|                 self.replacingView?.alpha = 0.0 | ||||
|                 self.view.alpha = 1.0 | ||||
|             } | ||||
| 
 | ||||
|             self.presentationView.superview?.layoutIfNeeded() | ||||
|             self.applyFinalMediaViewConstraints() | ||||
| 
 | ||||
|             // 2. Animate imageView from it's initial position, which should match where it was | ||||
|             // in the presenting view to it's final position, front and center in this view. This | ||||
|             // animation duration intentionally overlaps the previous | ||||
|             UIView.animate(withDuration: 0.2, | ||||
|                            delay: 0.08, | ||||
|                            options: .curveEaseOut, | ||||
|                            animations: { | ||||
| 
 | ||||
|                             self.presentationView.layer.cornerRadius = 0 | ||||
|                             self.presentationView.superview?.layoutIfNeeded() | ||||
| 
 | ||||
|                             self.view.backgroundColor = UIColor.white | ||||
|                 }, | ||||
|                 completion: { (_: Bool) in | ||||
|                     // At this point our presentation view should be overlayed perfectly | ||||
|                     // with our media view. Swapping them out should be imperceptible. | ||||
|                     self.pagerScrollView.isHidden = false | ||||
|                     self.presentationView.isHidden = true | ||||
| 
 | ||||
|                     self.view.isUserInteractionEnabled = true | ||||
| 
 | ||||
|                     guard let currentItem = self.currentItem else { | ||||
|                         owsFail("\(self.logTag) in \(#function) currentItem unexepcetdly nil") | ||||
|                         return | ||||
|                     } | ||||
|                     if currentItem.isVideo { | ||||
|                         currentItem.viewController.playVideo() | ||||
|                     } | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private var presentationViewConstraints: [NSLayoutConstraint] = [] | ||||
| 
 | ||||
|     private func applyInitialMediaViewConstraints() { | ||||
|         if (self.presentationViewConstraints.count > 0) { | ||||
|             NSLayoutConstraint.deactivate(self.presentationViewConstraints) | ||||
|             self.presentationViewConstraints = [] | ||||
|         } | ||||
| 
 | ||||
|         guard let originRect = self.originRect else { | ||||
|             owsFail("\(logTag) in \(#function) originRect was unexpectedly nil") | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         guard let presentationSuperview = self.presentationView.superview else { | ||||
|             owsFail("\(logTag) in \(#function) presentationView.superview was unexpectedly nil") | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         let convertedRect: CGRect = presentationSuperview.convert(originRect, from: UIApplication.shared.keyWindow) | ||||
| 
 | ||||
|         self.presentationViewConstraints += self.presentationView.autoSetDimensions(to: convertedRect.size) | ||||
|         self.presentationViewConstraints += [ | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .top, withInset:convertedRect.origin.y), | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .left, withInset:convertedRect.origin.x) | ||||
|         ] | ||||
|     } | ||||
| 
 | ||||
|     private func applyFinalMediaViewConstraints() { | ||||
|         if (self.presentationViewConstraints.count > 0) { | ||||
|             NSLayoutConstraint.deactivate(self.presentationViewConstraints) | ||||
|             self.presentationViewConstraints = [] | ||||
|         } | ||||
| 
 | ||||
|         self.presentationViewConstraints = [ | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .leading), | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .top), | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .bottom) | ||||
|         ] | ||||
|     } | ||||
| 
 | ||||
|     private func applyOffscreenMediaViewConstraints() { | ||||
|         if (self.presentationViewConstraints.count > 0) { | ||||
|             NSLayoutConstraint.deactivate(self.presentationViewConstraints) | ||||
|             self.presentationViewConstraints = [] | ||||
|         } | ||||
| 
 | ||||
|         self.presentationViewConstraints += [ | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .leading), | ||||
|             self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), | ||||
|             self.presentationView.autoPinEdge(.top, to: .bottom, of: self.view) | ||||
|         ] | ||||
|     } | ||||
| 
 | ||||
|     // MARK: Actions | ||||
| 
 | ||||
|     @objc | ||||
|     public func didPressDismissButton(_ sender: Any) { | ||||
|         dismissSelf(animated: true) | ||||
|     } | ||||
| 
 | ||||
|     @objc | ||||
|     public func didPressShare(_ sender: Any) { | ||||
|         guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { | ||||
|             owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") | ||||
|             return | ||||
|         } | ||||
|         currentViewController.didPressShare(sender) | ||||
|     } | ||||
| 
 | ||||
|     @objc | ||||
|     public func didPressDelete(_ sender: Any) { | ||||
|         guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { | ||||
|             owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") | ||||
|             return | ||||
|         } | ||||
|         currentViewController.didPressDelete(sender) | ||||
|     } | ||||
| 
 | ||||
|     @objc | ||||
|     public func didPressPlayBarButton(_ sender: Any) { | ||||
|         guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { | ||||
|             owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") | ||||
|             return | ||||
|         } | ||||
|         currentViewController.didPressPlayBarButton(sender) | ||||
|     } | ||||
| 
 | ||||
|     @objc | ||||
|     public func didPressPauseBarButton(_ sender: Any) { | ||||
|         guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else { | ||||
|             owsFail("\(logTag) in \(#function) currentViewController was unexpectedly nil") | ||||
|             return | ||||
|         } | ||||
|         currentViewController.didPressPauseBarButton(sender) | ||||
|     } | ||||
| 
 | ||||
|     // MARK: UIPageViewControllerDelegate | ||||
| 
 | ||||
|     public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         assert(pendingViewControllers.count == 1) | ||||
|         pendingViewControllers.forEach { viewController in | ||||
|             guard let pendingItem = self.cachedItems.first(where: { $0.viewController == viewController}) else { | ||||
|                 owsFail("\(logTag) in \(#function) unexpected mediaDetailViewController: \(viewController)") | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             // Ensure upcoming page respects current toolbar status | ||||
|             pendingItem.viewController.setShouldHideToolbars(self.shouldHideToolbars) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         assert(previousViewControllers.count == 1) | ||||
|         previousViewControllers.forEach { viewController in | ||||
|             guard let previousItem = self.cachedItems.first(where: { $0.viewController == viewController}) else { | ||||
|                 owsFail("\(logTag) in \(#function) unexpected mediaDetailViewController: \(viewController)") | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             // Do any cleanup for the no-longer visible view controller | ||||
|             if transitionCompleted { | ||||
|                 previousItem.viewController.zoomOut(animated: false) | ||||
|                 if previousItem.isVideo { | ||||
|                     previousItem.viewController.stopVideo() | ||||
|                 } | ||||
|                 updateFooterBarButtonItems(isPlayingVideo: false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // MARK: UIPageViewControllerDataSource | ||||
| 
 | ||||
|     public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
|         guard let currentIndex = cachedItems.index(where: { $0.viewController == viewController }) else { | ||||
|             owsFail("\(self.logTag) unknown view controller. \(viewController)") | ||||
|             return nil | ||||
|         } | ||||
|         let currentItem = cachedItems[currentIndex] | ||||
| 
 | ||||
|         let newIndex = currentIndex - 1 | ||||
|         if let cachedItem = cachedItems[safe: newIndex] { | ||||
|             return cachedItem.viewController | ||||
|         } | ||||
| 
 | ||||
|         guard let previousMediaMessage = previousMediaMessage(currentItem.message) else { | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         guard let previousItem = buildGalleryItem(mediaMessage: previousMediaMessage, thread: thread) else { | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         cachedItems.insert(previousItem, at: currentIndex) | ||||
|         return previousItem.viewController | ||||
|     } | ||||
| 
 | ||||
|     public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         guard let currentIndex = cachedItems.index(where: { $0.viewController == viewController }) else { | ||||
|             owsFail("\(self.logTag) unknown view controller. \(viewController)") | ||||
|             return nil | ||||
|         } | ||||
|         let currentItem = cachedItems[currentIndex] | ||||
| 
 | ||||
|         let newIndex = currentIndex + 1 | ||||
|         if let cachedItem = cachedItems[safe: newIndex] { | ||||
|             return cachedItem.viewController | ||||
|         } | ||||
| 
 | ||||
|         guard let nextMediaMessage = nextMediaMessage(currentItem.message) else { | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         guard let nextItem = buildGalleryItem(mediaMessage: nextMediaMessage, thread: thread) else { | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         cachedItems.insert(nextItem, at: newIndex) | ||||
|         return nextItem.viewController | ||||
|     } | ||||
| 
 | ||||
|     private func buildGalleryItem(mediaMessage: TSMessage, thread: TSThread) -> MediaGalleryItem? { | ||||
|         var fetchedAttachment: TSAttachment? = nil | ||||
|         var fetchedItem: ConversationViewItem? = nil | ||||
|         self.uiDatabaseConnection.read { transaction in | ||||
|             fetchedAttachment = mediaMessage.attachment(with: transaction) | ||||
|             fetchedItem = ConversationViewItem(interaction: mediaMessage, isGroupThread: thread.isGroupThread(), transaction: transaction) | ||||
|         } | ||||
| 
 | ||||
|         guard let attachmentStream = fetchedAttachment as? TSAttachmentStream else { | ||||
|             owsFail("attachment stream unexpectedly nil") | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         guard let viewItem = fetchedItem else { | ||||
|             owsFail("viewItem stream unexpectedly nil") | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         let viewController = MediaDetailViewController(attachmentStream: attachmentStream, viewItem: viewItem) | ||||
|         viewController.delegate = self | ||||
|         return MediaGalleryItem(message: mediaMessage, | ||||
|                                 attachmentStream: attachmentStream, | ||||
|                                 viewController: viewController) | ||||
|     } | ||||
| 
 | ||||
|     @nonobjc | ||||
|     public func presentationCount(for: UIPageViewController) -> Int { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         var count: UInt = 0 | ||||
|         self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in | ||||
|             count = self.mediaGalleryFinder.mediaCount(thread: self.thread, transaction: transaction) | ||||
|         } | ||||
|         return Int(count) | ||||
|     } | ||||
| 
 | ||||
|     @nonobjc | ||||
|     public func presentationIndex(for pageViewController: UIPageViewController) -> Int { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         guard let mediaPageViewController = pageViewController as? MediaPageViewController else { | ||||
|             owsFail("\(self.logTag) unknown view controller. \(pageViewController)") | ||||
|             return 0 | ||||
|         } | ||||
| 
 | ||||
|         var index: UInt = 0 | ||||
|         self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in | ||||
|             index = self.mediaGalleryFinder.mediaIndex(message: self.currentItem.message, transaction: transaction) | ||||
|         } | ||||
|         return Int(index) | ||||
|     } | ||||
| 
 | ||||
|     // MARK: MediaDetailViewControllerDelegate | ||||
| 
 | ||||
|     public func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) { | ||||
|         self.view.isUserInteractionEnabled = false | ||||
|         UIApplication.shared.isStatusBarHidden = false | ||||
| 
 | ||||
|         guard let currentItem = self.currentItem else { | ||||
|             owsFail("\(logTag) in \(#function) currentItem was unexpectedly nil") | ||||
|             self.presentingViewController?.dismiss(animated: false, completion: completion) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         // Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way. | ||||
|         currentItem.viewController.zoomOut(animated: true) | ||||
| 
 | ||||
|         self.pagerScrollView.isHidden = true | ||||
|         self.presentationView.isHidden = false | ||||
| 
 | ||||
|         // Move the presentationView back to it's initial position, i.e. where | ||||
|         // it sits on the screen in the conversation view. | ||||
|         let changedItems = currentItem != initialItem | ||||
|         if changedItems { | ||||
|             self.presentationView.image = currentItem.image | ||||
|             self.applyOffscreenMediaViewConstraints() | ||||
|         } else { | ||||
|             self.applyInitialMediaViewConstraints() | ||||
|         } | ||||
| 
 | ||||
|         if isAnimated { | ||||
|             UIView.animate(withDuration: changedItems ? 0.25 : 0.18, | ||||
|                            delay: 0.0, | ||||
|                            options:.curveEaseOut, | ||||
|                            animations: { | ||||
|                             self.presentationView.superview?.layoutIfNeeded() | ||||
| 
 | ||||
|                             // In case user has hidden bars, which changes background to black. | ||||
|                             self.view.backgroundColor = UIColor.white | ||||
| 
 | ||||
|                             if changedItems { | ||||
|                                 self.presentationView.alpha = 0 | ||||
|                             } else { | ||||
|                                 self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius | ||||
|                             } | ||||
|             }, | ||||
|                            completion:nil) | ||||
| 
 | ||||
|             // This intentionally overlaps the previous animation a bit | ||||
|             UIView.animate(withDuration: 0.1, | ||||
|                            delay: 0.15, | ||||
|                            options: .curveEaseInOut, | ||||
|                            animations: { | ||||
|                             guard let replacingView = self.replacingView else { | ||||
|                                 owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") | ||||
|                                 self.presentingViewController?.dismiss(animated: false, completion: completion) | ||||
|                                 return | ||||
|                             } | ||||
|                             replacingView.alpha = 1.0 | ||||
| 
 | ||||
|                             // fade out content and toolbars | ||||
|                             self.navigationController?.view.alpha = 0.0 | ||||
|             }, | ||||
|                            completion: { (_: Bool) in | ||||
|                             self.presentingViewController?.dismiss(animated: false, completion: completion) | ||||
|             }) | ||||
|         } else { | ||||
|             guard let replacingView = self.replacingView else { | ||||
|                 owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") | ||||
|                 self.presentingViewController?.dismiss(animated: false, completion: completion) | ||||
|                 return | ||||
|             } | ||||
|             replacingView.alpha = 1.0 | ||||
|             self.presentingViewController?.dismiss(animated: false, completion: completion) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) { | ||||
|         guard mediaDetailViewController == currentItem.viewController else { | ||||
|             Logger.verbose("\(logTag) in \(#function) ignoring stale delegate.") | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         self.shouldHideToolbars = isPlayingVideo | ||||
|         self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo) | ||||
|     } | ||||
| 
 | ||||
|     // MARK: Helpers | ||||
| 
 | ||||
|     private var threadId: String { | ||||
|         guard let unqiueThreadId = self.thread.uniqueId else { | ||||
|             owsFail("thread missing id in \(#function)") | ||||
|             return "" | ||||
|         } | ||||
| 
 | ||||
|         return unqiueThreadId | ||||
|     } | ||||
| 
 | ||||
|     private func nextMediaMessage(_ message: TSMessage) -> TSMessage? { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         guard let currentIndex = mediaMessages.index(of: message) else { | ||||
|             owsFail("currentIndex was unexpectedly nil in \(#function)") | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         let index: Int = mediaMessages.index(after: currentIndex) | ||||
|         return mediaMessages[safe: index] | ||||
|     } | ||||
| 
 | ||||
|     private func previousMediaMessage(_ message: TSMessage) -> TSMessage? { | ||||
|         Logger.debug("\(logTag) in \(#function)") | ||||
| 
 | ||||
|         guard let currentIndex = mediaMessages.index(of: message) else { | ||||
|             owsFail("currentIndex was unexpectedly nil in \(#function)") | ||||
|             return nil | ||||
|         } | ||||
| 
 | ||||
|         let index: Int = mediaMessages.index(before: currentIndex) | ||||
|         return mediaMessages[safe: index] | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @ -0,0 +1,11 @@ | ||||
| // | ||||
| //  Copyright (c) 2018 Open Whisper Systems. All rights reserved. | ||||
| // | ||||
| 
 | ||||
| public extension Collection { | ||||
| 
 | ||||
|     /// Returns the element at the specified index iff it is within bounds, otherwise nil. | ||||
|     public subscript (safe index: Index) -> Element? { | ||||
|         return indices.contains(index) ? self[index] : nil | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,28 @@ | ||||
| //
 | ||||
| //  Copyright (c) 2018 Open Whisper Systems. All rights reserved.
 | ||||
| //
 | ||||
| 
 | ||||
| NS_ASSUME_NONNULL_BEGIN | ||||
| 
 | ||||
| @class OWSStorage; | ||||
| @class TSMessage; | ||||
| @class TSThread; | ||||
| @class YapDatabaseReadTransaction; | ||||
| 
 | ||||
| @interface OWSMediaGalleryFinder : NSObject | ||||
| 
 | ||||
| // How many media items a thread has
 | ||||
| - (NSUInteger)mediaCountForThread:(TSThread *)thread transaction:(YapDatabaseReadTransaction *)transaction NS_SWIFT_NAME(mediaCount(thread:transaction:)); | ||||
| 
 | ||||
| // The ordinal position of a message within a thread's media gallery
 | ||||
| - (NSUInteger)mediaIndexForMessage:(TSMessage *)message transaction:(YapDatabaseReadTransaction *)transaction NS_SWIFT_NAME(mediaIndex(message:transaction:)); | ||||
| 
 | ||||
| - (void)enumerateMediaMessagesWithThread:(TSThread *)thread | ||||
|                              transaction:(YapDatabaseReadTransaction *)transaction | ||||
|                                    block:(void (^)(TSMessage *))messageBlock; | ||||
| 
 | ||||
| + (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage; | ||||
| 
 | ||||
| @end | ||||
| 
 | ||||
| NS_ASSUME_NONNULL_END | ||||
| @ -0,0 +1,149 @@ | ||||
| // | ||||
| //  Copyright (c) 2018 Open Whisper Systems. All rights reserved. | ||||
| // | ||||
| 
 | ||||
| #import "OWSMediaGalleryFinder.h" | ||||
| #import "OWSStorage.h" | ||||
| #import "TSAttachmentStream.h" | ||||
| #import "TSMessage.h" | ||||
| #import "TSThread.h" | ||||
| #import <YapDatabase/YapDatabaseAutoView.h> | ||||
| #import <YapDatabase/YapDatabaseTransaction.h> | ||||
| #import <YapDatabase/YapDatabaseViewTypes.h> | ||||
| #import <YapDatabase/YapWhitelistBlacklist.h> | ||||
| 
 | ||||
| NS_ASSUME_NONNULL_BEGIN | ||||
| 
 | ||||
| static NSString *const OWSMediaGalleryFinderExtensionName = @"OWSMediaGalleryFinderExtensionName"; | ||||
| 
 | ||||
| @implementation OWSMediaGalleryFinder | ||||
| 
 | ||||
| #pragma mark - Public Finder Methods | ||||
| 
 | ||||
| -  (NSUInteger)mediaCountForThread:(TSThread *)thread transaction:(YapDatabaseReadTransaction *)transaction | ||||
| { | ||||
|     NSString *group = [self mediaGroupWithThreadId:thread.uniqueId]; | ||||
|     return [[self galleryExtensionWithTransaction:transaction] numberOfItemsInGroup:group]; | ||||
| } | ||||
| 
 | ||||
| - (NSUInteger)mediaIndexForMessage:(TSMessage *)message transaction:(YapDatabaseReadTransaction *)transaction | ||||
| { | ||||
|     NSString *groupId; | ||||
|     NSUInteger index; | ||||
| 
 | ||||
|     BOOL wasFound = [[self galleryExtensionWithTransaction:transaction] getGroup:&groupId | ||||
|                                                                            index:&index | ||||
|                                                                           forKey:message.uniqueId | ||||
|                                                                     inCollection:[TSMessage collection]]; | ||||
| 
 | ||||
|     OWSAssert(wasFound); | ||||
| 
 | ||||
|     return index; | ||||
| } | ||||
| 
 | ||||
| - (void)enumerateMediaMessagesWithThread:(TSThread *)thread | ||||
|                              transaction:(YapDatabaseReadTransaction *)transaction | ||||
|                                    block:(void (^)(TSMessage *))messageBlock | ||||
| { | ||||
|     NSString *group = [self mediaGroupWithThreadId:thread.uniqueId]; | ||||
|     [[self galleryExtensionWithTransaction:transaction] | ||||
|         enumerateKeysAndObjectsInGroup:group | ||||
|                             usingBlock:^(NSString *_Nonnull collection, | ||||
|                                 NSString *_Nonnull key, | ||||
|                                 id _Nonnull object, | ||||
|                                 NSUInteger index, | ||||
|                                 BOOL *_Nonnull stop) { | ||||
|                                 OWSAssert([object isKindOfClass:[TSMessage class]]); | ||||
|                                 messageBlock((TSMessage *)object); | ||||
|                             }]; | ||||
| } | ||||
| 
 | ||||
| #pragma mark - Util | ||||
| 
 | ||||
| - (YapDatabaseAutoViewTransaction *)galleryExtensionWithTransaction:(YapDatabaseReadTransaction *)transaction | ||||
| { | ||||
|     YapDatabaseAutoViewTransaction *extension = [transaction extension:OWSMediaGalleryFinderExtensionName]; | ||||
|     OWSAssert(extension); | ||||
|      | ||||
|     return extension; | ||||
| } | ||||
| 
 | ||||
| + (NSString *)mediaGroupWithThreadId:(NSString *)threadId | ||||
| { | ||||
|     return [NSString stringWithFormat:@"%@-media", threadId]; | ||||
| } | ||||
| 
 | ||||
| - (NSString *)mediaGroupWithThreadId:(NSString *)threadId | ||||
| { | ||||
|     return [[self class] mediaGroupWithThreadId:threadId]; | ||||
| } | ||||
| 
 | ||||
| #pragma mark - Extension registration | ||||
| 
 | ||||
| + (void)asyncRegisterDatabaseExtensionsWithPrimaryStorage:(OWSStorage *)storage | ||||
| { | ||||
|     [storage asyncRegisterExtension:[self mediaGalleryDatabaseExtension] | ||||
|                            withName:OWSMediaGalleryFinderExtensionName]; | ||||
| } | ||||
| 
 | ||||
| + (YapDatabaseAutoView *)mediaGalleryDatabaseExtension | ||||
| { | ||||
|     YapDatabaseViewSorting *sorting = [YapDatabaseViewSorting withObjectBlock:^NSComparisonResult(YapDatabaseReadTransaction * _Nonnull transaction, NSString * _Nonnull group, NSString * _Nonnull collection1, NSString * _Nonnull key1, id  _Nonnull object1, NSString * _Nonnull collection2, NSString * _Nonnull key2, id  _Nonnull object2) { | ||||
|          | ||||
|         if (![object1 isKindOfClass:[TSMessage class]]) { | ||||
|             OWSFail(@"%@ Unexpected object while sorting: %@", self.logTag, [object1 class]); | ||||
|             return NSOrderedSame; | ||||
|         } | ||||
|         TSMessage *message1 = (TSMessage *)object1; | ||||
|          | ||||
|         if (![object2 isKindOfClass:[TSMessage class]]) { | ||||
|             OWSFail(@"%@ Unexpected object while sorting: %@", self.logTag, [object2 class]); | ||||
|             return NSOrderedSame; | ||||
|         } | ||||
|         TSMessage *message2 = (TSMessage *)object2; | ||||
|          | ||||
|         return [@(message1.timestampForSorting) compare:@(message2.timestampForSorting)]; | ||||
|     }]; | ||||
|      | ||||
|     YapDatabaseViewGrouping *grouping = [YapDatabaseViewGrouping withObjectBlock:^NSString * _Nullable(YapDatabaseReadTransaction * _Nonnull transaction, NSString * _Nonnull collection, NSString * _Nonnull key, id  _Nonnull object) { | ||||
|          | ||||
|         if (![object isKindOfClass:[TSMessage class]]) { | ||||
|             return nil; | ||||
|         } | ||||
|         TSMessage *message = (TSMessage *)object; | ||||
|          | ||||
|         OWSAssert(message.attachmentIds.count <= 1); | ||||
|         NSString *attachmentId = message.attachmentIds.firstObject; | ||||
|         if (attachmentId.length == 0) { | ||||
|             return nil; | ||||
|         } | ||||
|          | ||||
|         if ([self attachmentIdShouldAppearInMediaGallery:attachmentId transaction:transaction]) { | ||||
|             return [self mediaGroupWithThreadId:message.uniqueThreadId]; | ||||
|         } | ||||
|          | ||||
|         return nil; | ||||
|     }]; | ||||
|      | ||||
|     YapDatabaseViewOptions *options = [YapDatabaseViewOptions new]; | ||||
|     options.allowedCollections = [[YapWhitelistBlacklist alloc] initWithWhitelist:[NSSet setWithObject:TSMessage.collection]]; | ||||
|      | ||||
|     return [[YapDatabaseAutoView alloc] initWithGrouping:grouping sorting:sorting versionTag:@"1" options:options]; | ||||
| } | ||||
| 
 | ||||
| + (BOOL)attachmentIdShouldAppearInMediaGallery:(NSString *)attachmentId transaction:(YapDatabaseReadTransaction *)transaction | ||||
| { | ||||
|     TSAttachmentStream *attachment = [TSAttachmentStream fetchObjectWithUniqueID:attachmentId | ||||
|                                                                      transaction:transaction]; | ||||
| 
 | ||||
|     // Don't include nil or not yet downloaded attachments. | ||||
|     if (![attachment isKindOfClass:[TSAttachmentStream class]]) { | ||||
|         return NO; | ||||
|     } | ||||
|      | ||||
|     return attachment.isImage || attachment.isVideo || attachment.isAnimated; | ||||
| } | ||||
| 
 | ||||
| @end | ||||
| 
 | ||||
| NS_ASSUME_NONNULL_END | ||||
					Loading…
					
					
				
		Reference in New Issue