Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Calls/UserInterface/GroupCallViewController.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Combine
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import SwiftUI
import UIKit

// MARK: - GroupCallViewController

// TODO: Eventually add 1:1 call support to this view
// and replace CallViewController
final class GroupCallViewController: UIViewController {

    // MARK: Properties

    private let call: SignalCall
    private let groupCall: GroupCall
    private let ringRtcCall: SignalRingRTC.GroupCall
    private lazy var callControlsConfirmationToastManager = CallControlsConfirmationToastManager(
        presentingContainerView: callControlsConfirmationToastContainerView,
    )
    private lazy var bottomSheet: CallDrawerSheet = {
        let dataSource: any CallDrawerSheetDataSource = switch groupCall.concreteType {
        case .groupThread(let groupThreadCall):
            GroupCallSheetDataSource(groupCall: groupThreadCall)
        case .callLink(let callLinkCall):
            GroupCallSheetDataSource(groupCall: callLinkCall)
        }
        return CallDrawerSheet(
            call: call,
            callSheetDataSource: dataSource,
            callService: callService,
            confirmationToastManager: callControlsConfirmationToastManager,
            callControlsDelegate: self,
            sheetPanDelegate: self,
            callDrawerDelegate: self,
        )
    }()

    private lazy var fullscreenLocalMemberAddOnsView = SupplementalCallControlsForFullscreenLocalMember(
        call: call,
        groupCall: groupCall,
        callService: callService,
    )
    private lazy var callControlsConfirmationToastContainerView = UIView()
    private var callService: CallService { AppEnvironment.shared.callService }
    private var incomingCallControls: IncomingCallControls?
    private lazy var callHeader = CallHeader(groupCall: groupCall, delegate: self)
    private lazy var notificationView = GroupCallNotificationView(groupCall: groupCall)

