Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import SignalServiceKit
import SignalUI
import UIKit

extension MediaTileViewController: MediaGalleryCollectionViewUpdaterDelegate {

    private func indexSet(_ mediaGallerySectionIndexSet: MediaGallerySectionIndexSet) -> IndexSet {
        return mediaGallerySectionIndexSet.indexSet.shifted(by: 1)
    }

    func updaterDeleteSections(_ sections: MediaGallerySectionIndexSet) {
        collectionView?.deleteSections(indexSet(sections))
    }

    func updaterDeleteItems(at indexPaths: [MediaGalleryIndexPath]) {
        collectionView?.deleteItems(at: indexPaths.map {
            self.indexPath($0)
        })
    }

    func updaterInsertSections(_ sections: MediaGallerySectionIndexSet) {
        collectionView?.insertSections(indexSet(sections))
    }

    func updaterReloadItems(at indexPaths: [MediaGalleryIndexPath]) {
        collectionView?.reloadItems(at: indexPaths.map {
            self.indexPath($0)
        })
    }

    func updaterReloadSections(_ sections: MediaGallerySectionIndexSet) {
        collectionView?.reloadSections(indexSet(sections))
    }

    func updaterDidFinish(numberOfSectionsBefore: Int, numberOfSectionsAfter: Int) {
        Logger.debug("\(numberOfSectionsBefore) -> \(numberOfSectionsAfter)")
        owsPrecondition(numberOfSectionsAfter == mediaGallery.galleryDates.count)
        if numberOfSectionsBefore == 0, numberOfSectionsAfter > 0 {
            // Adding a "load newer" section. It goes at the end.
            collectionView?.insertSections(IndexSet(integer: localSection(numberOfSectionsAfter)))
        } else if numberOfSectionsBefore > 0, numberOfSectionsAfter == 0 {
            // Remove "load earlier" section at the beginning.
            collectionView?.deleteSections(IndexSet(integer: 0))
        }
        accessoriesHelper.updateFooterBarState()
    }
}

class MediaTileViewController: UICollectionViewController, MediaGalleryDelegate, UICollectionViewDelegateFlowLayout {

    private typealias Cell = (UICollectionViewCell & MediaGalleryCollectionViewCell)
    private typealias CollectionViewLayout = ScrollPositionPreserving & UICollectionViewFlowLayout
    private enum Layout {
        case list
        case grid

        func reuseIdentifier(mediaCategory: AllMediaCategory) -> String {
            switch mediaCategory {
            case .photoVideo:
                switch self {
                case .list:
                    return WidePhotoCell.reuseIdentifier
                case .grid:
                    return MediaTileCollectionViewCell.reuseIdentifier
                }
            case .audio:
                return AudioCell.reuseIdentifier
            case .otherFiles:
                return MediaGalleryFileCell.reuseIdentifier
            }
        }
    }

    private let thread: TSThread
    private let accessoriesHelper: MediaGalleryAccessoriesHelper
    private let spoilerState: SpoilerRenderState

    private lazy var mediaGallery: MediaGallery = {
        let mediaGallery = MediaGallery(thread: thread, mediaCategory: mediaCategory, spoilerState: spoilerState)
        mediaGallery.addDelegate(self)
        return mediaGallery
    }()

    private var currentCollectionViewLayout: CollectionViewLayout
    private var allCells = WeakArray<UICollectionViewCell>()

    var mediaCategory: AllMediaCategory = .defaultValue
    private var layout = Layout.grid

