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

import SignalRingRTC
import SignalServiceKit
import SignalUI

@objc
protocol CallControlsDelegate: AnyObject {
    func didPressRing()
    func didPressJoin()
    func didPressHangup()
    func didPressMore()
}

class CallControls: UIView {
    private lazy var topStackView = createTopStackView()
    private lazy var hangUpButton: CallButton = {
        let button = createButton(
            iconName: "phone-down-fill-28",
            accessibilityLabel: viewModel.hangUpButtonAccessibilityLabel,
        ) { [viewModel] _ in
            viewModel.didPressHangup()
        }
        button.unselectedBackgroundColor = UIColor(rgbHex: 0xEB5545)
        return button
    }()

    private(set) lazy var audioSourceButton = createButton(
        iconName: "speaker-fill-28",
        accessibilityLabel: viewModel.audioSourceAccessibilityLabel,
    ) { [viewModel] _ in
        viewModel.didPressAudioSource()
    }

    private lazy var muteButton = createButton(
        iconName: "mic-fill",
        selectedIconName: "mic-slash-fill-28",
        accessibilityLabel: viewModel.muteButtonAccessibilityLabel,
    ) { [viewModel] _ in
        viewModel.didPressMute()
    }

    private lazy var videoButton = createButton(
        iconName: "video-fill-28",
        selectedIconName: "video-slash-fill-28",
        accessibilityLabel: viewModel.videoButtonAccessibilityLabel,
    ) { [viewModel] _ in
        viewModel.didPressVideo()
    }

    private lazy var ringButton = createButton(
        iconName: "bell-ring-fill-28",
        selectedIconName: "bell-slash-fill",
        accessibilityLabel: viewModel.ringButtonAccessibilityLabel,
    ) { [viewModel] _ in
        viewModel.didPressRing()
    }

    private lazy var flipCameraButton: CallButton = {
        let button = createButton(
            iconName: "switch-camera-28",
            accessibilityLabel: viewModel.flipCameraButtonAccessibilityLabel,
        ) { [viewModel] _ in
            viewModel.didPressFlipCamera()
        }
        button.selectedIconColor = button.iconColor
        button.selectedBackgroundColor = button.unselectedBackgroundColor
        return button
    }()

    private lazy var moreButton = createButton(
        iconName: "more",
        accessibilityLabel: viewModel.moreButtonAccessibilityLabel,
    ) { [viewModel] _ in
        viewModel.didPressMore()
    }

    private lazy var joinButtonActivityIndicator = UIActivityIndicatorView(style: .medium)

    private lazy var joinButton: UIButton = {
        let height: CGFloat = HeightConstants.joinButtonHeight

        let button = OWSButton()
        button.setTitleColor(.ows_white, for: .normal)
        button.setBackgroundImage(UIImage.image(color: .ows_accentGreen), for: .normal)
        button.titleLabel?.font = UIFont.dynamicTypeHeadlineClamped
        button.clipsToBounds = true
        button.layer.cornerRadius = height / 2
        button.block = { [weak self, unowned button] in
            self?.viewModel.didPressJoin()
        }
        button.ows_contentEdgeInsets = UIEdgeInsets(top: 17, leading: 17, bottom: 17, trailing: 17)
        button.addSubview(joinButtonActivityIndicator)
        joinButtonActivityIndicator.autoCenterInSuperview()

        // Expand the button to fit text if necessary.
        button.autoSetDimension(.width, toSize: 168, relation: .greaterThanOrEqual)
        button.autoSetDimension(.height, toSize: height)
        return button
    }()

    static func joinButtonLabel(for call: SignalCall) -> String {
        return CallControlsViewModel.joinButtonLabel(for: call)
    }

    private weak var delegate: CallControlsDelegate!
    private let viewModel: CallControlsViewModel