    /// A UIStackView which allows taps on its subviews, but passes taps outside of those or in explicitly ignored views through to the parent.
    private class PassthroughStackView: UIStackView {
        var ignoredViews: WeakArray<UIView> = []

        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            // super.hitTest will return the deepest view hit, so if it's
            // just `self`, the highest view, that means a subview wasn't hit.
            let hitView = super.hitTest(point, with: event)
            if let hitView, hitView == self || ignoredViews.contains(hitView) {
                return nil
            }
            return hitView
        }
    }

    /// A container view which allows taps in the given height range, but
    /// passes through taps outside of it.
    private class ApprovalStackContainerView: UIView {
        var stackHeight: CGFloat = 0

        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            let isPointInStack = (self.height - point.y) <= stackHeight
            if isPointInStack {
                return super.hitTest(point, with: event)
            }
            return nil
        }
    }

    private let bottomVStack = PassthroughStackView()
    private let videoOverflowContainer = UIView()
    private let raisedHandsToastContainer = UIView()
    private lazy var raisedHandsToast = RaisedHandsToast(call: self.groupCall)
    private lazy var remoteMuteToast = RemoteMuteToast(call: self.groupCall)

    private var approvalRequestActionsSubscription: AnyCancellable?
    private lazy var callLinkApprovalViewModel: CallLinkApprovalViewModel = {
        let viewModel = CallLinkApprovalViewModel()

        approvalRequestActionsSubscription = viewModel.performRequestAction
            .sink { [weak self] action, request in
                guard let self else { return }
                switch action {
                case .approve:
                    self.ringRtcCall.approveUser(request.aci.rawUUID)
                case .deny:
                    self.ringRtcCall.denyUser(request.aci.rawUUID)
                case .viewDetails:
                    self.presentApprovalRequestDetails(approvalRequest: request)
                }
            }

        return viewModel
    }()

    private let approvalStackContainer = ApprovalStackContainerView()

    /// The `UIHostingController` with the approval request views in a stack.
    private lazy var approvalStack = UIHostingController(rootView: VStack {
        Spacer()
        ApprovalRequestStack(
            viewModel: self.callLinkApprovalViewModel,
            didTapMore: { [weak self] requests in
                self?.presentBulkApprovalSheet()
            },
            didChangeHeight: { [weak self] height in
                self?.approvalStackHeightConstraint?.constant = height
                self?.approvalStackContainer.stackHeight = height
                self?.updateCallUI(shouldAnimateViewFrames: true)
            },
        )
    })
    /// A view used in `bottomVStack` that takes the height of the approval stack. Does not actually hold any content.
    private let approvalStackHeightView = UIView()

    private lazy var callLinkLobbyToastLabel = UILabel()
    private lazy var callLinkLobbyToast: UIView = {
        let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
        backgroundView.layer.cornerRadius = 10
        backgroundView.clipsToBounds = true
        backgroundView.contentView.addSubview(callLinkLobbyToastLabel)
        backgroundView.contentView.layoutMargins = .init(margin: 12)
        callLinkLobbyToastLabel.autoPinEdgesToSuperviewMargins()
        callLinkLobbyToastLabel.font = .dynamicTypeFootnote
        callLinkLobbyToastLabel.textColor = .white
        callLinkLobbyToastLabel.textAlignment = .center
        callLinkLobbyToastLabel.numberOfLines = 0

        return backgroundView
    }()

    private lazy var videoGrid: GroupCallVideoGrid = {
        let result = GroupCallVideoGrid(call: call, groupCall: groupCall)
        result.memberViewErrorPresenter = self
        return result
    }()

    private lazy var videoOverflow: GroupCallVideoOverflow = {
        let result = GroupCallVideoOverflow(call: call, groupCall: groupCall, delegate: self)
        result.memberViewErrorPresenter = self
        return result
    }()

    private lazy var speakerView: CallMemberView = {
        let result = CallMemberView(type: .remoteInGroup(.speaker))
        result.errorPresenter = self
        return result
    }()

    private lazy var localMemberView: CallMemberView = {
        let result = CallMemberView(type: .local)
        result.errorPresenter = self
        result.animatableLocalMemberViewDelegate = self
        return result
    }()

    private var didUserEverSwipeToSpeakerView: Bool
    private var didUserEverSwipeToScreenShare: Bool
    private let swipeToastView = GroupCallSwipeToastView()

    private let speakerPage = UIView()

    private let scrollView = UIScrollView()

    private enum Page {
        case grid
        case speaker
    }

    private var page: Page = .grid {
        didSet {
            guard page != oldValue else { return }
            videoOverflow.reloadData()
            updateCallUI(shouldAnimateViewFrames: true)
            ImpactHapticFeedback.impactOccurred(style: .light)
        }
    }

    private let incomingReactionsView = IncomingReactionsView()

    private var isCallMinimized = false {
        didSet {
            speakerView.isCallMinimized = isCallMinimized
            scheduleBottomSheetTimeoutIfNecessary()
        }
    }

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

            // Scroll to speaker view when presenting begins.
            if isAnyRemoteDeviceScreenSharing {
                isAutoScrollingToScreenShare = true
                scrollView.setContentOffset(CGPoint(x: 0, y: speakerPage.frame.origin.y), animated: true)
            }
        }
    }

    private lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTouchRootView))
    private lazy var bottomVStackTopConstraint = self.bottomVStack.autoPinEdge(.bottom, to: .top, of: self.view)
    private lazy var videoOverflowTrailingConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .trailing)
    private var approvalStackHeightConstraint: NSLayoutConstraint?

    private lazy var bottomSheetStateManager: GroupCallBottomSheetStateManager = {
        return GroupCallBottomSheetStateManager(delegate: self)
    }()

    private var hasUnresolvedSafetyNumberMismatch = false
    private var hasDismissed = false

    private var membersAtJoin: Set<SignalServiceAddress>?

    private static let keyValueStore = KeyValueStore(collection: "GroupCallViewController")
    private static let didUserSwipeToSpeakerViewKey = "didUserSwipeToSpeakerView"
    private static let didUserSwipeToScreenShareKey = "didUserSwipeToScreenShare"

    /// When the local member view (which is displayed picture-in-picture) is
    /// tapped, it expands. If the frame is expanded, its enlarged frame is
    /// stored here. If the pip is not in the expanded state, this value is nil.
    private var expandedPipFrame: CGRect?

    /// Whether the local member view pip has an animation currently in progress.
    private var isPipAnimationInProgress = false

    /// Whether a relayout needs to occur after the pip animation completes.
    /// This is true when we suspended an attempted relayout triggered during
    /// the pip animation.
    private var shouldRelayoutAfterPipAnimationCompletes = false
    private var postAnimationUpdateMemberViewFramesSize: CGSize?

    private lazy var reactionsBurstView: ReactionsBurstView = {
        ReactionsBurstView(burstAligner: self.incomingReactionsView)
    }()

    private lazy var reactionsSink: ReactionsSink = {
        ReactionsSink(reactionReceivers: [
            self.incomingReactionsView,
            self.reactionsBurstView,
        ])
    }()

    private lazy var callControlsOverflowView: CallControlsOverflowView = {
        return CallControlsOverflowView(
            call: self.call,
            reactionSender: self.ringRtcCall,
            reactionsSink: self.reactionsSink,
            raiseHandSender: self.ringRtcCall,
            emojiPickerSheetPresenter: self.bottomSheet,
            callControlsOverflowPresenter: self,
        )
    }()

    private var callControlsOverflowBottomConstraint: NSLayoutConstraint?
    private var callControlsConfirmationToastContainerViewBottomConstraint: NSLayoutConstraint?

    static func load(call: SignalCall, groupCall: GroupCall, tx: DBReadTransaction) -> GroupCallViewController {
        let didUserEverSwipeToSpeakerView = keyValueStore.getBool(
            didUserSwipeToSpeakerViewKey,
            defaultValue: false,
            transaction: tx,
        )
        let didUserEverSwipeToScreenShare = keyValueStore.getBool(
            didUserSwipeToScreenShareKey,
            defaultValue: false,
            transaction: tx,
        )

        let phoneNumberSharingMode = SSKEnvironment.shared.udManagerRef.phoneNumberSharingMode(tx: tx).orDefault

        return GroupCallViewController(
            call: call,
            groupCall: groupCall,
            didUserEverSwipeToSpeakerView: didUserEverSwipeToSpeakerView,
            didUserEverSwipeToScreenShare: didUserEverSwipeToScreenShare,
            phoneNumberSharingMode: phoneNumberSharingMode,
        )
    }

    init(
        call: SignalCall,
        groupCall: GroupCall,
        didUserEverSwipeToSpeakerView: Bool,
        didUserEverSwipeToScreenShare: Bool,
        phoneNumberSharingMode: PhoneNumberSharingMode,
    ) {
        // TODO: Eventually unify UI for group and individual calls

        self.call = call
        self.groupCall = groupCall
        self.ringRtcCall = groupCall.ringRtcCall
        self.didUserEverSwipeToSpeakerView = didUserEverSwipeToSpeakerView
        self.didUserEverSwipeToScreenShare = didUserEverSwipeToScreenShare

        super.init(nibName: nil, bundle: nil)

        groupCall.addObserver(self)
        groupCall.addObserver(AppEnvironment.shared.callLinkProfileKeySharingManager)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didBecomeActive),
            name: UIApplication.didBecomeActiveNotification,
            object: nil,
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didCompleteAnySpamChallenge),
            name: SpamChallengeResolver.didCompleteAnyChallenge,
            object: nil,
        )

        self.callLinkLobbyToastLabel.text = switch phoneNumberSharingMode {
        case .everybody:
            OWSLocalizedString(
                "CALL_LINK_LOBBY_SHARING_INFO_PHONE_NUMBER_SHARING_ON",
                comment: "Text that appears on a toast in a call lobby before joining a call link informing the user what information will be shared with other call members when they have phone number sharing turned on.",
            )
        case .nobody:
            OWSLocalizedString(
                "CALL_LINK_LOBBY_SHARING_INFO_PHONE_NUMBER_SHARING_OFF",
                comment: "Text that appears on a toast in a call lobby before joining a call link informing the user what information will be shared with other call members when they have phone number sharing turned off.",
            )
        }
    }

    static func presentLobby(forGroupId groupId: GroupIdentifier, videoMuted: Bool = false) {
        self._presentLobby { viewController, modalViewController -> (() -> Void)? in
            return await self._prepareLobby(
                from: viewController,
                modalViewController: modalViewController,
                shouldAskForCameraPermission: !videoMuted,
                buildAndStartConnecting: {
                    let callService = AppEnvironment.shared.callService!
                    return callService.buildAndConnectGroupCall(for: groupId, isVideoMuted: videoMuted)
                },
            )
        }
    }

    static func presentLobby(
        for callLink: CallLink,
        callLinkStateRetrievalStrategy: CallService.CallLinkStateRetrievalStrategy = .fetch,
    ) {
        self._presentLobby { viewController, modalViewController in
            do {
                return try await self._prepareLobby(
                    from: viewController,
                    modalViewController: modalViewController,
                    shouldAskForCameraPermission: true,
                    buildAndStartConnecting: {
                        let callService = AppEnvironment.shared.callService!
                        return try await callService.buildAndConnectCallLinkCall(
                            callLink: callLink,
                            callLinkStateRetrievalStrategy: callLinkStateRetrievalStrategy,
                        )
                    },
                )
            } catch {
                Logger.warn("Call link lobby presentation failed with error \(error)")
                return {
                    OWSActionSheets.showActionSheet(
                        title: CallStrings.callLinkErrorSheetTitle,
                        message: OWSLocalizedString(
                            "CALL_LINK_JOIN_CALL_FAILURE_SHEET_DESCRIPTION",
                            comment: "Description of sheet presented when joining call from call link sheet fails.",
                        ),
                    )
                }
            }
        }
    }

    private static func _presentLobby(
        prepareLobby: @escaping @MainActor (_ viewController: UIViewController, _ modalViewController: UIViewController) async -> (() -> Void)?,
    ) {
        guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
            owsFail("Can't start a call if there's no view controller")
        }

        ModalActivityIndicatorViewController.present(
            fromViewController: frontmostViewController,
            title: CommonStrings.preparingModal,
            canCancel: false,
            presentationDelay: 0.25,
            asyncBlock: { modal in
                let presentLobbyOrError = await prepareLobby(frontmostViewController, modal)
                modal.dismissIfNotCanceled(completionIfNotCanceled: presentLobbyOrError ?? {})
            },
        )
    }

    private static func _prepareLobby(
        from viewController: UIViewController,
        modalViewController: UIViewController,
        shouldAskForCameraPermission: Bool,
        buildAndStartConnecting: () async throws -> (SignalCall, GroupCall)?,
    ) async rethrows -> (() -> Void)? {
        do throws(CallStarter.PrepareToStartCallError) {
            _ = try await CallStarter.prepareToStartCall(
                from: modalViewController,
                shouldAskForCameraPermission: shouldAskForCameraPermission,
            )
        } catch {
            return {
                CallStarter.showPrepareToStartCallError(error, from: viewController)
            }
        }

        guard let (call, groupCall) = try await buildAndStartConnecting() else {
            owsFailDebug("Can't show lobby if the call can't start")
            return nil
        }

        let vc = SSKEnvironment.shared.databaseStorageRef.read { tx in
            return GroupCallViewController.load(call: call, groupCall: groupCall, tx: tx)
        }

        return {
            vc.modalTransitionStyle = .crossDissolve
            AppEnvironment.shared.windowManagerRef.startCall(viewController: vc)
        }
    }

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

    // MARK: Lifecycle

    override func loadView() {
        view = UIView()
        view.clipsToBounds = true

        view.backgroundColor = .ows_black

        scrollView.delegate = self
        view.addSubview(scrollView)
        scrollView.isPagingEnabled = true
        scrollView.showsVerticalScrollIndicator = false
        scrollView.contentInsetAdjustmentBehavior = .never
        scrollView.alwaysBounceVertical = false
        scrollView.autoPinEdgesToSuperviewEdges()

        view.addSubview(callHeader)
        callHeader.autoPinWidthToSuperview()
        callHeader.autoPinEdge(toSuperviewEdge: .top)

        view.addSubview(notificationView)
        notificationView.autoPinEdgesToSuperviewEdges()

        view.addSubview(self.bottomVStack)
        self.bottomVStack.autoPinWidthToSuperview()
        self.bottomVStack.axis = .vertical
        self.bottomVStack.spacing = Constants.bottomVStackSpacing
        self.bottomVStack.preservesSuperviewLayoutMargins = true
        self.bottomVStack.alignment = .center
        self.bottomVStack.ignoredViews.append(fullscreenLocalMemberAddOnsView)

        switch groupCall.concreteType {
        case .groupThread:
            break
        case .callLink:
            // Lobby text
            self.bottomVStack.addArrangedSubview(self.callLinkLobbyToast)
            self.callLinkLobbyToast.autoPinWidthToSuperviewMargins()

            // Approvals
            self.addChild(self.approvalStack)

            approvalStackContainer.addSubview(self.approvalStack.view)
            self.approvalStack.view.autoPinEdgesToSuperviewEdges()
            self.view.addSubview(approvalStackContainer)
            self.approvalStack.view.backgroundColor = .clear
            self.approvalStack.didMove(toParent: self)

            // If passthroughView changed height to match the height of its content,
            // the SwiftUI content would jump around as the UIView's height changes,
            // so instead, make it taller than it needs, and pin its bottom to a
            // placeholder view that adjusts height based on the content.
            self.bottomVStack.addArrangedSubview(self.approvalStackHeightView)
            self.approvalStackHeightConstraint = self.approvalStackHeightView
                .autoSetDimension(.height, toSize: 0)
            self.pinWidthWithBottomSheetMaxWidth(approvalStackContainer)
            approvalStackContainer.autoHCenterInSuperview()
            approvalStackContainer.autoSetDimension(.height, toSize: 300)
            approvalStackContainer.autoPinEdge(.bottom, to: .bottom, of: self.approvalStackHeightView)
        }

        videoOverflowContainer.addSubview(self.videoOverflow)
        self.bottomVStack.addArrangedSubview(videoOverflowContainer)
        self.bottomVStack.ignoredViews.append(videoOverflowContainer)
        self.videoOverflowContainer.autoPinWidthToSuperview()
        self.videoOverflow.autoPinHeightToSuperview()
        self.videoOverflow.autoPinEdge(toSuperviewEdge: .leading)

        self.bottomVStack.insertArrangedSubview(raisedHandsToastContainer, at: 0)
        self.bottomVStack.ignoredViews.append(raisedHandsToastContainer)

        raisedHandsToastContainer.layoutMargins = .init(margin: 0)
        raisedHandsToastContainer.preservesSuperviewLayoutMargins = true
        raisedHandsToastContainer.isHiddenInStackView = true

        raisedHandsToastContainer.addSubview(raisedHandsToast)
        self.pinWidthWithBottomSheetMaxWidth(raisedHandsToastContainer)

        raisedHandsToast.autoPinEdges(toSuperviewMarginsExcludingEdge: .leading)
        raisedHandsToast.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual)
        raisedHandsToast.horizontalPinConstraint = raisedHandsToast.autoPinEdge(toSuperviewMargin: .leading)
        raisedHandsToast.delegate = self

        view.addSubview(remoteMuteToast)
        remoteMuteToast.autoPinEdgesToSuperviewEdges()

        scrollView.addSubview(videoGrid)
        scrollView.addSubview(speakerPage)

        view.addSubview(incomingReactionsView)
        incomingReactionsView.autoPinEdge(.leading, to: .leading, of: view, withOffset: 22)
        incomingReactionsView.autoPinEdge(.bottom, to: .top, of: self.bottomVStack, withOffset: -16)
        incomingReactionsView.widthAnchor.constraint(equalToConstant: IncomingReactionsView.Constants.viewWidth).isActive = true
        incomingReactionsView.heightAnchor.constraint(equalToConstant: IncomingReactionsView.viewHeight).isActive = true

        scrollView.addSubview(swipeToastView)
        swipeToastView.autoPinEdge(.bottom, to: .bottom, of: videoGrid, withOffset: -22)
        swipeToastView.autoHCenterInSuperview()
        swipeToastView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual)
        swipeToastView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual)

        view.addSubview(callControlsConfirmationToastContainerView)
        callControlsConfirmationToastContainerView.autoHCenterInSuperview()
        view.addSubview(callControlsOverflowView)
        callControlsOverflowView.isHidden = true
        if UIDevice.current.isIPad {
            callControlsOverflowView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
        } else {
            callControlsOverflowView.autoPinEdge(
                .trailing,
                to: .trailing,
                of: view,
                withOffset: -12,
            )
        }

        self.callControlsConfirmationToastContainerViewBottomConstraint = callControlsConfirmationToastContainerView.autoPinEdge(
            .bottom,
            to: .bottom,
            of: self.view,
            withOffset: callControlsConfirmationToastContainerViewBottomConstraintConstant,
        )
        self.callControlsOverflowBottomConstraint = self.callControlsOverflowView.autoPinEdge(
            .bottom,
            to: .bottom,
            of: self.view,
            withOffset: callControlsOverflowBottomConstraintConstant,
        )

        view.addSubview(reactionsBurstView)
        reactionsBurstView.autoPinEdgesToSuperviewEdges()

        view.addGestureRecognizer(tapGesture)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(otherUsersProfileChanged(notification:)),
            name: UserProfileNotifications.otherUsersProfileDidChange,
            object: nil,
        )
    }

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

        let wasOnSpeakerPage = self.page == .speaker

        coordinator.animate(alongsideTransition: { _ in
            self.updateCallUI(size: size)
            self.videoGrid.reloadData()
            self.videoOverflow.reloadData()
            self.scrollView.contentOffset = wasOnSpeakerPage ? CGPoint(x: 0, y: size.height) : .zero
        }, completion: nil)
    }

    private var hasAppeared = false
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        guard !hasAppeared else { return }
        hasAppeared = true

        callService.sendInitialPhoneOrientationNotification()

        if let splitViewSnapshot = SignalApp.shared.snapshotSplitViewController(afterScreenUpdates: false) {
            view.superview?.insertSubview(splitViewSnapshot, belowSubview: view)
            splitViewSnapshot.autoPinEdgesToSuperviewEdges()

            view.transform = .scale(1.5)
            view.alpha = 0

            UIView.animate(withDuration: 0.2, animations: {
                self.view.alpha = 1
                self.view.transform = .identity
            }) { _ in
                splitViewSnapshot.removeFromSuperview()
            }
        }
    }

    private var isReadyToHandleObserver = false
    override func viewIsAppearing(_ animated: Bool) {
        super.viewIsAppearing(animated)

        self.isReadyToHandleObserver = true

        updateCallUI()
    }

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

        if hasUnresolvedSafetyNumberMismatch, CurrentAppContext().isAppForegroundAndActive() {
            // If we're not active yet, this will be handled by the `didBecomeActive` callback.
            resolveSafetyNumberMismatch()
        }
    }

    override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
        super.present(viewControllerToPresent, animated: flag, completion: completion)
        scheduleBottomSheetTimeoutIfNecessary()
    }

    private var bottomSheetIsPresented: Bool {
        bottomSheet.presentingViewController != nil
    }

    private func presentBottomSheet() {
        guard !bottomSheetIsPresented else { return }
        bottomSheet.setBottomSheetMinimizedHeight()
        present(self.bottomSheet, animated: true)
    }

    private func dismissBottomSheet(animated: Bool = true) {
        guard bottomSheetIsPresented else { return }
        bottomSheet.dismiss(animated: animated)
    }

    @objc
    private func didBecomeActive() {
        if hasUnresolvedSafetyNumberMismatch {
            resolveSafetyNumberMismatch()
        }
    }

    @objc
    private func didCompleteAnySpamChallenge() {
        AppEnvironment.shared.callLinkProfileKeySharingManager.sendProfileKeyToParticipants(ofCall: self.groupCall)
        self.ringRtcCall.resendMediaKeys()
    }

    // MARK: Call members

    private func updateScrollViewFrames(size: CGSize? = nil) {
        view.layoutIfNeeded()

        let size = size ?? view.frame.size

        if !self.hasAtLeastTwoOthers {
            videoGrid.frame = .zero
            videoGrid.isHidden = true
            speakerPage.frame = CGRect(
                x: 0,
                y: 0,
                width: size.width,
                height: size.height,
            )
            scrollView.contentSize = size
            scrollView.contentOffset = .zero
            scrollView.isScrollEnabled = false
        } else {
            let wasVideoGridHidden = videoGrid.isHidden
            let hasOverflowMembersInGridView = videoGrid.maxItems < ringRtcCall.remoteDeviceStates.count
            let overflowGridHeight = hasOverflowMembersInGridView ? videoOverflow.height + 27 : 0

            scrollView.isScrollEnabled = true
            videoGrid.isHidden = false
            let height: CGFloat
            let offset: CGFloat
            switch bottomSheetStateManager.bottomSheetState {
            case .callControlsAndOverflow, .callControls, .callInfo, .transitioning:
                offset = self.bottomSheet.minimizedHeight
            case .hidden:
                offset = 16
            }
            height = size.height - view.safeAreaInsets.top - offset - overflowGridHeight
            videoGrid.frame = CGRect(
                x: 0,
                y: view.safeAreaInsets.top,
                width: size.width,
                height: height,
            )
            speakerPage.frame = CGRect(
                x: 0,
                y: size.height,
                width: size.width,
                height: size.height,
            )
            scrollView.contentSize = CGSize(width: size.width, height: size.height * 2)

            if wasVideoGridHidden {
                scrollView.contentOffset = .zero
            }
        }
    }

    func updateVideoOverflowTrailingConstraint() {
        var trailingConstraintConstant = -(GroupCallVideoOverflow.itemHeight * ReturnToCallViewController.inherentPipSize.aspectRatio + 4)
        if view.width + trailingConstraintConstant > videoOverflow.contentSize.width {
            trailingConstraintConstant += 16
        }
        videoOverflowTrailingConstraint.constant = trailingConstraintConstant
        view.layoutIfNeeded()
    }

    @discardableResult
    private func pinWidthWithBottomSheetMaxWidth(_ view: UIView) -> [NSLayoutConstraint] {
        let maxWidthConstraint = view.autoSetDimension(
            .width,
            toSize: bottomSheet.maxWidth,
            relation: .lessThanOrEqual,
        )
        let edgesConstraints = view.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual)
        let edgesConstraints2 = view.autoPinWidthToSuperviewMargins(relation: .equal)
        edgesConstraints2.forEach { $0.priority = .defaultHigh }
        return [maxWidthConstraint] + edgesConstraints + edgesConstraints2
    }

    private var addOnsConstraints: [NSLayoutConstraint]?
    private func constrainAddOnsOutsideBottomVStack() {
        addOnsConstraints.map(fullscreenLocalMemberAddOnsView.removeConstraints(_:))
        addOnsConstraints = [
            fullscreenLocalMemberAddOnsView.autoPinLeadingToSuperviewMargin(),
            fullscreenLocalMemberAddOnsView.autoPinTrailingToSuperviewMargin(),
            fullscreenLocalMemberAddOnsView.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: Constants.flipCamButtonTrailingToSuperviewEdgePadding),
        ]
    }

    private func constrainAddOnsInsideBottomVStack() {
        addOnsConstraints.map(fullscreenLocalMemberAddOnsView.removeConstraints(_:))
        addOnsConstraints = pinWidthWithBottomSheetMaxWidth(fullscreenLocalMemberAddOnsView)
    }

    private func updateAddOnsViewPosition() {
        let canFitNextToDrawer = view.width >= bottomSheet.maxWidth + view.layoutMargins.totalWidth + view.layoutMargins.trailing + 48

        if canFitNextToDrawer {
            guard fullscreenLocalMemberAddOnsView.superview != view else { return }

            bottomVStack.removeArrangedSubview(fullscreenLocalMemberAddOnsView)
            view.addSubview(fullscreenLocalMemberAddOnsView)
            constrainAddOnsOutsideBottomVStack()
        } else {
            guard fullscreenLocalMemberAddOnsView.superview != bottomVStack else { return }

            fullscreenLocalMemberAddOnsView.removeFromSuperview()
            if
                case .callLink = groupCall.concreteType,
                let toastIndex = bottomVStack.arrangedSubviews.firstIndex(of: callLinkLobbyToast)
            {
                bottomVStack.insertArrangedSubview(fullscreenLocalMemberAddOnsView, at: toastIndex)
            } else {
                bottomVStack.addArrangedSubview(fullscreenLocalMemberAddOnsView)
            }
            constrainAddOnsInsideBottomVStack()
        }
    }

    private var shouldHideAddOnsView: Bool {
        !groupCall.isJustMe || (groupCall.isJustMe && call.isOutgoingVideoMuted) || hasDismissed
    }

    private func updateBottomVStackItems() {
        let hasRaisedHands = !self.raisedHandsToast.raisedHands.isEmpty
        self.raisedHandsToastContainer.isHiddenInStackView = !hasRaisedHands
        self.fullscreenLocalMemberAddOnsView.isHiddenInStackView = self.shouldHideAddOnsView
        self.updateAddOnsViewPosition()

        /// If there are no approval requests, `callLinkApprovalViewModel`'s height
        /// will be zero, but we don't want to hide it because the approval view
        /// itself is pinned to it, and we want it to retain its position when
        /// the last item animates out.
        let hasApprovalRequests: Bool = switch self.groupCall.concreteType {
        case .groupThread: false
        case .callLink: !self.callLinkApprovalViewModel.requests.isEmpty
        }

        let hasOverflowMembers = self.videoOverflow.hasOverflowMembers
        if hasOverflowMembers {
            // Move video overflow to bottom
            if self.bottomVStack.arrangedSubviews.last != self.videoOverflowContainer {
                self.bottomVStack.removeArrangedSubview(self.videoOverflowContainer)
                self.bottomVStack.addArrangedSubview(self.videoOverflowContainer)
            }
        } else {
            // Move video overflow to top
            if self.bottomVStack.arrangedSubviews.first != self.videoOverflowContainer {
                self.bottomVStack.removeArrangedSubview(self.videoOverflowContainer)
                self.bottomVStack.insertArrangedSubview(self.videoOverflowContainer, at: 0)
            }
        }

        enum Item { case raisedHands, approvals }
        func setSpacing(_ spacing: CGFloat, after item: Item) {
            let view: UIView = switch item {
            case .raisedHands: self.raisedHandsToastContainer
            case .approvals: self.approvalStackHeightView
            }
            self.bottomVStack.setCustomSpacing(spacing, after: view)
        }

        let overflowNeedsPadding = hasOverflowMembers && self.page == .grid
        switch (overflowNeedsPadding, hasRaisedHands, hasApprovalRequests) {
        case (false, _, true):
            setSpacing(Constants.bottomVStackSpacing, after: .raisedHands)
            setSpacing(Constants.bottomVStackSpacing, after: .approvals)
        case (false, _, false):
            setSpacing(Constants.bottomVStackSpacing, after: .raisedHands)
            setSpacing(0, after: .approvals)
        case (true, _, true):
            setSpacing(Constants.bottomVStackSpacing, after: .raisedHands)
            setSpacing(Constants.videoOverflowExtraSpacing, after: .approvals)
        case (true, true, false):
            setSpacing(Constants.videoOverflowExtraSpacing, after: .raisedHands)
            setSpacing(0, after: .approvals)
        case (true, false, false):
            // Raised hands view is hidden
            setSpacing(0, after: .approvals)
        }
    }

    private enum Constants {
        static let spacingTopRaiseHandToastToBottomLocalPip: CGFloat = 12
        static let flipCamButtonTrailingToSuperviewEdgePadding: CGFloat = 34
        static let bottomVStackSpacing: CGFloat = 8
        static let videoOverflowExtraSpacing: CGFloat = 24
    }

    private func updateMemberViewFrames(
        size: CGSize? = nil,
        shouldRepositionBottomVStack: Bool = true,
    ) {
        guard !isPipAnimationInProgress else {
            // Wait for the pip to reach its new size before re-laying out.
            // Otherwise the pip snaps back to its size at the start of the
            // animation, effectively undoing it. When the animation is
            // complete, we'll call `updateMemberViewFrames`.
            self.shouldRelayoutAfterPipAnimationCompletes = true
            self.postAnimationUpdateMemberViewFramesSize = size
            return
        }

        view.layoutIfNeeded()

        let size = size ?? view.frame.size

        let yMax: CGFloat
        if shouldRepositionBottomVStack {
            switch bottomSheetStateManager.bottomSheetState {
            case .callControlsAndOverflow, .callControls, .callInfo, .transitioning:
                yMax = size.height - bottomSheet.minimizedHeight - 16
            case .hidden:
                yMax = size.height - 32
            }
            bottomVStackTopConstraint.constant = yMax
        } else {
            yMax = bottomVStackTopConstraint.constant
        }

        updateVideoOverflowTrailingConstraint()

        localMemberView.applyChangesToCallMemberViewAndVideoView { view in
            view.removeFromSuperview()
        }

        speakerView.applyChangesToCallMemberViewAndVideoView { view in
            view.removeFromSuperview()
        }
        if groupCall.isJustMe {
            localMemberView.applyChangesToCallMemberViewAndVideoView { view in
                speakerPage.addSubview(view)
                view.frame = CGRect(origin: .zero, size: size)
            }
        } else {
            speakerView.applyChangesToCallMemberViewAndVideoView { view in
                speakerPage.addSubview(view)
                view.autoPinEdgesToSuperviewEdges()
            }

            localMemberView.applyChangesToCallMemberViewAndVideoView { aView in
                view.insertSubview(aView, belowSubview: callControlsConfirmationToastContainerView)
            }

            let pipSize = CallMemberView.pipSize(
                expandedPipFrame: self.expandedPipFrame,
                remoteDeviceCount: ringRtcCall.remoteDeviceStates.count,
            )

            let y: CGFloat
            if nil != expandedPipFrame {
                // Special case necessary because when the pip is
                // expanded, the pip height does not follow along
                // with that of the video overflow, which is tiny.
                if self.raisedHandsToastContainer.isHiddenInStackView || (self.videoOverflow.hasOverflowMembers && self.page == .grid) {
                    // Bottom of pip should align with bottom of overflow (whether the overflow is hidden or not).
                    y = yMax - pipSize.height
                } else {
                    // Bottom of pip should align with top of raised hand toast, plus padding.
                    y = yMax - pipSize.height - raisedHandsToastContainer.height - Constants.spacingTopRaiseHandToastToBottomLocalPip
                }
            } else {
                let overflowY = videoOverflow.convert(videoOverflow.bounds.origin, to: self.view).y
                let overflowPipHeightDifference = pipSize.height - videoOverflow.height
                y = overflowY - overflowPipHeightDifference
            }
            localMemberView.applyChangesToCallMemberViewAndVideoView { view in
                view.frame = CGRect(
                    x: size.width - pipSize.width - 16,
                    y: y,
                    width: pipSize.width,
                    height: pipSize.height,
                )
            }
            flipCameraTooltipManager.presentTooltipIfNecessary(
                fromView: self.view,
                widthReferenceView: self.view,
                tailReferenceView: localMemberView,
                tailDirection: .down,
                isVideoMuted: call.isOutgoingVideoMuted,
            )
        }
    }

    // MARK: Other UI

    private func updateSwipeToastView() {
        let isSpeakerViewAvailable = self.hasAtLeastTwoOthers
        guard isSpeakerViewAvailable else {
            swipeToastView.isHidden = true
            return
        }

        if isAnyRemoteDeviceScreenSharing {
            if didUserEverSwipeToScreenShare {
                swipeToastView.isHidden = true
                return
            }
        } else if didUserEverSwipeToSpeakerView {
            swipeToastView.isHidden = true
            return
        }

        swipeToastView.alpha = 1.0 - (scrollView.contentOffset.y / view.height)
        swipeToastView.text = isAnyRemoteDeviceScreenSharing
            ? OWSLocalizedString(
                "GROUP_CALL_SCREEN_SHARE_TOAST",
                comment: "Toast view text informing user about swiping to screen share",
            )
            : OWSLocalizedString(
                "GROUP_CALL_SPEAKER_VIEW_TOAST",
                comment: "Toast view text informing user about swiping to speaker view",
            )

        if scrollView.contentOffset.y >= view.height {
            swipeToastView.isHidden = true

            if isAnyRemoteDeviceScreenSharing {
                if !isAutoScrollingToScreenShare {
                    didUserEverSwipeToScreenShare = true
                    SSKEnvironment.shared.databaseStorageRef.asyncWrite { writeTx in
                        Self.keyValueStore.setBool(true, key: Self.didUserSwipeToScreenShareKey, transaction: writeTx)
                    }
                }
            } else {
                didUserEverSwipeToSpeakerView = true
                SSKEnvironment.shared.databaseStorageRef.asyncWrite { writeTx in
                    Self.keyValueStore.setBool(true, key: Self.didUserSwipeToSpeakerViewKey, transaction: writeTx)
                }
            }

        } else if swipeToastView.isHidden {
            swipeToastView.alpha = 0
            swipeToastView.isHidden = false
            UIView.animate(withDuration: 0.2, delay: 3.0, options: []) {
                self.swipeToastView.alpha = 1
            }
        }
    }

    private var flipCameraTooltipManager = FlipCameraTooltipManager(db: DependenciesBridge.shared.db)

    private var hasShownCallControls = false

    private func updateCallUI(
        size: CGSize? = nil,
        shouldAnimateViewFrames: Bool = false,
        bottomSheetChangedStateFrom oldBottomSheetState: BottomSheetState? = nil,
    ) {
        let isFullScreen = groupCall.isJustMe
        localMemberView.configure(
            call: call,
            isFullScreen: isFullScreen,
        )

        localMemberView.applyChangesToCallMemberViewAndVideoView { view in
            // In the context of `isCallInPip`, the "pip" refers to when the entire call is in a pip
            // (ie, minimized in the app). This is not to be confused with the local member view pip
            // (ie, when the call is full screen and the local user is displayed in a pip).
            // The following line disallows having a [local member] pip within a [call] pip.
            view.isHidden = !isJustMe && AppEnvironment.shared.windowManagerRef.isCallInPip
        }

        if let speakerState = ringRtcCall.remoteDeviceStates.sortedBySpeakerTime.first {
            speakerView.configure(
                call: call,
                remoteGroupMemberDeviceState: speakerState,
            )
        } else {
            speakerView.clearConfiguration()
        }

        guard !isCallMinimized else { return }

        if
            case .groupThread(let groupThreadCall) = groupCall.concreteType,
            groupThreadCall.groupCallRingState.isIncomingRing
        {
            dismissBottomSheet(animated: false)
            createIncomingCallControlsIfNeeded().isHidden = false
            // These views aren't visible at this point, but we need them to be configured anyway.
            updateMemberViewFrames(size: size)
            updateScrollViewFrames(size: size)
            return
        } else if !self.hasShownCallControls {
            self.presentBottomSheet()
            self.hasShownCallControls = true
        }

        if let incomingCallControls, !incomingCallControls.isHidden {
            // We were showing the incoming call controls, but now we don't want to.
            // To make sure all views transition properly, pretend we were showing the regular controls all along.
            presentBottomSheet()

            incomingCallControls.isHidden = true
        }

        self.callControlDisplayStateDidChange(
            oldState: oldBottomSheetState ?? self.bottomSheetStateManager.bottomSheetState,
            newState: self.bottomSheetStateManager.bottomSheetState,
            size: size,
            shouldAnimateViewFrames: shouldAnimateViewFrames,
        )

        // Update constraints that hug call controls sheet
        callControlsOverflowBottomConstraint?.constant = callControlsOverflowBottomConstraintConstant
        callControlsConfirmationToastContainerViewBottomConstraint?.constant = callControlsConfirmationToastContainerViewBottomConstraintConstant

        if groupCall.isJustMe {
            flipCameraTooltipManager.dismissTooltip()
        }

        updateSwipeToastView()
    }

    private var callControlsOverflowBottomConstraintConstant: CGFloat {
        -self.bottomSheet.minimizedHeight - 12
    }

    private var callControlsConfirmationToastContainerViewBottomConstraintConstant: CGFloat {
        return -self.bottomSheet.minimizedHeight - 16
    }

    private func callControlDisplayStateDidChange(
        oldState: BottomSheetState,
        newState: BottomSheetState,
        size: CGSize?,
        shouldAnimateViewFrames: Bool,
    ) {
        func updateFrames(controlsAreHidden: Bool, shouldRepositionBottomVStack: Bool = true) {
            let raisedHandsToastWasAlreadyHidden = self.raisedHandsToastContainer.isHidden

            let action: () -> Void = {
                self.updateBottomVStackItems()
                self.updateMemberViewFrames(
                    size: size,
                    shouldRepositionBottomVStack: shouldRepositionBottomVStack,
                )
                self.updateScrollViewFrames(size: size)
            }
            let completion: () -> Void = {
                if
                    self.raisedHandsToast.raisedHands.isEmpty,
                    !raisedHandsToastWasAlreadyHidden
                {
                    self.raisedHandsToast.wasHidden()
                }
            }

            if shouldAnimateViewFrames {
                let animator = UIViewPropertyAnimator(duration: 0.3, springDamping: 1, springResponse: 0.3)
                animator.addAnimations(action)
                animator.addCompletion { _ in
                    completion()
                }
                animator.startAnimation()
            } else {
                action()
                completion()
            }
        }

        switch oldState {
        case .callControlsAndOverflow:
            switch newState {
            case .callControlsAndOverflow:
                updateFrames(controlsAreHidden: false)
            case .callControls:
                self.callControlsOverflowView.animateOut()
                updateFrames(controlsAreHidden: false)
            case .hidden:
                // This can happen if you tap the root view fast enough in succession.
                animateCallControls(
                    hideCallControls: true,
                    size: size,
                )
                self.callControlsOverflowView.animateOut()
            case .callInfo, .transitioning:
                self.callControlsOverflowView.animateOut()
            }
        case .callControls:
            switch newState {
            case .callControlsAndOverflow:
                self.callControlsOverflowView.animateIn()
                updateFrames(controlsAreHidden: false)
            case .callControls:
                updateFrames(controlsAreHidden: false)
            case .hidden:
                animateCallControls(
                    hideCallControls: true,
                    size: size,
                )
            case .callInfo, .transitioning:
                break
            }
        case .hidden:
            switch newState {
            case .callControlsAndOverflow:
                owsFailDebug("Impossible bottomSheetStateManager.bottomSheetState transition")
                // But if you must...
                animateCallControls(
                    hideCallControls: false,
                    size: size,
                )
                self.callControlsOverflowView.animateIn()
            case .callControls, .callInfo, .transitioning:
                animateCallControls(
                    hideCallControls: false,
                    size: size,
                )
            case .hidden:
                updateFrames(controlsAreHidden: true)
            }
        case .callInfo, .transitioning:
            switch newState {
            case .callControlsAndOverflow:
                self.callControlsOverflowView.animateIn()
            case .callControls:
                updateFrames(controlsAreHidden: false, shouldRepositionBottomVStack: false)
            case .callInfo, .transitioning:
                updateFrames(controlsAreHidden: true, shouldRepositionBottomVStack: false)
            case .hidden:
                owsFailDebug("Impossible bottomSheetStateManager.bottomSheetState transition")
            }
        }
    }

    private func animateCallControls(
        hideCallControls: Bool,
        size: CGSize?,
    ) {
        if hideCallControls {
            dismissBottomSheet()
        } else {
            bottomSheet.setBottomSheetMinimizedHeight()
            presentBottomSheet()
        }
        bottomSheet.transitionCoordinator?.animateAlongsideTransition(in: view, animation: { _ in
            self.callHeader.alpha = hideCallControls ? 0 : 1

            self.updateBottomVStackItems()
            self.updateMemberViewFrames(size: size)
            self.updateScrollViewFrames(size: size)
            self.view.layoutIfNeeded()
        }, completion: { _ in
            self.callHeader.isHidden = hideCallControls
            // If a hand is raised during this animation, the toast will be
            // positioned wrong unless this is called again in the completion.
            self.updateBottomVStackItems()

            if self.raisedHandsToast.raisedHands.isEmpty {
                self.raisedHandsToast.wasHidden()
            }
        })
        callHeader.isHidden = false
    }

    private func dismissCall(shouldHangUp: Bool = true) {
        if shouldHangUp {
            callService.callUIAdapter.localHangupCall(call)
        }
        didHangupCall()
    }

    private func didHangupCall() {
        guard !hasDismissed else {
            return
        }
        hasDismissed = true

        guard self.isViewLoaded else {
            // This can happen if the call is canceled before it's ever shown (ie a
            // ring that's not answered).
            AppEnvironment.shared.windowManagerRef.endCall(viewController: self)
            return
        }

        bottomSheetStateManager.submitState(.callControls)
        self.raisedHandsToast.raisedHands.removeAll()
        self.callLinkApprovalViewModel.requests.removeAll()

        guard
            let splitViewSnapshot = SignalApp.shared.snapshotSplitViewController(afterScreenUpdates: false),
            view.superview?.insertSubview(splitViewSnapshot, belowSubview: view) != nil
        else {
            // This can happen if we're in the background when the call is dismissed (say, from CallKit).
            AppEnvironment.shared.windowManagerRef.endCall(viewController: self)
            return
        }

        splitViewSnapshot.autoPinEdgesToSuperviewEdges()

        bottomSheet.cancelAnimationAndUpdateConstraints()
        bottomSheet.dismiss(animated: true) { [self] in
            dismissSelf(splitViewSnapshot: splitViewSnapshot)
        }
    }

    private func dismissSelf(splitViewSnapshot: UIView) {
        UIView.animate(withDuration: 0.2, animations: {
            self.view.alpha = 0
        }) { _ in
            splitViewSnapshot.removeFromSuperview()
            AppEnvironment.shared.windowManagerRef.endCall(viewController: self)
        }
    }

    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }

    override var prefersHomeIndicatorAutoHidden: Bool {
        return true
    }

    private var hasAtLeastTwoOthers: Bool {
        switch ringRtcCall.localDeviceState.joinState {
        case .notJoined, .joining, .pending:
            return false
        case .joined:
            return ringRtcCall.remoteDeviceStates.count >= 2
        }
    }

    /// The view controller to present new view controllers from.
    private var presenter: UIViewController {
        presentedViewController ?? self
    }

    private func presentApprovalRequestDetails(approvalRequest: CallLinkApprovalRequest) {
        let presenter = self.presenter
        // Present request details on top of the bulk request sheet by checking
        // one layer deeper than `presenter`.
        let presentingViewController = presenter.presentedViewController ?? presenter
        CallLinkApprovalRequestDetailsSheet(
            approvalRequest: approvalRequest,
            approvalViewModel: self.callLinkApprovalViewModel,
        )
        .present(from: presentingViewController, dismissalDelegate: self)
    }

    private func presentBulkApprovalSheet() {
        CallLinkBulkApprovalSheet(viewModel: callLinkApprovalViewModel)
            .present(from: presenter, dismissalDelegate: self)
    }

    // MARK: - Drawer timeout

    @objc
    private func didTouchRootView(sender: UIGestureRecognizer) {
        switch self.bottomSheetStateManager.bottomSheetState {
        case .callControlsAndOverflow, .hidden:
            bottomSheetStateManager.submitState(.callControls)
        case .callControls:
            if bottomSheetMustBeVisible {
                return
            }
            bottomSheetStateManager.submitState(.hidden)
        case .callInfo:
            bottomSheetStateManager.submitState(.callControls)
            self.bottomSheet.minimizeHeight()
        case .transitioning:
            break
        }
    }

    private var bottomSheetMustBeVisible: Bool {
        return groupCall.isJustMe
    }

    private var sheetTimeoutTimer: Timer?
    private func scheduleBottomSheetTimeoutIfNecessary() {
        let shouldAutomaticallyDismissDrawer: Bool = {
            switch self.bottomSheetStateManager.bottomSheetState {
            case .callControlsAndOverflow, .hidden:
                return false
            case .callControls:
                break
            case .callInfo, .transitioning:
                return false
            }

            if bottomSheetMustBeVisible {
                return false
            }

            if isCallMinimized {
                return false
            }

            let isPresentingOtherSheet = presentedViewController != nil && presentedViewController != bottomSheet
            let otherSheetIsPresented = isPresentingOtherSheet || bottomSheet.presentedViewController != nil
            if otherSheetIsPresented {
                return false
            }

            return true
        }()

        guard shouldAutomaticallyDismissDrawer else {
            cancelBottomSheetTimeout()
            return
        }

        guard sheetTimeoutTimer == nil else { return }
        sheetTimeoutTimer = .scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
            self?.timeoutBottomSheet()
        }
    }

    private func timeoutBottomSheet() {
        self.sheetTimeoutTimer = nil
        bottomSheetStateManager.submitState(.hidden)
    }

    private func cancelBottomSheetTimeout() {
        sheetTimeoutTimer?.invalidate()
        sheetTimeoutTimer = nil
    }

    private func showCallControlsIfTheyMustBeVisible() {
        if bottomSheetMustBeVisible {
            showCallControlsIfHidden()
        }
    }

    private func showCallControlsIfHidden() {
        switch self.bottomSheetStateManager.bottomSheetState {
        case .callControlsAndOverflow, .callControls:
            break
        case .hidden:
            bottomSheetStateManager.submitState(.callControls)
        case .callInfo, .transitioning:
            break
        }
    }

    // MARK: - Ringing/Incoming Call Controls

    private func createIncomingCallControlsIfNeeded() -> IncomingCallControls {
        if let incomingCallControls {
            return incomingCallControls
        }
        let incomingCallControls = IncomingCallControls(
            isVideoCall: true,
            didDeclineCall: { [unowned self] in self.dismissCall() },
            didAcceptCall: { [unowned self] hasVideo in self.acceptRingingIncomingCall(hasVideo: hasVideo) },
        )
        self.view.addSubview(incomingCallControls)
        incomingCallControls.autoPinWidthToSuperview()
        incomingCallControls.autoPinEdge(toSuperviewEdge: .bottom)
        self.incomingCallControls = incomingCallControls
        return incomingCallControls
    }

    private func acceptRingingIncomingCall(hasVideo: Bool) {
        // Explicitly unmute video in order to request permissions as needed.
        // (Audio is unmuted as part of the call UI adapter.)

        callService.updateIsLocalVideoMuted(isLocalVideoMuted: !hasVideo)
        // When turning off video, default speakerphone to on.
        if !hasVideo, !callService.audioService.hasExternalInputs {
            callService.audioService.requestSpeakerphone(call: call, isEnabled: true)
        }

        callService.callUIAdapter.answerCall(call)
    }

    // MARK: Profile updates

    @objc
    private func otherUsersProfileChanged(notification: Notification) {
        AssertIsOnMainThread()

        guard
            let changedAddress = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress,
            changedAddress.isValid
        else {
            owsFailDebug("changedAddress was unexpectedly nil")
            return
        }

        if let peekInfo = self.ringRtcCall.peekInfo {
            let joinedAndPendingMembers = peekInfo.joinedMembers + peekInfo.pendingUsers

            if
                joinedAndPendingMembers.contains(where: { uuid in
                    changedAddress == SignalServiceAddress(Aci(fromUUID: uuid))
                })
            {
                self.bottomSheet.updateMembers()

                switch self.ringRtcCall.kind {
                case .signalGroup:
                    break
                case .callLink:
                    // Refresh profiles in call link admin approval UI.
                    self.callLinkApprovalViewModel.loadRequestsWithSneakyTransaction(for: peekInfo.pendingUsers)
                }
            }
        }
    }
}

