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

import AVFoundation
import Foundation
import LibSignalClient
import Lottie
import Photos
import SignalServiceKit
import SignalUI
import UIKit

protocol PhotoCaptureViewControllerDelegate: AnyObject {
    func photoCaptureViewControllerDidFinish(_ photoCaptureViewController: PhotoCaptureViewController)
    func photoCaptureViewController(
        _ photoCaptureViewController: PhotoCaptureViewController,
        didFinishWithTextAttachment textAttachment: UnsentTextAttachment,
    )
    func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController)
    func photoCaptureViewControllerDidTryToCaptureTooMany(_ photoCaptureViewController: PhotoCaptureViewController)
    func photoCaptureViewControllerViewWillAppear(_ photoCaptureViewController: PhotoCaptureViewController)
    func photoCaptureViewControllerCanCaptureMoreItems(_ photoCaptureViewController: PhotoCaptureViewController) -> Bool
    func photoCaptureViewControllerDidRequestPresentPhotoLibrary(_ photoCaptureViewController: PhotoCaptureViewController)
    func photoCaptureViewController(
        _ photoCaptureViewController: PhotoCaptureViewController,
        didRequestSwitchCaptureModeTo captureMode: PhotoCaptureViewController.CaptureMode,
        completion: @escaping (Bool) -> Void,
    )
    func photoCaptureViewControllerCanShowTextEditor(_ photoCaptureViewController: PhotoCaptureViewController) -> Bool
}

protocol PhotoCaptureViewControllerDataSource: AnyObject {
    var numberOfMediaItems: Int { get }
    func addMedia(attachment: PreviewableAttachment)
}

class PhotoCaptureViewController: OWSViewController, OWSNavigationChildController {
    private let attachmentLimits: OutgoingAttachmentLimits

    init(attachmentLimits: OutgoingAttachmentLimits) {
        self.attachmentLimits = attachmentLimits
        super.init()
    }

    weak var delegate: PhotoCaptureViewControllerDelegate?
    weak var dataSource: PhotoCaptureViewControllerDataSource?
    private var interactiveDismiss: PhotoCaptureInteractiveDismiss?

    private lazy var qrCodeSampleBufferScanner = QRCodeSampleBufferScanner(delegate: self)
    private lazy var cameraCaptureSession = CameraCaptureSession(
        delegate: self,
        attachmentLimits: attachmentLimits,
        qrCodeSampleBufferScanner: qrCodeSampleBufferScanner,
    )

    private var qrCodeScanned = false {
        didSet {
            updateShouldProcessQRCodes()
        }
    }

    /// The underlying stored atomic for `shouldProcessQRCodes`.
    /// Update its value by calling `updateShouldProcessQRCodes`.
    private let _shouldProcessQRCodes = AtomicBool(false, lock: .init())

    private func updateShouldProcessQRCodes() {
        _shouldProcessQRCodes.set(!qrCodeScanned && !isRecordingVideo && isViewVisible)
    }

    private let sleepBlock = DeviceSleepBlockObject(blockReason: "Photo Capture")

    private var isCameraReady = false {
        didSet {
            guard isCameraReady != oldValue else { return }

            if isCameraReady {
                cameraCaptureSession.beginObservingVolumeButtons()
                DependenciesBridge.shared.deviceSleepManager!.addBlock(blockObject: sleepBlock)
            } else {
                cameraCaptureSession.stopObservingVolumeButtons()
                DependenciesBridge.shared.deviceSleepManager!.removeBlock(blockObject: sleepBlock)
            }
        }
    }

    private var hasCameraStarted = false {
        didSet {
            isCameraReady = isViewVisible && hasCameraStarted
        }
    }

    private var isViewVisible = false {
        didSet {
            isCameraReady = isViewVisible && hasCameraStarted
            updateShouldProcessQRCodes()
        }
    }

    deinit {
        cameraCaptureSession.stop().done {
            Logger.debug("stopCapture completed")
        }
    }

    // MARK: - Overrides

    override func viewDidLoad() {
        super.viewDidLoad()

        definesPresentationContext = true

        view.backgroundColor = Theme.darkThemeBackgroundColor
        view.preservesSuperviewLayoutMargins = true

        initializeUI()

        setupPhotoCapture()

        updateFlashModeControl(animated: false)

        if let navigationController {
            let interactiveDismiss = PhotoCaptureInteractiveDismiss(viewController: navigationController)
            interactiveDismiss.interactiveDismissDelegate = self
            interactiveDismiss.addGestureRecognizer(to: view)
            self.interactiveDismiss = interactiveDismiss
        }
    }

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

        delegate?.photoCaptureViewControllerViewWillAppear(self)