    init(
        call: SignalCall,
        callService: CallService,
        confirmationToastManager: CallControlsConfirmationToastManager,
        delegate: CallControlsDelegate,
    ) {
        let viewModel = CallControlsViewModel(
            call: call,
            callService: callService,
            confirmationToastManager: confirmationToastManager,
            delegate: delegate,
        )
        self.viewModel = viewModel
        self.delegate = delegate
        super.init(frame: .zero)

        viewModel.refreshView = { [weak self] in
            self?.updateControls()
        }

        let joinButtonContainer = UIView()
        joinButtonContainer.addSubview(joinButton)
        joinButtonContainer.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 0)
        joinButton.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual)
        joinButton.autoPinHeightToSuperview()

        let controlsStack = UIStackView(arrangedSubviews: [
            topStackView,
            joinButtonContainer,
        ])
        controlsStack.axis = .vertical
        controlsStack.spacing = HeightConstants.stackSpacing
        controlsStack.alignment = .center

        addSubview(controlsStack)
        controlsStack.autoPinWidthToSuperview()
        controlsStack.autoPinEdge(
            toSuperviewSafeArea: .bottom,
            withInset: HeightConstants.bottomPadding,
            relation: .lessThanOrEqual,
        )
        NSLayoutConstraint.autoSetPriority(.defaultHigh - 1) {
            controlsStack.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 56)
        }
        controlsStack.autoPinEdge(toSuperviewEdge: .top)

        updateControls()
    }

    func createTopStackView() -> UIStackView {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.spacing = 16

        stackView.addArrangedSubview(audioSourceButton)
        stackView.addArrangedSubview(flipCameraButton)
        stackView.addArrangedSubview(videoButton)
        stackView.addArrangedSubview(muteButton)
        stackView.addArrangedSubview(moreButton)
        stackView.addArrangedSubview(ringButton)
        stackView.addArrangedSubview(hangUpButton)

        return stackView
    }

    private var heightAfterLastUpdate: CGFloat = 0

    private var animator: UIViewPropertyAnimator?

    private func updateControls() {
        // Top row
        hangUpButton.isHidden = viewModel.hangUpButtonIsHidden
        muteButton.isHidden = viewModel.muteButtonIsHidden
        moreButton.isHidden = viewModel.moreButtonIsHidden
        videoButton.isHidden = viewModel.videoButtonIsHidden
        flipCameraButton.isHidden = viewModel.flipCameraButtonIsHidden
        ringButton.isHidden = viewModel.ringButtonIsHidden

        // Bottom row
        joinButton.superview?.isHidden = viewModel.joinButtonIsHidden

        // Sizing and spacing
        let controlCount = topStackView.arrangedSubviews.filter({ !$0.isHidden }).count
        topStackView.spacing = viewModel.controlSpacing(controlCount: controlCount)
        let shouldControlButtonsBeSmall = viewModel.shouldControlButtonsBeSmall(controlCount: controlCount)
        for view in topStackView.arrangedSubviews {
            if let button = view as? CallButton {
                button.isSmall = shouldControlButtonsBeSmall
            }
        }

        videoButton.isSelected = viewModel.videoButtonIsSelected
        muteButton.isSelected = viewModel.muteButtonIsSelected
        audioSourceButton.isSelected = viewModel.audioSourceButtonIsSelected
        ringButton.isSelected = viewModel.ringButtonIsSelected
        flipCameraButton.isSelected = viewModel.flipCameraButtonIsSelected
        moreButton.isSelected = viewModel.moreButtonIsSelected

        if !viewModel.audioSourceButtonIsHidden {
            let config = viewModel.audioSourceButtonConfiguration
            audioSourceButton.showDropdownArrow = config.showDropdownArrow
            audioSourceButton.iconName = config.iconName
        }

        if
            !viewModel.ringButtonIsHidden,
            let ringButtonConfig = viewModel.ringButtonConfiguration
        {
            ringButton.isUserInteractionEnabled = ringButtonConfig.isUserInteractionEnabled
            ringButton.isSelected = ringButtonConfig.isSelected
            ringButton.shouldDrawAsDisabled = ringButtonConfig.shouldDrawAsDisabled
        }

        if !viewModel.joinButtonIsHidden {
            let joinButtonConfig = viewModel.joinButtonConfig
            joinButton.setTitle(joinButtonConfig.label, for: .normal)
            joinButton.setTitleColor(joinButtonConfig.color, for: .normal)
            joinButton.ows_adjustsImageWhenHighlighted = joinButtonConfig.adjustsImageWhenHighlighted
            joinButton.isUserInteractionEnabled = joinButtonConfig.isUserInteractionEnabled
            if viewModel.shouldJoinButtonActivityIndicatorBeAnimating {
                joinButtonActivityIndicator.startAnimating()
            } else {
                joinButtonActivityIndicator.stopAnimating()
            }
        }

        hangUpButton.accessibilityLabel = viewModel.hangUpButtonAccessibilityLabel
        audioSourceButton.accessibilityLabel = viewModel.audioSourceAccessibilityLabel
        muteButton.accessibilityLabel = viewModel.muteButtonAccessibilityLabel
        videoButton.accessibilityLabel = viewModel.videoButtonAccessibilityLabel
        ringButton.accessibilityLabel = viewModel.ringButtonAccessibilityLabel
        flipCameraButton.accessibilityLabel = viewModel.flipCameraButtonAccessibilityLabel
        moreButton.accessibilityLabel = viewModel.moreButtonAccessibilityLabel

        if self.heightAfterLastUpdate != self.currentHeight {
            // callControlsHeightDidChange will animate changes
            self.animator?.stopAnimation(true)
            audioSourceButton.isHiddenInStackView = viewModel.audioSourceButtonIsHidden

            callControlsHeightObservers.elements.forEach {
                $0.callControlsHeightDidChange(newHeight: currentHeight)
            }
        } else if audioSourceButton.isHiddenInStackView != viewModel.audioSourceButtonIsHidden {
            // Animate audioSourceButton ourselves
            self.animator?.stopAnimation(true)
            let animator = UIViewPropertyAnimator(
                duration: 0.5,
                controlPoint1: .init(x: 0.25, y: 1),
                controlPoint2: .init(x: 0.25, y: 1),
            )
            animator.addAnimations { [unowned self] in
                self.audioSourceButton.isHiddenInStackView = self.viewModel.audioSourceButtonIsHidden
            }
            animator.startAnimation()
            self.animator = animator
        }

        self.heightAfterLastUpdate = self.currentHeight
    }

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

    private func createButton(
        iconName: String,
        selectedIconName: String? = nil,
        accessibilityLabel: String? = nil,
        action: @escaping UIActionHandler,
    ) -> CallButton {
        let button = CallButton(iconName: iconName)
        button.selectedIconName = selectedIconName
        button.accessibilityLabel = accessibilityLabel
        button.addAction(UIAction(handler: action), for: .touchUpInside)
        button.setContentHuggingHorizontalHigh()
        button.setCompressionResistanceHorizontalLow()
        return button
    }

    // MARK: Height Observing

    private var callControlsHeightObservers: WeakArray<any CallControlsHeightObserver> = []

    func addHeightObserver(_ observer: CallControlsHeightObserver) {
        callControlsHeightObservers.append(observer)
    }

    var currentHeight: CGFloat {
        var height = self.buttonRowHeight + HeightConstants.bottomPadding
        if !viewModel.joinButtonIsHidden {
            height += HeightConstants.joinButtonHeight
            height += HeightConstants.stackSpacing
        }
        return height
    }

    private var buttonRowHeight: CGFloat {
        self.muteButton.currentIconSize
    }

    private enum HeightConstants {
        static let joinButtonHeight: CGFloat = 56
        static let stackSpacing: CGFloat = 30
        static let bottomPadding: CGFloat = 40
    }
}