// MARK: CallViewControllerWindowReference

extension GroupCallViewController: CallViewControllerWindowReference {
    var localVideoViewReference: CallMemberView { localMemberView }
    var remoteVideoViewReference: CallMemberView { speakerView }

    var remoteVideoAddress: SignalServiceAddress {
        guard let firstMember = ringRtcCall.remoteDeviceStates.sortedByAddedTime.first else {
            return DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!.aciAddress
        }
        return firstMember.address
    }

    var isJustMe: Bool {
        groupCall.isJustMe
    }

    func minimizeIfNeeded() {
        if !isCallMinimized {
            didTapBackButton()
        }
    }

    func returnFromPip(pipWindow: UIWindow) {
        // The call "pip" uses our remote and local video views since only
        // one `AVCaptureVideoPreviewLayer` per capture session is supported.
        // We need to re-add them when we return to this view.
        guard speakerView.superview != speakerPage, localMemberView.superview != view else {
            return owsFailDebug("unexpectedly returned to call while we own the video views")
        }

        guard let splitViewSnapshot = SignalApp.shared.snapshotSplitViewController(afterScreenUpdates: false) else {
            return owsFailDebug("failed to snapshot rootViewController")
        }

        guard let pipSnapshot = pipWindow.snapshotView(afterScreenUpdates: false) else {
            return owsFailDebug("failed to snapshot pip")
        }

        isCallMinimized = false

        showCallControlsIfHidden()

        animateReturnFromPip(pipSnapshot: pipSnapshot, pipFrame: pipWindow.frame, splitViewSnapshot: splitViewSnapshot)
    }

