/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import { type Room } from "matrix-js-sdk/src/matrix"; import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { useFeatureEnabled, useSettingValue } from "../useSettings"; import SdkConfig from "../../SdkConfig"; import { useEventEmitter, useEventEmitterState } from "../useEventEmitter"; import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; import { useWidgets } from "../../utils/WidgetUtils"; import { WidgetType } from "../../widgets/WidgetType"; import { useCall, useConnectionState, useParticipantCount } from "../useCall"; import { useRoomMemberCount } from "../useRoomMembers"; import { ConnectionState } from "../../models/Call"; import { placeCall } from "../../utils/room/placeCall"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { useRoomState } from "../useRoomState"; import { _t } from "../../languageHandler"; import { isManagedHybridWidget, isManagedHybridWidgetEnabled } from "../../widgets/ManagedHybrid"; import { type IApp } from "../../stores/WidgetStore"; import { SdkContextClass } from "../../contexts/SDKContext"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../dispatcher/actions"; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { isVideoRoom } from "../../utils/video-rooms"; import { UIFeature } from "../../settings/UIFeature"; import { BetaPill } from "../../components/views/beta/BetaCard"; import { type InteractionName } from "../../PosthogTrackers"; import { ElementCallMemberEventType } from "../../call-types"; export enum PlatformCallType { ElementCall, JitsiCall, LegacyCall, } export const getPlatformCallTypeProps = ( platformCallType: PlatformCallType, ): { label: string; children?: ReactNode; analyticsName: InteractionName; } => { switch (platformCallType) { case PlatformCallType.ElementCall: return { label: _t("voip|element_call"), analyticsName: "WebVoipOptionElementCall", children: , }; case PlatformCallType.JitsiCall: return { label: _t("voip|jitsi_call"), analyticsName: "WebVoipOptionJitsi", }; case PlatformCallType.LegacyCall: return { label: _t("voip|legacy_call"), analyticsName: "WebVoipOptionLegacy", }; } }; const enum State { NoCall, NoPermission, Unpinned, Ongoing, NotJoined, } /** * Utility hook for resolving state and click handlers for Voice & Video call buttons in the room header * @param room the room to track * @returns the call button attributes for the given room */ export const useRoomCall = ( room: Room, ): { voiceCallDisabledReason: string | null; voiceCallClick(evt: React.MouseEvent | undefined, selectedType: PlatformCallType): void; videoCallDisabledReason: string | null; videoCallClick(evt: React.MouseEvent | undefined, selectedType: PlatformCallType): void; toggleCallMaximized: () => void; isViewingCall: boolean; isConnectedToCall: boolean; hasActiveCallSession: boolean; callOptions: PlatformCallType[]; showVideoCallButton: boolean; showVoiceCallButton: boolean; } => { // settings const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); const widgetsFeatureEnabled = useSettingValue(UIFeature.Widgets); const voipFeatureEnabled = useSettingValue(UIFeature.Voip); const useElementCallExclusively = useMemo(() => { return SdkConfig.get("element_call").use_exclusively; }, []); const hasLegacyCall = useEventEmitterState( LegacyCallHandler.instance, LegacyCallHandlerEvent.CallsChanged, () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, ); // settings const widgets = useWidgets(room); const jitsiWidget = useMemo(() => widgets.find((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]); const hasJitsiWidget = !!jitsiWidget; const managedHybridWidget = useMemo(() => widgets.find(isManagedHybridWidget), [widgets]); const hasManagedHybridWidget = !!managedHybridWidget; // group call const groupCall = useCall(room.roomId); const isConnectedToCall = useConnectionState(groupCall) === ConnectionState.Connected; const hasGroupCall = groupCall !== null; const hasActiveCallSession = useParticipantCount(groupCall) > 0; const isViewingCall = useEventEmitterState( SdkContextClass.instance.roomViewStore, UPDATE_EVENT, () => SdkContextClass.instance.roomViewStore.isViewingCall() || isVideoRoom(room), ); // room const memberCount = useRoomMemberCount(room); const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [ room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), room.currentState.mayClientSendStateEvent(ElementCallMemberEventType.name, room.client), ]); // The options provided to the RoomHeader. // If there are multiple options, the user will be prompted to choose. const callOptions = useMemo((): PlatformCallType[] => { const options: PlatformCallType[] = []; if (memberCount <= 2) { options.push(PlatformCallType.LegacyCall); } else if (mayEditWidgets || hasJitsiWidget) { options.push(PlatformCallType.JitsiCall); } if (groupCallsEnabled) { if (hasGroupCall || mayCreateElementCalls) { options.push(PlatformCallType.ElementCall); } if (useElementCallExclusively && !hasJitsiWidget) { return [PlatformCallType.ElementCall]; } } if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) { // only allow joining the ongoing Element call if there is one. return [PlatformCallType.ElementCall]; } return options; }, [ memberCount, mayEditWidgets, hasJitsiWidget, groupCallsEnabled, hasGroupCall, mayCreateElementCalls, useElementCallExclusively, groupCall?.widget.type, ]); let widget: IApp | undefined; if (callOptions.includes(PlatformCallType.JitsiCall) || callOptions.includes(PlatformCallType.LegacyCall)) { widget = jitsiWidget ?? managedHybridWidget; } if (callOptions.includes(PlatformCallType.ElementCall)) { widget = groupCall?.widget; } else { widget = groupCall?.widget ?? jitsiWidget; } const updateWidgetState = useCallback((): void => { setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top)); setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top)); }, [room, widget]); useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState); useEffect(() => { updateWidgetState(); }, [room, jitsiWidget, groupCall, updateWidgetState]); const [canPinWidget, setCanPinWidget] = useState(false); const [widgetPinned, setWidgetPinned] = useState(false); // We only want to prompt to pin the widget if it's not element call based. const isECWidget = WidgetType.CALL.matches(widget?.type ?? ""); const promptPinWidget = !isECWidget && canPinWidget && !widgetPinned; const connectedCalls = useEventEmitterState(CallStore.instance, CallStoreEvent.ConnectedCalls, () => Array.from(CallStore.instance.connectedCalls), ); const state = useMemo((): State => { if (connectedCalls.find((call) => call.roomId != room.roomId)) { return State.Ongoing; } if (hasGroupCall && (hasJitsiWidget || hasManagedHybridWidget)) { return promptPinWidget ? State.Unpinned : State.Ongoing; } if (hasLegacyCall) { return State.Ongoing; } if (!callOptions.includes(PlatformCallType.LegacyCall) && !mayCreateElementCalls && !mayEditWidgets) { return State.NoPermission; } return State.NoCall; }, [ callOptions, connectedCalls, hasGroupCall, hasJitsiWidget, hasLegacyCall, hasManagedHybridWidget, mayCreateElementCalls, mayEditWidgets, promptPinWidget, room.roomId, ]); const voiceCallClick = useCallback( (evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => { evt?.stopPropagation(); if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey ?? false); } }, [promptPinWidget, room, widget], ); const videoCallClick = useCallback( (evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => { evt?.stopPropagation(); if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey ?? false); } }, [widget, promptPinWidget, room], ); let voiceCallDisabledReason: string | null; let videoCallDisabledReason: string | null; switch (state) { case State.NoPermission: voiceCallDisabledReason = _t("voip|disabled_no_perms_start_voice_call"); videoCallDisabledReason = _t("voip|disabled_no_perms_start_video_call"); break; case State.Ongoing: voiceCallDisabledReason = _t("voip|disabled_ongoing_call"); videoCallDisabledReason = _t("voip|disabled_ongoing_call"); break; case State.Unpinned: case State.NotJoined: case State.NoCall: voiceCallDisabledReason = null; videoCallDisabledReason = null; } const toggleCallMaximized = useCallback(() => { defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: undefined, view_call: !isViewingCall, }); }, [isViewingCall, room.roomId]); // We hide the voice call button if it'd have the same effect as the video call button let hideVoiceCallButton = isManagedHybridWidgetEnabled(room) || !callOptions.includes(PlatformCallType.LegacyCall); let hideVideoCallButton = false; // We hide both buttons if they require widgets but widgets are disabled, or if the Voip feature is disabled. if ((memberCount > 2 && !widgetsFeatureEnabled) || !voipFeatureEnabled) { hideVoiceCallButton = true; hideVideoCallButton = true; } /** * We've gone through all the steps */ return { voiceCallDisabledReason, voiceCallClick, videoCallDisabledReason, videoCallClick, toggleCallMaximized: toggleCallMaximized, isViewingCall: isViewingCall, isConnectedToCall: isConnectedToCall, hasActiveCallSession: hasActiveCallSession, callOptions, showVoiceCallButton: !hideVoiceCallButton, showVideoCallButton: !hideVideoCallButton, }; };