protocol CallControlsHeightObserver {
    func callControlsHeightDidChange(newHeight: CGFloat)
}

// MARK: - View Model

@MainActor
private class CallControlsViewModel {
    private let call: SignalCall
    private let callService: CallService
    private weak var delegate: CallControlsDelegate?
    private let confirmationToastManager: CallControlsConfirmationToastManager
    fileprivate var refreshView: (() -> Void)?

    @MainActor
    init(
        call: SignalCall,
        callService: CallService,
        confirmationToastManager: CallControlsConfirmationToastManager,
        delegate: CallControlsDelegate,
    ) {
        self.call = call
        self.callService = callService
        self.confirmationToastManager = confirmationToastManager
        self.delegate = delegate
        switch call.mode {
        case .individual(let call):
            call.addObserverAndSyncState(self)
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            call.addObserver(self, syncStateImmediately: true)
        }
        callService.audioService.delegate = self
    }

    private var didOverrideDefaultMuteState = false

    @MainActor
    private var hasExternalAudioInputsAndAudioSource: Bool {
        let audioService = callService.audioService
        return audioService.hasExternalInputs && audioService.currentAudioSource != nil
    }

    @MainActor
    var audioSourceButtonIsHidden: Bool {
        if hasExternalAudioInputsAndAudioSource {
            return false
        } else if UIDevice.current.isIPad {
            // iPad *only* supports speaker mode, if there are no external
            // devices connected, so we don't need to show the button unless
            // we have alternate audio sources.
            return true
        } else {
            return !call.isOutgoingVideoMuted
        }
    }

