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

import SignalServiceKit
import SignalUI
import UIKit

class MediaPageViewController: UIPageViewController {
    private lazy var mediaInteractiveDismiss = MediaInteractiveDismiss(targetViewController: self)

    private let isShowingSingleMessage: Bool
    let mediaGallery: MediaGallery
    let spoilerState: SpoilerRenderState

    private let initialGalleryItem: MediaGalleryItem

    convenience init?(
        initialMediaAttachment: ReferencedAttachment,
        thread: TSThread,
        spoilerState: SpoilerRenderState,
        showingSingleMessage: Bool = false,
    ) {
        self.init(
            initialMediaAttachment: initialMediaAttachment,
            mediaGallery: MediaGallery(thread: thread, mediaCategory: .photoVideo, spoilerState: spoilerState),
            spoilerState: spoilerState,
            showingSingleMessage: showingSingleMessage,
        )
    }

    init?(
        initialMediaAttachment: ReferencedAttachment,
        mediaGallery: MediaGallery,
        spoilerState: SpoilerRenderState,
        showingSingleMessage: Bool = false,
    ) {
        self.mediaGallery = mediaGallery
        self.spoilerState = spoilerState
        self.isShowingSingleMessage = showingSingleMessage

        Logger.info("will ensureLoadedForDetailView")
        guard let initialItem = mediaGallery.ensureLoadedForDetailView(focusedAttachment: initialMediaAttachment) else {
            owsFailDebug("unexpectedly failed to build initialDetailItem.")
            return nil
        }
        Logger.info("ensureLoadedForDetailView done")

        self.initialGalleryItem = initialItem

        super.init(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal,
            options: [.interPageSpacing: 20],
        )

        extendedLayoutIncludesOpaqueBars = true
        modalPresentationStyle = .overFullScreen
        modalPresentationCapturesStatusBarAppearance = true
        dataSource = self
        delegate = self
        transitioningDelegate = self
    }

    @available(*, unavailable, message: "Unimplemented")
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - Controls

