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

import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import UIKit

enum GroupCallVideoContextMenuConfiguration {
    private static var contactManager: ContactManager { SSKEnvironment.shared.contactManagerRef }
    private static var db: DB { DependenciesBridge.shared.db }
    private static var tsAccountManager: TSAccountManager { DependenciesBridge.shared.tsAccountManager }
    private static var windowManager: WindowManager { AppEnvironment.shared.windowManagerRef }

    static func build(
        call: SignalCall,
        groupCall: GroupCall,
        ringRtcCall: SignalRingRTC.GroupCall,
        remoteDevice: RemoteDeviceState,
        interactionProvider: @escaping () -> UIContextMenuInteraction?,
    ) -> UIContextMenuConfiguration {
        return build(
            call: call,
            groupCall: groupCall,
            ringRtcCall: ringRtcCall,
            demuxId: remoteDevice.demuxId,
            aci: remoteDevice.aci,
            isAudioMuted: remoteDevice.audioMuted,
            interactionProvider: interactionProvider,
        )
    }

    static func build(
        call: SignalCall,
        groupCall: GroupCall,
        ringRtcCall: SignalRingRTC.GroupCall,
        demuxId: DemuxId,
        aci: Aci,
        isAudioMuted: Bool?,
        interactionProvider: @escaping () -> UIContextMenuInteraction?,
    ) -> UIContextMenuConfiguration {
        let displayName: String = db.read { tx in
            return contactManager.displayName(
                for: SignalServiceAddress(aci),
                tx: tx,
            ).resolvedValue()
        }

        return UIContextMenuConfiguration(
            previewProvider: {
                // A dedicated "call member" preview lets us avoid issues with
                // cell reuse, add/remove, etc in the various group-call video
                // collection views.
                return GroupCallVideoContextMenuPreviewController(
                    demuxId: demuxId,
                    aci: aci,
                    displayName: displayName,
                    call: call,
                    groupCall: groupCall,
                    interactionProvider: interactionProvider,
                )
            },
            actionProvider: { _ in
                let actions = contextMenuActions(
                    demuxId: demuxId,
                    aci: aci,
                    displayName: displayName,
                    isAudioMuted: isAudioMuted == true,
                    groupCall: groupCall,
                    ringRtcGroupCall: ringRtcCall,
                )

                return UIMenu(
                    title: displayName,
                    children: actions,
                )
            },
        )
    }

    static func contextMenuActions(
        demuxId: SignalRingRTC.DemuxId,
        aci: Aci,
        displayName: String,
        isAudioMuted: Bool,
        groupCall: GroupCall,
        ringRtcGroupCall: SignalRingRTC.GroupCall,
    ) -> [UIAction] {
        var contextMenuActions: [UIAction] = []

        if BuildFlags.RemoteMute.send {
            let attributes: UIMenuElement.Attributes = isAudioMuted ? .disabled : []

            contextMenuActions.append(UIAction(
                title: OWSLocalizedString(
                    "GROUP_CALL_CONTEXT_MENU_MUTE_AUDIO",
                    comment: "Context menu action to mute a call participant's audio.",
                ),
                image: .micSlash,
                attributes: attributes,
                handler: { [weak ringRtcGroupCall] _ in
                    guard let ringRtcGroupCall else { return }

                    MainActor.assumeIsolated {
                        ringRtcGroupCall.sendRemoteMuteRequest(demuxId)
                    }
                },
            ))
        }

        contextMenuActions.append(UIAction(
            title: OWSLocalizedString(
                "GROUP_CALL_CONTEXT_MENU_GO_TO_CHAT",
                comment: "Context menu action to navigate to the chat with a call participant.",
            ),
            image: .arrowSquareUprightLight,
            handler: { _ in
                MainActor.assumeIsolated {
                    windowManager.minimizeCallIfNeeded()
                    SignalApp.shared.presentConversationForAddress(
                        SignalServiceAddress(aci),
                        animated: true,
                    )
                }
            },
        ))

        contextMenuActions.append(UIAction(
            title: OWSLocalizedString(
                "GROUP_CALL_CONTEXT_MENU_PROFILE_DETAILS",
                comment: "Context menu action to view a call participant's profile details.",
            ),
            image: .personCircle,
            handler: { _ in
                guard let frontmostVC = CurrentAppContext().frontmostViewController() else {
                    return
                }

                MainActor.assumeIsolated {
                    windowManager.minimizeCallIfNeeded()
                    ProfileSheetSheetCoordinator(
                        address: SignalServiceAddress(aci),
                        groupViewHelper: nil,
                        spoilerState: SpoilerRenderState(),
                    ).presentAppropriateSheet(from: frontmostVC)
                }
            },
        ))

        if
            let callLinkCall = groupCall as? CallLinkCall,
            callLinkCall.isAdmin,
            let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction,
            !localIdentifiers.contains(serviceId: aci)
        {
            contextMenuActions.append(UIAction(
                title: OWSLocalizedString(
                    "GROUP_CALL_CONTEXT_MENU_REMOVE_FROM_CALL",
                    comment: "Context menu action to remove a call participant from the call.",
                ),
                image: .minusCircle,
                attributes: .destructive,
                handler: { _ in
                    removeFromCallWithConfirmation(
                        demuxId: demuxId,
                        displayName: displayName,
                        ringRtcGroupCall: ringRtcGroupCall,
                    )
                },
            ))
        }

        return contextMenuActions
    }