        let previewOrientation: AVCaptureVideoOrientation
        if UIDevice.current.isIPad, let windowScene = view.window?.windowScene {
            previewOrientation = AVCaptureVideoOrientation(interfaceOrientation: windowScene.interfaceOrientation) ?? .portrait
        } else {
            previewOrientation = .portrait
        }
        UIViewController.attemptRotationToDeviceOrientation()
        cameraCaptureSession.updateVideoPreviewConnection(toOrientation: previewOrientation)
        updateIconOrientations(isAnimated: false, captureOrientation: previewOrientation)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(sessionWasInterrupted),
            name: AVCaptureSession.wasInterruptedNotification,
            object: nil,
        )

        resumePhotoCapture()

        if let dataSource, dataSource.numberOfMediaItems > 0 {
            captureMode = .multi
        }
        updateDoneButtonAppearance()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        isViewVisible = true
        cameraCaptureSession.updateVideoCaptureOrientation()
    }

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

        isViewVisible = false
        pausePhotoCapture()
    }

    override var prefersStatusBarHidden: Bool {
        !UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad && AppEnvironment.shared.callService.callServiceState.currentCall == nil
    }

    override var preferredStatusBarStyle: UIStatusBarStyle {
        .lightContent
    }

    var prefersNavigationBarHidden: Bool {
        return true
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .portrait }

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

        guard UIDevice.current.isIPad else { return }

        // Since we support iPad multitasking, we cannot *disable* rotation of our views.
        // Rotating the preview layer is really distracting, so we fade out the preview layer
        // while the rotation occurs.
        self.previewView.alpha = 0
        coordinator.animate(
            alongsideTransition: { _ in },
            completion: { _ in
                UIView.animate(withDuration: 0.1) {
                    self.previewView.alpha = 1
                }
            },
        )
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()

        // Safe area insets will change during interactive dismiss - ignore those changes.
        guard !(interactiveDismiss?.interactionInProgress ?? false) else { return }

        if let contentLayoutGuideTop = previewViewContentLayoutGuideTop {
            contentLayoutGuideTop.constant = view.safeAreaInsets.top

            // Rounded corners if preview view isn't full-screen.
            previewView.previewLayer.cornerRadius = view.safeAreaInsets.top > 0 ? 18 : 0
        }

        if let bottomBarControlsLayoutGuideBottom {
            bottomBarControlsLayoutGuideBottom.constant = -view.safeAreaInsets.bottom
        }
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular
    }

    // MARK: - Layout Code

    private var isIPadUIInRegularMode = false {
        didSet {
            guard oldValue != isIPadUIInRegularMode else { return }
            updateIPadInterfaceLayout()
        }
    }

    private let previewViewLayoutGuide = UILayoutGuide()
    private var previewViewContentLayoutGuideTop: NSLayoutConstraint? // controls vertical position of `previewViewLayoutGuide` on iPhones.

    // Values match ContentTypeSelectionControl.selectedSegmentIndex.
    private enum ComposerMode: Int {
        case camera = 0
        case text
    }

    private var _internalComposerMode: ComposerMode = .camera
    private var composerMode: ComposerMode { _internalComposerMode }
    private func setComposerMode(_ composerMode: ComposerMode, animated: Bool) {
        owsAssertDebug(!isRecordingVideo, "Invalid state - should not be recording video")

        guard _internalComposerMode != composerMode else { return }
        _internalComposerMode = composerMode

        if composerMode == .text {
            startObservingKeyboardNotifications()
            initializeTextEditorUIIfNecessary()
        }

        updateTopBarAppearance(animated: animated)
        // No need to update bottom bar's visibility because it's always visible if CAMERA|TEXT switch is accessible.
        bottomBar.setMode(composerMode == .text ? .text : .camera, animated: animated)
        updateSideBarVisibility(animated: animated)

        // Show / hide camera controls and viewfinder.
        let hideCameraUI = composerMode != .camera
        let isFrontCamera = cameraCaptureSession.desiredPosition == .front
        frontCameraZoomControl?.setIsHidden(hideCameraUI || !isFrontCamera, animated: animated)
        rearCameraZoomControl?.setIsHidden(hideCameraUI || isFrontCamera, animated: animated)
        previewView.setIsHidden(hideCameraUI, animated: animated)
        doneButton.setIsHidden(shouldHideDoneButton, animated: animated)

        // Show / hide text editor controls.
        let hideTextComposerUI = composerMode != .text
        textStoryComposerView.setIsHidden(hideTextComposerUI, animated: animated)
        textEditorToolbar.setIsHidden(hideTextComposerUI, animated: animated)

        // Stop / start camera as necessary.
        switch composerMode {
        case .camera: resumePhotoCapture()
        case .text: pausePhotoCapture()
        }

        // Update CAMERA | TEXT switch if necessary.
        if bottomBar.contentTypeSelectionControl.selectedSegmentIndex != composerMode.rawValue {
            bottomBar.contentTypeSelectionControl.selectedSegmentIndex = composerMode.rawValue
        }
    }

    private var _internalIsRecordingVideo = false
    private var isRecordingVideo: Bool { _internalIsRecordingVideo }
    private func setIsRecordingVideo(_ isRecordingVideo: Bool, animated: Bool) {
        guard _internalIsRecordingVideo != isRecordingVideo else { return }
        _internalIsRecordingVideo = isRecordingVideo

        updateShouldProcessQRCodes()

        updateTopBarAppearance(animated: animated)
        topBar.recordingTimerView.isRecordingInProgress = isRecordingVideo
        if isRecordingVideo {
            topBar.recordingTimerView.duration = 0

            let captureControlState: CameraCaptureControl.State = UIAccessibility.isVoiceOverRunning ? .recordingUsingVoiceOver : .recording
            let animationDuration: TimeInterval = animated ? 0.4 : 0
            bottomBar.captureControl.setState(captureControlState, animationDuration: animationDuration)
            if let sideBar {
                sideBar.cameraCaptureControl.setState(captureControlState, animationDuration: animationDuration)
            }
        } else {
            let animationDuration: TimeInterval = animated ? 0.2 : 0
            bottomBar.captureControl.setState(.initial, animationDuration: animationDuration)
            if let sideBar {
                sideBar.cameraCaptureControl.setState(.initial, animationDuration: animationDuration)
            }
        }

        bottomBar.setMode(isRecordingVideo ? .videoRecording : .camera, animated: animated)
        if let sideBar {
            sideBar.isRecordingVideo = isRecordingVideo
        }

        doneButton.setIsHidden(shouldHideDoneButton, animated: animated)
    }

    enum CaptureMode {
        case single
        case multi
    }

    var captureMode: CaptureMode = .single {
        didSet {
            topBar.batchModeButton.setCaptureMode(captureMode, animated: true)
            if let sideBar {
                sideBar.batchModeButton.setCaptureMode(captureMode, animated: true)
            }
        }
    }

    private let topBar = CameraTopBar(frame: .zero)
    private func updateTopBarAppearance(animated: Bool) {
        let mode: CameraTopBar.Mode = {
            if isRecordingVideo {
                return .videoRecording
            }
            if composerMode == .text {
                return .closeButton
            }
            if isIPadUIInRegularMode {
                return .closeButton
            }
            return .cameraControls
        }()
        topBar.setMode(mode, animated: animated)
    }

    private lazy var bottomBar = CameraBottomBar(isContentTypeSelectionControlAvailable: delegate?.photoCaptureViewControllerCanShowTextEditor(self) ?? false)
    private var bottomBarControlsLayoutGuideBottom: NSLayoutConstraint?
    private func updateBottomBarVisibility(animated: Bool) {
        let isBarHidden: Bool = {
            if textEditorUIInitialized {
                return textStoryComposerView.isEditing
            }
            if bottomBar.isContentTypeSelectionControlAvailable {
                return false
            }
            return isIPadUIInRegularMode
        }()
        bottomBar.setIsHidden(isBarHidden, animated: animated)
    }

    private var sideBar: CameraSideBar? // Optional because most devices are iPhones and will never need this.
    private func updateSideBarVisibility(animated: Bool) {
        guard let sideBar else { return }
        sideBar.setIsHidden(composerMode == .text || !isIPadUIInRegularMode, animated: true)
    }

    // MARK: - Camera Controls

    private var frontCameraZoomControl: CameraZoomSelectionControl?
    private var rearCameraZoomControl: CameraZoomSelectionControl?
    private var cameraZoomControlIPhoneConstraints: [NSLayoutConstraint]?
    private var cameraZoomControlIPadConstraints: [NSLayoutConstraint]?

    private lazy var tapToFocusView: LottieAnimationView = {
        let view = LottieAnimationView(name: "tap_to_focus")
        view.animationSpeed = 1
        view.backgroundBehavior = .forceFinish
        view.contentMode = .scaleAspectFit
        view.isUserInteractionEnabled = false
        view.autoSetDimensions(to: CGSize(square: 150))
        view.setContentHuggingHigh()
        return view
    }()

    private lazy var tapToFocusCenterXConstraint = tapToFocusView.centerXAnchor.constraint(equalTo: previewView.leftAnchor)
    private lazy var tapToFocusCenterYConstraint = tapToFocusView.centerYAnchor.constraint(equalTo: previewView.topAnchor)
    private var lastUserFocusTapPoint: CGPoint?

    private var previewView: CapturePreviewView {
        cameraCaptureSession.previewView
    }

    // MARK: - Text Editor

    private var textEditorUIInitialized = false
    private var textEditoriPhoneConstraints = [NSLayoutConstraint]()
    private var textEditoriPadConstraints = [NSLayoutConstraint]()

    private lazy var textStoryComposerView = TextStoryComposerView(text: "")

    private lazy var textEditorToolbar: UIView = {
        let stackView = UIStackView(arrangedSubviews: [textBackgroundSelectionButton, textViewAttachLinkButton])
        stackView.axis = .horizontal
        stackView.spacing = 16
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()

    private lazy var textBackgroundSelectionButton = RoundGradientButton()
    private lazy var textViewAttachLinkButton: UIButton = {
        let button = RoundMediaButton(image: UIImage(imageLiteralResourceName: "link"), backgroundStyle: .blur)
        button.ows_contentEdgeInsets = UIEdgeInsets(margin: 3)
        button.layoutMargins = .zero
        return button
    }()

    // This constraint gets updated when onscreen keyboard appears/disappears.
    private var textStoryComposerContentLayoutGuideBottomIphone: NSLayoutConstraint?
    private var textStoryComposerContentLayoutGuideBottomIpad: NSLayoutConstraint?
    private var observingKeyboardNotifications = false

    private lazy var doneButton: MediaDoneButton = {
        let button = MediaDoneButton(type: .custom)
        button.badgeNumber = 0
        button.overrideUserInterfaceStyle = .dark
        return button
    }()

    private var shouldHideDoneButton: Bool {
        isRecordingVideo || composerMode == .text || doneButton.badgeNumber == 0
    }

    private lazy var doneButtonIPhoneConstraints = [
        doneButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
        doneButton.centerYAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerYAnchor),
    ]
    private var doneButtonIPadConstraints: [NSLayoutConstraint]?

    private func initializeUI() {
        // `previewViewLayoutGuide` defines area occupied by the content:
        // either camera viewfinder or text story composing area.
        view.addLayoutGuide(previewViewLayoutGuide)
        // Always full-width.
        view.addConstraints([
            previewViewLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            previewViewLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        if UIDevice.current.isIPad {
            // Full-height on iPads.
            view.addConstraints([
                previewViewLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
                previewViewLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            ])
        } else {
            // 9:16 aspect ratio on iPhones.
            // Note that there's no constraint on the bottom edge of the `previewViewLayoutGuide`.
            // This works because all iPhones have screens 9:16 or taller.
            view.addConstraint(previewViewLayoutGuide.heightAnchor.constraint(equalTo: previewViewLayoutGuide.widthAnchor, multiplier: 16 / 9))
            // Constrain to the top of the view now and update offset with the height of top safe area later.
            // Can't constrain to the safe area layout guide because safe area insets changes during interactive dismiss.
            let constraint = previewViewLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor)
            view.addConstraint(constraint)
            previewViewContentLayoutGuideTop = constraint
        }

        // Step 1. Initialize all UI elements for iPhone layout (which can also be used on an iPad).

        // Camera Viewfinder - simply occupies the entire frame of `previewViewLayoutGuide`.
        previewView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(previewView)
        view.addConstraints([
            previewView.leadingAnchor.constraint(equalTo: previewViewLayoutGuide.leadingAnchor),
            previewView.topAnchor.constraint(equalTo: previewViewLayoutGuide.topAnchor),
            previewView.trailingAnchor.constraint(equalTo: previewViewLayoutGuide.trailingAnchor),
            previewView.bottomAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor),
        ])
        configureCameraGestures()

        // Top Bar
        view.addSubview(topBar)
        topBar.closeButton.addTarget(self, action: #selector(didTapClose), for: .touchUpInside)
        topBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside)
        topBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside)
        topBar.autoPinWidthToSuperview()
        if UIDevice.current.isIPad {
            topBar.autoPinEdge(toSuperviewSafeArea: .top)
        } else {
            topBar.topAnchor.constraint(equalTo: previewViewLayoutGuide.topAnchor).isActive = true
        }

        // Bottom Bar (contains shutter button)
        view.addSubview(bottomBar)
        bottomBar.isCompactHeightLayout = !UIDevice.current.hasIPhoneXNotch
        bottomBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside)
        bottomBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside)
        if bottomBar.isContentTypeSelectionControlAvailable {
            bottomBar.contentTypeSelectionControl.selectedSegmentIndex = 0
            bottomBar.contentTypeSelectionControl.addTarget(self, action: #selector(contentTypeChanged), for: .valueChanged)
        }
        bottomBar.autoPinWidthToSuperview()
        if bottomBar.isCompactHeightLayout {
            // On devices with home button and iPads bar is simply pinned to the bottom of the screen
            // with a fixed margin that defines space under the shutter button or CAMERA|TEXT switch.
            bottomBar.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32).isActive = true
        } else {
            // On `notch` devices:
            //  i. Shutter button is placed 16 pts above the bottom edge of the preview view.
            bottomBar.shutterButtonLayoutGuide.bottomAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor, constant: -16).isActive = true

            //  ii. Other buttons are centered vertically in the black box between bottom of the preview view and top of bottom safe area.
            bottomBar.controlButtonsLayoutGuide.topAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor).isActive = true
            // Constrain to the bottom of the view now and update offset with the height of bottom safe area later.
            // Can't constrain to the safe area layout guide because safe area insets changes during interactive dismiss.
            let constraint = bottomBar.bottomAnchor.constraint(equalTo: view.bottomAnchor)
            view.addConstraint(constraint)
            bottomBarControlsLayoutGuideBottom = constraint
        }

        // Camera Zoom Controls
        cameraZoomControlIPhoneConstraints = []

        let availableFrontCameras = cameraCaptureSession.cameraZoomFactorMap(forPosition: .front)
        if availableFrontCameras.count > 0 {
            let cameras = availableFrontCameras.sorted { $0.0 < $1.0 }.map { ($0.0, $0.1) }

            let cameraZoomControl = CameraZoomSelectionControl(availableCameras: cameras)
            cameraZoomControl.delegate = self
            view.addSubview(cameraZoomControl)
            self.frontCameraZoomControl = cameraZoomControl

            let cameraZoomControlConstraints =
                [
                    cameraZoomControl.centerXAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerXAnchor),
                    cameraZoomControl.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.topAnchor, constant: -32),
                ]
            view.addConstraints(cameraZoomControlConstraints)
            cameraZoomControlIPhoneConstraints?.append(contentsOf: cameraZoomControlConstraints)
        }

        let availableRearCameras = cameraCaptureSession.cameraZoomFactorMap(forPosition: .back)
        if availableRearCameras.count > 0 {
            let cameras = availableRearCameras.sorted { $0.0 < $1.0 }.map { ($0.0, $0.1) }

            let cameraZoomControl = CameraZoomSelectionControl(availableCameras: cameras)
            cameraZoomControl.delegate = self
            view.addSubview(cameraZoomControl)
            self.rearCameraZoomControl = cameraZoomControl

            let cameraZoomControlConstraints =
                [
                    cameraZoomControl.centerXAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerXAnchor),
                    cameraZoomControl.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.topAnchor, constant: -32),
                ]
            view.addConstraints(cameraZoomControlConstraints)
            cameraZoomControlIPhoneConstraints?.append(contentsOf: cameraZoomControlConstraints)
        }
        updateUIOnCameraPositionChange()

        // Done Button
        view.addSubview(doneButton)
        doneButton.isHidden = true
        doneButton.translatesAutoresizingMaskIntoConstraints = false
        view.addConstraints(doneButtonIPhoneConstraints)
        doneButton.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside)

        // Focusing frame
        previewView.addSubview(tapToFocusView)
        previewView.addConstraints([tapToFocusCenterXConstraint, tapToFocusCenterYConstraint])

        // Step 2. Check if we're running on an iPad and update UI accordingly.
        // Note that `traitCollectionDidChange` won't be called during initial view loading process.
        isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular

        // This background footer doesn't let view controller underneath current VC
        // to be visible at the bottom of the screen during interactive dismiss.
        if UIDevice.current.hasIPhoneXNotch {
            let blackFooter = UIView()
            blackFooter.backgroundColor = view.backgroundColor
            view.insertSubview(blackFooter, at: 0)
            blackFooter.autoPinWidthToSuperview()
            blackFooter.autoPinEdge(toSuperviewEdge: .bottom)
            blackFooter.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5).isActive = true
        }
    }

    private func initializeIPadSpecificUIIfNecessary() {
        guard sideBar == nil else { return }

        let sideBar = CameraSideBar(frame: .zero)
        sideBar.cameraCaptureControl.delegate = cameraCaptureSession
        sideBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside)
        sideBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside)
        sideBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside)
        sideBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside)
        view.addSubview(sideBar)
        sideBar.autoPinTrailingToSuperviewMargin(withInset: 12)
        sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        self.sideBar = sideBar

        sideBar.batchModeButton.setImage(topBar.batchModeButton.image(for: .normal), for: .normal)
        updateFlashModeControl(animated: false)

        doneButtonIPadConstraints = [
            doneButton.centerXAnchor.constraint(equalTo: sideBar.centerXAnchor),
            doneButton.bottomAnchor.constraint(equalTo: sideBar.topAnchor, constant: -8),
        ]

        cameraZoomControlIPadConstraints = []
        if let cameraZoomControl = frontCameraZoomControl {
            let constraints = [
                cameraZoomControl.centerYAnchor.constraint(equalTo: sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor),
                cameraZoomControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
            ]
            cameraZoomControlIPadConstraints?.append(contentsOf: constraints)
        }
        if let cameraZoomControl = rearCameraZoomControl {
            let constraints = [
                cameraZoomControl.centerYAnchor.constraint(equalTo: sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor),
                cameraZoomControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
            ]
            cameraZoomControlIPadConstraints?.append(contentsOf: constraints)
        }

        if textEditorUIInitialized {
            initializeTextEditoriPadUI()
        }
    }

    private func updateIPadInterfaceLayout() {
        owsAssertDebug(UIDevice.current.isIPad)

        if isIPadUIInRegularMode {
            initializeIPadSpecificUIIfNecessary()

            view.removeConstraints(doneButtonIPhoneConstraints)
            if let doneButtonIPadConstraints {
                view.addConstraints(doneButtonIPadConstraints)
            }
        } else {
            if let doneButtonIPadConstraints {
                view.removeConstraints(doneButtonIPadConstraints)
            }
            view.addConstraints(doneButtonIPhoneConstraints)
        }

        if let cameraZoomControl = frontCameraZoomControl {
            cameraZoomControl.axis = isIPadUIInRegularMode ? .vertical : .horizontal
        }
        if let cameraZoomControl = rearCameraZoomControl {
            cameraZoomControl.axis = isIPadUIInRegularMode ? .vertical : .horizontal
        }
        if
            let iPhoneConstraints = cameraZoomControlIPhoneConstraints,
            let iPadConstraints = cameraZoomControlIPadConstraints
        {
            if isIPadUIInRegularMode {
                view.removeConstraints(iPhoneConstraints)
                view.addConstraints(iPadConstraints)
            } else {
                view.removeConstraints(iPadConstraints)
                view.addConstraints(iPhoneConstraints)
            }
        }

        updateTopBarAppearance(animated: true)
        updateBottomBarVisibility(animated: true)
        bottomBar.setLayout(isIPadUIInRegularMode ? .iPad : .iPhone, animated: true)
        updateSideBarVisibility(animated: true)

        if textEditorUIInitialized {
            textStoryComposerView.layer.cornerRadius = isIPadUIInRegularMode || UIDevice.current.hasIPhoneXNotch ? 18 : 0

            if isIPadUIInRegularMode {
                view.removeConstraints(textEditoriPhoneConstraints)
                view.addConstraints(textEditoriPadConstraints)

                bottomBar.constrainControlButtonsLayoutGuideHorizontallyTo(
                    leadingAnchor: textStoryComposerView.leadingAnchor,
                    trailingAnchor: textStoryComposerView.trailingAnchor,
                )
            } else {
                view.removeConstraints(textEditoriPadConstraints)
                view.addConstraints(textEditoriPhoneConstraints)

                bottomBar.constrainControlButtonsLayoutGuideHorizontallyTo(leadingAnchor: nil, trailingAnchor: nil)
            }
        }
    }

    func updateDoneButtonAppearance() {
        doneButton.badgeNumber = dataSource?.numberOfMediaItems ?? 0
        doneButton.isHidden = shouldHideDoneButton
        if bottomBar.isCompactHeightLayout {
            bottomBar.switchCameraButton.isHidden = !doneButton.isHidden
        }
    }

    private func updateUIOnCameraPositionChange(animated: Bool = false) {
        let isFrontCamera = cameraCaptureSession.desiredPosition == .front
        frontCameraZoomControl?.setIsHidden(!isFrontCamera, animated: animated)
        rearCameraZoomControl?.setIsHidden(isFrontCamera, animated: animated)
        bottomBar.switchCameraButton.isFrontCameraActive = isFrontCamera
        if let sideBar {
            sideBar.switchCameraButton.isFrontCameraActive = isFrontCamera
        }
    }

    private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) {
        guard !UIDevice.current.isIPad else { return }

        let transformFromOrientation: CGAffineTransform
        switch captureOrientation {
        case .portrait:
            transformFromOrientation = .identity
        case .portraitUpsideDown:
            transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
        case .landscapeRight:
            transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
        case .landscapeLeft:
            transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
        @unknown default:
            owsFailDebug("unexpected captureOrientation: \(captureOrientation.rawValue)")
            transformFromOrientation = .identity
        }

        // Don't "unrotate" the switch camera icon if the front facing camera had been selected.
        let transformFromCameraType: CGAffineTransform = cameraCaptureSession.desiredPosition == .front ? CGAffineTransform(rotationAngle: -.pi) : .identity

        var buttonsToUpdate: [UIView] = [topBar.batchModeButton, topBar.flashModeButton, bottomBar.photoLibraryButton]
        if let cameraZoomControl = frontCameraZoomControl {
            buttonsToUpdate.append(contentsOf: cameraZoomControl.cameraZoomLevelIndicators)
        }
        if let cameraZoomControl = rearCameraZoomControl {
            buttonsToUpdate.append(contentsOf: cameraZoomControl.cameraZoomLevelIndicators)
        }
        let updateOrientation = {
            buttonsToUpdate.forEach { $0.transform = transformFromOrientation }
            self.bottomBar.switchCameraButton.transform = transformFromOrientation.concatenating(transformFromCameraType)
        }

        if isAnimated {
            UIView.animate(withDuration: 0.3, animations: updateOrientation)
        } else {
            updateOrientation()
        }
    }
}