    // Top Bar
    private lazy var topPanel: UIView = {
        let view = UIView()
        view.preservesSuperviewLayoutMargins = true

        // iOS 26: Transparent bar with glass backgrounds for controls.
        // Pre-iOS 26: blur background.
        if #unavailable(iOS 26) {
            let blurBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
            blurBackgroundView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(blurBackgroundView)
            NSLayoutConstraint.activate([
                blurBackgroundView.topAnchor.constraint(equalTo: view.topAnchor),
                blurBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                blurBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                blurBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            ])
        }
        return view
    }()

    private var navigationBarVerticalPositionConstraint: NSLayoutConstraint?

    // Bottom Bar
    private lazy var bottomMediaPanel = MediaControlPanelView(
        mediaGallery: mediaGallery,
        delegate: self,
        spoilerState: spoilerState,
    )

    // MARK: UIViewController

    override var preferredStatusBarStyle: UIStatusBarStyle {
        guard #unavailable(iOS 26) else {
            return super.preferredStatusBarStyle
        }

        if Theme.isDarkThemeEnabled {
            return .lightContent
        }

        let useDarkContentStatusBar: Bool
        if mediaInteractiveDismiss.interactionInProgress {
            useDarkContentStatusBar = true
        } else if isBeingDismissed, let transitionCoordinator {
            useDarkContentStatusBar = !transitionCoordinator.isCancelled
        } else {
            useDarkContentStatusBar = false
        }

        if useDarkContentStatusBar {
            return .darkContent
        }
        return .lightContent
    }

    override var prefersStatusBarHidden: Bool {
        return shouldHideStatusBar
    }

    override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
        return .none
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        if #unavailable(iOS 26) {
            overrideUserInterfaceStyle = .dark
        }

        view.backgroundColor = .Signal.background

        mediaInteractiveDismiss.addGestureRecognizer(to: view)

        navigationItem.titleView = headerView

        // Top panel
        // Use UINavigation bar to ensure position of the < back button matches exactly of one in the presenting VC.
        let navigationBar = UINavigationBar()
        navigationBar.delegate = self
        navigationBar.tintColor = .Signal.label
        navigationBar.isUserInteractionEnabled = true
        let appearance = UINavigationBarAppearance()
        appearance.configureWithTransparentBackground()
        navigationBar.standardAppearance = appearance
        navigationBar.compactAppearance = appearance
        navigationBar.scrollEdgeAppearance = appearance
        navigationBar.setItems([UINavigationItem(title: ""), navigationItem], animated: false)
        navigationBar.translatesAutoresizingMaskIntoConstraints = false
        topPanel.addSubview(navigationBar)

        // See `viewSafeAreaInsetsDidChange` why this is needed.
        navigationBarVerticalPositionConstraint = navigationBar.topAnchor.constraint(equalTo: topPanel.topAnchor)
        NSLayoutConstraint.activate([
            navigationBarVerticalPositionConstraint!,
            navigationBar.bottomAnchor.constraint(equalTo: topPanel.bottomAnchor),
        ])

        // On iOS 26 navigation bar extends all the way to left and right screen edges even in landscape.
        if #available(iOS 26, *) {
            NSLayoutConstraint.activate([
                navigationBar.leadingAnchor.constraint(equalTo: topPanel.leadingAnchor),
                navigationBar.trailingAnchor.constraint(equalTo: topPanel.trailingAnchor),
            ])
        } else {
            NSLayoutConstraint.activate([
                navigationBar.leadingAnchor.constraint(equalTo: topPanel.safeAreaLayoutGuide.leadingAnchor),
                navigationBar.trailingAnchor.constraint(equalTo: topPanel.safeAreaLayoutGuide.trailingAnchor),
            ])
        }

        // Add top panel and constrain it to view's leading, top and trailing edges.
        // Navigation bar (set up above) determines this panel's height.
        topPanel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(topPanel)
        NSLayoutConstraint.activate([
            topPanel.topAnchor.constraint(equalTo: view.topAnchor),
            topPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            topPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])

        // Bottom panel
        bottomMediaPanel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(bottomMediaPanel)
        NSLayoutConstraint.activate([
            bottomMediaPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            bottomMediaPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            bottomMediaPanel.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        updateControlsForCurrentOrientation()

        // Load initial page and update all UI to reflect it.
        setCurrentItem(initialGalleryItem, direction: .forward, shouldAutoPlayVideo: true, animated: false)

        mediaGallery.addDelegate(self)
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        setNeedsStatusBarAppearanceUpdate()
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        if traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass {
            updateControlsForCurrentOrientation()
        }
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        if let navigationBarVerticalPositionConstraint {
            // On iPhones with a Dynamic Island standard position of a navigation bar is bottom of the status bar,
            // which is ~5 dp smaller than the top safe area inset (https://useyourloaf.com/blog/iphone-14-screen-sizes/) .
            // Since it is not possible to constrain top edge of our manually maintained navigation bar to that position
            // the workaround is to detect when top safe area inset is larger than the status bar height and adjust as needed.
            var topInset = view.safeAreaInsets.top
            if
                #unavailable(iOS 26),
                let statusBarHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height,
                statusBarHeight < topInset
            {
                topInset = statusBarHeight
                if #available(iOS 18, *) {
                    topInset += (2 + .hairlineWidth)
                } else if #available(iOS 16, *) {
                    topInset -= .hairlineWidth
                }
            }
            // On iOS 26 in landscape the navigation bar is offset 24 dp from the screen top edge.
            if #available(iOS 26, *), topInset.isZero {
                topInset = 24
            }
            navigationBarVerticalPositionConstraint.constant = topInset
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        cachedPages.removeAll()
    }

    // MARK: Paging

    private var cachedPages: [MediaGalleryItem: MediaItemViewController] = [:]

    private func buildGalleryPage(galleryItem: MediaGalleryItem) -> MediaItemViewController {
        if let cachedPage = cachedPages[galleryItem] {
            Logger.debug("cache hit.")
            return cachedPage
        }

        Logger.debug("cache miss.")

        let viewController = MediaItemViewController(galleryItem: galleryItem)
        viewController.delegate = self
        cachedPages[galleryItem] = viewController
        return viewController
    }

    private var currentViewController: MediaItemViewController? {
        let viewController = viewControllers?.first as? MediaItemViewController
        owsAssertBeta(viewController != nil)
        return viewController
    }

    private var currentItem: MediaGalleryItem! {
        return currentViewController?.galleryItem
    }

    private var currentPageSwipeDirection: UIPageViewController.NavigationDirection = .forward

    private func setCurrentItem(
        _ item: MediaGalleryItem,
        direction: UIPageViewController.NavigationDirection,
        shouldAutoPlayVideo: Bool = false,
        animated: Bool,
    ) {
        if let previousPage = viewControllers?.first as? MediaItemViewController {
            previousPage.videoPlaybackStatusObserver = nil
            previousPage.zoomOut(animated: false)
            previousPage.stopVideoIfPlaying()
        }

        let mediaPage = buildGalleryPage(galleryItem: item)
        mediaPage.shouldAutoPlayVideo = true
        setViewControllers([mediaPage], direction: direction, animated: animated) { _ in
            self.didTransitionToNewPage(animated: animated, direction: direction)
        }
    }

    private func didTransitionToNewPage(animated: Bool, direction: UIPageViewController.NavigationDirection) {
        guard let currentViewController else {
            owsFailBeta("No MediaItemViewController")
            return
        }

        bottomMediaPanel.configureWithMediaItem(
            currentViewController.galleryItem,
            videoPlayer: currentViewController.videoPlayer,
            transitionDirection: direction,
            animated: animated,
        )

        updateScreenTitle(using: currentViewController.galleryItem)
        currentViewController.videoPlaybackStatusObserver = bottomMediaPanel
        showOrHideTopAndBottomPanelsAsNecessary(animated: animated)
    }

    // MARK: Show / hide toolbars

    private var _shouldHideToolbars: Bool = false

    private var shouldHideToolbars: Bool {
        get { _shouldHideToolbars }
        set { setShouldHideToolbars(newValue, animated: false) }
    }

    private func setShouldHideToolbars(_ shouldHide: Bool, animated: Bool = false) {
        _shouldHideToolbars = shouldHide
        showOrHideTopAndBottomPanelsAsNecessary(animated: animated)
        setNeedsStatusBarAppearanceUpdate()
    }

    private func showOrHideTopAndBottomPanelsAsNecessary(animated: Bool) {
        topPanel.setIsHidden(shouldHideToolbars, animated: animated)
        bottomMediaPanel.setIsHidden(shouldHideToolbars || bottomMediaPanel.shouldBeHidden, animated: animated)
        if #available(iOS 26, *) {
            let targetColor = shouldHideToolbars ? UIColor.black : UIColor.Signal.background
            if animated {
                let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut)
                animator.addAnimations {
                    self.view.backgroundColor = targetColor
                }
                animator.startAnimation()
            } else {
                view.backgroundColor = targetColor
            }
        }
    }

    private var shouldHideStatusBar: Bool {
        guard !UIDevice.current.isIPad else { return shouldHideToolbars }
        return shouldHideToolbars || traitCollection.verticalSizeClass == .compact
    }

    private func updateControlsForCurrentOrientation() {
        // Bottom bar might be hidden while in landscape and visible in portrait, for the same media.
        showOrHideTopAndBottomPanelsAsNecessary(animated: false)

        if traitCollection.verticalSizeClass == .compact {
            // Order of buttons is reversed: first button in array is the outermost in the navbar.
            navigationItem.rightBarButtonItems = [contextMenuBarButton, barButtonForwardMedia, barButtonShareMedia]
        } else {
            navigationItem.rightBarButtonItems = [contextMenuBarButton]
        }
    }

    // MARK: Context Menu

    private lazy var contextMenuBarButton: UIBarButtonItem = {
        let buttonImageName: String = if #available(iOS 26, *) { "more" } else { "more-circle" }
        let contextMenuBarButton = UIBarButtonItem(
            image: UIImage(named: buttonImageName),
            landscapeImagePhone: UIImage(named: buttonImageName + "-20"),
            style: .plain,
            target: nil,
            action: nil,
        )
        contextMenuBarButton.menu = UIMenu(
            title: "",
            children: [
                // TODO: Video Playback Speed
                // TODO: Edit
                UIAction(
                    title: OWSLocalizedString(
                        "MEDIA_VIEWER_SAVE_MEDIA_ACTION",
                        comment: "Context menu item in media viewer. Refers to saving currently displayed photo/video to the Photos app.",
                    ),
                    image: Theme.iconImage(.contextMenuSave),
                    handler: { [weak self] _ in
                        self?.saveCurrentMediaToPhotos()
                    },
                ),
                UIAction(
                    title: OWSLocalizedString(
                        "MEDIA_VIEWER_GO_TO_MESSAGE_ACTION",
                        comment: "Context menu item in media viewer. Refers to scrolling the conversation to the currently displayed photo/video.",
                    ),
                    image: Theme.iconImage(.buttonMessage),
                    handler: { [weak self] _ in
                        self?.presentConversationForCurrentMedia()
                    },
                ),
                UIAction(
                    title: OWSLocalizedString(
                        "MEDIA_VIEWER_DELETE_MEDIA_ACTION",
                        comment: "Context menu item in media viewer. Refers to deleting currently displayed photo/video.",
                    ),
                    image: Theme.iconImage(.contextMenuDelete),
                    attributes: .destructive,
                    handler: { [weak self] _ in
                        self?.deleteCurrentMedia()
                    },
                ),
            ],
        )
        return contextMenuBarButton
    }()

    // MARK: Bar Buttons

    private lazy var barButtonShareMedia = UIBarButtonItem(
        image: Theme.iconImage(.buttonShare),
        landscapeImagePhone: UIImage(imageLiteralResourceName: "share-20"),
        style: .plain,
        target: self,
        action: #selector(didPressShare),
    )

    private lazy var barButtonForwardMedia = UIBarButtonItem(
        image: Theme.iconImage(.buttonForward),
        landscapeImagePhone: UIImage(imageLiteralResourceName: "forward-20"),
        style: .plain,
        target: self,
        action: #selector(didPressForward),
    )

    // MARK: Helpers

    private func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) {
        guard let currentViewController else { return }

        // Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way.
        currentViewController.zoomOut(animated: true)
        currentViewController.stopVideoIfPlaying()

        navigationController?.setNavigationBarHidden(false, animated: false)

        dismiss(animated: isAnimated, completion: completion)
    }

    // MARK: Actions

    @objc
    private func didTapBackButton(_ sender: Any) {
        Logger.debug("")
        dismissSelf(animated: true)
    }

    @objc
    private func didPressShare(_ sender: Any) {
        shareCurrentMedia(fromNavigationBar: true)
    }

    /// Forwards all media from the message containing the currently gallery
    /// item.
    ///
    /// Skips any media that we do not have downloaded.
    @objc
    private func didPressForward(_ sender: Any) {
        forwardCurrentMedia()
    }

    private func forwardCurrentMedia() {
        let messageForCurrentItem = currentItem.message

        let mediaAttachments: [ReferencedAttachment] = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            guard let rowId = messageForCurrentItem.sqliteRowId else { return [] }
            return DependenciesBridge.shared.attachmentStore
                .fetchReferencedAttachments(for: .messageBodyAttachment(messageRowId: rowId), tx: transaction)
        }

        let mediaAttachmentStreams: [ReferencedAttachmentStream] = mediaAttachments.compactMap { attachment in
            guard let attachmentStream = attachment.asReferencedStream else {
                // Our current media item should always be an attachment
                // stream (downloaded). However, we can't guarantee that the
                // same is true for other media in the message to forward. For
                // example, another piece of media in this message may have
                // failed to download.
                //
                // If so, we should continue trying to forward the ones we can.

                Logger.warn("Skipping attachment that is not an attachment stream. Did this attachment fail to download?")
                return nil
            }

            return attachmentStream
        }

        let mediaCount = mediaAttachmentStreams.count

        switch mediaCount {
        case 0:
            owsFail("We should always have at least one attachment stream, for the current item.")
        case 1:
            ForwardMessageViewController.present(
                forAttachmentStreams: mediaAttachmentStreams,
                fromMessage: messageForCurrentItem,
                from: self,
                delegate: self,
            )
        default:
            // If we are forwarding multiple items, warn the user first.

            let titleFormatString = OWSLocalizedString(
                "MEDIA_PAGE_FORWARD_MEDIA_CONFIRM_TITLE_%d",
                tableName: "PluralAware",
                comment: "Text confirming the user wants to forward media. Embeds {{ %1$@ the number of media to be forwarded }}.",
            )

            OWSActionSheets.showConfirmationAlert(
                message: OWSLocalizedString(
                    "MEDIA_PAGE_FORWARD_MEDIA_CONFIRM_MESSAGE",
                    comment: "Text explaining that the user will forward all media from a message.",
                ),
                proceedTitle: String.localizedStringWithFormat(
                    titleFormatString,
                    mediaCount,
                ),
                proceedAction: { [weak self] _ in
                    guard let self else { return }

                    ForwardMessageViewController.present(
                        forAttachmentStreams: mediaAttachmentStreams,
                        fromMessage: messageForCurrentItem,
                        from: self,
                        delegate: self,
                    )
                },
            )
        }
    }

    private func shareCurrentMedia(fromNavigationBar: Bool) {
        guard let currentViewController else { return }
        guard
            let attachmentStream = (try? [currentViewController.galleryItem.attachmentStream].asShareableAttachments())?.first
        else {
            return
        }
        let sender = fromNavigationBar ? barButtonShareMedia : bottomMediaPanel
        AttachmentSharing.showShareUI(for: attachmentStream, sender: sender)
    }

    // MARK: -

    private func saveCurrentMediaToPhotos() {
        guard let mediaItem = currentItem else { return }

        AttachmentSaving.saveToPhotoLibrary(
            referencedAttachmentStreams: [mediaItem.attachmentStream],
        )
    }

    private func presentConversationForCurrentMedia() {
        guard let mediaItem = currentItem else { return }

        dismissSelf(animated: true) {
            SignalApp.shared.presentConversationForThread(
                threadUniqueId: mediaItem.message.uniqueThreadId,
                focusMessageId: mediaItem.message.uniqueId,
                animated: true,
            )
        }
    }

    private func deleteCurrentMedia() {
        guard let mediaItem = currentItem else { return }

        let actionSheet = ActionSheetController(title: nil, message: nil)
        let deleteAction = ActionSheetAction(
            title: CommonStrings.deleteButton,
            style: .destructive,
        ) { _ in
            self.mediaGallery.delete(items: [mediaItem], initiatedBy: self)
        }
        actionSheet.addAction(OWSActionSheets.cancelAction)
        actionSheet.addAction(deleteAction)

        presentActionSheet(actionSheet)
    }

    // MARK: Dynamic Header

    private func senderName(from message: TSMessage) -> String {
        switch message {
        case let incomingMessage as TSIncomingMessage:
            return SSKEnvironment.shared.databaseStorageRef.read { tx in
                return SSKEnvironment.shared.contactManagerRef.displayName(for: incomingMessage.authorAddress, tx: tx).resolvedValue()
            }
        case is TSOutgoingMessage:
            return CommonStrings.you
        default:
            owsFailDebug("Unknown message type: \(type(of: message))")
            return ""
        }
    }

    private lazy var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.timeStyle = .short

        return formatter
    }()

    private lazy var headerNameLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.textColor = .Signal.label
        if #available(iOS 26, *) {
            label.font = .dynamicTypeSubheadlineClamped.semibold()
            // "semibold" fonts aren't dynamic anymore - have to track changes manually.
            label.registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { (label: UILabel, _) in
                label.font = .dynamicTypeSubheadlineClamped.semibold()
            }
        } else {
            label.font = UIFont.regularFont(ofSize: 15)
            label.adjustsFontSizeToFitWidth = true
            label.minimumScaleFactor = 0.8
        }
        return label
    }()

    private lazy var headerDateLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.textColor = .Signal.label
        if #available(iOS 26, *) {
            label.font = .dynamicTypeCaption1Clamped
            label.adjustsFontForContentSizeCategory = true
        } else {
            label.font = .regularFont(ofSize: 11)
            label.adjustsFontSizeToFitWidth = true
            label.minimumScaleFactor = 0.8
        }
        return label
    }()

    private lazy var headerView: UIView = {
        let stackView = UIStackView(arrangedSubviews: [headerNameLabel, headerDateLabel])
        stackView.axis = .vertical
        stackView.translatesAutoresizingMaskIntoConstraints = false

        let containerView = UIView()
        if #available(iOS 26, *) {
            // Can't return `glassEffectView` as `headerView` because UINavigationBar stretches it to fill width.
            let glassEffect = UIGlassEffect(style: .regular)
            glassEffect.isInteractive = true
            let glassEffectView = UIVisualEffectView(effect: glassEffect)
            glassEffectView.cornerConfiguration = .capsule()
            glassEffectView.translatesAutoresizingMaskIntoConstraints = false
            glassEffectView.contentView.addSubview(stackView)

            let contentInset = UIEdgeInsets(hMargin: 24, vMargin: 4)
            NSLayoutConstraint.activate([
                stackView.topAnchor.constraint(greaterThanOrEqualTo: glassEffectView.topAnchor, constant: contentInset.top),
                stackView.centerYAnchor.constraint(equalTo: glassEffectView.centerYAnchor),
                stackView.leadingAnchor.constraint(equalTo: glassEffectView.leadingAnchor, constant: contentInset.leading),
                stackView.trailingAnchor.constraint(equalTo: glassEffectView.trailingAnchor, constant: -contentInset.trailing),
            ])

            containerView.addSubview(glassEffectView)
            NSLayoutConstraint.activate([
                glassEffectView.topAnchor.constraint(equalTo: containerView.topAnchor),
                glassEffectView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor),
                glassEffectView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
                glassEffectView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
            ])

            // On iOS 26 navigation bar is transparent and can accomodate `titleView` of any height.
            // Set minimum height to default 44pts thus allowing it to grow with font size.
            containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
        } else {
            containerView.addSubview(stackView)
            NSLayoutConstraint.activate([
                stackView.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor),
                stackView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),

                stackView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor),
                stackView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
            ])
        }

        return containerView
    }()

    private func updateScreenTitle(using mediaItem: MediaGalleryItem) {
        headerNameLabel.text = senderName(from: mediaItem.message)

        // use sent date
        let date = Date(timeIntervalSince1970: Double(mediaItem.message.timestamp) / 1000)
        headerDateLabel.text = dateFormatter.string(from: date)
    }
}