    private static func removeFromCallWithConfirmation(
        demuxId: DemuxId,
        displayName: String,
        ringRtcGroupCall: SignalRingRTC.GroupCall,
    ) {
        let actionSheet = ActionSheetController(
            title: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_TITLE",
                    comment: "Title for action sheet confirming removal of a member from a group call. embeds {{ name }}",
                ),
                displayName,
            ),
        )
        actionSheet.overrideUserInterfaceStyle = .dark

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_REMOVE_ACTION",
                comment: "Label for the button to confirm removing a member from a group call.",
            ),
        ) { _ in
            ringRtcGroupCall.removeClient(demuxId: demuxId)
        })

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_BLOCK_ACTION",
                comment: "Label for a button to block a member from a group call.",
            ),
        ) { _ in
            ringRtcGroupCall.blockClient(demuxId: demuxId)
        })

        actionSheet.addAction(.cancel)

        guard
            let frontmostCallViewController = windowManager.callViewWindow
                .findFrontmostViewController(ignoringAlerts: true)
        else {
            owsFailDebug("Missing frontmostViewController from call window: how?")
            return
        }

        frontmostCallViewController.presentActionSheet(actionSheet)
    }
}

// MARK: -

/// Wraps a `CallMemberView` for the purposes of a context-menu preview.
private class GroupCallVideoContextMenuPreviewController: UIViewController, GroupCallObserver {
    private let demuxId: DemuxId
    private let aci: Aci
    private let displayName: String
    private let interactionProvider: () -> UIContextMenuInteraction?

    private weak var call: SignalCall?
    private weak var groupCall: GroupCall?

    private lazy var callMemberView = CallMemberView(type: .remoteInGroup(.contextMenuPreview))

    init(
        demuxId: DemuxId,
        aci: Aci,
        displayName: String,
        call: SignalCall,
        groupCall: GroupCall,
        interactionProvider: @escaping () -> UIContextMenuInteraction?,
    ) {
        self.demuxId = demuxId
        self.aci = aci
        self.displayName = displayName
        self.call = call
        self.groupCall = groupCall
        self.interactionProvider = interactionProvider
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { owsFail("") }

    override func viewDidLoad() {
        super.viewDidLoad()

        callMemberView.applyChangesToCallMemberViewAndVideoView { _view in
            view.addSubview(_view)
            _view.autoPinEdgesToSuperviewEdges()
        }

        reconfigureCallMemberView()
        groupCall?.addObserver(self)
    }

    // MARK: - GroupCallObserver

    func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) {
        reconfigureCallMemberView()
    }

    func groupCallPeekChanged(_ call: GroupCall) {
        reconfigureCallMemberView()
    }

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

    func groupCallReceivedRaisedHands(_ call: GroupCall, raisedHands: [DemuxId]) {
        reconfigureCallMemberView()
    }

    private func reconfigureCallMemberView() {
        guard
            let call,
            let groupCall,
            let remoteDevice = groupCall.ringRtcCall.remoteDeviceStates[demuxId]
        else {
            return
        }

        callMemberView.configure(call: call, remoteGroupMemberDeviceState: remoteDevice)

        if let interaction = interactionProvider() {
            let actions = GroupCallVideoContextMenuConfiguration.contextMenuActions(
                demuxId: demuxId,
                aci: aci,
                displayName: displayName,
                isAudioMuted: remoteDevice.audioMuted == true,
                groupCall: groupCall,
                ringRtcGroupCall: groupCall.ringRtcCall,
            )

            interaction.updateVisibleMenu { menu in
                return menu.replacingChildren(actions)
            }
        }
    }
}