Files
element-call/src/room/GroupCallView.tsx
T

426 lines
14 KiB
TypeScript
Raw Normal View History

2022-05-04 17:09:48 +01:00
/*
Copyright 2022-2024 New Vector Ltd.
2022-05-04 17:09:48 +01:00
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
2022-05-04 17:09:48 +01:00
*/
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk/src/client";
2024-04-23 15:15:13 +02:00
import {
Room,
isE2EESupported as isE2EESupportedBrowser,
} from "livekit-client";
import { logger } from "matrix-js-sdk/src/logger";
import { type 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";
2025-01-06 18:00:20 +01:00
import { useNavigate } from "react-router-dom";
2022-08-02 00:46:16 +02:00
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "../widget";
2024-04-23 15:15:13 +02:00
import { FullScreenView } from "../FullScreenView";
2022-01-05 15:35:12 -08:00
import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
2022-01-05 15:35:12 -08:00
import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
2023-05-26 20:41:32 +02:00
import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media";
2023-06-30 18:12:58 +01:00
import { ActiveCall } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import {
useMediaDevices,
type MediaDevices,
} from "../livekit/MediaDevicesContext";
2023-08-16 18:41:27 +01:00
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
2024-04-23 15:15:13 +02:00
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
2023-09-27 15:17:04 -04:00
import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams";
2023-10-30 17:06:59 +00:00
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
2022-08-02 00:46:16 +02:00
declare global {
interface Window {
2023-08-16 18:41:27 +01:00
rtcSession?: MatrixRTCSession;
2022-08-02 00:46:16 +02:00
}
}
2022-08-02 00:46:16 +02:00
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
2023-09-18 20:47:47 -04:00
confineToRoom: boolean;
preload: boolean;
2023-10-25 13:49:18 +02:00
skipLobby: boolean;
hideHeader: boolean;
2023-08-16 18:41:27 +01:00
rtcSession: MatrixRTCSession;
2024-04-23 15:15:13 +02:00
muteStates: MuteStates;
widget: WidgetHelpers | null;
2022-08-02 00:46:16 +02:00
}
2023-09-22 18:05:13 -04:00
export const GroupCallView: FC<Props> = ({
2022-01-05 15:35:12 -08:00
client,
isPasswordlessUser,
2023-09-18 20:47:47 -04:00
confineToRoom,
preload,
2023-10-25 13:49:18 +02:00
skipLobby,
hideHeader,
2023-08-16 18:41:27 +01:00
rtcSession,
2024-04-23 15:15:13 +02:00
muteStates,
widget,
2023-09-22 18:05:13 -04:00
}) => {
2023-08-16 18:41:27 +01:00
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
const leaveSoundContext = useLatest(
useAudioContext({
sounds: callEventAudioSounds,
latencyHint: "interactive",
}),
);
// This should use `useEffectEvent` (only available in experimental versions)
2024-04-23 15:15:13 +02:00
useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
2024-04-23 15:15:13 +02:00
muteStates.audio.setEnabled?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
2024-04-23 15:15:13 +02:00
2022-01-05 15:35:12 -08:00
useEffect(() => {
2023-08-16 18:41:27 +01:00
window.rtcSession = rtcSession;
2024-06-04 11:20:25 -04:00
return (): void => {
2023-08-16 18:41:27 +01:00
delete window.rtcSession;
};
2023-08-16 18:41:27 +01:00
}, [rtcSession]);
2022-07-08 20:55:18 +01:00
2024-09-11 01:27:02 -04:00
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]);
2023-05-26 20:41:32 +02:00
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(rtcSession.room);
const roomAvatar = useRoomAvatar(rtcSession.room);
2024-01-26 10:03:08 +01:00
const { perParticipantE2EE, returnToLobby } = useUrlParams();
2024-04-23 15:15:13 +02:00
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
usePageTitle(roomName);
2023-07-04 18:46:27 +01:00
const matrixInfo = useMemo((): MatrixInfo => {
return {
2023-08-31 15:46:09 +02:00
userId: client.getUserId()!,
2023-07-04 18:46:27 +01:00
displayName: displayName!,
avatarUrl: avatarUrl!,
2023-08-16 18:41:27 +01:00
roomId: rtcSession.room.roomId,
roomName,
2023-08-16 18:41:27 +01:00
roomAlias: rtcSession.room.getCanonicalAlias(),
roomAvatar,
2024-04-23 15:15:13 +02:00
e2eeSystem,
2023-07-04 18:46:27 +01:00
};
}, [
2024-04-23 15:15:13 +02:00
client,
displayName,
avatarUrl,
2024-04-23 15:15:13 +02:00
rtcSession.room,
roomName,
roomAvatar,
2024-04-23 15:15:13 +02:00
e2eeSystem,
]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
2023-10-09 20:49:03 +01:00
() => new Set<string>(memberships.map((m) => m.sender!)).size,
2023-10-11 10:42:04 -04:00
[memberships],
);
2023-05-26 20:41:32 +02:00
2023-08-02 15:29:37 -04:00
const deviceContext = useMediaDevices();
const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext;
// TODO: why do we use a ref here instead of using muteStates directly?
2023-08-02 15:29:37 -04:00
const latestMuteStates = useRef<MuteStates>();
latestMuteStates.current = muteStates;
useEffect(() => {
const defaultDeviceSetup = async ({
audioInput,
videoInput,
}: JoinCallData): Promise<void> => {
2023-10-25 13:49:18 +02:00
// 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);
if (audioInput) {
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
2023-10-25 13:49:18 +02:00
if (!deviceId) {
logger.warn("Unknown audio input: " + audioInput);
// override the default mute state
2023-08-02 15:29:37 -04:00
latestMuteStates.current!.audio.setEnabled?.(false);
} else {
2023-10-25 13:49:18 +02:00
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`,
);
2023-10-25 13:49:18 +02:00
latestDevices.current!.audioInput.select(deviceId);
}
2023-10-25 13:49:18 +02:00
}
if (videoInput) {
const deviceId = findDeviceByName(videoInput, "videoinput", devices);
2023-10-25 13:49:18 +02:00
if (!deviceId) {
logger.warn("Unknown video input: " + videoInput);
// override the default mute state
2023-08-09 17:34:53 -04:00
latestMuteStates.current!.video.setEnabled?.(false);
2023-08-02 15:29:37 -04:00
} else {
2023-10-25 13:49:18 +02:00
logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`,
);
2023-10-25 13:49:18 +02:00
latestDevices.current!.videoInput.select(deviceId);
}
2023-10-25 13:49:18 +02:00
}
};
2024-01-26 10:03:08 +01:00
if (skipLobby) {
2024-11-11 12:51:31 -05:00
if (widget) {
if (preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSession(rtcSession, perParticipantE2EE);
widget.api.transport.reply(ev.detail, {});
2024-11-11 12:51:31 -05:00
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
2024-11-11 12:51:31 -05:00
};
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSession(rtcSession, perParticipantE2EE);
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
2024-11-11 12:51:31 -05:00
}
} else {
void enterRTCSession(rtcSession, perParticipantE2EE);
}
}
}, [widget, rtcSession, preload, skipLobby, perParticipantE2EE]);
2022-01-05 15:35:12 -08:00
const [left, setLeft] = useState(false);
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
2025-01-06 18:00:20 +01:00
const navigate = useNavigate();
2022-01-05 15:35:12 -08:00
const onLeave = useCallback(
(leaveError?: Error): 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;
setLeaveError(leaveError);
setLeft(true);
PosthogAnalytics.instance.eventCallEnded.track(
2023-08-16 18:41:27 +01:00
rtcSession.room.roomId,
rtcSession.memberships.length,
2023-10-11 10:42:04 -04:00
sendInstantly,
2024-09-23 14:35:41 +01:00
rtcSession,
);
leaveRTCSession(
rtcSession,
// Wait for the sound in widget mode (it's not long)
sendInstantly && audioPromise ? audioPromise : undefined,
)
// 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);
});
},
[
widget,
rtcSession,
isPasswordlessUser,
confineToRoom,
leaveSoundContext,
2025-01-06 18:00:20 +01:00
navigate,
],
);
useEffect(() => {
2023-08-16 18:41:27 +01:00
if (widget && isJoined) {
2024-01-26 10:03:08 +01:00
// set widget to sticky once joined.
widget.api.setAlwaysOnScreen(true).catch((e) => {
logger.error("Error calling setAlwaysOnScreen(true)", e);
});
2024-01-26 10:03:08 +01:00
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): 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);
2024-06-04 11:20:25 -04:00
return (): void => {
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
};
}
}, [widget, isJoined, rtcSession]);
2023-06-16 18:07:13 +02:00
const onReconnect = useCallback(() => {
setLeft(false);
setLeaveError(undefined);
enterRTCSession(rtcSession, perParticipantE2EE).catch((e) => {
logger.error("Error re-entering RTC session on reconnect", e);
});
}, [rtcSession, perParticipantE2EE]);
2023-08-16 18:41:27 +01:00
const joinRule = useJoinRule(rtcSession.room);
2023-09-27 15:17:04 -04:00
const [shareModalOpen, setInviteModalOpen] = useState(false);
const onDismissInviteModal = useCallback(
() => setInviteModalOpen(false),
2023-10-11 10:42:04 -04:00
[setInviteModalOpen],
2023-09-17 14:35:35 -04:00
);
const onShareClickFn = useCallback(
2023-09-27 15:17:04 -04:00
() => setInviteModalOpen(true),
2023-10-11 10:42:04 -04:00
[setInviteModalOpen],
);
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
const { t } = useTranslation();
2024-04-23 15:15:13 +02:00
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
// If we have a encryption system but the browser does not support it.
return (
<FullScreenView>
2023-11-22 20:07:15 +00:00
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
2023-11-20 13:00:43 +00:00
<Text>{t("browser_media_e2ee_unsupported")}</Text>
<Link to="/">{t("common.home")}</Link>
</FullScreenView>
);
2023-08-11 16:59:26 +02:00
}
2023-09-17 14:35:35 -04:00
const shareModal = (
2023-09-27 15:17:04 -04:00
<InviteModal
2023-09-19 18:23:44 +01:00
room={rtcSession.room}
2023-09-17 14:35:35 -04:00
open={shareModalOpen}
2023-09-27 15:17:04 -04:00
onDismiss={onDismissInviteModal}
2023-09-17 14:35:35 -04:00
/>
);
2024-01-26 10:03:08 +01:00
const lobbyView = (
<>
{shareModal}
<LobbyView
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
2024-01-26 10:03:08 +01:00
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}
onShareClick={onShareClick}
/>
</>
);
if (isJoined) {
2023-05-26 20:41:32 +02:00
return (
<>
{shareModal}
2023-06-30 18:12:58 +01:00
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
participantCount={participantCount}
2023-06-30 18:12:58 +01:00
onLeave={onLeave}
hideHeader={hideHeader}
2023-08-02 15:29:37 -04:00
muteStates={muteStates}
2024-04-23 15:15:13 +02:00
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}
onShareClick={onShareClick}
2023-06-30 18:12:58 +01:00
/>
</>
2023-05-26 20:41:32 +02:00
);
2024-01-26 10:03:08 +01:00
} 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 ||
2023-09-18 20:47:47 -04:00
(PosthogAnalytics.instance.isEnabled() && widget === null) ||
leaveError
) {
return (
<>
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
confineToRoom={confineToRoom}
leaveError={leaveError}
reconnect={onReconnect}
/>
;
</>
);
} 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;
}
2024-01-26 10:03:08 +01:00
} else if (left && widget !== null) {
// Left in widget mode:
if (!returnToLobby) {
return null;
}
} else if (preload || skipLobby) {
return null;
2022-01-05 15:35:12 -08:00
}
2024-01-26 10:03:08 +01:00
return lobbyView;
2023-09-22 18:05:13 -04:00
};