extension MediaPageViewController: UIPageViewControllerDelegate {

    func pageViewController(
        _ pageViewController: UIPageViewController,
        willTransitionTo pendingViewControllers: [UIViewController],
    ) {
        guard
            let currentPage = pageViewController.viewControllers?.first as? MediaItemViewController,
            let newPage = pendingViewControllers.first as? MediaItemViewController
        else {
            return
        }
        if currentPage.galleryItem.orderingKey < newPage.galleryItem.orderingKey {
            currentPageSwipeDirection = .forward
        } else {
            currentPageSwipeDirection = .reverse
        }
    }

    func pageViewController(
        _ pageViewController: UIPageViewController,
        didFinishAnimating finished: Bool,
        previousViewControllers: [UIViewController],
        transitionCompleted: Bool,
    ) {
        if let previousPage = previousViewControllers.first as? MediaItemViewController {
            previousPage.zoomOut(animated: false)
            previousPage.stopVideoIfPlaying()
            previousPage.videoPlaybackStatusObserver = nil
        }

        if transitionCompleted {
            didTransitionToNewPage(animated: true, direction: currentPageSwipeDirection)
        }
    }
}

extension MediaPageViewController: UIPageViewControllerDataSource {
    private func itemIsAllowed(_ item: MediaGalleryItem) -> Bool {
        // Normally, we can show any media item, but if we're limited
        // to showing a single message, don't page beyond that message
        return !isShowingSingleMessage || currentItem.message == item.message
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        Logger.debug("")

        guard let currentPage = viewController as? MediaItemViewController else {
            owsFailDebug("unexpected viewController: \(viewController)")
            return nil
        }

        guard let precedingItem = mediaGallery.galleryItem(before: currentPage.galleryItem), itemIsAllowed(precedingItem) else {
            return nil
        }

        return buildGalleryPage(galleryItem: precedingItem)
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        Logger.debug("")

        guard let currentPage = viewController as? MediaItemViewController else {
            owsFailDebug("unexpected viewController: \(viewController)")
            return nil
        }

        guard let nextItem = mediaGallery.galleryItem(after: currentPage.galleryItem), itemIsAllowed(nextItem) else {
            // no more pages
            return nil
        }

        return buildGalleryPage(galleryItem: nextItem)
    }
}