    func set(mediaCategory: AllMediaCategory, isGridLayout: Bool) {
        UIView.performWithoutAnimation {
            let mediaCategoryChanged = self.mediaCategory != mediaCategory
            if mediaCategoryChanged {
                mediaGallery.removeAllDelegates()
                mediaGallery = MediaGallery(thread: thread, mediaCategory: mediaCategory, spoilerState: spoilerState)
                mediaGallery.addDelegate(self)
                self.mediaCategory = mediaCategory
            }
            let layout: Layout = isGridLayout ? .grid : .list
            let layoutChanged = self.layout != layout
            var indexPath: IndexPath?
            if layoutChanged || mediaCategoryChanged {
                self.layout = layout
                indexPath = oldestVisibleIndexPath
                rebuildLayout()
            }
            if mediaCategoryChanged {
                collectionView.reloadData()
                _ = mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize)
                if !mediaGallery.galleryDates.isEmpty {
                    eagerlyLoadMoreIfPossible()
                }
                collectionView.reloadData()
                if mediaGallery.galleryDates.count > 0 {
                    let lastSectionItemCount = mediaGallery.numberOfItemsInSection(mediaGallery.galleryDates.count - 1)
                    indexPath = IndexPath(item: lastSectionItemCount - 1, section: mediaGallery.galleryDates.count)
                } else {
                    indexPath = nil
                }
            }

            collectionView.layoutIfNeeded()
            if let indexPath {
                if mediaCategoryChanged {
                    collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false)
                } else {
                    collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
                }
            }
        }
    }

    /// This is used to avoid running two animations concurrently. It doesn't look good on iOS 16 (and probably all other versions).
    private var activeAnimationCount = 0

    init(
        thread: TSThread,
        accessoriesHelper: MediaGalleryAccessoriesHelper,
        spoilerState: SpoilerRenderState,
    ) {
        self.thread = thread
        self.accessoriesHelper = accessoriesHelper
        self.spoilerState = spoilerState
        let layout = Self.buildLayout(layout, mediaCategory: mediaCategory)
        self.currentCollectionViewLayout = layout
        super.init(collectionViewLayout: layout)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: Subviews

    // CV section index to MG section index
    private func mediaGallerySection(_ section: Int) -> Int {
        return section - 1
    }

    // MG section index to CV section index
    private func localSection(_ section: Int) -> Int {
        return section + 1
    }

    // CV index path to MG
    private func mediaGalleryIndexPath(_ indexPath: IndexPath) -> MediaGalleryIndexPath {
        var temp = indexPath
        temp.section = mediaGallerySection(indexPath.section)

        return MediaGalleryIndexPath(temp)
    }

    private func indexPath(_ mediaGalleryIndexPath: MediaGalleryIndexPath) -> IndexPath {
        var temp = mediaGalleryIndexPath.indexPath
        temp.section = localSection(mediaGalleryIndexPath.section)
        return temp
    }

    private var indexPathsOfVisibleRealItems: [IndexPath] {
        let numberOfDates = mediaGallery.galleryDates.count
        return reallyVisibleIndexPaths.filter { path in
            path.section > 0 && path.section <= numberOfDates
        }.sorted { lhs, rhs in
            lhs < rhs
        }
    }

    private var oldestVisibleIndexPath: IndexPath? {
        return indexPathsOfVisibleRealItems.min { lhs, rhs in
            lhs.section < rhs.section
        }
    }

    private func filter(_ mediaFilter: AllMediaFilter) {
        let maybeDate = oldestVisibleIndexPath.map { mediaGallery.galleryDates[mediaGallerySection($0.section)] }
        let indexPathToScrollTo = mediaGallery.setMediaFilter(
            mediaFilter,
            loadUntil: maybeDate ?? GalleryDate(date: Date.distantPast),
            batchSize: kLoadBatchSize,
            firstVisibleIndexPath: oldestVisibleIndexPath.map { mediaGalleryIndexPath($0) },
        )

        if let indexPath = indexPathToScrollTo {
            // Scroll to approximately where you were before.
            collectionView.scrollToItem(
                at: self.indexPath(indexPath),
                at: .top,
                animated: false,
            )
        }
        eagerLoadingDidComplete = false
        eagerlyLoadMoreIfPossible()

        accessoriesHelper.updateFooterBarState()
    }

    // MARK: View Lifecycle Overrides

    override func loadView() {
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        title = MediaStrings.allMedia

        collectionView.register(MediaTileCollectionViewCell.self, forCellWithReuseIdentifier: MediaTileCollectionViewCell.reuseIdentifier)
        collectionView.register(WidePhotoCell.self, forCellWithReuseIdentifier: WidePhotoCell.reuseIdentifier)
        collectionView.register(AudioCell.self, forCellWithReuseIdentifier: AudioCell.reuseIdentifier)
        collectionView.register(MediaGalleryFileCell.self, forCellWithReuseIdentifier: MediaGalleryFileCell.reuseIdentifier)
        collectionView.register(
            MediaGalleryDateHeader.self,
            forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
            withReuseIdentifier: MediaGalleryDateHeader.reuseIdentifier,
        )
        collectionView.register(
            MediaGalleryStaticHeader.self,
            forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
            withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier,
        )
        collectionView.register(
            MediaGalleryEmptyContentView.self,
            forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
            withReuseIdentifier: MediaGalleryEmptyContentView.reuseIdentifier,
        )
        collectionView.delegate = self
        collectionView.alwaysBounceVertical = true
        collectionView.preservesSuperviewLayoutMargins = true
        collectionView.backgroundColor = .Signal.groupedBackground

        accessoriesHelper.installViews()

        NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)

        if #unavailable(iOS 26) {
            NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .themeDidChange, object: nil)
            applyTheme()
        }
    }

    override func viewWillAppear(_ animated: Bool) {
        defer { super.viewWillAppear(animated) }

        // You must layout before mutating mediaGallery. Otherwise, MediaTileViewLayout.prepare() could be
        // called while processing an update. That will violate the exclusive access rules since it calls
        // (indirectly) into MediaGallerySections.
        view.layoutIfNeeded()

        if mediaGallery.galleryDates.isEmpty {
            _ = self.mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize)
            if mediaGallery.galleryDates.isEmpty {
                // There must be no media.
                return
            }
            eagerlyLoadMoreIfPossible()
        }
        let cvAudioPlayer = AppEnvironment.shared.cvAudioPlayerRef
        cvAudioPlayer.shouldAutoplayNextAudioAttachment = { [weak self] in
            if self?.view.window == nil {
                return true
            }
            if self?.presentedViewController != nil {
                return true
            }
            if self?.navigationController?.topViewController != self?.parent {
                return true
            }
            return false
        }
        // Unclear why but without a slight delay the scroll doesn't apply on appear.
        // TODO: remove dispatch and figure out why the scroll is interrupted.
        DispatchQueue.main.async {
            let lastSectionItemCount = self.mediaGallery.numberOfItemsInSection(self.mediaGallery.galleryDates.count - 1)
            self.collectionView.scrollToItem(
                at: IndexPath(item: lastSectionItemCount - 1, section: self.mediaGallery.galleryDates.count),
                at: .bottom,
                animated: false,
            )
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        AppEnvironment.shared.cvAudioPlayerRef.shouldAutoplayNextAudioAttachment = nil
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        coordinator.animate { _ in
            self.recalculateLayoutMetrics()
        }
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        recalculateLayoutMetrics()
    }

    // MARK: Theme

    @objc
    @available(iOS, obsoleted: 26)
    private func applyTheme() {
        accessoriesHelper.applyTheme()
    }

    @objc
    private func contentSizeCategoryDidChange() {
        cachedDateHeaderHeight = nil
        cachedLoadingDataHeaderHeight = nil
        if layout == .list {
            recalculateListLayoutMetrics()
        }
        currentCollectionViewLayout.invalidateLayout()
    }

    // MARK: Orientation

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return UIDevice.current.defaultSupportedOrientations
    }

    // MARK: - List View / Grid View

    private func rebuildLayout() {
        currentCollectionViewLayout = Self.buildLayout(layout, mediaCategory: mediaCategory)
        collectionView.setCollectionViewLayout(currentCollectionViewLayout, animated: false)
        collectionView.reloadData()
    }

    // MARK: UICollectionViewDelegate

    private let scrollFlag = MediaTileScrollFlag()

    private var willDecelerate = false {
        didSet {
            if oldValue, !willDecelerate {
                mediaGallery.runAsyncCompletionsIfPossible()
            }
        }
    }

    private var scrollingToTop = false

    override func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
        scrollingToTop = true
        return true
    }

    override func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
        scrollingToTop = false
        showOrHideScrollFlag()
    }

    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        willDecelerate = decelerate
    }

    override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        willDecelerate = false
        showOrHideScrollFlag()
    }

    private var scrollFlagShouldBeVisible = false

    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        autoLoadMoreIfNecessary()
        showOrHideScrollFlag()
        if scrollFlag.superview != nil {
            updateScrollFlag()
        }
    }

    private func showOrHideScrollFlag() {
        if collectionView.isTracking || scrollingToTop {
            willDecelerate = false
            scrollFlagShouldBeVisible = true
            if scrollFlag.superview == nil {
                collectionView?.addSubview(scrollFlag)
            }
            scrollFlag.alpha = 1.0
        } else if scrollFlagShouldBeVisible, !willDecelerate {
            scrollFlagShouldBeVisible = false
            UIView.animate(withDuration: 0.25) {
                self.scrollFlag.alpha = 0.0
            }
        }
    }

    // Like indexPathsForVisibleItems but excludes those obscured by the navigation bar.
    private var reallyVisibleIndexPaths: [IndexPath] {
        guard let superview = collectionView.superview else {
            return []
        }
        let visibleFrame = collectionView.convert(
            collectionView.frame.inset(by: collectionView.safeAreaInsets),
            from: superview,
        )
        return collectionView.indexPathsForVisibleItems.filter { indexPath in
            guard let cell = collectionView.cellForItem(at: indexPath) else {
                return false
            }
            return cell.frame.intersects(visibleFrame)
        }
    }

    private func updateScrollFlag() {
        guard
            mediaGallery.galleryDates.count > 0,
            let indexPath = reallyVisibleIndexPaths.min()
        else {
            scrollFlag.alpha = 0.0
            return
        }
        let i = mediaGallerySection(indexPath.section)
        let date = mediaGallery.galleryDates[i]
        scrollFlag.stringValue = date.localizedString
        scrollFlag.sizeToFit()

        let center = guessCenterOfFlag()
        if center.x.isFinite, center.y.isFinite {
            scrollFlag.center = guessCenterOfFlag()
        }
    }

    private func guessCenterOfFlag() -> CGPoint {
        let currentOffset = collectionView.contentOffset.y
        let contentHeight = collectionView.contentSize.height
        let visibleHeight = collectionView.bounds.height

        // This crazy mess is to figure out where the scroll indicator is. It is complicated by
        // the fact that the scrollbar's exact location is not exposed, nor is the minimum
        // height of the indicator.

        let interfaceOrientation = view.window?.windowScene?.interfaceOrientation ?? .unknown

        let scrollbarInsets = {
            // This code is cursed and I'm sorry.
            switch interfaceOrientation {
            case .portrait, .portraitUpsideDown, .unknown, .landscapeRight:
                // Note that adjustedContentInset is used because it seems to include the
                // rounded corners of the device, whereas safeAreaInsets is not enough.
                var result = collectionView.adjustedContentInset
                if UIDevice.current.userInterfaceIdiom == .pad {
                    result.bottom = 8.0
                }
                result.right = 0
                if collectionView.verticalScrollIndicatorInsets.bottom > 0 {
                    // Footer height takes precedence over adjustedContentInset, which is not terribly accurate.
                    result.bottom = collectionView.verticalScrollIndicatorInsets.bottom
                }
                return result
            case .landscapeLeft:
                // In landscape the horizontal line to indicate swipe direction makes the bottom inset
                // too big. safeAreaInsets isn't right either. I don't think iOS exposes anything so
                // I'm hardcoding this until a better idea comes along.
                var result = collectionView.adjustedContentInset
                result.bottom = 8.0
                // In landscape left the scroll indicator is shifted way in, but its adjusted
                // content inset is 0.
                result.right = collectionView.safeAreaInsets.right
                if collectionView.verticalScrollIndicatorInsets.bottom > 0 {
                    // Footer height takes precedence over adjustedContentInset, which is not terribly accurate.
                    result.bottom = collectionView.verticalScrollIndicatorInsets.bottom
                }
                return result
            @unknown default:
                return collectionView.adjustedContentInset
            }
        }()

        // If iOS had a scrollbar, `scrollbarHeight` is how tall it would be. The scroll
        // indicator moves through an area of this height.
        let scrollbarHeight = collectionView.frame.height - scrollbarInsets.top - scrollbarInsets.bottom
        let rightInset = 20.0 + scrollbarInsets.right

        // Set a minimum height for the handle. iOS has no API for this.
        let minimumHandleHeight = UIDevice.current.userInterfaceIdiom == .pad ? 42.0 : 36.0

        // How much of the content is visible?
        let fractionVisible = visibleHeight / contentHeight

        // Guess how tall the indicator is.
        let indicatorHeight = max(
            minimumHandleHeight,
            scrollbarHeight * fractionVisible,
        )

        // First we calculate what fraction (from 0 to 1) is the top of the visible area at. This can't move through
        // the entire contentSize (except during overscroll, which we ignore). Reucing the denominator by visibleHeight
        // ensures it's at 1.0 when you're scrolled all the way down.
        let topFrac = collectionView.contentOffset.y / max(
            collectionView.contentOffset.y,
            collectionView.contentSize.height - visibleHeight,
        )
        // Next we figure out what the "range of motion" of the indicator is. It can only move through
        // scrollbarHeight minus its minimum height.
        let indicatorRangeOfMotion = scrollbarHeight - indicatorHeight

        // Now it's easy to calculate the vertical offset of the *top* of the indicator within the scrollbar.
        let indicatorOffsetInScrollbar = indicatorRangeOfMotion * topFrac

        // Calculate the top of the scrollbar.
        let scrollbarMinY = scrollbarInsets.top + currentOffset

        // Finally, we can calculate the coordinate of the vertical center of the indicator.
        let indicatorMidY = indicatorOffsetInScrollbar + indicatorHeight / 2.0 + scrollbarMinY

        let center = CGPoint(
            x: collectionView.bounds.width - rightInset - scrollFlag.bounds.width / 2.0,
            y: indicatorMidY,
        )
        return center
    }

    private var previousAdjustedContentInset: UIEdgeInsets = UIEdgeInsets()

    override func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
        defer { previousAdjustedContentInset = scrollView.adjustedContentInset }
        guard !mediaGallery.galleryDates.isEmpty else {
            return
        }

        if scrollView.contentSize.height > scrollView.bounds.height - scrollView.adjustedContentInset.totalHeight {
            // Were we pinned to the bottom before? If so, scroll back down.
            let dy = scrollView.adjustedContentInset.totalHeight - previousAdjustedContentInset.totalHeight
            if scrollView.contentOffset.y + dy + scrollView.bounds.height >= scrollView.contentSize.height {
                scrollView.contentOffset.y =
                    scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom
            }
        }

        updateScrollFlag()
    }

    override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
        guard !mediaGallery.galleryDates.isEmpty else {
            return false
        }

        switch indexPath.section {
        case kLoadOlderSectionIdx, loadNewerSectionIdx:
            return false
        default:
            return true
        }
    }

    override func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool {
        guard !mediaGallery.galleryDates.isEmpty else {
            return false
        }

        switch indexPath.section {
        case kLoadOlderSectionIdx, loadNewerSectionIdx:
            return false
        default:
            return true
        }
    }

    override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
        guard !mediaGallery.galleryDates.isEmpty else {
            return false
        }

        switch indexPath.section {
        case kLoadOlderSectionIdx, loadNewerSectionIdx:
            return false
        default:
            return true
        }
    }

    override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let galleryItem = galleryItem(at: indexPath) else {
            owsFailDebug("galleryCell was unexpectedly nil")
            return
        }

        if accessoriesHelper.isInBatchSelectMode {
            accessoriesHelper.didModifySelection()
        } else {
            collectionView.deselectItem(at: indexPath, animated: true)

            guard
                let pageVC = MediaPageViewController(
                    initialMediaAttachment: galleryItem.attachmentStream,
                    mediaGallery: mediaGallery,
                    spoilerState: spoilerState,
                )
            else {
                return
            }

            present(pageVC, animated: true)
        }
    }

    override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
        accessoriesHelper.didModifySelection()
    }

    // MARK: UICollectionViewDataSource

    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        let dates = mediaGallery.galleryDates
        guard !dates.isEmpty else {
            // empty gallery
            Logger.debug("No gallery dates - return 1")
            return 1
        }

        // One for each galleryDate plus a "loading older" and "loading newer" section
        let count = dates.count
        Logger.debug("\(count) gallery dates - return \(count + 2)")
        return count + 2
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
        guard !mediaGallery.galleryDates.isEmpty else {
            // empty gallery
            return 0
        }

        if sectionIdx == kLoadOlderSectionIdx {
            // load older
            return 0
        }

        if sectionIdx == loadNewerSectionIdx {
            // load more recent
            return 0
        }

        let count = mediaGallery.numberOfItemsInSection(mediaGallerySection(sectionIdx))
        return count
    }

    override func collectionView(
        _ collectionView: UICollectionView,
        viewForSupplementaryElementOfKind kind: String,
        at indexPath: IndexPath,
    ) -> UICollectionReusableView {

        let defaultView: (() -> UICollectionReusableView) = { UICollectionReusableView() }

        guard !mediaGallery.galleryDates.isEmpty else {
            guard
                let sectionHeader = collectionView.dequeueReusableSupplementaryView(
                    ofKind: kind,
                    withReuseIdentifier: MediaGalleryEmptyContentView.reuseIdentifier,
                    for: indexPath,
                ) as? MediaGalleryEmptyContentView
            else {
                owsFailDebug("unable to build section header for kLoadOlderSectionIdx")
                return defaultView()
            }
            sectionHeader.contentType = mediaCategory
            sectionHeader.isFilterOn = mediaGallery.isFiltering
            sectionHeader.clearFilterAction = { [weak self] in
                self?.disableFiltering()
            }
            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 \(indexPath)")
                    return defaultView()
                }
                sectionHeader.titleLabel.text = OWSLocalizedString(
                    "GALLERY_TILES_LOADING_OLDER_LABEL",
                    comment: "Label indicating loading is in progress",
                )
                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 \(indexPath)")
                    return defaultView()
                }
                sectionHeader.titleLabel.text = OWSLocalizedString(
                    "GALLERY_TILES_LOADING_MORE_RECENT_LABEL",
                    comment: "Label indicating loading is in progress",
                )
                return sectionHeader
            default:
                guard
                    let sectionHeader = collectionView.dequeueReusableSupplementaryView(
                        ofKind: kind,
                        withReuseIdentifier: MediaGalleryDateHeader.reuseIdentifier,
                        for: indexPath,
                    ) as? MediaGalleryDateHeader
                else {
                    owsFailDebug("unable to build section header for indexPath: \(indexPath)")
                    return defaultView()
                }
                guard let date = mediaGallery.galleryDates[safe: mediaGallerySection(indexPath.section)] else {
                    owsFailDebug("unknown section for indexPath: \(indexPath)")
                    return defaultView()
                }
                sectionHeader.leadingEdgeTextInset = layout == .grid ? 0 : 8
                if let wideMediaTileViewLayout = currentCollectionViewLayout as? WideMediaTileViewLayout {
                    sectionHeader.textMarginBottomAdjustment = wideMediaTileViewLayout.contentCardVerticalInset
                } else {
                    sectionHeader.textMarginBottomAdjustment = 0
                }
                sectionHeader.configure(title: date.localizedString)
                return sectionHeader
            }
        }

        return defaultView()
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: layout.reuseIdentifier(mediaCategory: mediaCategory), for: indexPath) as? Cell else {
            owsFailDebug("unexpected cell for indexPath: \(indexPath)")
            return UICollectionViewCell()
        }
        allCells.cullExpired()
        if !allCells.contains(cell) {
            allCells.append(cell)
        }
        cell.indexPathDidChange(indexPath, itemCount: collectionView.numberOfItems(inSection: indexPath.section))
        guard !mediaGallery.galleryDates.isEmpty else {
            owsFailDebug("unexpected cell for loadNewerSectionIdx")
            cell.makePlaceholder()
            return cell
        }

        switch indexPath.section {
        case kLoadOlderSectionIdx:
            owsFailDebug("unexpected cell for kLoadOlderSectionIdx")
            cell.makePlaceholder()
        case loadNewerSectionIdx:
            owsFailDebug("unexpected cell for loadNewerSectionIdx")
            cell.makePlaceholder()
        default:
            // Loading must be done asynchronously because this can be called while applying a
            // pending update. Attempting to mutate MediaGallerySections synchronously could cause a
            // deadlock.
            guard let galleryItem = galleryItem(at: indexPath, loadAsync: true) else {
                Logger.debug("Using placeholder for unloaded path \(indexPath)")
                cell.makePlaceholder()
                break
            }

            cell.configure(item: cellItem(for: galleryItem), spoilerState: spoilerState)
        }
        return cell
    }

    private let mediaCache = CVMediaCache()

    private func cellItem(for galleryItem: MediaGalleryItem) -> MediaGalleryCellItem {
        switch mediaCategory {
        case .photoVideo:
            return .photoVideo(MediaGalleryCellItemPhotoVideo(galleryItem: galleryItem))
        case .audio:
            return .audio(MediaGalleryCellItemAudio(
                message: galleryItem.message,
                interaction: galleryItem.message,
                thread: thread,
                attachmentStream: galleryItem.attachmentStream,
                receivedAtDate: galleryItem.receivedAtDate,
                isVoiceMessage: galleryItem.renderingFlag == .voiceMessage,
                mediaCache: mediaCache,
                metadata: galleryItem.mediaMetadata!,
            ))
        case .otherFiles:
            return .otherFile(MediaGalleryCellItemOtherFile(
                message: galleryItem.message,
                interaction: galleryItem.message,
                thread: thread,
                attachmentStream: galleryItem.attachmentStream,
                receivedAtDate: galleryItem.receivedAtDate,
                mediaCache: mediaCache,
                metadata: galleryItem.mediaMetadata!,
            ))
        }
    }

    override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard let cell = cell as? Cell else {
            owsFailDebug("unexpected cell: \(cell)")
            return
        }

        cell.setAllowsMultipleSelection(collectionView.allowsMultipleSelection, animated: false)
    }

    private func galleryItem(at indexPath: IndexPath, loadAsync: Bool = false) -> MediaGalleryItem? {
        let underlyingPath = mediaGalleryIndexPath(indexPath)
        if let loadedGalleryItem = mediaGallery.galleryItem(at: underlyingPath) {
            return loadedGalleryItem
        }

        mediaGallery.ensureGalleryItemsLoaded(
            .after,
            sectionIndex: underlyingPath.section,
            itemIndex: underlyingPath.item,
            amount: kLoadBatchSize,
            shouldLoadAlbumRemainder: false,
            async: loadAsync,
            userData: MediaGalleryUpdateUserData(disableAnimations: true),
        )

        return mediaGallery.galleryItem(at: underlyingPath)
    }

    private func updateVisibleCells() {
        for cell in collectionView.visibleCells {
            guard let cell = cell as? Cell else {
                owsFailDebug("unexpected cell: \(cell)")
                continue
            }

            cell.setAllowsMultipleSelection(collectionView.allowsMultipleSelection, animated: true)
        }
    }

    // MARK: UICollectionViewDelegateFlowLayout

    private static let invalidLayoutItemSize = CGSize(square: 10)

    private class func buildLayout(_ layout: Layout, mediaCategory: AllMediaCategory) -> CollectionViewLayout {
        let layout: CollectionViewLayout = {
            switch layout {
            case .list:
                switch mediaCategory {
                case .photoVideo:
                    return WideMediaTileViewLayout(contentCardVerticalInset: WidePhotoCell.contentCardVerticalInset)
                case .audio:
                    return WideMediaTileViewLayout(contentCardVerticalInset: AudioCell.contentCardVerticalInset)
                case .otherFiles:
                    return WideMediaTileViewLayout(
                        contentCardVerticalInset: MediaGalleryFileCell.contentCardVerticalInset,
                    )
                }
            case .grid:
                return SquareMediaTileViewLayout()
            }
        }()

        layout.itemSize = invalidLayoutItemSize
        layout.sectionInsetReference = .fromSafeArea
        layout.sectionHeadersPinToVisibleBounds = false

        return layout
    }

    private func recalculateLayoutMetrics() {
        switch layout {
        case .grid:
            recalculateGridLayoutMetrics()
        case .list:
            recalculateListLayoutMetrics()
        }
    }

    private func recalculateGridLayoutMetrics() {
        let kItemsPerPortraitRow = 4

        let containerSize = view.safeAreaLayoutGuide.layoutFrame.size
        let minimumViewWidth = min(containerSize.width, containerSize.height)
        let approxItemWidth = minimumViewWidth / CGFloat(kItemsPerPortraitRow)

        let itemCount = round(containerSize.width / approxItemWidth)
        let interSpaceWidth = (itemCount - 1) * currentCollectionViewLayout.minimumInteritemSpacing
        let availableWidth = max(0, containerSize.width - interSpaceWidth)

        let itemWidth = floor(availableWidth / CGFloat(itemCount))
        let newItemSize = CGSize(square: itemWidth)
        if newItemSize != currentCollectionViewLayout.itemSize {
            currentCollectionViewLayout.itemSize = newItemSize
            currentCollectionViewLayout.invalidateLayout()
        }
    }

    private func recalculateListLayoutMetrics() {
        let horizontalSectionInset: CGFloat
        let cellHeight: CGFloat

        switch mediaCategory {
        case .photoVideo:
            horizontalSectionInset = OWSTableViewController2.defaultHOuterMargin
            cellHeight = WidePhotoCell.cellHeight()
        case .audio:
            horizontalSectionInset = 0
            cellHeight = AudioCell.defaultCellHeight
        case .otherFiles:
            horizontalSectionInset = 0
            cellHeight = MediaGalleryFileCell.defaultCellHeight
        }

        let newItemSize = CGSize(
            width: floor(view.safeAreaLayoutGuide.layoutFrame.size.width) - horizontalSectionInset * 2,
            height: cellHeight,
        )
        if newItemSize != currentCollectionViewLayout.itemSize || horizontalSectionInset != currentCollectionViewLayout.sectionInset.left {
            currentCollectionViewLayout.itemSize = newItemSize
            currentCollectionViewLayout.sectionInset = UIEdgeInsets(hMargin: horizontalSectionInset, vMargin: 0)
        }
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        referenceSizeForHeaderInSection section: Int,
    ) -> CGSize {

        guard !mediaGallery.galleryDates.isEmpty else {
            // Make section header occupy entire visible collection view heigth so that the text is centered.
            let collectionViewViewportHeight = collectionView.frame.height - collectionView.adjustedContentInset.totalHeight
            return CGSize(width: collectionView.frame.height, height: collectionViewViewportHeight)
        }

        let height: CGFloat = {
            switch section {
            case kLoadOlderSectionIdx:
                // Show "loading older..." iff there is still older data to be fetched
                return mediaGallery.hasFetchedOldest ? 0 : loadingDataHeaderHeight()
            case loadNewerSectionIdx:
                // Show "loading newer..." iff there is still more recent data to be fetched
                return mediaGallery.hasFetchedMostRecent ? 0 : loadingDataHeaderHeight()
            default:
                return dateHeaderHeight()
            }
        }()
        guard height > 0 else {
            // No section header
            return .zero
        }
        return CGSize(width: collectionView.frame.width, height: height)
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        insetForSectionAt section: Int,
    ) -> UIEdgeInsets {
        guard !mediaGallery.galleryDates.isEmpty else { return .zero }

        guard layout == .list else { return .zero }

        if section == loadNewerSectionIdx {
            // Additional 16pt margin at the bottom of the list.
            return UIEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
        }

        return currentCollectionViewLayout.sectionInset
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath,
    ) -> CGSize {
        guard currentCollectionViewLayout == collectionViewLayout else {
            owsFailDebug("Unknown layout")
            return .zero
        }

        if currentCollectionViewLayout.itemSize == Self.invalidLayoutItemSize {
            recalculateLayoutMetrics()
        }

        switch layout {
        case .grid:
            return currentCollectionViewLayout.itemSize
        case .list:
            switch mediaCategory {
            case .photoVideo:
                return currentCollectionViewLayout.itemSize
            case .audio:
                let defaultCellSize = currentCollectionViewLayout.itemSize
                if let galleryItem = galleryItem(at: indexPath, loadAsync: true) {
                    let cellItem = cellItem(for: galleryItem)
                    let cellHeight = AudioCell.cellHeight(for: cellItem, maxWidth: defaultCellSize.width)
                    return CGSize(width: defaultCellSize.width, height: cellHeight)
                }
                return defaultCellSize
            case .otherFiles:
                let defaultCellSize = currentCollectionViewLayout.itemSize
                if let galleryItem = galleryItem(at: indexPath, loadAsync: true) {
                    let cellItem = cellItem(for: galleryItem)
                    let cellHeight = MediaGalleryFileCell.cellHeight(for: cellItem, maxWidth: defaultCellSize.width)
                    return CGSize(width: defaultCellSize.width, height: cellHeight)
                }
                return defaultCellSize
            }
        }
    }

    private var cachedDateHeaderHeight: CGFloat?
    private var cachedLoadingDataHeaderHeight: CGFloat?

    private func dateHeaderHeight() -> CGFloat {
        var headerHeight: CGFloat
        if let cachedDateHeaderHeight {
            headerHeight = cachedDateHeaderHeight
        } else {
            let headerView = MediaGalleryDateHeader()
            headerView.configure(title: "M")
            headerHeight = headerView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize).height
            cachedDateHeaderHeight = headerHeight
        }
        if let wideMediaTileLayout = currentCollectionViewLayout as? WideMediaTileViewLayout {
            headerHeight -= wideMediaTileLayout.contentCardVerticalInset
        }
        return headerHeight
    }

    private func loadingDataHeaderHeight() -> CGFloat {
        if let cachedLoadingDataHeaderHeight {
            return cachedLoadingDataHeaderHeight
        }
        let headerView = MediaGalleryStaticHeader()
        headerView.titleLabel.text = "M"
        let size = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        cachedLoadingDataHeaderHeight = size.height
        return size.height
    }

    // MARK: MediaGalleryDelegate

    private func updateCellIndexPaths() {
        for cell in allCells.elements {
            guard let indexPath = collectionView.indexPath(for: cell) else {
                continue
            }
            (cell as? Cell)?.indexPathDidChange(
                indexPath,
                itemCount: collectionView.numberOfItems(inSection: indexPath.section),
            )
        }
    }

    func mediaGallery(_ mediaGallery: MediaGallery, applyUpdate update: MediaGallery.Update) {
        Logger.debug("Will begin batch update. animating=\(activeAnimationCount)")
        let shouldAnimate = activeAnimationCount == 0 && !update.userData.contains(where: { $0.disableAnimations })

        let saved = UIView.areAnimationsEnabled
        UIView.setAnimationsEnabled(shouldAnimate && UIView.areAnimationsEnabled && saved)

        if update.userData.contains(where: { $0.shouldRecordContentSizeBeforeInsertingToTop }) {
            currentCollectionViewLayout.recordContentSizeBeforeInsertingToTop()
        }
        // Within `performBatchUpdates` and before our closure runs, `UICollectionView` may call `numberOfItemsInSection`
        // and it will expect us to give it "old" values (before any changes are applied).
        self.collectionView.performBatchUpdates {
            Logger.debug("Did begin batch update")

            let oldItemCounts = (0..<self.mediaGallery.galleryDates.count).map {
                self.mediaGallery.numberOfItemsInSection($0)
            }

            // This causes "new" values to become visible.
            let journal = update.commit()

            var updater = MediaGalleryCollectionViewUpdater(
                itemCounts: oldItemCounts,
            )
            updater.delegate = self
            updater.update(journal)

            Logger.debug("Finished batch update")
        } completion: { [weak self] _ in
            if shouldAnimate {
                self?.activeAnimationCount -= 1
            }
        }
        updateCellIndexPaths()

        UIView.setAnimationsEnabled(saved)
        if shouldAnimate {
            activeAnimationCount += 1
        }
    }

    func mediaGallery(_ mediaGallery: MediaGallery, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) { }

    func mediaGalleryDidDeleteItem(_ mediaGallery: MediaGallery) { }

    func mediaGalleryDidReloadItems(_ mediaGallery: MediaGallery) { }

    func didAddSectionInMediaGallery(_ mediaGallery: MediaGallery) {
        _ = mediaGallery.loadLaterSections(batchSize: kLoadBatchSize)
    }

    func didReloadAllSectionsInMediaGallery(_ mediaGallery: MediaGallery) {
        // If you receive a new attachment for an earlier month, MediaGallerySections resets itself and throws out a bunch of data.
        // It resets hasFetched{Oldest,MostRecent} to false. This causes "Loading older…" and "Loading newer…" to become visible,
        // even if there are no older or newer months in the db. Those only get updated on scroll.
        // If the collection view's content size is less than its visible size then scrolling is impossible and they are stuck forever.
        // Load sections until we either have everything or there's enough room for the user to scroll.
        while collectionView.contentSize.height < collectionView.visibleSize.height, !mediaGallery.hasFetchedOldest || !mediaGallery.hasFetchedMostRecent {
            if !mediaGallery.hasFetchedOldest {
                _ = mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize)
            }
            if !mediaGallery.hasFetchedMostRecent {
                _ = mediaGallery.loadLaterSections(batchSize: kLoadBatchSize)
            }
        }
        if eagerLoadingDidComplete {
            // There might be more to load so restart eager loading.
            eagerLoadingDidComplete = false
            eagerlyLoadMoreIfPossible()
        }
    }

    func mediaGalleryShouldDeferUpdate(_ mediaGallery: MediaGallery) -> Bool {
        return willDecelerate
    }

    // MARK: Lazy Loading

    var isFetchingMoreData = false
    let kLoadBatchSize: Int = 100

    let kLoadOlderSectionIdx: Int = 0
    var loadNewerSectionIdx: Int {
        return localSection(mediaGallery.galleryDates.count)
    }

    private var eagerLoadingDidComplete = false
    private var eagerLoadOutstanding = false

    private func eagerlyLoadMoreIfPossible() {
        guard !mediaGallery.hasFetchedOldest else {
            eagerLoadingDidComplete = true
            return
        }
        guard !eagerLoadOutstanding else {
            return
        }
        let userData = MediaGalleryUpdateUserData(
            disableAnimations: true,
            shouldRecordContentSizeBeforeInsertingToTop: true,
        )
        eagerLoadOutstanding = true
        // This is a low priority update because we never want eager loads to starve user-initiated
        // loads (such as loading more sections because of scrolling or loading items to display).
        mediaGallery.asyncLoadEarlierSections(
            batchSize: kLoadBatchSize,
            highPriority: false,
            userData: userData,
        ) { [weak self] newSections in
            self?.eagerLoadOutstanding = false
            self?.eagerlyLoadMoreIfPossible()
        }
    }

    func autoLoadMoreIfNecessary() {
        let kEdgeThreshold: CGFloat = 800

        guard let collectionView = self.collectionView else {
            owsFailDebug("collectionView was unexpectedly nil")
            return
        }

        let contentOffsetY = collectionView.contentOffset.y
        let oldContentHeight = collectionView.contentSize.height
        let direction: GalleryDirection

        var shouldRecordContentSizeBeforeInsertingToTop = false
        if contentOffsetY < kEdgeThreshold, !mediaGallery.hasFetchedOldest {
            // Near the top, load older content
            shouldRecordContentSizeBeforeInsertingToTop = true
            direction = .before

        } else if oldContentHeight - contentOffsetY < kEdgeThreshold, !mediaGallery.hasFetchedMostRecent {
            // Near the bottom, load newer content
            direction = .after

        } else {
            return
        }

        guard !isFetchingMoreData else {
            return
        }

        let userData = MediaGalleryUpdateUserData(
            disableAnimations: true,
            shouldRecordContentSizeBeforeInsertingToTop: shouldRecordContentSizeBeforeInsertingToTop,
        )

        isFetchingMoreData = true
        switch direction {
        case .before:
            mediaGallery.asyncLoadEarlierSections(
                batchSize: kLoadBatchSize,
                highPriority: true,
                userData: userData,
            ) { [weak self] newSections in
                self?.isFetchingMoreData = false
            }
        case .after:
            mediaGallery.asyncLoadLaterSections(batchSize: kLoadBatchSize, userData: userData) { [weak self] newSections in
                self?.isFetchingMoreData = false
            }
        case .around:
            preconditionFailure() // unused
        }
    }
}