// MARK: - Text Editor

extension PhotoCaptureViewController {

    private func initializeTextEditorUIIfNecessary() {
        guard !textEditorUIInitialized else { return }

        // Connect button actions.
        bottomBar.proceedButton.addTarget(self, action: #selector(didTapTextStoryProceedButton), for: .touchUpInside)
        textBackgroundSelectionButton.addTarget(self, action: #selector(didTapTextBackgroundButton), for: .touchUpInside)
        textViewAttachLinkButton.addTarget(self, action: #selector(didTapAttachLinkPreviewButton), for: .touchUpInside)
        updateTextBackgroundSelectionButton()

        // Set up composer view.
        textStoryComposerView.delegate = self
        textStoryComposerView.translatesAutoresizingMaskIntoConstraints = false
        textStoryComposerView.layer.cornerRadius = isIPadUIInRegularMode || UIDevice.current.hasIPhoneXNotch ? 18 : 0
        view.insertSubview(textStoryComposerView, aboveSubview: previewView)
        textEditoriPhoneConstraints.append(contentsOf: [
            textStoryComposerView.leadingAnchor.constraint(equalTo: previewViewLayoutGuide.leadingAnchor),
            textStoryComposerView.topAnchor.constraint(equalTo: previewViewLayoutGuide.topAnchor),
            textStoryComposerView.trailingAnchor.constraint(equalTo: previewViewLayoutGuide.trailingAnchor),
            textStoryComposerView.bottomAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor),
        ])

        // Swipe right to switch to camera.
        let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeToCamera(gesture:)))
        swipeGesture.direction = CurrentAppContext().isRTL ? .left : .right
        textStoryComposerView.addGestureRecognizer(swipeGesture)

        // Choose Background and Attach Link buttons.
        // Toolbar is added to VC's view because it might be located outside of the textStoryComposerView.
        view.addSubview(textEditorToolbar)
        // Align leading edge of Background button to leading edge of the content area of the `bottomBar`,
        // which is in turn might constrained to the leading edge of text editor "card".
        view.addConstraint(textEditorToolbar.leadingAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.leadingAnchor))
        if bottomBar.isCompactHeightLayout {
            // On devices without top and bottom safe areas buttons are placed above CAMERA | TEXT controls.
            textEditoriPhoneConstraints.append(
                textEditorToolbar.bottomAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.topAnchor),
            )
        } else {
            // On devices with bottom safe area buttons are pinned to the bottom edge of the colored background,
            // which always clears CAMERA | TEXT controls.
            textEditoriPhoneConstraints.append(
                textEditorToolbar.bottomAnchor.constraint(equalTo: textStoryComposerView.bottomAnchor, constant: -16),
            )
        }

        // This constraint defines bottom edge of the area that contains text view and link preview inside of the `textStoryComposerView`.
        // Initially the bottom edge is pinned to the top of `textEditorToolbar`.
        // If on-screen keyboard appears the constraint is updated so that content clears the keyboard.
        textStoryComposerContentLayoutGuideBottomIphone = textStoryComposerView.contentLayoutGuide.bottomAnchor.constraint(
            equalTo: textEditorToolbar.bottomAnchor,
        )
        textEditoriPhoneConstraints.append(textStoryComposerContentLayoutGuideBottomIphone!)

        if isIPadUIInRegularMode {
            initializeTextEditoriPadUI()
        } else {
            view.addConstraints(textEditoriPhoneConstraints)
        }

        view.setNeedsLayout()
        UIView.performWithoutAnimation {
            self.view.layoutIfNeeded()
        }

        textEditorUIInitialized = true
    }

    private func initializeTextEditoriPadUI() {
        owsAssertDebug(textEditoriPadConstraints.isEmpty)

        // Container - 16:9 aspect ratio, constrained vertically, centered on the screen horizontally.
        textEditoriPadConstraints.append(contentsOf: [
            textStoryComposerView.topAnchor.constraint(equalTo: topBar.bottomAnchor, constant: -8),
            textStoryComposerView.bottomAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.topAnchor, constant: -24),
            textStoryComposerView.centerXAnchor.constraint(equalTo: previewViewLayoutGuide.centerXAnchor),
            textStoryComposerView.widthAnchor.constraint(equalTo: textStoryComposerView.heightAnchor, multiplier: 9 / 16),
        ])

        // This constraint defines bottom edge of the text content area
        // and would allow to resize content to clear onscreen keyboard.
        textStoryComposerContentLayoutGuideBottomIpad = textStoryComposerView.contentLayoutGuide.bottomAnchor.constraint(
            equalTo: textStoryComposerView.bottomAnchor,
            constant: -8,
        )
        textEditoriPadConstraints.append(textStoryComposerContentLayoutGuideBottomIpad!)

        // Background and Add Link buttons are vertically centered with CAMERA|TEXT switch and Proceed button.
        textEditoriPadConstraints.append(
            textEditorToolbar.centerYAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.centerYAnchor),
        )

        // Additional constraint that will at least 20 dp between Add Link button and CAMERA|TEXT switch.
        // This constraint will override
        textEditoriPadConstraints.append(
            textEditorToolbar.trailingAnchor.constraint(
                lessThanOrEqualTo: bottomBar.contentTypeSelectionControl.leadingAnchor,
                constant: -20,
            ),
        )
        if isIPadUIInRegularMode {
            bottomBar.constrainControlButtonsLayoutGuideHorizontallyTo(
                leadingAnchor: textStoryComposerView.leadingAnchor,
                trailingAnchor: textStoryComposerView.trailingAnchor,
            )
        }

        view.addConstraints(textEditoriPadConstraints)
    }

    private func updateTextEditorToolbarVisibility(animated: Bool) {
        textEditorToolbar.setIsHidden(textStoryComposerView.isEditing || composerMode != .text, animated: animated)
    }

    // Update background of the background selection button to match the editor.
    private func updateTextBackgroundSelectionButton() {
        switch textStoryComposerView.background {
        case .color(let color):
            textBackgroundSelectionButton.gradientView.colors = [color, color]

        case .gradient(let gradient):
            textBackgroundSelectionButton.gradientView.colors = gradient.colors
            textBackgroundSelectionButton.gradientView.locations = gradient.locations
            textBackgroundSelectionButton.gradientView.setAngle(gradient.angle)
        }
    }

    // MARK: - Keyboard Handling

    private func startObservingKeyboardNotifications() {
        guard !observingKeyboardNotifications else { return }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardWillShowNotification,
            object: nil,
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardWillHideNotification,
            object: nil,
        )
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleKeyboardNotification(_:)),
            name: UIResponder.keyboardWillChangeFrameNotification,
            object: nil,
        )
        observingKeyboardNotifications = true
    }

    @objc
    private func handleKeyboardNotification(_ notification: Notification) {
        guard composerMode == .text else { return }

        guard let iPhoneConstraint = textStoryComposerContentLayoutGuideBottomIphone else { return }
        let iPadConstraint = textStoryComposerContentLayoutGuideBottomIpad

        guard
            let userInfo = notification.userInfo,
            let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }

        // Detect floating keyboards - those should not adjust bottom inset for text input area.
        // Note that floating keyboard could co-exist with iPhone-like layouts.
        let keyboardFrame = textStoryComposerView.convert(endFrame, from: nil)
        let isNonFloatingKeyboardVisible = keyboardFrame.height > 0 &&
            keyboardFrame.minX <= textStoryComposerView.bounds.minX &&
            keyboardFrame.maxX >= textStoryComposerView.bounds.maxX

        let iPhoneInset: CGFloat
        let iPadInset: CGFloat
        if isNonFloatingKeyboardVisible {
            let convertedKeyboardFrame = textEditorToolbar.convert(keyboardFrame, from: textStoryComposerView)
            iPhoneInset = convertedKeyboardFrame.minY - textEditorToolbar.bounds.maxY
            iPadInset = keyboardFrame.minY - textStoryComposerView.bounds.maxY
        } else {
            iPhoneInset = textEditorToolbar.bounds.height
            iPadInset = 0
        }

        let layoutUpdateBlock = {
            iPhoneConstraint.constant = min(iPhoneInset, 0) - 8
            iPadConstraint?.constant = min(iPadInset, 0) - 8
        }
        if
            let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
            let rawAnimationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
            let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve)
        {
            UIView.animate(
                withDuration: animationDuration,
                delay: 0,
                options: animationCurve.asAnimationOptions,
                animations: { [self] in
                    layoutUpdateBlock()
                    view.setNeedsLayout()
                    view.layoutIfNeeded()
                },
            )
        } else {
            UIView.performWithoutAnimation {
                layoutUpdateBlock()
            }
        }
    }

    // MARK: - Background

    private class RoundGradientButton: RoundMediaButton {
        let gradientView = GradientView(colors: [])

        init() {
            let gradientCircleView = PillView()
            gradientCircleView.isUserInteractionEnabled = false
            gradientCircleView.layer.borderWidth = 2
            gradientCircleView.layer.borderColor = UIColor.white.cgColor
            gradientCircleView.addSubview(gradientView)
            gradientCircleView.autoSetDimensions(to: CGSize(square: 28))
            gradientView.autoPinEdgesToSuperviewEdges()

            super.init(image: nil, backgroundStyle: .blur, customView: gradientCircleView)

            ows_contentEdgeInsets = .zero
            layoutMargins = .zero
        }

        override var intrinsicContentSize: CGSize { CGSize(square: 44) }
    }

    // MARK: - Button Actions

    @objc
    private func didTapTextBackgroundButton() {
        textStoryComposerView.switchToNextBackground()
        updateTextBackgroundSelectionButton()
    }

    @objc
    private func didTapAttachLinkPreviewButton() {
        let linkPreviewViewController = LinkPreviewAttachmentViewController(textStoryComposerView.linkPreviewDraft)
        linkPreviewViewController.delegate = self
        present(linkPreviewViewController, animated: true)
    }

    @objc
    private func didTapTextStoryProceedButton() {
        let body: StyleOnlyMessageBody
        let textStyle: TextAttachment.TextStyle
        switch textStoryComposerView.textContent {
        case .empty:
            body = StyleOnlyMessageBody(plaintext: "")
            textStyle = .regular
        case .styledRanges(let contentBody):
            body = contentBody
            textStyle = .regular
        case .styled(let text, let style):
            body = StyleOnlyMessageBody(plaintext: text)
            textStyle = style
        }
        let textForegroundColor = textStoryComposerView.textForegroundColor
        let textBackgroundColor = textStoryComposerView.textBackgroundColor
        let background = textStoryComposerView.background

        // Styles are used only when forwading; we only get plaintext here.
        let unsentTextAttachment = UnsentTextAttachment(
            body: body,
            textStyle: textStyle,
            textForegroundColor: textForegroundColor,
            textBackgroundColor: textBackgroundColor,
            background: background,
            linkPreviewDraft: textStoryComposerView.linkPreviewDraft,
        )

        delegate?.photoCaptureViewController(self, didFinishWithTextAttachment: unsentTextAttachment)
    }

    @objc
    func didSwipeToCamera(gesture: UISwipeGestureRecognizer) {
        guard composerMode == .text else { return }
        setComposerMode(.camera, animated: true)
    }
}

