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

470 lines
15 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";
import { type MatrixClient } from "matrix-js-sdk/src/client";
2024-04-23 15:15:13 +02:00
import {
isE2EESupported as isE2EESupportedBrowser,
Room,
2024-04-23 15:15:13 +02:00
} 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";
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";
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";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
2025-02-24 17:45:40 +07:00
import { useMediaDevices } from "../livekit/MediaDevicesContext";
2023-08-16 18:41:27 +01:00
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
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 { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import {
E2EENotSupportedError,
ElementCallError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
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;
isJoined: boolean;
2024-04-23 15:15:13 +02:00
muteStates: MuteStates;
widget: WidgetHelpers | null;
2022-08-02 00:46:16 +02:00
}
export const GroupCallView: FC<Props> = (props) => {
return (
<GroupCallErrorBoundaryContextProvider>
<GroupCallViewInner {...props} />
</GroupCallErrorBoundaryContextProvider>
);
};
export const GroupCallViewInner: 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,
isJoined,
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 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();
2025-02-24 17:45:40 +07:00
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
2023-08-02 15:29:37 -04:00
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
): Promise<void> => {
try {
await enterRTCSession(rtcSession, perParticipantE2EE);
} 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<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 enterRTCSessionOrError(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 enterRTCSessionOrError(rtcSession, perParticipantE2EE);
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
2024-11-11 12:51:31 -05:00
}
} else {
void enterRTCSessionOrError(rtcSession, perParticipantE2EE);
}
}
2025-02-24 17:45:40 +07:00
}, [
widget,
rtcSession,
preload,
skipLobby,
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
2025-02-24 17:45:40 +07:00
]);
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
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(
rtcSession.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);
});
},
[
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, "user").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 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;
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
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 enterRTCSessionOrError(rtcSession, perParticipantE2EE)
}
2024-01-26 10:03:08 +01:00
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}
onShareClick={onShareClick}
/>
</>
);
let body: ReactNode;
if (isJoined) {
body = (
<>
{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 ||
(PosthogAnalytics.instance.isEnabled() && widget === null)
) {
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
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:
if (!returnToLobby) {
body = null;
2024-01-26 10:03:08 +01:00
}
} else if (preload || skipLobby) {
body = null;
} else {
body = lobbyView;
2022-01-05 15:35:12 -08:00
}
2024-01-26 10:03:08 +01:00
return (
<GroupCallErrorBoundary
recoveryActionHandler={(action) => {
if (action == "reconnect") {
setLeft(false);
enterRTCSessionOrError(rtcSession, perParticipantE2EE).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={
(/**error*/) => {
if (rtcSession.isJoined()) onLeave("error");
}
}
>
{body}
</GroupCallErrorBoundary>
);
2023-09-22 18:05:13 -04:00
};