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

505 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";
2025-03-13 13:58:43 +01:00
import { type MatrixClient } 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";
import { JoinRule, type Room } from "matrix-js-sdk";
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,
ErrorCode,
RTCSessionError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import {
useNewMembershipManagerSetting as useNewMembershipManagerSetting,
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
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
}
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,
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
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
setError(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
),
);
},
);
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);
2024-01-26 10:03:08 +01:00
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
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!,
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(
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 enterRTCSessionOrError = async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
setError(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 }),
);
setError(error);
}
}
};
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
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,
useNewMembershipManager,
);
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,
useNewMembershipManager,
);
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
2024-11-11 12:51:31 -05:00
}
} else {
void enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
);
}
}
2025-02-24 17:45:40 +07:00
}, [
widget,
rtcSession,
preload,
skipLobby,
perParticipantE2EE,
latestDevices,
latestMuteStates,
useNewMembershipManager,
2025-02-24 17:45:40 +07:00
]);
2022-01-05 15:35:12 -08:00
const [left, setLeft] = useState(false);
const [error, setError] = useState<ElementCallError | null>(null);
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(
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,
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(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}
onEnter={() =>
void enterRTCSessionOrError(
rtcSession,
perParticipantE2EE,
useNewMembershipManager,
)
}
2024-01-26 10:03:08 +01:00
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={participantCount}
onShareClick={onShareClick}
/>
</>
);
let body: ReactNode;
if (error) {
// If an ElementCallError was recorded, then create a component that will fail to render and throw
// the error. This will then be handled by the ErrorBoundary component.
const ErrorComponent = (): ReactNode => {
2025-03-11 20:19:14 +01:00
throw error;
};
body = <ErrorComponent />;
} else 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:
body = returnToLobby ? lobbyView : 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
widget={widget}
recoveryActionHandler={(action) => {
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}
</GroupCallErrorBoundary>
);
2023-09-22 18:05:13 -04:00
};