    func willMoveToPip(pipWindow: UIWindow) {
        flipCameraTooltipManager.dismissTooltip()
        localMemberView.applyChangesToCallMemberViewAndVideoView { view in
            if !isJustMe {
                view.isHidden = true
            } else {
                view.frame = CGRect(origin: .zero, size: pipWindow.bounds.size)
            }
        }
    }

    private func animateReturnFromPip(pipSnapshot: UIView, pipFrame: CGRect, splitViewSnapshot: UIView) {
        guard let window = view.window else { return owsFailDebug("missing window") }
        view.superview?.insertSubview(splitViewSnapshot, belowSubview: view)
        splitViewSnapshot.autoPinEdgesToSuperviewEdges()

        let originalContentOffset = scrollView.contentOffset

        view.frame = pipFrame
        view.addSubview(pipSnapshot)
        pipSnapshot.autoPinEdgesToSuperviewEdges()

        view.layoutIfNeeded()

        UIView.animate(withDuration: 0.2, animations: {
            pipSnapshot.alpha = 0
            self.view.frame = window.frame
            self.updateCallUI()
            self.videoGrid.reloadData()
            self.scrollView.contentOffset = originalContentOffset
            self.view.layoutIfNeeded()
        }) { _ in
            splitViewSnapshot.removeFromSuperview()
            pipSnapshot.removeFromSuperview()

            if self.hasUnresolvedSafetyNumberMismatch {
                self.resolveSafetyNumberMismatch()
            }
        }
    }