extension PhotoCaptureViewController: TextStoryComposerViewDelegate {

    fileprivate func textStoryComposerDidBeginEditing(_ textStoryComposer: TextStoryComposerView) {
        updateBottomBarVisibility(animated: true)
        updateTextEditorToolbarVisibility(animated: true)
    }

    fileprivate func textStoryComposerDidEndEditing(_ textStoryComposer: TextStoryComposerView) {
        updateBottomBarVisibility(animated: true)
        updateTextEditorToolbarVisibility(animated: true)
    }

    fileprivate func textStoryComposerDidChange(_ textStoryComposer: TextStoryComposerView) {
        bottomBar.proceedButton.isEnabled = !textStoryComposer.isEmpty
    }
}

extension PhotoCaptureViewController: LinkPreviewAttachmentViewControllerDelegate {

    func linkPreviewAttachmentViewController(
        _ viewController: LinkPreviewAttachmentViewController,
        didFinishWith linkPreview: OWSLinkPreviewDraft,
    ) {
        textStoryComposerView.linkPreviewDraft = linkPreview
        viewController.dismiss(animated: true)
    }
}

// MARK: - Button Actions

extension PhotoCaptureViewController {

    @objc
    private func didTapClose() {
        delegate?.photoCaptureViewControllerDidCancel(self)
    }