extension MediaPageViewController: InteractivelyDismissableViewController {
    func performInteractiveDismissal(animated: Bool) {
        dismissSelf(animated: true)
    }
}

extension MediaPageViewController: MediaGalleryDelegate {
    func mediaGallery(_ mediaGallery: MediaGallery, applyUpdate update: MediaGallery.Update) {
        Logger.debug("")
    }

    func mediaGallery(_ mediaGallery: MediaGallery, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) {
        Logger.debug("")

        guard items.contains(currentItem) else {
            Logger.debug("irrelevant item")
            return
        }

        // If we setCurrentItem with (animated: true) while this VC is in the background, then
        // the next/previous cache isn't expired, and we're able to swipe back to the just-deleted vc.
        // So to get the correct behavior, we should only animate these transitions when this
        // vc is in the foreground
        let isAnimated = initiatedBy === self

        if isShowingSingleMessage {
            // In message details, which doesn't use the slider, so don't swap pages.
        } else if let nextItem = mediaGallery.galleryItem(after: currentItem) {
            setCurrentItem(nextItem, direction: .forward, animated: isAnimated)
        } else if let previousItem = mediaGallery.galleryItem(before: currentItem) {
            setCurrentItem(previousItem, direction: .reverse, animated: isAnimated)
        } else {
            // else we deleted the last piece of media, return to the conversation view
            dismissSelf(animated: true)
        }
    }