extension MediaTileViewController: MediaPresentationContextProvider {

    func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
        // First time presentation can occur before layout.
        view.layoutIfNeeded()

        guard case let .gallery(galleryItem) = item else {
            owsFailDebug("Unexpected media type")
            return nil
        }

        guard let underlyingPath = mediaGallery.indexPath(for: galleryItem) else {
            owsFailDebug("galleryItemIndexPath was unexpectedly nil")
            return nil
        }
        let indexPath = self.indexPath(underlyingPath)

        guard let visibleIndex = collectionView.indexPathsForVisibleItems.firstIndex(of: indexPath) else {
            return nil
        }

        guard let cell = collectionView.visibleCells[safe: visibleIndex] as? Cell else {
            owsFailDebug("cell was unexpectedly nil")
            return nil
        }
        return cell.mediaPresentationContext(collectionView: collectionView, in: coordinateSpace)
    }
}

// MARK: - Private Helper Classes

private class MediaGalleryStaticHeader: UICollectionReusableView {

    static let reuseIdentifier = "MediaGalleryStaticHeader"

    let titleLabel: UILabel = {
        let label = UILabel()
        label.adjustsFontForContentSizeCategory = true
        label.textAlignment = .center
        label.font = .dynamicTypeHeadlineClamped
        label.numberOfLines = 2
        label.textColor = .Signal.secondaryLabel
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        addSubview(titleLabel)
        titleLabel.autoPinEdgesToSuperviewMargins()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

private class MediaGalleryDateHeader: UICollectionReusableView {

    static let reuseIdentifier = "MediaGalleryDateHeader"

    private let label: UILabel = {
        let label = UILabel()
        label.adjustsFontForContentSizeCategory = true
        label.font = .dynamicTypeHeadlineClamped
        label.textAlignment = .natural
        label.textColor = .Signal.label
        return label
    }()

    private lazy var labelLeadingEdgeConstraint = label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor)

    var leadingEdgeTextInset: CGFloat = 0 {
        didSet {
            labelLeadingEdgeConstraint.constant = leadingEdgeTextInset
        }
    }

    private static let textMarginBottom: CGFloat = 10
    // This property allows us to decrease distance between text and bottom edge of the header view
    // with the purpose of keeping vertical spacing between header view text and top edge
    // of the first content "card" in section same (applicable in the list view only).
    var textMarginBottomAdjustment: CGFloat = 0 {
        didSet {
            labelBottomEdgeConstraint.constant = Self.textMarginBottom - textMarginBottomAdjustment
        }
    }

    private lazy var labelBottomEdgeConstraint = bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: Self.textMarginBottom)