    @objc
    private func didTapSwitchCamera() {
        switchCameraPosition()
    }

    private func switchCameraPosition() {
        if let switchCameraButton = isIPadUIInRegularMode ? sideBar?.switchCameraButton : bottomBar.switchCameraButton {
            switchCameraButton.performSwitchAnimation()
        }
        cameraCaptureSession.switchCameraPosition().done { [weak self] in
            self?.updateUIOnCameraPositionChange(animated: true)
            self?.cameraCaptureSession.updateVideoCaptureOrientation()
        }.catch { error in
            self.showFailureUI(error: error)
        }
    }

    @objc
    private func didTapFlashMode() {
        cameraCaptureSession.toggleFlashMode().done {
            self.updateFlashModeControl(animated: true)
        }.catch { error in
            owsFailDebug("Error: \(error)")
        }
    }

    @objc
    private func didTapBatchMode() {
        guard let delegate else {
            return
        }
        let targetMode: CaptureMode = {
            switch captureMode {
            case .single: return .multi
            case .multi: return .single
            }
        }()
        delegate.photoCaptureViewController(self, didRequestSwitchCaptureModeTo: targetMode) { approved in
            if approved {
                self.captureMode = targetMode
                self.updateDoneButtonAppearance()
            }
        }
    }

    @objc
    private func didTapPhotoLibrary() {
        delegate?.photoCaptureViewControllerDidRequestPresentPhotoLibrary(self)
    }

    @objc
    private func didTapDoneButton() {
        delegate?.photoCaptureViewControllerDidFinish(self)
    }

    @objc
    private func contentTypeChanged() {
        guard let newComposerMode = ComposerMode(rawValue: bottomBar.contentTypeSelectionControl.selectedSegmentIndex) else { return }
        setComposerMode(newComposerMode, animated: true)
    }
}

// MARK: - Camera Gesture Recognizers

extension PhotoCaptureViewController {

    private func configureCameraGestures() {
        previewView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:))))

        let doubleTapToSwitchCameraGesture = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapToSwitchCamera(tapGesture:)))
        doubleTapToSwitchCameraGesture.numberOfTapsRequired = 2
        previewView.addGestureRecognizer(doubleTapToSwitchCameraGesture)

        let tapToFocusGesture = UITapGestureRecognizer(target: self, action: #selector(didTapFocusExpose(tapGesture:)))
        tapToFocusGesture.require(toFail: doubleTapToSwitchCameraGesture)
        previewView.addGestureRecognizer(tapToFocusGesture)

        // Swipe left to switch to text story composer.
        if bottomBar.isContentTypeSelectionControlAvailable {
            let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeToTextComposer(gesture:)))
            swipeGesture.direction = CurrentAppContext().isRTL ? .right : .left
            previewView.addGestureRecognizer(swipeGesture)
        }
    }

    @objc
    private func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) {
        switch pinchGesture.state {
        case .began:
            cameraCaptureSession.beginPinchZoom()
            fallthrough
        case .changed:
            cameraCaptureSession.updatePinchZoom(withScale: pinchGesture.scale)
        case .ended:
            cameraCaptureSession.completePinchZoom(withScale: pinchGesture.scale)
        default:
            break
        }
    }

    @objc
    private func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) {
        guard !isRecordingVideo else {
            // - Orientation gets out of sync when switching cameras mid movie.
            // - Audio gets out of sync when switching cameras mid movie
            // https://stackoverflow.com/questions/13951182/audio-video-out-of-sync-after-switch-camera
            return
        }

        switchCameraPosition()
    }

    @objc
    private func didTapFocusExpose(tapGesture: UITapGestureRecognizer) {
        let viewLocation = tapGesture.location(in: previewView)
        let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation)
        cameraCaptureSession.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true)
        lastUserFocusTapPoint = devicePoint

        if let focusFrameSuperview = tapToFocusView.superview {
            positionTapToFocusView(center: tapGesture.location(in: focusFrameSuperview))
            startFocusAnimation()
        }
    }

    @objc
    private func didSwipeToTextComposer(gesture: UISwipeGestureRecognizer) {
        guard composerMode == .camera else { return }
        guard bottomBar.captureControl.state == .initial else { return }
        setComposerMode(.text, animated: true)
    }
}

// MARK: - Tap to Focus

extension PhotoCaptureViewController {

    private func positionTapToFocusView(center: CGPoint) {
        tapToFocusCenterXConstraint.constant = center.x
        tapToFocusCenterYConstraint.constant = center.y
    }

    private func startFocusAnimation() {
        tapToFocusView.stop()
        tapToFocusView.play(fromProgress: 0.0, toProgress: 0.9)
    }

    private func completeFocusAnimation(forFocusPoint focusPoint: CGPoint) {
        guard let lastUserFocusTapPoint else { return }

        guard lastUserFocusTapPoint.within(0.005, of: focusPoint) else {
            return
        }

        tapToFocusView.play(toProgress: 1.0)
    }
}

// MARK: - Photo Capture

extension PhotoCaptureViewController {

    private func setupPhotoCapture() {
        bottomBar.captureControl.delegate = cameraCaptureSession
        if let sideBar {
            sideBar.cameraCaptureControl.delegate = cameraCaptureSession
        }

        // If the session is already running, we're good to go.
        guard !cameraCaptureSession.avCaptureSession.isRunning else {
            self.hasCameraStarted = true
            return
        }

        firstly {
            cameraCaptureSession.prepare()
        }.catch { [weak self] error in
            guard let self else { return }
            self.showFailureUI(error: error)
        }
    }

