mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			915 lines
		
	
	
		
			33 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			915 lines
		
	
	
		
			33 KiB
		
	
	
	
		
			Swift
		
	
| //
 | |
| //  Copyright (c) 2019 Open Whisper Systems. All rights reserved.
 | |
| //
 | |
| 
 | |
| import Foundation
 | |
| 
 | |
| public protocol MediaTileViewControllerDelegate: class {
 | |
|     func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem)
 | |
| }
 | |
| 
 | |
| public class MediaTileViewController: UICollectionViewController, MediaGalleryDataSourceDelegate, UICollectionViewDelegateFlowLayout {
 | |
| 
 | |
|     private weak var mediaGalleryDataSource: MediaGalleryDataSource?
 | |
| 
 | |
|     private var galleryItems: [GalleryDate: [MediaGalleryItem]] {
 | |
|         guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
 | |
|             owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
 | |
|             return [:]
 | |
|         }
 | |
|         return mediaGalleryDataSource.sections
 | |
|     }
 | |
| 
 | |
|     private var galleryDates: [GalleryDate] {
 | |
|         guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
 | |
|             owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
 | |
|             return []
 | |
|         }
 | |
|         return mediaGalleryDataSource.sectionDates
 | |
|     }
 | |
|     public var focusedItem: MediaGalleryItem?
 | |
| 
 | |
|     private let uiDatabaseConnection: YapDatabaseConnection
 | |
| 
 | |
|     public weak var delegate: MediaTileViewControllerDelegate?
 | |
| 
 | |
|     deinit {
 | |
|         Logger.debug("deinit")
 | |
|     }
 | |
| 
 | |
|     fileprivate let mediaTileViewLayout: MediaTileViewLayout
 | |
| 
 | |
|     init(mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection) {
 | |
| 
 | |
|         self.mediaGalleryDataSource = mediaGalleryDataSource
 | |
|         assert(uiDatabaseConnection.isInLongLivedReadTransaction())
 | |
|         self.uiDatabaseConnection = uiDatabaseConnection
 | |
| 
 | |
|         let layout: MediaTileViewLayout = type(of: self).buildLayout()
 | |
|         self.mediaTileViewLayout = layout
 | |
|         super.init(collectionViewLayout: layout)
 | |
|     }
 | |
| 
 | |
|     required public init?(coder aDecoder: NSCoder) {
 | |
|         notImplemented()
 | |
|     }
 | |
| 
 | |
|     // MARK: Subviews
 | |
| 
 | |
|     lazy var footerBar: UIToolbar = {
 | |
|         let footerBar = UIToolbar()
 | |
|         let footerItems = [
 | |
|             UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
 | |
|             deleteButton,
 | |
|             UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
 | |
|         ]
 | |
|         footerBar.setItems(footerItems, animated: false)
 | |
| 
 | |
|         footerBar.barTintColor = Colors.navigationBarBackground
 | |
|         footerBar.tintColor = Colors.text
 | |
| 
 | |
|         return footerBar
 | |
|     }()
 | |
| 
 | |
|     lazy var deleteButton: UIBarButtonItem = {
 | |
|         let deleteButton = UIBarButtonItem(barButtonSystemItem: .trash,
 | |
|                                            target: self,
 | |
|                                            action: #selector(didPressDelete))
 | |
|         deleteButton.tintColor = Theme.darkThemeNavbarIconColor
 | |
| 
 | |
|         return deleteButton
 | |
|     }()
 | |
| 
 | |
|     // MARK: View Lifecycle Overrides
 | |
| 
 | |
|     override public func viewDidLoad() {
 | |
|         super.viewDidLoad()
 | |
|         
 | |
|         ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: MediaStrings.allMedia, hasCustomBackButton: false)
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         collectionView.backgroundColor = Colors.navigationBarBackground
 | |
| 
 | |
|         collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
 | |
|         collectionView.register(MediaGallerySectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: MediaGallerySectionHeader.reuseIdentifier)
 | |
|         collectionView.register(MediaGalleryStaticHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier)
 | |
| 
 | |
|         collectionView.delegate = self
 | |
| 
 | |
|         // feels a bit weird to have content smashed all the way to the bottom edge.
 | |
|         collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)
 | |