    func mediaGalleryDidDeleteItem(_ mediaGallery: MediaGallery) {
        // Either this is an internal deletion, in which case willDelete would have been called already,
        // or it's an external deletion, in which case mediaGalleryDidReloadItems would have been called already.
    }

    func mediaGalleryDidReloadItems(_ mediaGallery: MediaGallery) {
        didReloadAllSectionsInMediaGallery(mediaGallery)
    }

    func didAddSectionInMediaGallery(_ mediaGallery: MediaGallery) {
        // Does not affect the current item.
    }

    func didReloadAllSectionsInMediaGallery(_ mediaGallery: MediaGallery) {
        let attachment = currentItem.attachmentStream
        guard let reloadedItem = mediaGallery.ensureLoadedForDetailView(focusedAttachment: attachment) else {
            // Assume the item was deleted.
            dismissSelf(animated: true)
            return
        }
        setCurrentItem(reloadedItem, direction: .forward, animated: false)
    }

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

extension MediaPageViewController: MediaItemViewControllerDelegate {

    func mediaItemViewControllerDidTapMedia(_ viewController: MediaItemViewController) {
        setShouldHideToolbars(!shouldHideToolbars, animated: true)
    }

    func mediaItemViewControllerWillBeginZooming(_ viewController: MediaItemViewController) {
        setShouldHideToolbars(true, animated: true)
    }