    struct AudioSourceButtonConfiguration {
        let showDropdownArrow: Bool
        let iconName: String
    }

    @MainActor
    var audioSourceButtonConfiguration: AudioSourceButtonConfiguration {
        let showDropdownArrow: Bool
        let iconName: String
        if
            callService.audioService.hasExternalInputs,
            let audioSource = callService.audioService.currentAudioSource
        {
            showDropdownArrow = true
            if audioSource.isBuiltInEarPiece {
                iconName = "phone-fill-28"
            } else if audioSource.isBuiltInSpeaker {
                iconName = "speaker-fill-28"
            } else {
                iconName = "speaker-bt-fill-28"
            }
        } else {
            // No bluetooth audio detected
            showDropdownArrow = false
            iconName = "speaker-fill-28"
        }
        return AudioSourceButtonConfiguration(showDropdownArrow: showDropdownArrow, iconName: iconName)
    }

    var hangUpButtonIsHidden: Bool {
        switch call.mode {
        case .individual:
            return false
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            return !call.hasJoinedOrIsWaitingForAdminApproval
        }
    }

    var muteButtonIsHidden: Bool {
        return false
    }

    var videoButtonIsHidden: Bool {
        return false
    }

    var flipCameraButtonIsHidden: Bool {
        if call.isOutgoingVideoMuted {
            return true
        }

        switch call.mode {
        case .individual(let call):
            return ![.idle, .dialing, .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(call.state)
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            if call.isJustMe {
                return true
            }
            return call.hasJoinedOrIsWaitingForAdminApproval
        }
    }

    var joinButtonIsHidden: Bool {
        switch call.mode {
        case .individual:
            // TODO: Introduce lobby for starting 1:1 video calls.
            return true
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            return call.hasJoinedOrIsWaitingForAdminApproval
        }
    }

    struct JoinButtonConfiguration {
        let label: String
        let color: UIColor
        let adjustsImageWhenHighlighted: Bool
        let isUserInteractionEnabled: Bool
    }

    @MainActor
    var joinButtonConfig: JoinButtonConfiguration {
        if call.isFull {
            // Make the button look disabled, but don't actually disable it.
            // We want to show a toast if the user taps anyway.
            return JoinButtonConfiguration(
                label: OWSLocalizedString(
                    "GROUP_CALL_IS_FULL",
                    comment: "Text explaining the group call is full",
                ),
                color: .ows_whiteAlpha40,
                adjustsImageWhenHighlighted: false,
                isUserInteractionEnabled: true,
            )
        }
        if call.joinState == .joining {
            return JoinButtonConfiguration(
                label: "",
                color: .ows_whiteAlpha40,
                adjustsImageWhenHighlighted: false,
                isUserInteractionEnabled: false,
            )
        }
        return JoinButtonConfiguration(
            label: Self.joinButtonLabel(for: call),
            color: .white,
            adjustsImageWhenHighlighted: true,
            isUserInteractionEnabled: true,
        )
    }

    @MainActor
    static func joinButtonLabel(for call: SignalCall) -> String {
        switch call.mode {
        case .individual:
            // We only show a lobby for 1:1 calls when the call is being initiated.
            // TODO: The work of adding the lobby for 1:1 calls in the unified call view
            // controller (currently GroupCallViewController) is not yet complete.
            return startCallText()
        case .groupThread(let call):
            return call.ringRestrictions.contains(.callInProgress) ? CallStrings.joinGroupCall : startCallText()
        case .callLink(let call):
            return call.mayNeedToAskToJoin ? askToJoinText() : CallStrings.joinGroupCall
        }
    }

    private static func startCallText() -> String {
        return OWSLocalizedString(
            "CALL_START_BUTTON",
            comment: "Button to start a call",
        )
    }

    private static func askToJoinText() -> String {
        return OWSLocalizedString(
            "ASK_TO_JOIN_CALL",
            comment: "Button to try to join a call. The admin may need to approve the request before the user can join.",
        )
    }

    var shouldJoinButtonActivityIndicatorBeAnimating: Bool {
        return (call.joinState == .joining || call.joinState == .pending) && !joinButtonIsHidden
    }

    @MainActor
    var ringButtonIsHidden: Bool {
        switch call.mode {
        case .individual:
            return true
        case .groupThread(let call):
            return call.joinState == .joined || call.ringRestrictions.contains(.callInProgress)
        case .callLink:
            return true
        }
    }

    struct RingButtonConfiguration {
        let isUserInteractionEnabled: Bool
        let isSelected: Bool
        let shouldDrawAsDisabled: Bool
    }

    @MainActor
    var ringButtonConfiguration: RingButtonConfiguration? {
        switch call.mode {
        case .individual:
            // We never show the ring button for 1:1 calls.
            return nil
        case .groupThread(let call):
            // Leave the button visible but locked if joining, like the "join call" button.
            let isUserInteractionEnabled = call.joinState == .notJoined
            let isSelected: Bool
            if
                call.ringRestrictions.isEmpty,
                case .shouldRing = call.groupCallRingState
            {
                isSelected = false
            } else {
                isSelected = true
            }
            // Leave the button enabled so we can present an explanatory toast, but show it disabled.
            let shouldDrawAsDisabled = !call.ringRestrictions.isEmpty
            return RingButtonConfiguration(
                isUserInteractionEnabled: isUserInteractionEnabled,
                isSelected: isSelected,
                shouldDrawAsDisabled: shouldDrawAsDisabled,
            )
        case .callLink:
            return nil
        }
    }

    var moreButtonIsHidden: Bool {
        switch call.mode {
        case .individual:
            return true
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            return call.ringRtcCall.localDeviceState.joinState != .joined
        }
    }

    var videoButtonIsSelected: Bool {
        return call.isOutgoingVideoMuted
    }

    var muteButtonIsSelected: Bool {
        return call.isOutgoingAudioMuted
    }

    @MainActor
    var ringButtonIsSelected: Bool {
        if let config = ringButtonConfiguration {
            return config.isSelected
        }
        // Ring button shouldn't be shown in this case anyway.
        return false
    }

    @MainActor
    var audioSourceButtonIsSelected: Bool {
        return callService.audioService.isSpeakerEnabled
    }

    var flipCameraButtonIsSelected: Bool {
        return false
    }

    var moreButtonIsSelected: Bool {
        return false
    }

    func controlSpacing(controlCount: Int) -> CGFloat {
        return (UIDevice.current.isNarrowerThanIPhone6 && controlCount > 4) ? 12 : 16
    }

    func shouldControlButtonsBeSmall(controlCount: Int) -> Bool {
        return UIDevice.current.isIPad ? false : controlCount > 4
    }
}

extension CallControlsViewModel: GroupCallObserver {
    func groupCallLocalDeviceStateChanged(_ call: GroupCall) {
        refreshView?()
    }