    private func safetyNumberMismatchAddresses(untrustedThreshold: Date?) -> [SignalServiceAddress] {
        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            let addressesToCheck: [SignalServiceAddress]
            if
                case .groupThread(let groupThreadCall) = groupCall.concreteType,
                ringRtcCall.localDeviceState.joinState == .notJoined
            {
                // If we haven't joined the call yet, we want to alert for all members of the group
                let groupThread = TSGroupThread.fetch(forGroupId: groupThreadCall.groupId, tx: transaction)
                addressesToCheck = groupThread!.recipientAddresses(with: transaction)
            } else {
                // If we are in the call, we only care about safety numbers for the active call participants
                addressesToCheck = ringRtcCall.remoteDeviceStates.map { $0.value.address }
            }

            let identityManager = DependenciesBridge.shared.identityManager
            return addressesToCheck.filter { memberAddress in
                identityManager.untrustedIdentityForSending(
                    to: memberAddress,
                    untrustedThreshold: untrustedThreshold,
                    tx: transaction,
                ) != nil
            }
        }
    }

    fileprivate func resolveSafetyNumberMismatch() {
        let resendMediaKeysAndResetMismatch = { [unowned self] in
            self.ringRtcCall.resendMediaKeys()
            self.hasUnresolvedSafetyNumberMismatch = false
        }

        if !isCallMinimized, CurrentAppContext().isAppForegroundAndActive() {
            presentSafetyNumberChangeSheetIfNecessary { [weak self] success in
                guard let self else { return }
                if success {
                    resendMediaKeysAndResetMismatch()
                } else {
                    self.dismissCall()
                }
            }
        } else {
            let unresolvedAddresses = safetyNumberMismatchAddresses(untrustedThreshold: nil)
            guard !unresolvedAddresses.isEmpty else {
                // Spurious warning, maybe from delayed callbacks.
                resendMediaKeysAndResetMismatch()
                return
            }

            // If a problematic member was present at join, leaves, and then joins again,
            // we'll still treat them as having been there "since join", but that's okay.
            // It's not worth trying to track this more precisely.
            let atLeastOneUnresolvedPresentAtJoin = unresolvedAddresses.contains { membersAtJoin?.contains($0) ?? false }
            switch groupCall.concreteType {
            case .groupThread(let call):
                let databaseStorage = SSKEnvironment.shared.databaseStorageRef
                let groupThread = databaseStorage.read { tx in
                    return TSGroupThread.fetch(forGroupId: call.groupId, tx: tx)
                }
                guard let groupThread else {
                    owsFail("Missing thread for active call.")
                }
                SSKEnvironment.shared.notificationPresenterRef.notifyForGroupCallSafetyNumberChange(
                    callTitle: groupThread.groupNameOrDefault,
                    threadUniqueId: groupThread.uniqueId,
                    roomId: nil,
                    presentAtJoin: atLeastOneUnresolvedPresentAtJoin,
                )
            case .callLink(let call):
                SSKEnvironment.shared.notificationPresenterRef.notifyForGroupCallSafetyNumberChange(
                    callTitle: call.callLinkState.localizedName,
                    threadUniqueId: nil,
                    roomId: call.callLink.rootKey.deriveRoomId(),
                    presentAtJoin: atLeastOneUnresolvedPresentAtJoin,
                )
            }
        }
    }

    fileprivate func presentSafetyNumberChangeSheetIfNecessary(untrustedThreshold: Date? = nil, completion: @escaping (Bool) -> Void) {
        let localDeviceHasNotJoined = ringRtcCall.localDeviceState.joinState == .notJoined
        let newUntrustedThreshold = Date()
        let addressesToAlert = safetyNumberMismatchAddresses(untrustedThreshold: untrustedThreshold)

        // There are no unverified addresses that we're currently concerned about. No need to show a sheet
        guard !addressesToAlert.isEmpty else { return completion(true) }

        if let existingSheet = (presentedViewController as? SafetyNumberConfirmationSheet) ?? (presentedViewController?.presentedViewController as? SafetyNumberConfirmationSheet) {
            // The set of untrusted addresses may have changed.
            // It's a bit clunky, but we'll just dismiss the existing sheet before putting up a new one.
            existingSheet.dismiss(animated: false)
        }

        let continueCallString = OWSLocalizedString("GROUP_CALL_CONTINUE_BUTTON", comment: "Button to continue an ongoing group call")
        let leaveCallString = OWSLocalizedString("GROUP_CALL_LEAVE_BUTTON", comment: "Button to leave a group call")
        let cancelString = CommonStrings.cancelButton

        let approveText: String
        let denyText: String
        if localDeviceHasNotJoined {
            approveText = CallControls.joinButtonLabel(for: call)
            denyText = cancelString
        } else {
            approveText = continueCallString
            denyText = leaveCallString
        }

        let sheet = SafetyNumberConfirmationSheet(
            addressesToConfirm: addressesToAlert,
            confirmationText: approveText,
            cancelText: denyText,
        ) { [weak self] didApprove in
            if let self, didApprove {
                self.presentSafetyNumberChangeSheetIfNecessary(untrustedThreshold: newUntrustedThreshold, completion: completion)
            } else {
                completion(false)
            }
        }
        sheet.overrideUserInterfaceStyle = .dark
        sheet.allowsDismissal = localDeviceHasNotJoined
        presenter.present(sheet, animated: true, completion: nil)
    }
}