    func mediaItemViewControllerFullyZoomedOut(_ viewController: MediaItemViewController) {
        setShouldHideToolbars(false, animated: true)
    }
}

extension MediaGalleryItem: GalleryRailItem {
    public func buildRailItemView() -> UIView {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = thumbnailImageSync()
        return imageView
    }
}

extension MediaGalleryAlbum: GalleryRailItemProvider {
    var railItems: [GalleryRailItem] {
        return self.items
    }
}

extension MediaPageViewController: MediaControlPanelDelegate {

    func mediaControlPanelDidRequestForwardMedia(_ panel: MediaControlPanelView) {
        forwardCurrentMedia()
    }

    func mediaControlPanelDidRequestShareMedia(_ panel: MediaControlPanelView) {
        shareCurrentMedia(fromNavigationBar: false)
    }

    func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) {
        guard let targetItem = imageRailItem as? MediaGalleryItem else {
            owsFailDebug("unexpected imageRailItem: \(imageRailItem)")
            return
        }

        let direction: UIPageViewController.NavigationDirection
        direction = currentItem.albumIndex < targetItem.albumIndex ? .forward : .reverse
        setCurrentItem(targetItem, direction: direction, animated: true)
    }
}

extension MediaPageViewController: MediaPresentationContextProvider {