    func groupCallPeekChanged(_ call: GroupCall) {
        // Mute if there's more than 8 people in the call.
        if call.shouldMuteAutomatically(), !didOverrideDefaultMuteState, !muteButtonIsSelected {
            callService.updateIsLocalAudioMuted(isLocalAudioMuted: true)
        }
        refreshView?()
    }

    func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) {
        refreshView?()
    }

    func groupCallEnded(_ call: GroupCall, reason: CallEndReason) {
        refreshView?()
    }
}

extension CallControlsViewModel: IndividualCallObserver {
    func individualCallStateDidChange(_ call: IndividualCall, state: CallState) {
        refreshView?()
    }

    func individualCallLocalVideoMuteDidChange(_ call: IndividualCall, isVideoMuted: Bool) {
        refreshView?()
    }

    func individualCallLocalAudioMuteDidChange(_ call: IndividualCall, isAudioMuted: Bool) {
        refreshView?()
    }

    func individualCallHoldDidChange(_ call: IndividualCall, isOnHold: Bool) {
        refreshView?()
    }

    func individualCallRemoteVideoMuteDidChange(_ call: IndividualCall, isVideoMuted: Bool) {
        refreshView?()
    }

    func individualCallRemoteSharingScreenDidChange(_ call: IndividualCall, isRemoteSharingScreen: Bool) {
        refreshView?()
    }
}