| 
 | |
|         self.view.addSubview(self.footerBar)
 | |
|         footerBar.autoPinWidthToSuperview()
 | |
|         footerBar.autoSetDimension(.height, toSize: kFooterBarHeight)
 | |
|         self.footerBarBottomConstraint = footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -kFooterBarHeight)
 | |
| 
 | |
|         updateSelectButton()
 | |
|         self.mediaTileViewLayout.invalidateLayout()
 | |
|     }
 | |
| 
 | |
|     private func indexPath(galleryItem: MediaGalleryItem) -> IndexPath? {
 | |
|         guard let sectionIdx = galleryDates.firstIndex(of: galleryItem.galleryDate) else {
 | |
|             return nil
 | |
|         }
 | |
|         guard let rowIdx = galleryItems[galleryItem.galleryDate]!.firstIndex(of: galleryItem) else {
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         return IndexPath(row: rowIdx, section: sectionIdx + 1)
 | |
|     }
 | |
| 
 | |
|     override public func viewWillAppear(_ animated: Bool) {
 | |
|         super.viewWillAppear(animated)
 | |
| 
 | |
|         guard let focusedItem = self.focusedItem else {
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         guard let indexPath = self.indexPath(galleryItem: focusedItem) else {
 | |
|             owsFailDebug("unexpectedly unable to find indexPath for focusedItem: \(focusedItem)")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         Logger.debug("scrolling to focused item at indexPath: \(indexPath)")
 | |
|         self.view.layoutIfNeeded()
 | |
|         self.collectionView?.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
 | |
|         self.autoLoadMoreIfNecessary()
 | |
|     }
 | |
| 
 | |
|     override public func viewWillTransition(to size: CGSize,
 | |
|                                             with coordinator: UIViewControllerTransitionCoordinator) {
 | |
|         self.mediaTileViewLayout.invalidateLayout()
 | |
|     }
 | |
| 
 | |
|     public override func viewWillLayoutSubviews() {
 | |
|         super.viewWillLayoutSubviews()
 | |
|         self.updateLayout()
 | |
|     }
 | |
| 
 | |
|     // MARK: Orientation
 | |
| 
 | |
|     override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
 | |
|         return .allButUpsideDown
 | |
|     }
 | |
| 
 | |
|     // MARK: UICollectionViewDelegate
 | |
| 
 | |
|     override public func scrollViewDidScroll(_ scrollView: UIScrollView) {
 | |
|         self.autoLoadMoreIfNecessary()
 | |
|     }
 | |
| 
 | |
|     override public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
 | |
|         self.isUserScrolling = true
 | |
|     }
 | |
| 
 | |
|     override public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
 | |
|         self.isUserScrolling = false
 | |
|     }
 | |
| 
 | |
|     override public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
 | |
| 
 | |
|         Logger.debug("")
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             return false
 | |
|         }
 | |
| 
 | |
|         switch indexPath.section {
 | |
|         case kLoadOlderSectionIdx, loadNewerSectionIdx:
 | |
|             return false
 | |
|         default:
 | |
|             return true
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     override public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
 | |
| 
 | |
|         Logger.debug("")
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             return false
 | |
|         }
 | |
| 
 | |
|         switch indexPath.section {
 | |
|         case kLoadOlderSectionIdx, loadNewerSectionIdx:
 | |
|             return false
 | |
|         default:
 | |
|             return true
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
 | |
| 
 | |
|         Logger.debug("")
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             return false
 | |
|         }
 | |
| 
 | |