    func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
        guard let mediaView = currentViewController?.mediaView else { return nil }

        guard nil != mediaView.superview else {
            owsFailDebug("superview was unexpectedly nil")
            return nil
        }

        view.layoutIfNeeded()

        let backgroundColor: UIColor = if #available(iOS 26, *) { .Signal.background } else { .black }
        return MediaPresentationContext(
            mediaView: mediaView,
            presentationFrame: mediaView.frame,
            backgroundColor: backgroundColor,
        )
    }

    func mediaWillPresent(toContext: MediaPresentationContext) {
        view.backgroundColor = .clear
    }

    func mediaDidPresent(toContext: MediaPresentationContext) {
        view.backgroundColor = .Signal.background
    }

    func mediaWillDismiss(fromContext: MediaPresentationContext) {
        view.backgroundColor = .clear
    }

    func mediaDidDismiss(fromContext: MediaPresentationContext) {
        view.backgroundColor = .Signal.background
    }
}

extension MediaPageViewController: UIViewControllerTransitioningDelegate {
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController,
    ) -> UIViewControllerAnimatedTransitioning? {
        guard self == presented else {
            owsFailDebug("unexpected presented: \(presented)")
            return nil
        }

        return MediaZoomAnimationController(galleryItem: currentItem)
    }

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        guard self == dismissed else {
            owsFailDebug("unexpected dismissed: \(dismissed)")
            return nil
        }

        let animationController = MediaDismissAnimationController(
            galleryItem: currentItem,
            interactionController: mediaInteractiveDismiss,
        )
        mediaInteractiveDismiss.interactiveDismissDelegate = animationController

        return animationController
    }

    func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        guard
            let animationController = animator as? MediaDismissAnimationController,
            animationController.interactionController.interactionInProgress
        else {
            return nil
        }
        return animationController.interactionController
    }
}

extension MediaPageViewController: ForwardMessageDelegate {
    func forwardMessageFlowDidComplete(items: [ForwardMessageItem], recipientThreads: [TSThread]) {
        dismiss(animated: true) {
            ForwardMessageViewController.finalizeForward(
                items: items,
                recipientThreads: recipientThreads,
                fromViewController: self,
            )
        }
    }

    func forwardMessageFlowDidCancel() {
        dismiss(animated: true)
    }
}

extension MediaPageViewController: UINavigationBarDelegate {

    func navigationBar(_ navigationBar: UINavigationBar, shouldPop item: UINavigationItem) -> Bool {
        dismissSelf(animated: true)
        return false
    }

    func navigationBar(_ navigationBar: UINavigationBar, didPop item: UINavigationItem) {
        dismissSelf(animated: true)
    }
}