/* Copyright 2022-2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { type FC, type ReactNode, useCallback, useEffect, useMemo, useState, } from "react"; import { type MatrixClient } from "matrix-js-sdk/src/client"; import { Room as LivekitRoom, isE2EESupported as isE2EESupportedBrowser, } from "livekit-client"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixRTCSessionEvent, type MatrixRTCSession, } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { useNavigate } from "react-router-dom"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { ElementWidgetActions, type JoinCallData, type WidgetHelpers, } from "../widget"; import { LobbyView } from "./LobbyView"; import { type 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, type MuteStates } from "./MuteStates"; import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers"; 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 { useAudioContext } from "../useAudioContext"; import { callEventAudioSounds } from "./CallEventAudioRenderer"; import { useLatest } from "../useLatest"; import { usePageTitle } from "../usePageTitle"; import { E2EENotSupportedError, ElementCallError, ErrorCode, RTCSessionError, UnknownCallError, } from "../utils/errors.ts"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; import { useNewMembershipManagerSetting as useNewMembershipManagerSetting, useSetting, } from "../settings/settings"; import { useTypedEventEmitter } from "../useEvents"; import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; declare global { interface Window { rtcSession?: MatrixRTCSession; } } interface Props { client: MatrixClient; isPasswordlessUser: boolean; confineToRoom: boolean; preload: boolean; skipLobby: boolean; hideHeader: boolean; rtcSession: MatrixRTCSession; isJoined: boolean; muteStates: MuteStates; widget: WidgetHelpers | null; } export const GroupCallView: FC = ({ client, isPasswordlessUser, confineToRoom, preload, skipLobby, hideHeader, rtcSession, isJoined, muteStates, widget, }) => { const memberships = useMatrixRTCSessionMemberships(rtcSession); const leaveSoundContext = useLatest( useAudioContext({ sounds: callEventAudioSounds, latencyHint: "interactive", }), ); // 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]); const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); useTypedEventEmitter( rtcSession, MatrixRTCSessionEvent.MembershipManagerError, (error) => { showGroupCallErrorBoundary( new RTCSessionError( ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE, error.message ?? error, ), ); }, ); 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 room = rtcSession.room as Room; const { displayName, avatarUrl } = useProfile(client); const roomName = useRoomName(room); const roomAvatar = useRoomAvatar(room); const { perParticipantE2EE, returnToLobby } = useUrlParams(); const e2eeSystem = useRoomEncryptionSystem(room.roomId); const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting); usePageTitle(roomName); const matrixInfo = useMemo((): MatrixInfo => { return { userId: client.getUserId()!, displayName: displayName!, avatarUrl: avatarUrl!, roomId: room.roomId, roomName, roomAlias: room.getCanonicalAlias(), roomAvatar, e2eeSystem, }; }, [client, displayName, avatarUrl, roomName, room, 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 = useLatest(deviceContext); const latestMuteStates = useLatest(muteStates); const enterRTCSessionOrError = useCallback( async ( rtcSession: MatrixRTCSession, perParticipantE2EE: boolean, newMembershipManager: boolean, ): Promise => { try { await enterRTCSession( rtcSession, perParticipantE2EE, newMembershipManager, ); } catch (e) { if (e instanceof ElementCallError) { showGroupCallErrorBoundary(e); } else { logger.error(`Unknown Error while entering RTC session`, e); const error = new UnknownCallError( e instanceof Error ? e : new Error("Unknown error", { cause: e }), ); showGroupCallErrorBoundary(error); } } }, [showGroupCallErrorBoundary], ); useEffect(() => { const defaultDeviceSetup = async ({ audioInput, videoInput, }: 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 LivekitRoom.getLocalDevices(undefined, true); if (audioInput) { const deviceId = findDeviceByName(audioInput, "audioinput", devices); if (!deviceId) { logger.warn("Unknown audio input: " + audioInput); // override the default mute state latestMuteStates.current!.audio.setEnabled?.(false); } else { logger.debug( `Found audio input ID ${deviceId} for name ${audioInput}`, ); latestDevices.current!.audioInput.select(deviceId); } } if (videoInput) { const deviceId = findDeviceByName(videoInput, "videoinput", devices); if (!deviceId) { logger.warn("Unknown video input: " + videoInput); // override the default mute state latestMuteStates.current!.video.setEnabled?.(false); } else { logger.debug( `Found video input ID ${deviceId} for name ${videoInput}`, ); latestDevices.current!.videoInput.select(deviceId); } } }; if (skipLobby) { if (widget) { if (preload) { // 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 enterRTCSessionOrError( rtcSession, perParticipantE2EE, useNewMembershipManager, ); 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 { // No lobby and no preload: we enter the rtc session right away (async (): Promise => { await enterRTCSessionOrError( rtcSession, perParticipantE2EE, useNewMembershipManager, ); })().catch((e) => { logger.error("Error joining RTC session", e); }); } } else { void enterRTCSessionOrError( rtcSession, perParticipantE2EE, useNewMembershipManager, ); } } }, [ widget, rtcSession, preload, skipLobby, perParticipantE2EE, latestDevices, latestMuteStates, enterRTCSessionOrError, useNewMembershipManager, ]); const [left, setLeft] = useState(false); const navigate = useNavigate(); const onLeave = useCallback( (cause: "user" | "error" = "user"): void => { const audioPromise = leaveSoundContext.current?.playSound("left"); // 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; setLeft(true); // we need to wait until the callEnded event is tracked on posthog. // Otherwise the iFrame gets killed before the callEnded event got tracked. const posthogRequest = new Promise((resolve) => { PosthogAnalytics.instance.eventCallEnded.track( room.roomId, rtcSession.memberships.length, sendInstantly, rtcSession, ); window.setTimeout(resolve, 10); }); leaveRTCSession( rtcSession, cause, // Wait for the sound in widget mode (it's not long) Promise.all([audioPromise, posthogRequest]), ) // Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts. .then(async () => { if ( !isPasswordlessUser && !confineToRoom && !PosthogAnalytics.instance.isEnabled() ) { await navigate("/"); } }) .catch((e) => { logger.error("Error leaving RTC session", e); }); }, [ leaveSoundContext, widget, rtcSession, room.roomId, isPasswordlessUser, confineToRoom, navigate, ], ); 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, "user").catch((e) => { logger.error("Failed to leave RTC session", e); }); }; widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup); return (): void => { widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup); }; } }, [widget, isJoined, rtcSession]); const joinRule = useJoinRule(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; if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) { // If we have a encryption system but the browser does not support it. throw new E2EENotSupportedError(); } const shareModal = ( ); const lobbyView = ( <> {shareModal} void enterRTCSessionOrError( rtcSession, perParticipantE2EE, useNewMembershipManager, ) } confineToRoom={confineToRoom} hideHeader={hideHeader} participantCount={participantCount} onShareClick={onShareClick} /> ); let body: ReactNode; if (isJoined) { body = ( <> {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) ) { body = ( ); } 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. body = null; } } else if (left && widget !== null) { // Left in widget mode: body = returnToLobby ? lobbyView : null; } else if (preload || skipLobby) { body = null; } else { body = lobbyView; } return ( { if (action == "reconnect") { setLeft(false); enterRTCSessionOrError( rtcSession, perParticipantE2EE, useNewMembershipManager, ).catch((e) => { logger.error("Error re-entering RTC session", e); }); } }} onError={ (/**error*/) => { if (rtcSession.isJoined()) onLeave("error"); } } > {body} ); };