// MARK: CallObserver

extension GroupCallViewController: GroupCallObserver {
    func groupCallLocalDeviceStateChanged(_ call: GroupCall) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }

        // It would be nice to animate more device state changes, but some
        // can cause unwanted animations, so only add them as tested.
        let addOnsViewVisibilityWillChange = shouldHideAddOnsView != fullscreenLocalMemberAddOnsView.isHiddenInStackView
        updateCallUI(shouldAnimateViewFrames: addOnsViewVisibilityWillChange)

        let isCallLink: Bool = switch groupCall.concreteType {
        case .groupThread:
            false
        case .callLink:
            true
        }

        Logger.debug("\(ringRtcCall.localDeviceState.joinState)\t\(hasDismissed)")

        switch ringRtcCall.localDeviceState.joinState {
        case .joined:
            if membersAtJoin == nil {
                membersAtJoin = Set(ringRtcCall.remoteDeviceStates.lazy.map { $0.value.address })
            }

            if isCallLink {
                callLinkLobbyToast.isHiddenInStackView = true
            }
        case .pending, .joining, .notJoined:
            membersAtJoin = nil
            if isCallLink, !hasDismissed {
                callLinkLobbyToast.isHiddenInStackView = false
            }
        }
    }

    func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }

        isAnyRemoteDeviceScreenSharing = ringRtcCall.remoteDeviceStates.values.first { $0.sharingScreen == true } != nil

        showCallControlsIfTheyMustBeVisible()

        updateCallUI()
        scheduleBottomSheetTimeoutIfNecessary()
    }

    func groupCallPeekChanged(_ call: GroupCall) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }

        switch call.concreteType {
        case .groupThread:
            break
        case .callLink:
            let requests = call.ringRtcCall.peekInfo?.pendingUsers ?? []
            self.callLinkApprovalViewModel.loadRequestsWithSneakyTransaction(for: requests)
        }

        updateCallUI()
    }

    func groupCallEnded(_ call: GroupCall, reason: CallEndReason) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)

        let title: String
        let message: String?
        let shouldDismissCallAfterDismissingActionSheet: Bool

        switch reason {
        case .deviceExplicitlyDisconnected:
            dismissCall(shouldHangUp: false)
            return

        case .hasMaxDevices:
            if let maxDevices = ringRtcCall.maxDevices {
                let formatString = OWSLocalizedString(
                    "GROUP_CALL_HAS_MAX_DEVICES_%d",
                    tableName: "PluralAware",
                    comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}.",
                )
                title = String.localizedStringWithFormat(formatString, maxDevices)
                message = nil
            } else {
                title = OWSLocalizedString(
                    "GROUP_CALL_HAS_MAX_DEVICES_UNKNOWN_COUNT",
                    comment: "An error displayed to the user when the group call ends because it has exceeded the max devices.",
                )
                message = nil
            }
            shouldDismissCallAfterDismissingActionSheet = true

        case .removedFromCall:
            title = OWSLocalizedString(
                "GROUP_CALL_REMOVED",
                comment: "The title of an alert when you've been removed from a group call.",
            )
            message = OWSLocalizedString(
                "GROUP_CALL_REMOVED_MESSAGE",
                comment: "The message of an alert when you've been removed from a group call.",
            )
            shouldDismissCallAfterDismissingActionSheet = true

        case .deniedRequestToJoinCall:
            title = OWSLocalizedString(
                "GROUP_CALL_REQUEST_DENIED",
                comment: "The title of an alert when tried to join a call using a link but the admin rejected your request.",
            )
            message = OWSLocalizedString(
                "GROUP_CALL_REQUEST_DENIED_MESSAGE",
                comment: "The message of an alert when tried to join a call using a link but the admin rejected your request.",
            )
            shouldDismissCallAfterDismissingActionSheet = true

        case
            .serverExplicitlyDisconnected,
            .callManagerIsBusy,
            .sfuClientFailedToJoin,
            .failedToCreatePeerConnectionFactory,
            .failedToNegotiateSrtpKeys,
            .failedToCreatePeerConnection,
            .failedToStartPeerConnection,
            .failedToUpdatePeerConnection,
            .failedToSetMaxSendBitrate,
            .iceFailedWhileConnecting,
            .iceFailedAfterConnected,
            .serverChangedDemuxId:
            Logger.warn("Group call ended with reason \(reason)")
            title = OWSLocalizedString(
                "GROUP_CALL_UNEXPECTEDLY_ENDED",
                comment: "An error displayed to the user when the group call unexpectedly ends.",
            )
            message = nil
            shouldDismissCallAfterDismissingActionSheet = false

        case
            .localHangup,
            .remoteHangup,
            .remoteHangupNeedPermission,
            .remoteHangupAccepted,
            .remoteHangupDeclined,
            .remoteHangupBusy,
            .remoteBusy,
            .remoteGlare,
            .remoteReCall,
            .timeout,
            .internalFailure,
            .signalingFailure,
            .connectionFailure,
            .appDroppedCall:
            Logger.error("Received Direct Call reason in a Group Call context")
            return
        }

        if self.isReadyToHandleObserver {
            showCallControlsIfTheyMustBeVisible()
            updateCallUI()
        }

        let actionSheet = ActionSheetController(title: title, message: message)
        actionSheet.addAction(ActionSheetAction(
            title: CommonStrings.okButton,
            style: .default,
            handler: { [weak self] _ in
                if shouldDismissCallAfterDismissingActionSheet {
                    self?.dismissCall()
                }
            },
        ))
        presenter.presentActionSheet(actionSheet)
    }

    func groupCallReceivedReactions(_ call: GroupCall, reactions: [SignalRingRTC.Reaction]) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }
        let localAci = SSKEnvironment.shared.databaseStorageRef.read { tx in
            return DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci
        }
        guard let localAci else {
            owsFailDebug("Local user is in call but doesn't have ACI!")
            return
        }
        let mappedReactions = SSKEnvironment.shared.databaseStorageRef.read { tx in
            return reactions.map { reaction in
                let name: String
                let aci: Aci
                if
                    let remoteDeviceState = ringRtcCall.remoteDeviceStates[reaction.demuxId],
                    remoteDeviceState.aci != localAci
                {
                    name = SSKEnvironment.shared.contactManagerRef.displayName(for: remoteDeviceState.address, tx: tx).resolvedValue()
                    aci = remoteDeviceState.aci
                } else {
                    name = CommonStrings.you
                    aci = localAci
                }
                return Reaction(
                    emoji: reaction.value,
                    name: name,
                    aci: aci,
                    timestamp: Date.timeIntervalSinceReferenceDate,
                )
            }
        }
        self.reactionsSink.addReactions(reactions: mappedReactions)
    }

    func groupCallReceivedRaisedHands(_ call: GroupCall, raisedHands: [DemuxId]) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }
        self.raisedHandsToast.raisedHands = raisedHands
        self.updateCallUI(shouldAnimateViewFrames: true)
    }

    func groupCallReceivedRemoteMute(_ call: GroupCall, muteSource: Aci) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }
        self.remoteMuteToast.displaySelfMuted(muteSource: muteSource)
        callService.callUIAdapter.setIsMuted(call: self.call, isMuted: true)
        self.updateCallUI(shouldAnimateViewFrames: true)
    }

    func groupCallObservedRemoteMute(_ call: GroupCall, muteSource: Aci, muteTarget: Aci) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }
        self.remoteMuteToast.displayOtherMuted(source: muteSource, target: muteTarget)
        self.updateCallUI(shouldAnimateViewFrames: true)
    }

    func handleUntrustedIdentityError(_ call: GroupCall) {
        AssertIsOnMainThread()
        owsPrecondition(self.groupCall === call)
        guard self.isReadyToHandleObserver else {
            return
        }
        if !hasUnresolvedSafetyNumberMismatch {
            hasUnresolvedSafetyNumberMismatch = true
            resolveSafetyNumberMismatch()
        }
    }
}