    private func pausePhotoCapture() {
        guard cameraCaptureSession.avCaptureSession.isRunning else { return }
        cameraCaptureSession.stop().done { [weak self] in
            self?.hasCameraStarted = false
        }.catch { [weak self] error in
            self?.showFailureUI(error: error)
        }
    }

    private func resumePhotoCapture() {
        guard !cameraCaptureSession.avCaptureSession.isRunning else { return }
        cameraCaptureSession.resume().done { [weak self] in
            self?.hasCameraStarted = true
        }.catch { [weak self] error in
            self?.showFailureUI(error: error)
        }
    }

    private func showFailureUI(error: Error) {
        Logger.warn("error: \(error)")
        OWSActionSheets.showActionSheet(
            title: nil,
            message: error.userErrorDescription,
            buttonTitle: CommonStrings.dismissButton,
            buttonAction: { [weak self] _ in self?.dismiss(animated: true) },
        )
    }

    private func updateFlashModeControl(animated: Bool) {
        topBar.flashModeButton.setFlashMode(cameraCaptureSession.flashMode, animated: animated)
        if let sideBar {
            sideBar.flashModeButton.setFlashMode(cameraCaptureSession.flashMode, animated: animated)
        }
    }
}

extension PhotoCaptureViewController: InteractiveDismissDelegate {

    func interactiveDismissDidBegin(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
        view.backgroundColor = .clear
    }

    func interactiveDismiss(
        _ interactiveDismiss: UIPercentDrivenInteractiveTransition,
        didChangeProgress: CGFloat,
        touchOffset: CGPoint,
    ) { }

    func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
        dismiss(animated: true)
    }

    func interactiveDismissDidCancel(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
        view.backgroundColor = Theme.darkThemeBackgroundColor
    }
}

extension PhotoCaptureViewController: CameraZoomSelectionControlDelegate {

    func cameraZoomControl(_ cameraZoomControl: CameraZoomSelectionControl, didSelect camera: CameraCaptureSession.CameraType) {
        let position: AVCaptureDevice.Position = cameraZoomControl == frontCameraZoomControl ? .front : .back
        cameraCaptureSession.switchCamera(to: camera, at: position, animated: true)
    }

    func cameraZoomControl(_ cameraZoomControl: CameraZoomSelectionControl, didChangeZoomFactor zoomFactor: CGFloat) {
        cameraCaptureSession.changeVisibleZoomFactor(to: zoomFactor, animated: true)
    }
}

// MARK: - QRCodeSampleBufferScannerDelegate

extension PhotoCaptureViewController: QRCodeSampleBufferScannerDelegate {
    var shouldProcessQRCodes: Bool {
        _shouldProcessQRCodes.get()
    }

    func qrCodeFound(string qrCodeString: String?, data qrCodeData: Data?) {
        guard let qrCodeString else {
            return
        }

        if
            let url = URL(string: qrCodeString),
            let usernameLink = Usernames.UsernameLink(usernameLinkUrl: url)
        {
            qrCodeScanned = true

            Task {
                guard
                    let (username, aci) = await UsernameQuerier().queryForUsernameLink(
                        link: usernameLink,
                        fromViewController: self,
                        failureSheetDismissalDelegate: self,
                    )
                else {
                    return
                }

                showUsernameLinkSheet(username: username, aci: aci)
            }
        } else if let provisioningURL = DeviceProvisioningURL(urlString: qrCodeString) {

            let tsAccountManager = DependenciesBridge.shared.tsAccountManager
            let registeredState = try? tsAccountManager.registeredStateWithMaybeSneakyTransaction()
            switch provisioningURL.linkType {
            case .linkDevice where registeredState?.isPrimary == true:
                qrCodeScanned = true
                let linkDeviceWarningActionSheet = ActionSheetController(
                    message: OWSLocalizedString(
                        "LINKED_DEVICE_URL_OPENED_ACTION_SHEET_IN_APP_CAMERA_MESSAGE",
                        comment: "Message for an action sheet telling users how to link a device, when trying to open a device-linking URL from the in-app camera.",
                    ),
                )

                let showLinkedDevicesAction = ActionSheetAction(title: CommonStrings.continueButton) { _ in
                    self.dismiss(animated: true) {
                        SignalApp.shared.showAppSettings(mode: .linkedDevices)
                    }
                }

                let cancelAction = ActionSheetAction(title: CommonStrings.cancelButton) { _ in
                    self.qrCodeScanned = false
                }

                linkDeviceWarningActionSheet.addAction(showLinkedDevicesAction)
                linkDeviceWarningActionSheet.addAction(cancelAction)
                presentActionSheet(linkDeviceWarningActionSheet)

            case .quickRestore:
                qrCodeScanned = true
                let presentBlock = {
                    self.dismiss(animated: true) {
                        AppEnvironment.shared.outgoingDeviceRestorePresenter.present(
                            provisioningURL: provisioningURL,
                            presentingViewController: CurrentAppContext().frontmostViewController()!,
                            animated: true,
                        )
                    }
                }
                // If anything is presented over the phone capture view, dismiss it first -
                // then dismiss the photo view and present the restore UI
                if navigationController?.presentedViewController != nil {
                    self.navigationController?.presentedViewController?.dismiss(animated: true) {
                        presentBlock()
                    }
                } else {
                    presentBlock()
                }

            case .linkDevice:
                Logger.warn("Scanned linkDevice provisioning URL, but not a registered primary.")
            }
        }
    }

    func scanFailed(error: Error) {
        self.showFailureUI(error: error)
    }

    private func showUsernameLinkSheet(
        username: String,
        aci: Aci,
    ) {
        // `shouldProcessQRCodes` should prevent QR codes being scanned after a
        // recording is done, but a race condition between the recording ending
        // and this view hiding can allow a scan to slip through, so do an extra
        // check after the username is queried before showing the sheet.
        guard isViewVisible else { return }
        OWSActionSheets.showConfirmationAlert(
            title: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "PHOTO_CAPTURE_USERNAME_QR_CODE_FOUND_TITLE_FORMAT",
                    comment: "Title for sheet presented from photo capture view indicating that a username QR code was found. Embeds {{username}}.",
                ),
                username,
            ),
            message: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "PHOTO_CAPTURE_USERNAME_QR_CODE_FOUND_MESSAGE_FORMAT",
                    comment: "Message for a sheet presented from photo capture view indicating that a username QR code was found. Embeds {{username}}.",
                ),
                username,
            ),
            proceedTitle: OWSLocalizedString(
                "PHOTO_CAPTURE_USERNAME_QR_CODE_FOUND_CTA",
                comment: "Button label for opening the chat on a sheet presented from photo capture view indicating that a username QR code was found.",
            ),
            proceedAction: { [weak self] _ in
                SignalApp.shared.presentConversationForAddress(
                    SignalServiceAddress(aci),
                    animated: false,
                )
                self?.dismiss(animated: true)
            },
            fromViewController: self,
            dismissalDelegate: self,
        )
    }
}

// MARK: - SheetDismissalDelegate

extension PhotoCaptureViewController: SheetDismissalDelegate {
    func didDismissPresentedSheet() {
        // Allow another QR code to be scanned
        qrCodeScanned = false
    }
}

// MARK: - CameraCaptureSessionDelegate

extension PhotoCaptureViewController: CameraCaptureSessionDelegate {

    // MARK: - Photo