|         switch indexPath.section {
 | |
|         case kLoadOlderSectionIdx, loadNewerSectionIdx:
 | |
|             return false
 | |
|         default:
 | |
|             return true
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     override public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
 | |
|         Logger.debug("")
 | |
| 
 | |
|         guard let gridCell = self.collectionView(collectionView, cellForItemAt: indexPath) as? PhotoGridViewCell else {
 | |
|             owsFailDebug("galleryCell was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         guard let galleryItem = (gridCell.item as? GalleryGridCellItem)?.galleryItem else {
 | |
|             owsFailDebug("galleryItem was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         if isInBatchSelectMode {
 | |
|             updateDeleteButton()
 | |
|         } else {
 | |
|             collectionView.deselectItem(at: indexPath, animated: true)
 | |
|             self.delegate?.mediaTileViewController(self, didTapView: gridCell.imageView, mediaGalleryItem: galleryItem)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
 | |
|         Logger.debug("")
 | |
| 
 | |
|         if isInBatchSelectMode {
 | |
|             updateDeleteButton()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private var isUserScrolling: Bool = false {
 | |
|         didSet {
 | |
|             autoLoadMoreIfNecessary()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: UICollectionViewDataSource
 | |
| 
 | |
|     override public func numberOfSections(in collectionView: UICollectionView) -> Int {
 | |
|         guard galleryDates.count > 0 else {
 | |
|             // empty gallery
 | |
|             return 1
 | |
|         }
 | |
| 
 | |
|         // One for each galleryDate plus a "loading older" and "loading newer" section
 | |
|         return galleryItems.keys.count + 2
 | |
|     }
 | |
| 
 | |
|     override public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             // empty gallery
 | |
|             return 0
 | |
|         }
 | |
| 
 | |
|         if sectionIdx == kLoadOlderSectionIdx {
 | |
|             // load older
 | |
|             return 0
 | |
|         }
 | |
| 
 | |
|         if sectionIdx == loadNewerSectionIdx {
 | |
|             // load more recent
 | |
|             return 0
 | |
|         }
 | |
| 
 | |
|         guard let sectionDate = self.galleryDates[safe: sectionIdx - 1] else {
 | |
|             owsFailDebug("unknown section: \(sectionIdx)")
 | |
|             return 0
 | |
|         }
 | |
| 
 | |
|         guard let section = self.galleryItems[sectionDate] else {
 | |
|             owsFailDebug("no section for date: \(sectionDate)")
 | |
|             return 0
 | |
|         }
 | |
| 
 | |
|         return section.count
 | |
|     }
 | |
| 
 | |
|     override public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
 | |
| 
 | |
|         let defaultView = UICollectionReusableView()
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else {
 | |
| 
 | |
|                 owsFailDebug("unable to build section header for kLoadOlderSectionIdx")
 | |
|                 return defaultView
 | |
|             }
 | |
|             let title = NSLocalizedString("GALLERY_TILES_EMPTY_GALLERY", comment: "Label indicating media gallery is empty")
 | |
|             sectionHeader.configure(title: title)
 | |
|             return sectionHeader
 | |
|         }
 | |
| 
 | |
|         if (kind == UICollectionView.elementKindSectionHeader) {
 | |
|             switch indexPath.section {
 | |
|             case kLoadOlderSectionIdx:
 | |
|                 guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else {
 | |
| 
 | |
|                     owsFailDebug("unable to build section header for kLoadOlderSectionIdx")
 | |
|                     return defaultView
 | |
|                 }
 | |
|                 let title = NSLocalizedString("GALLERY_TILES_LOADING_OLDER_LABEL", comment: "Label indicating loading is in progress")
 | |
|                 sectionHeader.configure(title: title)
 | |
|                 return sectionHeader
 | |
|             case loadNewerSectionIdx:
 | |
|                 guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else {
 | |
| 
 | |
|                     owsFailDebug("unable to build section header for kLoadOlderSectionIdx")
 | |
|                     return defaultView
 | |
|                 }
 | |
|                 let title = NSLocalizedString("GALLERY_TILES_LOADING_MORE_RECENT_LABEL", comment: "Label indicating loading is in progress")
 | |
|                 sectionHeader.configure(title: title)
 | |
|                 return sectionHeader
 | |
|             default:
 | |
|                 guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGallerySectionHeader.reuseIdentifier, for: indexPath) as? MediaGallerySectionHeader else {
 | |
|                     owsFailDebug("unable to build section header for indexPath: \(indexPath)")
 | |
|                     return defaultView
 | |
|                 }
 | |
|                 guard let date = self.galleryDates[safe: indexPath.section - 1] else {
 | |
|                     owsFailDebug("unknown section for indexPath: \(indexPath)")
 | |
|                     return defaultView
 | |
|                 }
 | |
| 
 | |
|                 sectionHeader.configure(title: date.localizedString)
 | |
|                 return sectionHeader
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return defaultView
 | |
|     }
 | |
| 
 | |
|     override public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
 | |
|         Logger.debug("indexPath: \(indexPath)")
 | |
| 
 | |
|         let defaultCell = UICollectionViewCell()
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             owsFailDebug("unexpected cell for loadNewerSectionIdx")
 | |
|             return defaultCell
 | |
|         }
 | |
| 
 | |
|         switch indexPath.section {
 | |
|         case kLoadOlderSectionIdx:
 | |
|             owsFailDebug("unexpected cell for kLoadOlderSectionIdx")
 | |
|             return defaultCell
 | |
|         case loadNewerSectionIdx:
 | |
|             owsFailDebug("unexpected cell for loadNewerSectionIdx")
 | |
|             return defaultCell
 | |
|         default:
 | |
|             guard let galleryItem = galleryItem(at: indexPath) else {
 | |
|                 owsFailDebug("no message for path: \(indexPath)")
 | |
|                 return defaultCell
 | |
|             }
 | |
| 
 | |
|             guard let cell = self.collectionView?.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
 | |
|                 owsFailDebug("unexpected cell for indexPath: \(indexPath)")
 | |
|                 return defaultCell
 | |
|             }
 | |
| 
 | |
|             let gridCellItem = GalleryGridCellItem(galleryItem: galleryItem)
 | |
|             cell.configure(item: gridCellItem)
 | |
| 
 | |
|             return cell
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func galleryItem(at indexPath: IndexPath) -> MediaGalleryItem? {
 | |
|         guard let sectionDate = self.galleryDates[safe: indexPath.section - 1] else {
 | |
|             owsFailDebug("unknown section: \(indexPath.section)")
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         guard let sectionItems = self.galleryItems[sectionDate] else {
 | |
|             owsFailDebug("no section for date: \(sectionDate)")
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         guard let galleryItem = sectionItems[safe: indexPath.row] else {
 | |
|             owsFailDebug("no message for row: \(indexPath.row)")
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         return galleryItem
 | |
|     }
 | |
| 
 | |
|     // MARK: UICollectionViewDelegateFlowLayout
 | |
| 
 | |
|     static let kInterItemSpacing: CGFloat = 2
 | |
|     private class func buildLayout() -> MediaTileViewLayout {
 | |
|         let layout = MediaTileViewLayout()
 | |
| 
 | |
|         if #available(iOS 11, *) {
 | |
|             layout.sectionInsetReference = .fromSafeArea
 | |
|         }
 | |
|         layout.minimumInteritemSpacing = kInterItemSpacing
 | |
|         layout.minimumLineSpacing = kInterItemSpacing
 | |
|         layout.sectionHeadersPinToVisibleBounds = true
 | |
| 
 | |
|         return layout
 | |
|     }
 | |
| 
 | |
|     func updateLayout() {
 | |
|         let containerWidth: CGFloat
 | |
|         if #available(iOS 11.0, *) {
 | |
|             containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
 | |
|         } else {
 | |
|             containerWidth = self.view.frame.size.width
 | |
|         }
 | |
| 
 | |
|         let kItemsPerPortraitRow = 4
 | |
|         let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
 | |
|         let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
 | |
| 
 | |
|         let itemCount = round(containerWidth / approxItemWidth)
 | |
|         let spaceWidth = (itemCount + 1) * type(of: self).kInterItemSpacing
 | |
|         let availableWidth = containerWidth - spaceWidth
 | |
| 
 | |
|         let itemWidth = floor(availableWidth / CGFloat(itemCount))
 | |
|         let newItemSize = CGSize(width: itemWidth, height: itemWidth)
 | |
| 
 | |
|         if (newItemSize != mediaTileViewLayout.itemSize) {
 | |
|             mediaTileViewLayout.itemSize = newItemSize
 | |
|             mediaTileViewLayout.invalidateLayout()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public func collectionView(_ collectionView: UICollectionView,
 | |
|                                layout collectionViewLayout: UICollectionViewLayout,
 | |
|                                referenceSizeForHeaderInSection section: Int) -> CGSize {
 | |
| 
 | |
|         let kMonthHeaderSize: CGSize = CGSize(width: 0, height: 50)
 | |
|         let kStaticHeaderSize: CGSize = CGSize(width: 0, height: 100)
 | |
| 
 | |
|         guard galleryDates.count > 0 else {
 | |
|             return kStaticHeaderSize
 | |
|         }
 | |
| 
 | |
|         guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
 | |
|             owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
 | |
|             return CGSize.zero
 | |
|         }
 | |
| 
 | |
|         switch section {
 | |
|         case kLoadOlderSectionIdx:
 | |
|             // Show "loading older..." iff there is still older data to be fetched
 | |
|             return mediaGalleryDataSource.hasFetchedOldest ? CGSize.zero : kStaticHeaderSize
 | |
|         case loadNewerSectionIdx:
 | |
|             // Show "loading newer..." iff there is still more recent data to be fetched
 | |
|             return mediaGalleryDataSource.hasFetchedMostRecent ? CGSize.zero : kStaticHeaderSize
 | |
|         default:
 | |
|             return kMonthHeaderSize
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Batch Selection
 | |
| 
 | |
|     var isInBatchSelectMode = false {
 | |
|         didSet {
 | |
|             collectionView!.allowsMultipleSelection = isInBatchSelectMode
 | |
|             updateSelectButton()
 | |
|             updateDeleteButton()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func updateDeleteButton() {
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 {
 | |
|             self.deleteButton.isEnabled = true
 | |
|         } else {
 | |
|             self.deleteButton.isEnabled = false
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func updateSelectButton() {
 | |
|         if isInBatchSelectMode {
 | |
|             self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didCancelSelect))
 | |
|         } else {
 | |
|             self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"),
 | |
|                                                                      style: .plain,
 | |
|                                                                      target: self,
 | |
|                                                                      action: #selector(didTapSelect))
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didTapSelect(_ sender: Any) {
 | |
|         isInBatchSelectMode = true
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         // show toolbar
 | |
|         UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: {
 | |
|             NSLayoutConstraint.deactivate([self.footerBarBottomConstraint])
 | |
|             self.footerBarBottomConstraint = self.footerBar.autoPinEdge(toSuperviewSafeArea: .bottom)
 | |
| 
 | |
|             self.footerBar.superview?.layoutIfNeeded()
 | |
| 
 | |
|             // ensure toolbar doesn't cover bottom row.
 | |
|             collectionView.contentInset.bottom += self.kFooterBarHeight
 | |
|         }, completion: nil)
 | |
| 
 | |
|         // disabled until at least one item is selected
 | |
|         self.deleteButton.isEnabled = false
 | |
| 
 | |
|         // Don't allow the user to leave mid-selection, so they realized they have
 | |
|         // to cancel (lose) their selection if they leave.
 | |
|         self.navigationItem.hidesBackButton = true
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didCancelSelect(_ sender: Any) {
 | |
|         endSelectMode()
 | |
|     }
 | |
| 
 | |
|     func endSelectMode() {
 | |
|         isInBatchSelectMode = false
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         // hide toolbar
 | |
|         UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut, animations: {
 | |
|             NSLayoutConstraint.deactivate([self.footerBarBottomConstraint])
 | |
|             self.footerBarBottomConstraint = self.footerBar.autoPinEdge(toSuperviewEdge: .bottom, withInset: -self.kFooterBarHeight)
 | |
|             self.footerBar.superview?.layoutIfNeeded()
 | |
| 
 | |
|             // undo "ensure toolbar doesn't cover bottom row."
 | |
|             collectionView.contentInset.bottom -= self.kFooterBarHeight
 | |
|         }, completion: nil)
 | |
| 
 | |
|         self.navigationItem.hidesBackButton = false
 | |
| 
 | |
|         // deselect any selected
 | |
|         collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)}
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didPressDelete(_ sender: Any) {
 | |
|         Logger.debug("")
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         guard let indexPaths = collectionView.indexPathsForSelectedItems else {
 | |
|             owsFailDebug("indexPaths was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         let items: [MediaGalleryItem] = indexPaths.compactMap { return self.galleryItem(at: $0) }
 | |
| 
 | |
|         guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
 | |
|             owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         let confirmationTitle: String = {
 | |
|             if indexPaths.count == 1 {
 | |
|                 return NSLocalizedString("MEDIA_GALLERY_DELETE_SINGLE_MESSAGE", comment: "Confirmation button text to delete selected media message from the gallery")
 | |
|             } else {
 | |
|                 let format = NSLocalizedString("MEDIA_GALLERY_DELETE_MULTIPLE_MESSAGES_FORMAT", comment: "Confirmation button text to delete selected media from the gallery, embeds {{number of messages}}")
 | |
|                 return String(format: format, indexPaths.count)
 | |
|             }
 | |
|         }()
 | |
| 
 | |
|         let deleteAction = UIAlertAction(title: confirmationTitle, style: .destructive) { _ in
 | |
|             mediaGalleryDataSource.delete(items: items, initiatedBy: self)
 | |
|             self.endSelectMode()
 | |
|         }
 | |
| 
 | |
|         let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
 | |
|         actionSheet.addAction(deleteAction)
 | |
|         actionSheet.addAction(OWSAlerts.cancelAction)
 | |
| 
 | |
|         presentAlert(actionSheet)
 | |
|     }
 | |
| 
 | |
|     var footerBarBottomConstraint: NSLayoutConstraint!
 | |
|     let kFooterBarHeight: CGFloat = 40
 | |
| 
 | |
|     // MARK: MediaGalleryDataSourceDelegate
 | |
| 
 | |
|     func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) {
 | |
|         Logger.debug("")
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         // We've got to lay out the collectionView before any changes are made to the date source
 | |
|         // otherwise we'll fail when we try to remove the deleted sections/rows
 | |
|         collectionView.layoutIfNeeded()
 | |
|     }
 | |
| 
 | |
|     func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) {
 | |
|         Logger.debug("with deletedSections: \(deletedSections) deletedItems: \(deletedItems)")
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         guard mediaGalleryDataSource.galleryItemCount > 0  else {
 | |
|             // Show Empty
 | |
|             self.collectionView?.reloadData()
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         collectionView.performBatchUpdates({
 | |
|             collectionView.deleteSections(deletedSections)
 | |
|             collectionView.deleteItems(at: deletedItems)
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     // MARK: Lazy Loading
 | |
| 
 | |
|     // This should be substantially larger than one screen size so we don't have to call it
 | |
|     // multiple times in a rapid succession, but not so large that loading get's really chopping
 | |
|     let kMediaTileViewLoadBatchSize: UInt = 40
 | |
|     var oldestLoadedItem: MediaGalleryItem? {
 | |
|         guard let oldestDate = galleryDates.first else {
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         return galleryItems[oldestDate]?.first
 | |
|     }
 | |
| 
 | |
|     var mostRecentLoadedItem: MediaGalleryItem? {
 | |
|         guard let mostRecentDate = galleryDates.last else {
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         return galleryItems[mostRecentDate]?.last
 | |
|     }
 | |
| 
 | |
|     var isFetchingMoreData: Bool = false
 | |
| 
 | |
|     let kLoadOlderSectionIdx = 0
 | |
|     var loadNewerSectionIdx: Int {
 | |
|         return galleryDates.count + 1
 | |
|     }
 | |
| 
 | |
|     public func autoLoadMoreIfNecessary() {
 | |
|         let kEdgeThreshold: CGFloat = 800
 | |
| 
 | |
|         if (self.isUserScrolling) {
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         guard let collectionView = self.collectionView else {
 | |
|             owsFailDebug("collectionView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
 | |
|             owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         let contentOffsetY = collectionView.contentOffset.y
 | |
|         let oldContentHeight = collectionView.contentSize.height
 | |
| 
 | |
|         if contentOffsetY < kEdgeThreshold {
 | |
|             // Near the top, load older content
 | |
| 
 | |
|             guard let oldestLoadedItem = self.oldestLoadedItem else {
 | |
|                 Logger.debug("no oldest item")
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             guard !mediaGalleryDataSource.hasFetchedOldest else {
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             guard !isFetchingMoreData else {
 | |
|                 Logger.debug("already fetching more data")
 | |
|                 return
 | |
|             }
 | |
|             isFetchingMoreData = true
 | |
| 
 | |
|             CATransaction.begin()
 | |
|             CATransaction.setDisableActions(true)
 | |
| 
 | |
|             // mediaTileViewLayout will adjust content offset to compensate for the change in content height so that
 | |
|             // the same content is visible after the update. I considered doing something like setContentOffset in the
 | |
|             // batchUpdate completion block, but it caused a distinct flicker, which I was able to avoid with the
 | |
|             // `CollectionViewLayout.prepare` based approach.
 | |
|             mediaTileViewLayout.isInsertingCellsToTop = true
 | |
|             mediaTileViewLayout.contentSizeBeforeInsertingToTop = collectionView.contentSize
 | |
|             collectionView.performBatchUpdates({
 | |
|                 mediaGalleryDataSource.ensureGalleryItemsLoaded(.before, item: oldestLoadedItem, amount: self.kMediaTileViewLoadBatchSize) { addedSections, addedItems in
 | |
|                     Logger.debug("insertingSections: \(addedSections) items: \(addedItems)")
 | |
| 
 | |
|                     collectionView.insertSections(addedSections)
 | |
|                     collectionView.insertItems(at: addedItems)
 | |
|                 }
 | |
|             }, completion: { finished in
 | |
|                 Logger.debug("performBatchUpdates finished: \(finished)")
 | |
|                 self.isFetchingMoreData = false
 | |
|                 CATransaction.commit()
 | |
|             })
 | |
| 
 | |
|         } else if oldContentHeight - contentOffsetY < kEdgeThreshold {
 | |
|             // Near the bottom, load newer content
 | |
| 
 | |
|             guard let mostRecentLoadedItem = self.mostRecentLoadedItem else {
 | |
|                 Logger.debug("no mostRecent item")
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             guard !mediaGalleryDataSource.hasFetchedMostRecent else {
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             guard !isFetchingMoreData else {
 | |
|                 Logger.debug("already fetching more data")
 | |
|                 return
 | |
|             }
 | |
|             isFetchingMoreData = true
 | |
| 
 | |
|             CATransaction.begin()
 | |
|             CATransaction.setDisableActions(true)
 | |
|             UIView.performWithoutAnimation {
 | |
|                 collectionView.performBatchUpdates({
 | |
|                     mediaGalleryDataSource.ensureGalleryItemsLoaded(.after, item: mostRecentLoadedItem, amount: self.kMediaTileViewLoadBatchSize) { addedSections, addedItems in
 | |
|                         Logger.debug("insertingSections: \(addedSections), items: \(addedItems)")
 | |
|                         collectionView.insertSections(addedSections)
 | |
|                         collectionView.insertItems(at: addedItems)
 | |
|                     }
 | |
|                 }, completion: { finished in
 | |
|                     Logger.debug("performBatchUpdates finished: \(finished)")
 | |
|                     self.isFetchingMoreData = false
 | |
|                     CATransaction.commit()
 | |
|                 })
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Private Helper Classes
 | |
| 
 | |
| // Accomodates remaining scrolled to the same "apparent" position when new content is inserted
 | |
| // into the top of a collectionView. There are multiple ways to solve this problem, but this
 | |
| // is the only one which avoided a perceptible flicker.
 | |
| private class MediaTileViewLayout: UICollectionViewFlowLayout {
 | |
| 
 | |
|     fileprivate var isInsertingCellsToTop: Bool = false
 | |
|     fileprivate var contentSizeBeforeInsertingToTop: CGSize?
 | |
| 
 | |
|     override public func prepare() {
 | |
|         super.prepare()
 | |
| 
 | |
|         if isInsertingCellsToTop {
 | |
|             if let collectionView = collectionView, let oldContentSize = contentSizeBeforeInsertingToTop {
 | |
|                 let newContentSize = collectionViewContentSize
 | |
|                 let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
 | |
|                 let newOffset = CGPoint(x: collectionView.contentOffset.x, y: contentOffsetY)
 | |
|                 collectionView.setContentOffset(newOffset, animated: false)
 | |
|             }
 | |
|             contentSizeBeforeInsertingToTop = nil
 | |
|             isInsertingCellsToTop = false
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| private class MediaGallerySectionHeader: UICollectionReusableView {
 | |
| 
 | |
|     static let reuseIdentifier = "MediaGallerySectionHeader"
 | |
| 
 | |
|     // HACK: scrollbar incorrectly appears *behind* section headers
 | |
|     // in collection view on iOS11 =(
 | |
|     private class AlwaysOnTopLayer: CALayer {
 | |
|         override var zPosition: CGFloat {
 | |
|             get { return 0 }
 | |
|             set {}
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     let label: UILabel
 | |
| 
 | |
|     override class var layerClass: AnyClass {
 | |
|         get {
 | |
|             // HACK: scrollbar incorrectly appears *behind* section headers
 | |
|             // in collection view on iOS11 =(
 | |
|             if #available(iOS 11, *) {
 | |
|                 return AlwaysOnTopLayer.self
 | |
|             } else {
 | |
|                 return super.layerClass
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     override init(frame: CGRect) {
 | |
|         label = UILabel()
 | |
|         label.textColor = Theme.darkThemePrimaryColor
 | |
| 
 | |
|         let blurEffect = Theme.darkThemeBarBlurEffect
 | |
|         let blurEffectView = UIVisualEffectView(effect: blurEffect)
 | |
| 
 | |
|         blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
 | |
| 
 | |
|         super.init(frame: frame)
 | |
| 
 | |
|         self.backgroundColor = isLightMode ? Colors.cellBackground : Theme.darkThemeNavbarBackgroundColor.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
 | |
| 
 | |
|         self.addSubview(blurEffectView)
 | |
|         self.addSubview(label)
 | |
| 
 | |
|         blurEffectView.autoPinEdgesToSuperviewEdges()
 | |
|         blurEffectView.isHidden = isLightMode
 | |
|         label.autoPinEdge(toSuperviewMargin: .trailing)
 | |
|         label.autoPinEdge(toSuperviewMargin: .leading)
 | |
|         label.autoVCenterInSuperview()
 | |
|     }
 | |
| 
 | |
|     @available(*, unavailable, message: "Unimplemented")
 | |
|     required init?(coder aDecoder: NSCoder) {
 | |
|         notImplemented()
 | |
|     }
 | |
| 
 | |
|     public func configure(title: String) {
 | |
|         self.label.text = title
 | |
|     }
 | |
| 
 | |
|     override public func prepareForReuse() {
 | |
|         super.prepareForReuse()
 | |
| 
 | |
|         self.label.text = nil
 | |
|     }
 | |
| }
 | |
| 
 | |
| private class MediaGalleryStaticHeader: UICollectionViewCell {
 | |
| 
 | |
|     static let reuseIdentifier = "MediaGalleryStaticHeader"
 | |
| 
 | |
|     let label = UILabel()
 | |
| 
 | |
|     override init(frame: CGRect) {
 | |
|         super.init(frame: frame)
 | |
| 
 | |
|         addSubview(label)
 | |
| 
 | |
|         label.textColor = Theme.darkThemePrimaryColor
 | |
|         label.textAlignment = .center
 | |
|         label.numberOfLines = 0
 | |
|         label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
 | |
|     }
 | |
| 
 | |
|     @available(*, unavailable, message: "Unimplemented")
 | |
|     required public init?(coder aDecoder: NSCoder) {
 | |
|         notImplemented()
 | |
|     }
 | |
| 
 | |
|     public func configure(title: String) {
 | |
|         self.label.text = title
 | |
|     }
 | |
| 
 | |
|     public override func prepareForReuse() {
 | |
|         self.label.text = nil
 | |
|     }
 | |
| }
 | |
| 
 | |
| class GalleryGridCellItem: PhotoGridItem {
 | |
|     let galleryItem: MediaGalleryItem
 | |
| 
 | |
|     init(galleryItem: MediaGalleryItem) {
 | |
|         self.galleryItem = galleryItem
 | |
|     }
 | |
| 
 | |
|     var type: PhotoGridItemType {
 | |
|         if galleryItem.isVideo {
 | |
|             return .video
 | |
|         } else if galleryItem.isAnimated {
 | |
|             return .animated
 | |
|         } else {
 | |
|             return .photo
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
 | |
|         return galleryItem.thumbnailImage(async: completion)
 | |
|     }
 | |
| }
 |