    override init(frame: CGRect) {
        super.init(frame: frame)

        preservesSuperviewLayoutMargins = true

        addSubview(label)
        NSLayoutConstraint.activate([labelLeadingEdgeConstraint, labelBottomEdgeConstraint])
        label.autoPinTrailingToSuperviewMargin()
        label.autoPinEdge(toSuperviewEdge: .top, withInset: 32)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(title: String) {
        label.text = title
    }
}

private class MediaGalleryEmptyContentView: UICollectionReusableView {

    static let reuseIdentifier = "MediaGalleryEmptyContentView"

    private let titleLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 2
        label.adjustsFontForContentSizeCategory = true
        label.font = .dynamicTypeSubheadlineClamped.semibold()
        label.lineBreakMode = .byWordWrapping
        label.textAlignment = .center
        label.textColor = .Signal.label
        return label
    }()

    private let subtitleLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 5
        label.adjustsFontForContentSizeCategory = true
        label.font = .dynamicTypeSubheadlineClamped
        label.lineBreakMode = .byWordWrapping
        label.textAlignment = .center
        label.textColor = .Signal.label
        return label
    }()

    private lazy var clearFilterButton = UIButton(
        configuration: .smallSecondary(title: OWSLocalizedString(
            "MEDIA_GALLERY_CLEAR_FILTER_BUTTON",
            comment: "Button to reset media filter. Displayed when filter results in no media visible.",
        )),
        primaryAction: UIAction { [weak self] _ in
            self?.clearFilterPressed()
        },
    )

    private lazy var stackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
        if isFilterOn {
            stackView.addArrangedSubview(clearFilterButton)
        }
        stackView.axis = .vertical
        stackView.alignment = .center
        stackView.spacing = 4
        stackView.setCustomSpacing(8, after: subtitleLabel)
        return stackView
    }()

    var contentType: AllMediaCategory = .photoVideo {
        didSet {
            reload()
        }
    }

    var isFilterOn: Bool = true {
        didSet {
            reload()
        }
    }

    var clearFilterAction: (() -> Void)?

    private func clearFilterPressed() {
        clearFilterAction?()
    }

    private func reload() {
        let title: String?
        let subtitle: String?

        if isFilterOn {
            title = NSLocalizedString(
                "MEDIA_GALLERY_NO_FILTER_RESULTS",
                comment: "Displayed in All Media screen when there's no media - first line.",
            )
            subtitle = nil
        } else {
            switch contentType {
            case .photoVideo:
                title = NSLocalizedString(
                    "MEDIA_GALLERY_NO_MEDIA_TITLE",
                    comment: "Displayed in All Media screen when there's no media - first line.",
                )
                subtitle = NSLocalizedString(
                    "MEDIA_GALLERY_NO_MEDIA_SUBTITLE",
                    comment: "Displayed in All Media screen when there's no media - second line.",
                )
            case .audio:
                title = NSLocalizedString(
                    "MEDIA_GALLERY_NO_AUDIO_TITLE",
                    comment: "Displayed in All Media (Audio) screen when there's no audio files - first line.",
                )
                subtitle = NSLocalizedString(
                    "MEDIA_GALLERY_NO_AUDIO_SUBTITLE",
                    comment: "Displayed in All Media (Audio) screen when there's no audio files - second line.",
                )
            case .otherFiles:
                title = NSLocalizedString(
                    "MEDIA_GALLERY_NO_FILES_TITLE",
                    comment: "Displayed in All Media (Audio) screen when there's no non-audiovisual files - first line.",
                )
                subtitle = NSLocalizedString(
                    "MEDIA_GALLERY_NO_FILES_SUBTITLE",
                    comment: "Displayed in All Media (Audio) screen when there's no non-audiovisual files - second line.",
                )
            }
        }

        titleLabel.text = title
        subtitleLabel.text = subtitle
        if isFilterOn {
            if stackView.arrangedSubviews.count == 2 {
                stackView.addArrangedSubview(clearFilterButton)
            }
            clearFilterButton.isHidden = false
        } else if stackView.arrangedSubviews.count > 2 {
            clearFilterButton.isHidden = true
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(stackView)
        stackView.autoCenterInSuperviewMargins()
        stackView.autoPinEdge(toSuperviewSafeArea: .top, withInset: 32, relation: .greaterThanOrEqual)
        stackView.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 32, relation: .greaterThanOrEqual)
        stackView.autoPinEdge(toSuperviewSafeArea: .leading, withInset: 32, relation: .greaterThanOrEqual)
        stackView.autoPinEdge(toSuperviewSafeArea: .trailing, withInset: 32, relation: .greaterThanOrEqual)
        reload()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension MediaTileViewController: MediaGalleryPrimaryViewController {

    typealias MenuItem = MediaGalleryAccessoriesHelper.MenuItem

    var scrollView: UIScrollView { return collectionView }

    var isEmpty: Bool {
        return mediaGallery.galleryDates.isEmpty
    }

    var hasSelection: Bool {
        if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 {
            return true
        }
        return false
    }

    func selectionInfo() -> (count: Int, totalSize: Int64)? {
        guard
            let items = collectionView.indexPathsForSelectedItems?.compactMap({ galleryItem(at: $0) }),
            !items.isEmpty
        else {
            return nil
        }

        let totalSize = items.reduce(Int64(0), { result, item in
            result + Int64(item.attachmentStream.attachmentStream.unencryptedByteCount)
        })
        return (items.count, totalSize)
    }

    func selectAll() {
        let scrollPosition = collectionView.contentOffset
        for section in 0..<collectionView.numberOfSections {
            for index in 0..<collectionView.numberOfItems(inSection: section) {
                collectionView.selectItem(
                    at: IndexPath(item: index, section: section),
                    animated: false,
                    scrollPosition: [],
                )
            }
        }
        collectionView.setContentOffset(scrollPosition, animated: false)
    }

    var mediaGalleryFilterMenuItems: [MediaGalleryAccessoriesHelper.MenuItem] {
        let menuItems: [MenuItem]
        switch mediaCategory {
        case .photoVideo:
            menuItems = [
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_FILTER_NONE",
                        comment: "Menu option to remove content type restriction in All Media view",
                    ),
                    isChecked: mediaGallery.mediaFilter == AllMediaFilter.defaultMediaType(for: mediaCategory),
                    handler: { [weak self] in
                        self?.disableFiltering()
                    },
                ),
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_FILTER_PHOTOS",
                        comment: "Menu option to limit All Media view to displaying only photos",
                    ),
                    isChecked: mediaGallery.mediaFilter == .photos,
                    handler: { [weak self] in
                        self?.filter(.photos)
                    },
                ),
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_FILTER_VIDEOS",
                        comment: "Menu option to limit All Media view to displaying only videos",
                    ),
                    isChecked: mediaGallery.mediaFilter == .videos,
                    handler: { [weak self] in
                        self?.filter(.videos)
                    },
                ),
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_FILTER_GIFS",
                        comment: "Menu option to limit All Media view to displaying only GIFs",
                    ),
                    isChecked: mediaGallery.mediaFilter == .gifs,
                    handler: { [weak self] in
                        self?.filter(.gifs)
                    },
                ),
            ]

        case .audio:
            menuItems = [
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_AUDIO_FILTER_ALL",
                        comment: "Menu option to remove content type restriction in All Media (Audio) view",
                    ),
                    isChecked: mediaGallery.mediaFilter == AllMediaFilter.defaultMediaType(for: mediaCategory),
                    handler: { [weak self] in
                        self?.disableFiltering()
                    },
                ),
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_AUDIO_FILTER_VOICE_MSG",
                        comment: "Menu option to limit All Media (Audio) view to displaying only Voice Messages",
                    ),
                    isChecked: mediaGallery.mediaFilter == .voiceMessages,
                    handler: { [weak self] in
                        self?.filter(.voiceMessages)
                    },
                ),
                MenuItem(
                    title:
                    OWSLocalizedString(
                        "ALL_MEDIA_AUDIO_FILTER_AUDIO_FILES",
                        comment: "Menu option to limit All Media (Audio) view to displaying non-voice message audio files",
                    ),
                    isChecked: mediaGallery.mediaFilter == .audioFiles,
                    handler: { [weak self] in
                        self?.filter(.audioFiles)
                    },
                ),
            ]

        case .otherFiles:
            return []
        }

        return menuItems
    }

    func disableFiltering() {
        let date: GalleryDate?
        if let indexPath = oldestVisibleIndexPath?.shiftingSection(by: -1) {
            date = mediaGallery.galleryDates[indexPath.section]
        } else {
            date = nil
        }
        let indexPathToScrollTo = mediaGallery.setMediaFilter(
            AllMediaFilter.defaultMediaType(for: mediaCategory),
            loadUntil: date ?? GalleryDate(date: .distantFuture),
            batchSize: kLoadBatchSize,
            firstVisibleIndexPath: oldestVisibleIndexPath.map { mediaGalleryIndexPath($0) },
        )

        if date == nil {
            if mediaGallery.galleryDates.isEmpty {
                _ = self.mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize)
            }
            if eagerLoadingDidComplete {
                // Filtering removed everything so we must restart eager loading.
                eagerLoadingDidComplete = false
                eagerlyLoadMoreIfPossible()
            }
        }
        if let indexPath = indexPathToScrollTo {
            collectionView.scrollToItem(
                at: self.indexPath(indexPath),
                at: .top,
                animated: false,
            )
        }

        accessoriesHelper.updateFooterBarState()
    }

    func batchSelectionModeDidChange(isInBatchSelectMode: Bool) {
        collectionView!.allowsMultipleSelection = isInBatchSelectMode
        updateVisibleCells()
    }

    func didEndSelectMode() {
        // deselect any selected
        collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) }
    }

    func deleteSelectedItems() {
        guard let indexPaths = collectionView.indexPathsForSelectedItems else {
            owsFailDebug("indexPaths was unexpectedly nil")
            return
        }

        let items: [MediaGalleryItem] = indexPaths.compactMap { return self.galleryItem(at: $0) }
        guard items.count == indexPaths.count else {
            owsFailDebug("trying to delete an item that never loaded")
            return
        }

        let actionSheetTitle = String.localizedStringWithFormat(
            OWSLocalizedString(
                "MEDIA_GALLERY_DELETE_MEDIA_TITLE",
                tableName: "PluralAware",
                comment: "Title for confirmation prompt when deleting N items in All Media screen.",
            ),
            indexPaths.count,
        )
        let actionSheetMessage = OWSLocalizedString(
            "MEDIA_GALLERY_DELETE_MEDIA_BODY",
            comment: "Explanatory text displayed when deleting N items in All Media screen.",
        )
        let toastText = String.localizedStringWithFormat(
            OWSLocalizedString(
                "MEDIA_GALLERY_DELETE_MEDIA_TOAST",
                tableName: "PluralAware",
                comment: "Toast displayed after successful deletion of N items in All Media screen.",
            ),
            indexPaths.count,
        )

        let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage)
        actionSheet.addAction(ActionSheetAction(
            title: CommonStrings.deleteButton,
            style: .destructive,
            handler: { [self] _ in
                let galleryIndexPaths = indexPaths.map { self.mediaGalleryIndexPath($0) }
                self.mediaGallery.delete(items: items, atIndexPaths: galleryIndexPaths, initiatedBy: self)
                self.accessoriesHelper.endSelectMode()
                DispatchQueue.main.async {
                    self.presentToast(text: toastText, extraVInset: self.collectionView.contentInset.bottom)
                }
            },
        ))
        actionSheet.addAction(OWSActionSheets.cancelAction)

        presentActionSheet(actionSheet)
    }

    func shareSelectedItems(_ sender: Any) {
        guard let indexPaths = collectionView.indexPathsForSelectedItems else {
            owsFailDebug("indexPaths was unexpectedly nil")
            return
        }

        let attachments = indexPaths.compactMap {
            self.galleryItem(at: $0)?.attachmentStream
        }
        let items: [ShareableAttachment] = (try? attachments.asShareableAttachments()) ?? []
        guard items.count == indexPaths.count else {
            owsFailDebug("trying to delete an item that never loaded")
            return
        }

        AttachmentSharing.showShareUI(for: items, sender: sender)
    }
}

private extension IndexSet {
    func shifted(startingAt index: Int? = nil, by amount: Int) -> IndexSet {
        var result = self
        result.shift(startingAt: index ?? self.first ?? 0, by: amount)
        return result
    }
}

private extension IndexPath {
    func shiftingSection(by delta: Int) -> IndexPath {
        return IndexPath(item: item, section: section + delta)
    }
}

private class MediaTileCollectionViewCell: PhotoGridViewCell, MediaGalleryCollectionViewCell {

    var item: MediaGalleryCellItem?

    func setAllowsMultipleSelection(_ allowed: Bool, animated: Bool) {
        allowsMultipleSelection = allowed
    }

    func configure(item: MediaGalleryCellItem, spoilerState: SpoilerRenderState) {
        self.item = item
        guard case .photoVideo(let mediaGalleryCellItemPhotoVideo) = item else {
            owsFailDebug("Invalid item.")
            return
        }
        super.configure(item: mediaGalleryCellItemPhotoVideo)
    }

    func indexPathDidChange(_ indexPath: IndexPath, itemCount: Int) { }
}