    func cameraCaptureSessionDidStart(_ session: CameraCaptureSession) {
        let captureFeedbackView = UIView()
        captureFeedbackView.backgroundColor = .black
        view.insertSubview(captureFeedbackView, aboveSubview: previewView)
        captureFeedbackView.autoPinEdgesToSuperviewEdges()

        // Ensure the capture feedback is laid out before we remove it,
        // depending on where we're coming from a layout pass might not
        // trigger in 0.05 seconds otherwise.
        view.setNeedsLayout()
        view.layoutIfNeeded()

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
            captureFeedbackView.removeFromSuperview()
        }
    }

    func cameraCaptureSession(_ session: CameraCaptureSession, didFinishProcessing attachment: PreviewableAttachment) {
        dataSource?.addMedia(attachment: attachment)

        updateDoneButtonAppearance()

        if captureMode == .multi {
            resumePhotoCapture()
        } else {
            delegate?.photoCaptureViewControllerDidFinish(self)
        }
    }

    func cameraCaptureSession(_ session: CameraCaptureSession, didFailWith error: Error) {
        setIsRecordingVideo(false, animated: true)

        if error is VideoCaptureFailedError {
            // Don't show an error if the user aborts recording before video
            // recording has begun.
            return
        }
        showFailureUI(error: error)
    }

    func cameraCaptureSessionCanCaptureMoreItems(_ session: CameraCaptureSession) -> Bool {
        return delegate?.photoCaptureViewControllerCanCaptureMoreItems(self) ?? false
    }

    func photoCaptureDidTryToCaptureTooMany(_ session: CameraCaptureSession) {
        delegate?.photoCaptureViewControllerDidTryToCaptureTooMany(self)
    }

    // MARK: - Video

    func cameraCaptureSessionWillStartVideoRecording(_ session: CameraCaptureSession) {
        setIsRecordingVideo(true, animated: true)
    }

    func cameraCaptureSessionDidStartVideoRecording(_ session: CameraCaptureSession) {
    }

    func cameraCaptureSessionDidStopVideoRecording(_ session: CameraCaptureSession) {
        setIsRecordingVideo(false, animated: true)
    }

    func cameraCaptureSession(_ session: CameraCaptureSession, videoRecordingDurationChanged duration: TimeInterval) {
        topBar.recordingTimerView.duration = duration
    }

    // MARK: -

    var zoomScaleReferenceDistance: CGFloat? {
        if isIPadUIInRegularMode {
            return previewView.bounds.width / 2
        }
        return previewView.bounds.height / 2
    }

    func cameraCaptureSession(_ session: CameraCaptureSession, didChangeZoomFactor zoomFactor: CGFloat, forCameraPosition position: AVCaptureDevice.Position) {
        guard let cameraZoomControl = position == .front ? frontCameraZoomControl : rearCameraZoomControl else { return }
        cameraZoomControl.currentZoomFactor = zoomFactor
    }

    func beginCaptureButtonAnimation(_ duration: TimeInterval) {
        bottomBar.captureControl.setState(.recording, animationDuration: duration)
        if let sideBar {
            sideBar.cameraCaptureControl.setState(.recording, animationDuration: duration)
        }
    }

    func endCaptureButtonAnimation(_ duration: TimeInterval) {
        bottomBar.captureControl.setState(.initial, animationDuration: duration)
        if let sideBar {
            sideBar.cameraCaptureControl.setState(.initial, animationDuration: duration)
        }
    }

    func cameraCaptureSession(_ session: CameraCaptureSession, didChangeOrientation orientation: AVCaptureVideoOrientation) {
        updateIconOrientations(isAnimated: true, captureOrientation: orientation)
        if UIDevice.current.isIPad {
            session.updateVideoPreviewConnection(toOrientation: orientation)
        }
    }

    func cameraCaptureSession(_ session: CameraCaptureSession, didFinishFocusingAt focusPoint: CGPoint) {
        completeFocusAnimation(forFocusPoint: focusPoint)
    }

    @objc
    func sessionWasInterrupted(notification: Notification) {
        if let userInfo = notification.userInfo {
            guard
                let reasonValue = userInfo[AVCaptureSessionInterruptionReasonKey] as? NSNumber,
                let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue.intValue)
            else {
                Logger.info("session was interrupted for no apparent reason")
                return
            }
            Logger.info("session was interrupted with reason code: \(reason.rawValue)")
        }
    }
}

private protocol TextStoryComposerViewDelegate: AnyObject {
    func textStoryComposerDidBeginEditing(_ textStoryComposer: TextStoryComposerView)
    func textStoryComposerDidEndEditing(_ textStoryComposer: TextStoryComposerView)
    func textStoryComposerDidChange(_ textStoryComposer: TextStoryComposerView)
}

private class TextStoryComposerView: TextAttachmentView, UITextViewDelegate {

    weak var delegate: TextStoryComposerViewDelegate?

    init(text: String) {
        super.init(
            text: text,
            style: .regular,
            textForegroundColor: .white,
            textBackgroundColor: nil,
            background: TextStoryComposerView.defaultBackground,
        )

        // Placeholder Label
        textPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(textPlaceholderLabel)
        addConstraints([
            textPlaceholderLabel.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
            textPlaceholderLabel.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
            textPlaceholderLabel.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor),
            textPlaceholderLabel.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
        ])

        // Prepare text styling toolbar - attached to keyboard.
        let toolbarSize = textViewAccessoryToolbar.systemLayoutSizeFitting(
            CGSize(width: UIScreen.main.bounds.width, height: .greatestFiniteMagnitude),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel,
        )
        textViewAccessoryToolbar.bounds.size = toolbarSize
        textView.inputAccessoryView = textViewAccessoryToolbar

        // Text View
        textViewBackgroundView.layer.cornerRadius = LayoutConstants.textBackgroundCornerRadius
        textViewBackgroundView.addSubview(textView)
        addSubview(textViewBackgroundView)

