/* Copyright 2022-2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHistory } from "react-router-dom"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room, isE2EESupported as isE2EESupportedBrowser, } from "livekit-client"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { JoinRule } from "matrix-js-sdk/src/matrix"; import { Heading, Text } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { widget, ElementWidgetActions, JoinCallData } from "../widget"; import { FullScreenView } from "../FullScreenView"; import { LobbyView } from "./LobbyView"; import { MatrixInfo } from "./VideoPreview"; import { CallEndedView } from "./CallEndedView"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { useProfile } from "../profile/useProfile"; import { findDeviceByName } from "../utils/media"; import { ActiveCall } from "./InCallView"; import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates"; import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers"; import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; import { useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { Link } from "../button/Link"; declare global { interface Window { rtcSession?: MatrixRTCSession; } } interface Props { client: MatrixClient; isPasswordlessUser: boolean; confineToRoom: boolean; preload: boolean; skipLobby: boolean; hideHeader: boolean; rtcSession: MatrixRTCSession; muteStates: MuteStates; } export const GroupCallView: FC = ({ client, isPasswordlessUser, confineToRoom, preload, skipLobby, hideHeader, rtcSession, muteStates, }) => { const memberships = useMatrixRTCSessionMemberships(rtcSession); const isJoined = useMatrixRTCSessionJoinState(rtcSession); // This should use `useEffectEvent` (only available in experimental versions) useEffect(() => { if (memberships.length >= MUTE_PARTICIPANT_COUNT) muteStates.audio.setEnabled?.(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { window.rtcSession = rtcSession; return (): void => { delete window.rtcSession; }; }, [rtcSession]); useEffect(() => { // Sanity check the room object if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room) logger.warn( `We've ended up with multiple rooms for the same ID (${rtcSession.room.roomId}). This indicates a bug in the group call loading code, and may lead to incomplete room state.`, ); }, [client, rtcSession.room]); const { displayName, avatarUrl } = useProfile(client); const roomName = useRoomName(rtcSession.room); const roomAvatar = useRoomAvatar(rtcSession.room); const { perParticipantE2EE, returnToLobby } = useUrlParams(); const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId); const matrixInfo = useMemo((): MatrixInfo => { return { userId: client.getUserId()!, displayName: displayName!, avatarUrl: avatarUrl!, roomId: rtcSession.room.roomId, roomName, roomAlias: rtcSession.room.getCanonicalAlias(), roomAvatar, e2eeSystem, }; }, [ client, displayName, avatarUrl, rtcSession.room, roomName, roomAvatar, e2eeSystem, ]); // Count each member only once, regardless of how many devices they use const participantCount = useMemo( () => new Set(memberships.map((m) => m.sender!)).size, [memberships], ); const deviceContext = useMediaDevices(); const latestDevices = useRef(); latestDevices.current = deviceContext; const latestMuteStates = useRef(); latestMuteStates.current = muteStates; useEffect(() => { const defaultDeviceSetup = async ( requestedDeviceData: JoinCallData, ): Promise => { // XXX: I think this is broken currently - LiveKit *won't* request // permissions and give you device names unless you specify a kind, but // here we want all kinds of devices. This needs a fix in livekit-client // for the following name-matching logic to do anything useful. const devices = await Room.getLocalDevices(undefined, true); const { audioInput, videoInput } = requestedDeviceData; if (audioInput === null) { latestMuteStates.current!.audio.setEnabled?.(false); } else { const deviceId = findDeviceByName(audioInput, "audioinput", devices); if (!deviceId) { logger.warn("Unknown audio input: " + audioInput); latestMuteStates.current!.audio.setEnabled?.(false); } else { logger.debug( `Found audio input ID ${deviceId} for name ${audioInput}`, ); latestDevices.current!.audioInput.select(deviceId); latestMuteStates.current!.audio.setEnabled?.(true); } } if (videoInput === null) { latestMuteStates.current!.video.setEnabled?.(false); } else { const deviceId = findDeviceByName(videoInput, "videoinput", devices); if (!deviceId) { logger.warn("Unknown video input: " + videoInput); latestMuteStates.current!.video.setEnabled?.(false); } else { logger.debug( `Found video input ID ${deviceId} for name ${videoInput}`, ); latestDevices.current!.videoInput.select(deviceId); latestMuteStates.current!.video.setEnabled?.(true); } } }; if (widget && preload && skipLobby) { // In preload mode without lobby we wait for a join action before entering const onJoin = (ev: CustomEvent): void => { (async (): Promise => { await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); await enterRTCSession(rtcSession, perParticipantE2EE); widget!.api.transport.reply(ev.detail, {}); })().catch((e) => { logger.error("Error joining RTC session", e); }); }; widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); return (): void => { widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); }; } else if (widget && !preload && skipLobby) { // No lobby and no preload: we enter the rtc session right away (async (): Promise => { await defaultDeviceSetup({ audioInput: null, videoInput: null }); await enterRTCSession(rtcSession, perParticipantE2EE); })().catch((e) => { logger.error("Error joining RTC session", e); }); } }, [rtcSession, preload, skipLobby, perParticipantE2EE]); const [left, setLeft] = useState(false); const [leaveError, setLeaveError] = useState(undefined); const history = useHistory(); const onLeave = useCallback( (leaveError?: Error): void => { setLeaveError(leaveError); setLeft(true); // In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // therefore we want the event to be sent instantly without getting queued/batched. const sendInstantly = !!widget; PosthogAnalytics.instance.eventCallEnded.track( rtcSession.room.roomId, rtcSession.memberships.length, sendInstantly, rtcSession, ); // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. leaveRTCSession(rtcSession) .then(() => { if ( !isPasswordlessUser && !confineToRoom && !PosthogAnalytics.instance.isEnabled() ) { history.push("/"); } }) .catch((e) => { logger.error("Error leaving RTC session", e); }); }, [rtcSession, isPasswordlessUser, confineToRoom, history], ); useEffect(() => { if (widget && isJoined) { // set widget to sticky once joined. widget!.api.setAlwaysOnScreen(true).catch((e) => { logger.error("Error calling setAlwaysOnScreen(true)", e); }); const onHangup = (ev: CustomEvent): void => { widget!.api.transport.reply(ev.detail, {}); // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. leaveRTCSession(rtcSession).catch((e) => { logger.error("Failed to leave RTC session", e); }); }; widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup); return (): void => { widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup); }; } }, [isJoined, rtcSession]); const onReconnect = useCallback(() => { setLeft(false); setLeaveError(undefined); enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => { logger.error("Error re-entering RTC session on reconnect", e); }); }, [rtcSession, perParticipantE2EE]); const joinRule = useJoinRule(rtcSession.room); const [shareModalOpen, setInviteModalOpen] = useState(false); const onDismissInviteModal = useCallback( () => setInviteModalOpen(false), [setInviteModalOpen], ); const onShareClickFn = useCallback( () => setInviteModalOpen(true), [setInviteModalOpen], ); const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null; const { t } = useTranslation(); if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) { // If we have a encryption system but the browser does not support it. return ( {t("browser_media_e2ee_unsupported_heading")} {t("browser_media_e2ee_unsupported")} {t("common.home")} ); } const shareModal = ( ); const lobbyView = ( <> {shareModal} void enterRTCSession(rtcSession, perParticipantE2EE)} confineToRoom={confineToRoom} hideHeader={hideHeader} participantCount={participantCount} onShareClick={onShareClick} /> ); if (isJoined) { return ( <> {shareModal} ); } else if (left && widget === null) { // Left in SPA mode: // The call ended view is shown for two reasons: prompting guests to create // an account, and prompting users that have opted into analytics to provide // feedback. We don't show a feedback prompt to widget users however (at // least for now), because we don't yet have designs that would allow widget // users to dismiss the feedback prompt and close the call window without // submitting anything. if ( isPasswordlessUser || (PosthogAnalytics.instance.isEnabled() && widget === null) || leaveError ) { return ( ); } else { // If the user is a regular user, we'll have sent them back to the homepage, // so just sit here & do nothing: otherwise we would (briefly) mount the // LobbyView again which would open capture devices again. return null; } } else if (left && widget !== null) { // Left in widget mode: if (!returnToLobby) { return null; } } else if (preload || skipLobby) { return null; } return lobbyView; };