extension CallControlsViewModel: CallAudioServiceDelegate {
    func callAudioServiceDidChangeAudioSession(_ callAudioService: CallAudioService) {
        refreshView?()
    }

    func callAudioServiceDidChangeAudioSource(_ callAudioService: CallAudioService, audioSource: AudioSource?) {
        refreshView?()
    }
}

extension CallControlsViewModel {
    @MainActor
    func didPressHangup() {
        callService.callUIAdapter.localHangupCall(call)
        delegate?.didPressHangup()
    }

    @MainActor
    func didPressAudioSource() {
        if callService.audioService.hasExternalInputs {
            callService.audioService.presentRoutePicker()
        } else {
            let shouldEnableSpeakerphone = !audioSourceButtonIsSelected
            callService.audioService.requestSpeakerphone(call: self.call, isEnabled: shouldEnableSpeakerphone)
            confirmationToastManager.toastInducingCallControlChangeDidOccur(state: .speakerphone(isOn: shouldEnableSpeakerphone))
        }
        refreshView?()
    }

    @MainActor
    func didPressMute() {
        let shouldMute = !muteButtonIsSelected
        callService.updateIsLocalAudioMuted(isLocalAudioMuted: shouldMute)
        callService.callUIAdapter.setIsMuted(call: call, isMuted: shouldMute)
        confirmationToastManager.toastInducingCallControlChangeDidOccur(state: .mute(isOn: shouldMute))
        didOverrideDefaultMuteState = true
        refreshView?()
    }

    @MainActor
    func didPressVideo() {
        callService.updateIsLocalVideoMuted(isLocalVideoMuted: !call.isOutgoingVideoMuted)

        // When turning off video, default speakerphone to on.
        if call.isOutgoingVideoMuted, !callService.audioService.hasExternalInputs {
            callService.audioService.requestSpeakerphone(call: self.call, isEnabled: true)
        }
        refreshView?()
    }