        updateTextViewAttributes()
        updateVisibilityOfComponents(animated: false)

        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped)))
    }

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

    // Slightly smaller vertical margins for UITextView because UITextView
    // has larger embedded padding above and below the text.
    private static let textViewBackgroundVMargin = LayoutConstants.textBackgroundVMargin - 8
    private static let textViewBackgroundHMargin = LayoutConstants.textBackgroundHMargin

    override func layoutSubviews() {
        super.layoutSubviews()
        let contentWidth = layoutMarginsGuide.layoutFrame.width
        if
            let contentWidthConstraint = textViewAccessoryToolbar.contentWidthConstraint,
            contentWidthConstraint.constant != contentWidth,
            contentWidth > 0
        {
            contentWidthConstraint.constant = contentWidth
        }
    }

    override func layoutTextContentAndLinkPreview() {
        super.layoutTextContentAndLinkPreview()

        var textViewSize = textContentSize

        // Min dimensions for an empty text view.
        textViewSize.width = max(textViewSize.width, 20)
        textViewSize.height = max(textViewSize.height, 48)

        // Limit text view height to available content height, deducting link preview area height if needed.
        var linkPreviewAreaHeight: CGFloat = 0
        if linkPreviewView != nil {
            linkPreviewAreaHeight = linkPreviewWrapperView.frame.height + LayoutConstants.linkPreviewAreaTopMargin
        }
        textViewSize.height = min(
            textViewSize.height,
            contentLayoutGuide.layoutFrame.height - linkPreviewAreaHeight - 2 * TextStoryComposerView.textViewBackgroundVMargin,
        )

        // Enable / disable vertical text scrolling if all text doesn't fit the available screen space.
        if textContentSize.height > textViewSize.height {
            textView.isScrollEnabled = true
        } else {
            textView.isScrollEnabled = false
        }
        textView.bounds.size = textViewSize

        textViewBackgroundView.bounds.size = CGSize(
            width: textViewSize.width + 2 * TextStoryComposerView.textViewBackgroundHMargin,
            height: textViewSize.height + 2 * TextStoryComposerView.textViewBackgroundVMargin,
        )
        textViewBackgroundView.center = CGPoint(
            x: contentLayoutGuide.layoutFrame.center.x,
            y: contentLayoutGuide.layoutFrame.center.y - 0.5 * linkPreviewAreaHeight,
        )
        textView.center = textViewBackgroundView.bounds.center

        linkPreviewWrapperView.center = CGPoint(
            x: linkPreviewWrapperView.center.x,
            y: textViewBackgroundView.frame.maxY + LayoutConstants.linkPreviewAreaTopMargin + 0.5 * linkPreviewWrapperView.bounds.height,
        )
    }

    override func calculateTextContentSize() -> CGSize {
        guard isEditing else {
            return super.calculateTextContentSize()
        }
        let maxTextViewSize = contentLayoutGuide.layoutFrame.insetBy(
            dx: LayoutConstants.textBackgroundHMargin,
            dy: TextStoryComposerView.textViewBackgroundVMargin,
        ).size
        return textView.systemLayoutSizeFitting(
            maxTextViewSize,
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel,
        )
    }

    // MARK: -

    override var isEditing: Bool { textView.isFirstResponder }

    private var text: String? {
        get {
            switch super.textContent {
            case .empty:
                return nil
            case .styledRanges(let body):
                owsFailDebug("Should not have styled ranges in story text composer")
                return body.text
            case .styled(let body, _):
                return body
            }
        }
        set {
            super.textContent = .styled(body: newValue ?? "", style: textStyle)
        }
    }

    private var textStyle: TextAttachment.TextStyle = .regular {
        didSet {
            guard let text else {
                return
            }
            super.textContent = .styled(body: text, style: self.textStyle)
        }
    }

    var isEmpty: Bool {
        guard let text else { return true }
        return text.isEmpty && linkPreview == nil
    }

    // MARK: - Text View

    private lazy var textView: MediaTextView = {
        let textView = MediaTextView()
        textView.delegate = self
        textView.showsVerticalScrollIndicator = false
        return textView
    }()

    private let textViewBackgroundView = UIView()

    private lazy var textViewAccessoryToolbar: TextStylingToolbar = {
        let toolbar = TextStylingToolbar()
        toolbar.preservesSuperviewLayoutMargins = true
        toolbar.addTarget(self, action: #selector(didChangeTextColor), for: .valueChanged)
        toolbar.textStyleButton.addTarget(self, action: #selector(didTapTextStyleButton), for: .touchUpInside)
        toolbar.decorationStyleButton.addTarget(self, action: #selector(didTapDecorationStyleButton), for: .touchUpInside)
        toolbar.doneButton.addTarget(self, action: #selector(didTapTextViewDoneButton), for: .touchUpInside)
        return toolbar
    }()

    private let textPlaceholderLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.numberOfLines = 0
        label.textColor = .ows_whiteAlpha60
        label.font = .dynamicTypeLargeTitle1Clamped
        label.text = OWSLocalizedString(
            "STORY_COMPOSER_TAP_ADD_TEXT",
            comment: "Placeholder text in text stories compose UI",
        )
        return label
    }()

    override func updateVisibilityOfComponents(animated: Bool) {
        super.updateVisibilityOfComponents(animated: animated)

        let isEditing = isEditing
        textPlaceholderLabel.setIsHidden(isEditing || !isEmpty, animated: animated)
        textViewBackgroundView.setIsHidden(!isEditing, animated: animated)
    }

    private func updateTextViewAttributes() {
        let selectedTextRange = textView.selectedTextRange

        let text = text ?? ""
        textView.text = transformedText(text, for: textStyle)

        let (fontPointSize, textAlignment) = sizeAndAlignment(forText: text)
        textView.updateWith(
            textForegroundColor: textForegroundColor,
            font: .font(for: textStyle, withPointSize: fontPointSize),
            textAlignment: textAlignment,
            textDecorationColor: nil,
            decorationStyle: .none,
        )
        textView.selectedTextRange = selectedTextRange
        textViewBackgroundView.backgroundColor = textBackgroundColor
    }

    private func adjustFontSizeIfNecessary() {
        guard let currentFontSize = textView.font?.pointSize else { return }
        let text = text?.stripped ?? ""
        let desiredFontSize = sizeAndAlignment(forText: text).fontPointSize
        guard desiredFontSize != currentFontSize else { return }
        updateTextAttributes()
        updateTextViewAttributes()
    }

    private func validateTextViewAttributes() {
        guard let attributedString = textView.attributedText else { return }

        // Re-apply attributes to the entire text view's text if more than one font style is detected.
        // That could happen as a result of undo / redo operation.
        var shouldReapplyAttributes = false
        var previousFont: UIFont?
        attributedString.enumerateAttribute(.font, in: attributedString.entireRange) { attributeValue, range, stop in
            guard let font = attributeValue as? UIFont else { return }

            if let previousFont, !previousFont.isEqual(font) {
                shouldReapplyAttributes = true
                stop.pointee = true
            }
            previousFont = font
        }
        if shouldReapplyAttributes {
            updateTextViewAttributes()
        }
    }

    @objc
    private func placeholderTapped() {
        if textView.isFirstResponder {
            textView.acceptAutocorrectSuggestion()
            textView.resignFirstResponder()
        } else {
            textView.becomeFirstResponder()
        }
    }

    @objc
    private func didTapTextStyleButton() {
        let textStyle = textViewAccessoryToolbar.textStyle.next()
        textViewAccessoryToolbar.textStyle = textStyle

        self.textStyle = {
            switch textStyle {
            case .regular: return .regular
            case .bold: return .bold
            case .serif: return .serif
            case .script: return .script
            case .condensed: return .condensed
            }
        }()

        updateTextViewAttributes()
    }

    @objc
    private func didTapDecorationStyleButton() {
        // "Underline" and "Outline" are not available in text story composer.
        var decorationStyle = textViewAccessoryToolbar.decorationStyle.next()
        if decorationStyle == .outline || decorationStyle == .underline {
            decorationStyle = .none
        }
        textViewAccessoryToolbar.decorationStyle = decorationStyle

        // `textViewAccessoryToolbar` defines both foreground and background color for text based on the decoration style.
        let textForegroundColor = textViewAccessoryToolbar.textForegroundColor
        let textBackgroundColor = textViewAccessoryToolbar.textBackgroundColor
        setTextForegroundColor(textForegroundColor, backgroundColor: textBackgroundColor)

        updateTextViewAttributes()
    }

    @objc
    private func didChangeTextColor() {
        // Depending on text decoration style color picker changes either color of the text or background color.
        // That's why we need to update both.
        let textForegroundColor = textViewAccessoryToolbar.textForegroundColor
        let textBackgroundColor = textViewAccessoryToolbar.textBackgroundColor
        setTextForegroundColor(textForegroundColor, backgroundColor: textBackgroundColor)

        updateTextViewAttributes()
    }

    @objc
    private func didTapTextViewDoneButton() {
        textView.acceptAutocorrectSuggestion()
        textView.resignFirstResponder()
    }

    // MARK: - UITextViewDelegate

    func textViewDidBeginEditing(_ textView: UITextView) {
        updateVisibilityOfComponents(animated: true)
        delegate?.textStoryComposerDidBeginEditing(self)
        setNeedsLayout()
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        text = text?.stripped
        textView.text = text
        updateTextAttributes()
        updateVisibilityOfComponents(animated: true)
        delegate?.textStoryComposerDidEndEditing(self)
    }

    private var updatingTextViewText = false

    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText: String) -> Bool {

        guard !updatingTextViewText else { return false }

        let originalInput = text ?? ""
        let (shouldChange, changedString) = TextHelper.shouldChangeCharactersInRange(
            with: originalInput,
            editingRange: range,
            replacementString: replacementText,
            maxGlyphCount: 700,
        )

        if let changedString {
            text = changedString
            textView.text = transformedText(changedString, for: textStyle)
            textView.delegate?.textViewDidChange?(textView)
            return false
        }

        guard shouldChange else {
            return false
        }

        text = (originalInput as NSString).replacingCharacters(in: range, with: replacementText)

        let transformedText = transformedText(text ?? "", for: textStyle)
        guard text == transformedText else {
            // If this method is called as a result of using apple's autocomplete suggestion bar
            // there is a bug where setting the UITextView's text will trigger another call of this delegate
            // method. Inputting text any other way suppresses calls to this delegate method as a result
            // of changes to the text within the method itself. To work around this apple bug, keep track of
            // re-entrancy manually and suppress it ourselves.
            updatingTextViewText = true
            textView.text = transformedText
            textView.delegate?.textViewDidChange?(textView)
            updatingTextViewText = false
            return false
        }

        return true
    }

    func textViewDidChange(_ textView: UITextView) {
        // If you swipe type, a space is inserted between words, by putting that space
        // before the subsequent word in the `shouldChangeTextIn: range:` method.
        // If you swipe type and then tap a single letter, `shouldChangeTextIn:` only gets
        // the letters, NOT the space, but the NSConcreteTextStorage _somehow_ gets that
        // space. In order to avoid this leading to discrepancies between `self.text` and
        // the text being displayed, we sync the two up here, after the space has been applied.
        self.text = transformedText(textView.text ?? "", for: textStyle)
        adjustFontSizeIfNecessary()
        validateTextViewAttributes()
        delegate?.textStoryComposerDidChange(self)
        setNeedsLayout()
    }

    // MARK: - Link Preview

    fileprivate var linkPreviewDraft: OWSLinkPreviewDraft? {
        didSet {
            if let linkPreviewDraft {
                let state: LinkPreviewState
                if let callLink = CallLink(url: linkPreviewDraft.url) {
                    state = LinkPreviewCallLink(previewType: .draft(linkPreviewDraft), callLink: callLink)
                } else {
                    state = LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)
                }
                linkPreview = state
            } else {
                linkPreview = nil
            }
            delegate?.textStoryComposerDidChange(self)
        }
    }

    private lazy var deleteLinkPreviewButton: UIButton = {
        let button = RoundMediaButton(image: Theme.iconImage(.buttonX), backgroundStyle: .blurLight)
        button.tintColor = Theme.lightThemePrimaryColor
        button.ows_contentEdgeInsets = UIEdgeInsets(margin: 8)
        button.layoutMargins = UIEdgeInsets(margin: 2)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(didTapDeleteLinkPreviewButton), for: .touchUpInside)
        return button
    }()

    override func reloadLinkPreviewAppearance() {
        super.reloadLinkPreviewAppearance()

        guard let linkPreviewView else { return }

        if deleteLinkPreviewButton.superview == nil {
            linkPreviewWrapperView.addSubview(deleteLinkPreviewButton)
        }
        linkPreviewWrapperView.bringSubviewToFront(deleteLinkPreviewButton)
        linkPreviewWrapperView.addConstraints([
            deleteLinkPreviewButton.centerXAnchor.constraint(equalTo: linkPreviewView.trailingAnchor, constant: -5),
            deleteLinkPreviewButton.centerYAnchor.constraint(equalTo: linkPreviewView.topAnchor, constant: 5),
        ])

        updateVisibilityOfComponents(animated: true)
    }

    @objc
    private func didTapDeleteLinkPreviewButton() {
        linkPreviewDraft = nil
    }

    // MARK: - Background

    private var currentBackgroundIndex = 0 {
        didSet {
            background = TextStoryComposerView.textBackgrounds[currentBackgroundIndex]
        }
    }

    private static var defaultBackground: TextAttachment.Background { textBackgrounds[0] }

    private static var textBackgrounds: [TextAttachment.Background] = [
        .color(.init(rgbHex: 0x688BD4)),
        .color(.init(rgbHex: 0x8687C1)),
        .color(.init(rgbHex: 0xB47F8C)),
        .color(.init(rgbHex: 0x899188)),
        .color(.init(rgbHex: 0x539383)),
        .gradient(.init(colors: [.init(rgbHex: 0x19A9FA), .init(rgbHex: 0x7097D7), .init(rgbHex: 0xD1998D), .init(rgbHex: 0xFFC369)])),
        .gradient(.init(colors: [.init(rgbHex: 0x4437D8), .init(rgbHex: 0x6B70DE), .init(rgbHex: 0xB774E0), .init(rgbHex: 0xFF8E8E)])),
        .gradient(.init(colors: [.init(rgbHex: 0x004044), .init(rgbHex: 0x2C5F45), .init(rgbHex: 0x648E52), .init(rgbHex: 0x93B864)])),
    ]

    func switchToNextBackground() {
        var nextBackgroundIndex = currentBackgroundIndex + 1
        if nextBackgroundIndex > TextStoryComposerView.textBackgrounds.count - 1 {
            nextBackgroundIndex = 0
        }
        currentBackgroundIndex = nextBackgroundIndex
    }
}