// MARK: CallHeaderDelegate

extension GroupCallViewController: CallHeaderDelegate {
    func didTapBackButton() {
        if groupCall.hasJoinedOrIsWaitingForAdminApproval {
            isCallMinimized = true
            AppEnvironment.shared.windowManagerRef.leaveCallView()
            // This ensures raised hands are removed
            updateCallUI()
        } else {
            dismissCall()
        }
    }

    func didTapMembersButton() {
        switch self.bottomSheetStateManager.bottomSheetState {
        case .callControls, .callControlsAndOverflow, .transitioning:
            bottomSheetStateManager.submitState(.callInfo)
            self.bottomSheet.maximizeHeight(animated: true)
        case .hidden:
            bottomSheetStateManager.submitState(.callInfo)
            self.bottomSheet.maximizeHeight(animated: false)
        case .callInfo:
            bottomSheetStateManager.submitState(.callControls)
            self.bottomSheet.minimizeHeight(animated: true)
        }
    }
}

// MARK: RaisedHandsToastDelegate

extension GroupCallViewController: RaisedHandsToastDelegate {
    func didTapViewRaisedHands() {
        self.didTapMembersButton()
    }

    func raisedHandsToastDidChangeHeight() {
        self.updateCallUI(shouldAnimateViewFrames: true)
    }
}