    @MainActor
    func didPressRing() {
        switch call.mode {
        case .individual:
            owsFailDebug("Can't control ringing for an individual call.")
        case .groupThread(let call):
            if call.ringRestrictions.isEmpty {
                switch call.groupCallRingState {
                case .shouldRing:
                    call.groupCallRingState = .doNotRing
                    confirmationToastManager.toastInducingCallControlChangeDidOccur(state: .ring(isOn: false))
                case .doNotRing:
                    call.groupCallRingState = .shouldRing
                    confirmationToastManager.toastInducingCallControlChangeDidOccur(state: .ring(isOn: true))
                default:
                    owsFailBeta("Ring button should not have been available to press!")
                }
                refreshView?()
            }
            delegate?.didPressRing()
        case .callLink:
            owsFailDebug("Can't ring Call Link call.")
        }
    }

    @MainActor
    func didPressFlipCamera() {
        if let isUsingFrontCamera = call.videoCaptureController.isUsingFrontCamera {
            callService.updateCameraSource(call: call, isUsingFrontCamera: !isUsingFrontCamera)
            refreshView?()
        }
    }

    @objc
    func didPressJoin() {
        delegate?.didPressJoin()
    }

    @objc
    func didPressMore() {
        delegate?.didPressMore()
    }
}

// MARK: - Accessibility

extension CallControlsViewModel {
    public var hangUpButtonAccessibilityLabel: String {
        switch call.mode {
        case .individual:
            return OWSLocalizedString(
                "CALL_VIEW_HANGUP_LABEL",
                comment: "Accessibility label for hang up call",
            )
        case .groupThread, .callLink:
            return OWSLocalizedString(
                "CALL_VIEW_LEAVE_CALL_LABEL",
                comment: "Accessibility label for leaving a call",
            )
        }
    }

    public var audioSourceAccessibilityLabel: String {
        // TODO: This is not the most helpful descriptor.
        return OWSLocalizedString(
            "CALL_VIEW_AUDIO_SOURCE_LABEL",
            comment: "Accessibility label for selection the audio source",
        )
    }

    public var muteButtonAccessibilityLabel: String {
        if call.isOutgoingAudioMuted {
            return OWSLocalizedString(
                "CALL_VIEW_UNMUTE_LABEL",
                comment: "Accessibility label for unmuting the microphone",
            )
        } else {
            return OWSLocalizedString(
                "CALL_VIEW_MUTE_LABEL",
                comment: "Accessibility label for muting the microphone",
            )
        }
    }

    public var videoButtonAccessibilityLabel: String {
        if call.isOutgoingVideoMuted {
            return OWSLocalizedString(
                "CALL_VIEW_TURN_VIDEO_ON_LABEL",
                comment: "Accessibility label for turning on the camera",
            )
        } else {
            return OWSLocalizedString(
                "CALL_VIEW_TURN_VIDEO_OFF_LABEL",
                comment: "Accessibility label for turning off the camera",
            )
        }
    }

    public var ringButtonAccessibilityLabel: String? {
        switch call.mode {
        case .individual, .callLink:
            // These call types do not use the ring button.
            break
        case .groupThread(let call):
            switch call.groupCallRingState {
            case .shouldRing:
                return OWSLocalizedString(
                    "CALL_VIEW_TURN_OFF_RINGING",
                    comment: "Accessibility label for turning off call ringing",
                )
            case .doNotRing:
                return OWSLocalizedString(
                    "CALL_VIEW_TURN_ON_RINGING",
                    comment: "Accessibility label for turning on call ringing",
                )
            default:
                // Ring button shouldn't be available.
                break
            }
        }
        return nil
    }

    public var flipCameraButtonAccessibilityLabel: String {
        return OWSLocalizedString(
            "CALL_VIEW_SWITCH_CAMERA_DIRECTION",
            comment: "Accessibility label to toggle front- vs. rear-facing camera",
        )
    }

    public var moreButtonAccessibilityLabel: String {
        return OWSLocalizedString(
            "CALL_VIEW_MORE_LABEL",
            comment: "Accessibility label for the More button in the Call Controls row.",
        )
    }
}