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

533 lines
16 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 OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
2022-05-04 17:09:48 +01:00
*/
import {
type FC,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
2025-03-13 18:36:01 +01:00
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
2024-04-23 15:15:13 +02:00
import {
Room as LivekitRoom,
2024-04-23 15:15:13 +02:00
isE2EESupported as isE2EESupportedBrowser,
} from "livekit-client";
2025-03-13 13:58:43 +01:00
import { logger } from "matrix-js-sdk/lib/logger";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
2025-03-13 13:58:43 +01:00
} from "matrix-js-sdk/lib/matrixrtc";
2025-01-06 18:00:20 +01:00
import { useNavigate } from "react-router-dom";
2022-08-02 00:46:16 +02:00
2025-03-13 18:15:58 +01:00
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "../widget";
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";
import { ActiveCall } from "./InCallView";
2025-08-28 17:40:35 +02:00
import { type MuteStates } from "../state/MuteStates";
import { useMediaDevices } from "../MediaDevicesContext";
2023-08-16 18:41:27 +01:00
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
2025-06-04 22:51:13 +02:00
import {
saveKeyForRoom,
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 {
getUrlParams,
HeaderStyle,
type UrlParams,
useUrlParams,
} from "../UrlParams";
2023-10-30 17:06:59 +00:00
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
2025-09-15 15:41:15 +01:00
import {
callEventAudioSounds,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import {
ConnectionLostError,
E2EENotSupportedError,
ElementCallError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { useTypedEventEmitter } from "../useEvents";
2025-05-16 11:32:32 +02:00
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
2025-06-26 05:08:57 -04:00
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
2025-08-28 17:40:35 +02:00
/**
* If there already are this many participants in the call, we automatically mute
* the user.
*/
export const MUTE_PARTICIPANT_COUNT = 8;
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: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
2025-06-26 05:08:57 -04:00
header: HeaderStyle;
2023-08-16 18:41:27 +01:00
rtcSession: MatrixRTCSession;
2025-08-27 15:06:14 +02:00
joined: boolean;
setJoined: (value: boolean) => void;
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,
2025-06-26 05:08:57 -04:00
header,
2023-08-16 18:41:27 +01:00
rtcSession,
2025-08-27 15:06:14 +02:00
joined,
setJoined,
2024-04-23 15:15:13 +02:00
muteStates,
widget,
2023-09-22 18:05:13 -04:00
}) => {
// Used to thread through any errors that occur outside the error boundary
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
2023-08-16 18:41:27 +01:00
const memberships = useMatrixRTCSessionMemberships(rtcSession);
2025-05-16 11:32:32 +02:00
const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest(
useAudioContext({
sounds: callEventAudioSounds,
latencyHint: "interactive",
2025-05-13 21:11:12 +02:00
muted: muteAllAudio,
}),
);
// This should use `useEffectEvent` (only available in experimental versions)
2024-04-23 15:15:13 +02:00
useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
2025-08-28 18:41:13 +02:00
muteStates.audio.setEnabled$.value?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
2024-04-23 15:15:13 +02:00
useEffect(() => {
logger.info("[Lifecycle] GroupCallView Component mounted");
return (): void => {
logger.info("[Lifecycle] GroupCallView Component unmounted");
};
}, []);
// This CSS is the only way we could find to not make element call scroll for
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...)
useEffect(() => {
document.body.classList.add("no-scroll-body");
return (): void => {
document.body.classList.remove("no-scroll-body");
};
}, []);
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
// TODO move this into the callViewModel LocalMembership.ts
2025-12-02 19:40:08 +01:00
// We might actually not need this at all. Since we get into fatalError on those errors already?
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => setExternalError(new ConnectionLostError()),
);
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]);
const room = rtcSession.room as Room;
2023-05-26 20:41:32 +02:00
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
2025-06-04 22:51:13 +02:00
// Save the password once we start the groupCallView
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
usePageTitle(roomName);
2025-06-26 05:08:57 -04:00
useAppBarTitle(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!,
roomId: room.roomId,
roomName,
roomAlias: room.getCanonicalAlias(),
roomAvatar,
2024-04-23 15:15:13 +02:00
e2eeSystem,
2023-07-04 18:46:27 +01:00
};
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.userId!)).size,
2023-10-11 10:42:04 -04:00
[memberships],
);
2023-05-26 20:41:32 +02:00
const mediaDevices = useMediaDevices();
2025-02-24 17:45:40 +07:00
const latestMuteStates = useLatest(muteStates);
2023-08-02 15:29:37 -04:00
const enterRTCSessionOrError = useCallback(
async (rtcSession: MatrixRTCSession): Promise<void> => {
try {
2025-08-27 15:06:14 +02:00
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
2025-09-22 14:17:38 +02:00
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(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 }),
);
setExternalError(error);
}
}
2025-08-27 15:06:14 +02:00
return Promise.resolve();
},
2025-08-27 15:06:14 +02:00
[setJoined],
);
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 LivekitRoom.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
2025-08-28 18:41:13 +02:00
latestMuteStates.current!.audio.setEnabled$.value?.(false);
2023-08-02 15:29:37 -04:00
} else {
2023-10-25 13:49:18 +02:00
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`,
);
mediaDevices.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
2025-08-28 18:41:13 +02:00
latestMuteStates.current!.video.setEnabled$.value?.(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}`,
);
mediaDevices.videoInput.select(deviceId);
}
2023-10-25 13:49:18 +02:00
}
};
2024-01-26 10:03:08 +01:00
if (skipLobby) {
if (widget && 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);
2025-08-27 15:06:14 +02:00
setJoined(true);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session on preload", 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
2025-08-27 15:06:14 +02:00
setJoined(true);
}
}
2025-02-24 17:45:40 +07:00
}, [
widget,
rtcSession,
preload,
skipLobby,
perParticipantE2EE,
mediaDevices,
2025-02-24 17:45:40 +07:00
latestMuteStates,
2025-08-27 15:06:14 +02:00
setJoined,
2025-02-24 17:45:40 +07:00
]);
2025-09-23 12:25:05 +02:00
// TODO refactor this + "joined" to just one callState
2022-01-05 15:35:12 -08:00
const [left, setLeft] = useState(false);
2025-01-06 18:00:20 +01:00
const navigate = useNavigate();
2022-01-05 15:35:12 -08:00
2025-09-16 16:52:17 +02:00
const onLeft = useCallback(
2025-10-27 14:31:23 +01:00
(
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
): void => {
2025-09-16 16:52:17 +02:00
let playSound: CallEventSounds = "left";
if (reason === "timeout" || reason === "decline") playSound = reason;
2025-09-23 12:25:05 +02:00
setJoined(false);
2025-09-16 16:52:17 +02:00
setLeft(true);
2025-09-15 15:41:15 +01:00
const audioPromise = leaveSoundContext.current?.playSound(playSound);
// We need to wait until the callEnded event is tracked on PostHog,
// otherwise the iframe may get killed first.
const posthogRequest = new Promise((resolve) => {
// To increase the likelihood of the PostHog event being sent out in
// widget mode before the iframe is killed, we ask it to skip the
// usual queuing/batching of requests.
const sendInstantly = widget !== null;
PosthogAnalytics.instance.eventCallEnded.track(
room.roomId,
rtcSession.memberships.length,
sendInstantly,
rtcSession,
);
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up the
// closing of the widget that long anyway, so giving it 10 ms will do.
window.setTimeout(resolve, 10);
});
2025-09-16 16:52:17 +02:00
void Promise.all([audioPromise, posthogRequest])
.catch((e) =>
logger.error(
"Failed to play leave audio and/or send PostHog leave event",
e,
),
)
.then(async () => {
if (
!isPasswordlessUser &&
!confineToRoom &&
!PosthogAnalytics.instance.isEnabled()
)
2025-09-16 16:52:17 +02:00
void navigate("/");
if (widget) {
// After this point the iframe could die at any moment!
try {
await widget.api.setAlwaysOnScreen(false);
} catch (e) {
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
);
}
// On a normal user hangup we can shut down and close the widget. But if an
// error occurs we should keep the widget open until the user reads it.
2025-10-27 14:31:23 +01:00
if (reason != "error" && !getUrlParams().returnToLobby) {
try {
await widget.api.transport.send(ElementWidgetActions.Close, {});
} catch (e) {
logger.error("Failed to send close action", e);
}
widget.api.transport.stop();
}
}
});
},
[
2025-09-23 12:25:05 +02:00
setJoined,
leaveSoundContext,
widget,
room.roomId,
2025-09-16 16:52:17 +02:00
rtcSession,
isPasswordlessUser,
confineToRoom,
2025-01-06 18:00:20 +01:00
navigate,
],
);
useEffect(() => {
if (widget && joined)
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);
});
2025-08-27 15:06:14 +02:00
}, [widget, joined, rtcSession]);
2023-06-16 18:07:13 +02:00
const joinRule = useJoinRule(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;
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.
throw new E2EENotSupportedError();
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
room={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}
2025-08-27 15:06:14 +02:00
onEnter={() => setJoined(true)}
2024-01-26 10:03:08 +01:00
confineToRoom={confineToRoom}
2025-06-26 05:08:57 -04:00
hideHeader={header === HeaderStyle.None}
2024-01-26 10:03:08 +01:00
participantCount={participantCount}
onShareClick={onShareClick}
/>
</>
);
let body: ReactNode;
if (externalError) {
// If an error was recorded within this component but outside
// GroupCallErrorBoundary, create a component that rethrows the error from
// within the error boundary, so it can be handled uniformly
const ErrorComponent = (): ReactNode => {
throw externalError;
};
body = <ErrorComponent />;
2025-08-27 15:06:14 +02:00
} else if (joined) {
body = (
<>
{shareModal}
2023-06-30 18:12:58 +01:00
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
2025-09-16 16:52:17 +02:00
onLeft={onLeft}
2025-06-26 05:08:57 -04:00
header={header}
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 ||
(PosthogAnalytics.instance.isEnabled() && widget === null)
) {
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
2025-06-26 05:08:57 -04:00
hideHeader={header === HeaderStyle.None}
confineToRoom={confineToRoom}
/>
);
} 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;
}
2024-01-26 10:03:08 +01:00
} else if (left && widget !== null) {
// Left in widget mode:
body = returnToLobby ? lobbyView : null;
2024-01-26 10:03:08 +01:00
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
body = null;
} else {
body = lobbyView;
2022-01-05 15:35:12 -08:00
}
2024-01-26 10:03:08 +01:00
return (
<GroupCallErrorBoundary
widget={widget}
2025-09-08 14:21:38 +02:00
recoveryActionHandler={async (action) => {
setExternalError(null);
if (action == "reconnect") {
setLeft(false);
2025-09-08 14:21:38 +02:00
await enterRTCSessionOrError(rtcSession).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={
(/**error*/) => {
2025-10-27 14:31:23 +01:00
if (rtcSession.isJoined()) onLeft("error");
}
}
>
{body}
</GroupCallErrorBoundary>
);
2023-09-22 18:05:13 -04:00
};