// MARK: GroupCallVideoOverflowDelegate

extension GroupCallViewController: GroupCallVideoOverflowDelegate {
    var firstOverflowMemberIndex: Int {
        switch self.page {
        case .grid:
            return videoGrid.maxItems
        case .speaker:
            return 1
        }
    }
}

// MARK: UIScrollViewDelegate

extension GroupCallViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let isScrolledPastHalfway = scrollView.contentOffset.y > view.height / 2
        self.page = isScrolledPastHalfway ? .speaker : .grid

        if isAutoScrollingToScreenShare {
            isAutoScrollingToScreenShare = scrollView.contentOffset.y != speakerView.frame.origin.y
        }

        updateSwipeToastView()
    }
}

// MARK: CallControlsDelegate

extension GroupCallViewController: CallControlsDelegate {
    func didPressRing() {
        switch groupCall.concreteType {
        case .groupThread(let groupThreadCall):
            if groupThreadCall.ringRestrictions.isEmpty {
                // Refresh the call header.
                callHeader.groupCallLocalDeviceStateChanged(groupThreadCall)
            } else if groupThreadCall.ringRestrictions.contains(.groupTooLarge) {
                let toast = ToastController(text: OWSLocalizedString("GROUP_CALL_TOO_LARGE_TO_RING", comment: "Text displayed when trying to turn on ringing when calling a large group."))
                toast.presentToastView(from: .top, of: view, inset: view.safeAreaInsets.top + 8)
            }
        case .callLink:
            owsFail("Can't ring a call link")
        }
    }

    func didPressJoin() {
        if call.isFull {
            let text: String
            if let maxDevices = ringRtcCall.maxDevices {
                let formatString = OWSLocalizedString(
                    "GROUP_CALL_HAS_MAX_DEVICES_%d",
                    tableName: "PluralAware",
                    comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}.",
                )
                text = String.localizedStringWithFormat(formatString, maxDevices)
            } else {
                text = OWSLocalizedString(
                    "GROUP_CALL_HAS_MAX_DEVICES_UNKNOWN_COUNT",
                    comment: "An error displayed to the user when the group call ends because it has exceeded the max devices.",
                )
            }

            let toastController = ToastController(text: text)
            // Leave the toast up longer than usual because this message is pretty long.
            toastController.presentToastView(
                from: .top,
                of: view,
                inset: view.safeAreaInsets.top + 8,
                dismissAfter: .seconds(8),
            )
            return
        }

        presentSafetyNumberChangeSheetIfNecessary { [weak self] success in
            guard let self else { return }
            guard success else { return }
            self.callService.joinGroupCallIfNecessary(self.call, groupCall: self.groupCall)
        }
    }

    func didPressHangup() {
    }

    func didPressMore() {
        if self.callControlsOverflowView.isHidden {
            bottomSheetStateManager.submitState(.callControlsAndOverflow)
        } else {
            bottomSheetStateManager.submitState(.callControls)
        }
    }
}

// MARK: CallMemberErrorPresenter

extension GroupCallViewController: CallMemberErrorPresenter {
    func presentErrorSheet(title: String, message: String) {
        let actionSheet = ActionSheetController(title: title, message: message)
        actionSheet.overrideUserInterfaceStyle = .dark
        actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton))
        presentActionSheet(actionSheet)
    }
}

// MARK: AnimatableLocalMemberViewDelegate

extension GroupCallViewController: AnimatableLocalMemberViewDelegate {
    var enclosingBounds: CGRect {
        return self.view.bounds
    }

    var remoteDeviceCount: Int {
        return ringRtcCall.remoteDeviceStates.count
    }

    func animatableLocalMemberViewDidCompleteExpandAnimation(_ localMemberView: CallMemberView) {
        self.expandedPipFrame = localMemberView.frame
        self.isPipAnimationInProgress = false
        performRetroactiveUiUpdateIfNecessary()
    }

    func animatableLocalMemberViewDidCompleteShrinkAnimation(_ localMemberView: CallMemberView) {
        self.expandedPipFrame = nil
        self.isPipAnimationInProgress = false
        performRetroactiveUiUpdateIfNecessary()
    }

    private func performRetroactiveUiUpdateIfNecessary() {
        if self.shouldRelayoutAfterPipAnimationCompletes {
            if let postAnimationUpdateMemberViewFramesSize {
                updateMemberViewFrames(size: postAnimationUpdateMemberViewFramesSize)
                self.postAnimationUpdateMemberViewFramesSize = nil
            }

            self.shouldRelayoutAfterPipAnimationCompletes = false
        }
    }

    func animatableLocalMemberViewWillBeginAnimation(_ localMemberView: CallMemberView) {
        self.isPipAnimationInProgress = true
        self.flipCameraTooltipManager.dismissTooltip()
    }
}

// MARK: - CallControlsOverflowPresenter

extension GroupCallViewController: CallControlsOverflowPresenter {
    func callControlsOverflowWillAppear() {
        self.cancelBottomSheetTimeout()
    }

    func callControlsOverflowDidDisappear() {
        self.scheduleBottomSheetTimeoutIfNecessary()
    }

    func willSendReaction() {
        bottomSheetStateManager.submitState(.callControls)
    }

    func didTapRaiseOrLowerHand() {
        bottomSheetStateManager.submitState(.callControls)
    }
}

// MARK: - SheetPanDelegate

extension GroupCallViewController: SheetPanDelegate {
    func sheetPanDidBegin() {
        bottomSheetStateManager.submitState(.transitioning)
        self.callControlsConfirmationToastManager.forceDismissToast()
    }

    func sheetPanDidEnd() {
        self.setBottomSheetStateAfterTransition()
    }

    func sheetPanDecelerationDidBegin() {
        bottomSheetStateManager.submitState(.transitioning)
    }

    func sheetPanDecelerationDidEnd() {
        self.setBottomSheetStateAfterTransition()
    }

    private func setBottomSheetStateAfterTransition() {
        if bottomSheet.isPresentingCallInfo() {
            bottomSheetStateManager.submitState(.callInfo)
        } else if bottomSheet.isPresentingCallControls() {
            bottomSheetStateManager.submitState(.callControls)
        } else if bottomSheet.isCrossFading() {
            bottomSheetStateManager.submitState(.transitioning)
        }
    }
}

// MARK: - CallDrawerDelegate

extension GroupCallViewController: CallDrawerDelegate {
    func didPresentViewController(_ viewController: UIViewController) {
        self.scheduleBottomSheetTimeoutIfNecessary()
    }

    func didTapDone() {
        bottomSheetStateManager.submitState(.callControls)
        self.bottomSheet.minimizeHeight()
    }
}

// MARK: - Bottom Sheet State Management

enum BottomSheetState {
    /// "Overflow" refers to the "..." menu that shows reactions & "Raise Hand".
    case callControlsAndOverflow
    case callControls
    case callInfo
    case transitioning
    case hidden
}

/// TODO: It may make sense to pull sheet timeout logic into this class.
class GroupCallBottomSheetStateManager {
    private weak var delegate: GroupCallBottomSheetStateDelegate?
    private(set) var bottomSheetState: BottomSheetState = .callControls {
        didSet {
            guard bottomSheetState != oldValue else { return }
            delegate?.bottomSheetStateDidChange(oldState: oldValue)
        }
    }

    fileprivate init(delegate: GroupCallBottomSheetStateDelegate) {
        self.delegate = delegate
    }

    func submitState(_ state: BottomSheetState) {
        if let delegate, !delegate.areStateChangesSuspended {
            bottomSheetState = state
        }
    }
}

private protocol GroupCallBottomSheetStateDelegate: AnyObject {
    var areStateChangesSuspended: Bool { get }
    func bottomSheetStateDidChange(oldState: BottomSheetState)
}

extension GroupCallViewController: GroupCallBottomSheetStateDelegate {
    var areStateChangesSuspended: Bool {
        self.callControlsOverflowView.isAnimating
    }

    func bottomSheetStateDidChange(oldState: BottomSheetState) {
        updateCallUI(bottomSheetChangedStateFrom: oldState)
        scheduleBottomSheetTimeoutIfNecessary()
    }
}

extension GroupCallViewController: SheetDismissalDelegate {
    func didDismissPresentedSheet() {
        scheduleBottomSheetTimeoutIfNecessary()
    }
}