mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Rename GroupCallView to CallView, and fold ElementCallView into it
Three layers stood between a shell and the call: `ElementCall` (the component), `ElementCallView` and `GroupCallView`. The middle one only held the `joined` flag and the mute state, both of which nothing outside the call reads any more, so it is folded into the view it wrapped. That view is now `CallView`, since "group call" is a name from before Element Call handled anything else. Its docstring says what it is: the whole lifecycle of a call — lobby, the call itself, and what comes after — with the lobby and the post-call screen each present or skipped depending on the parameters and the host. Both the standalone RoomPage and the component render it directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8e8bc5ddde
commit
f70fba1dd6
+2
-2
@@ -51,7 +51,7 @@ import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmen
|
||||
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { ElementCallView } from "../src/ElementCallView";
|
||||
import { CallView } from "../src/room/CallView";
|
||||
import { ErrorPage } from "../src/FullScreenView";
|
||||
import { ClientProvider } from "../src/ClientContext";
|
||||
import { HostBridgeProvider } from "../src/HostBridge";
|
||||
@@ -269,7 +269,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<ElementCallView
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations 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, useState } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { GroupCallView } from "./room/GroupCallView";
|
||||
import { useMuteStates } from "./state/useMuteStates";
|
||||
import { type UrlParams } from "./UrlParams";
|
||||
|
||||
interface Props {
|
||||
/** The client to place the call with. */
|
||||
client: MatrixClient;
|
||||
/** The call to join. */
|
||||
rtcSession: MatrixRTCSession;
|
||||
/**
|
||||
* Whether the user is signed in as a guest, and so should be offered the
|
||||
* chance to create an account when the call ends.
|
||||
*/
|
||||
isPasswordlessUser: boolean;
|
||||
/** Whether to keep the user in this call rather than letting them navigate. */
|
||||
confineToRoom: boolean;
|
||||
/** Whether to wait for the host to ask us to join. */
|
||||
preload: UrlParams["preload"];
|
||||
/** Whether to enter the call directly, without showing the lobby first. */
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
}
|
||||
|
||||
/**
|
||||
* A call, as a component.
|
||||
*
|
||||
* This owns being in a call, and nothing about how Element Call came to be
|
||||
* showing one: no routing, no authentication, no resolving of room aliases.
|
||||
* Those belong to whatever is hosting it — the standalone app's own shell, or
|
||||
* an application embedding Element Call directly.
|
||||
*/
|
||||
export const ElementCallView: FC<Props> = ({
|
||||
client,
|
||||
rtcSession,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
}): ReactNode => {
|
||||
// Whether the user is in the call is the call's own business, not its host's.
|
||||
const [joined, setJoined] = useState(false);
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
if (muteStates === null) return null;
|
||||
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
joined={joined}
|
||||
setJoined={setJoined}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
// TODO-MULTI-SFU: Restore or discard these tests. The role of GroupCallView has
|
||||
// TODO-MULTI-SFU: Restore or discard these tests. The role of CallView has
|
||||
// changed (it no longer manages the connection to the same extent), so they may
|
||||
// need extra work to adapt.
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { CallView } from "./CallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
import {
|
||||
type HostBridge,
|
||||
@@ -60,7 +60,6 @@ import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type MuteStates } from "../state/MuteStates.ts";
|
||||
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
@@ -119,7 +118,7 @@ beforeEach(() => {
|
||||
playSoundLooping: vi.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
|
||||
// A trivial implementation of Active call to ensure we are testing CallView exclusively here.
|
||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
||||
({ onLeft: onLeave }) => {
|
||||
return (
|
||||
@@ -135,7 +134,7 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
function createGroupCallView(
|
||||
function createCallView(
|
||||
hostBridge: HostBridge,
|
||||
joined = true,
|
||||
options: {
|
||||
@@ -172,23 +171,15 @@ function createGroupCallView(
|
||||
constant([localRtcMember]),
|
||||
);
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
video: { enabled: false },
|
||||
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
|
||||
} as unknown as MuteStates;
|
||||
const groupCallView = (
|
||||
<GroupCallView
|
||||
const callView = (
|
||||
<CallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
// Straight into the (mocked) call, past the lobby
|
||||
skipLobby
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
/>
|
||||
);
|
||||
const { getByText } = render(
|
||||
@@ -199,10 +190,10 @@ function createGroupCallView(
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{groupCallView}
|
||||
{callView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
callView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
@@ -216,9 +207,9 @@ function createGroupCallView(
|
||||
};
|
||||
}
|
||||
|
||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
test.skip("CallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText, rtcSession } = createGroupCallView(nullHostBridge);
|
||||
const { getByText, rtcSession } = createCallView(nullHostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
expect(playSound).toHaveBeenCalledWith("left");
|
||||
@@ -233,7 +224,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
|
||||
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
||||
});
|
||||
|
||||
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||
test.skip("CallView plays a leave sound synchronously in widget mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
||||
let resolvePlaySound: () => void;
|
||||
@@ -248,7 +239,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText, rtcSession } = createGroupCallView(hostBridge);
|
||||
const { getByText, rtcSession } = createCallView(hostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
@@ -281,7 +272,7 @@ test("Should ask the host to close when all other left and play a sound", async
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const { getByText } = createCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateOtherLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
@@ -303,7 +294,7 @@ test("Should not ask the host to close when auto leave due to error", async () =
|
||||
close,
|
||||
};
|
||||
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const { getByText } = createCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateErrorLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
@@ -315,7 +306,7 @@ test("Should not ask the host to close when auto leave due to error", async () =
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
test.skip("CallView leaves the session when an error occurs", async () => {
|
||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
if (error !== null) throw error;
|
||||
@@ -326,7 +317,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge);
|
||||
const { rtcSession } = createCallView(nullHostBridge);
|
||||
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
||||
screen.getByText("Something went wrong");
|
||||
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||
@@ -336,14 +327,14 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
test.skip("CallView shows errors that occur during joining", async () => {
|
||||
const user = userEvent.setup();
|
||||
// This should not mock this error that deep. it should only mock the CallViewModel.
|
||||
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
|
||||
onTestFinished(() => {
|
||||
enterRTCSession.mockReset();
|
||||
});
|
||||
createGroupCallView(nullHostBridge, false);
|
||||
createCallView(nullHostBridge, false);
|
||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
@@ -362,7 +353,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
{ cause: stickyError },
|
||||
);
|
||||
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||
const { rtcSession } = createCallView(nullHostBridge, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
@@ -374,7 +365,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
});
|
||||
|
||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||
const { rtcSession } = createCallView(nullHostBridge, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
@@ -390,7 +381,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro
|
||||
|
||||
test("user can reconnect after a membership manager error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true);
|
||||
const { rtcSession } = createCallView(nullHostBridge, true);
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||
);
|
||||
@@ -71,6 +71,7 @@ import { useAppBarTitle } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { useRootElement } from "../RootElementContext.ts";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
import { useMuteStates } from "../state/useMuteStates.ts";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@@ -85,18 +86,67 @@ declare global {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The client to place the call with. */
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
confineToRoom: boolean;
|
||||
preload: UrlParams["preload"];
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
/** The call to join. */
|
||||
rtcSession: MatrixRTCSession;
|
||||
/**
|
||||
* Whether the user is signed in as a guest, and so should be offered the
|
||||
* chance to create an account when the call ends.
|
||||
*/
|
||||
isPasswordlessUser: boolean;
|
||||
/** Whether to keep the user in this call rather than letting them navigate. */
|
||||
confineToRoom: boolean;
|
||||
/** Whether to wait for the host to ask us to join. */
|
||||
preload: UrlParams["preload"];
|
||||
/** Whether to enter the call directly, without showing the lobby first. */
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
}
|
||||
|
||||
/**
|
||||
* A call, from start to finish.
|
||||
*
|
||||
* This owns the whole lifecycle of being in a call: the lobby, where the user
|
||||
* checks their camera and microphone before joining; the call itself; and the
|
||||
* screen shown once it has ended. Not every call has every stage — the lobby
|
||||
* is skipped when the user is put straight into the call, or when the host
|
||||
* wants to say when to join; and after the call there may be a post-call
|
||||
* screen, a return to the lobby, or nothing, depending on whether the host
|
||||
* decides what comes next. The view decides which stages apply from the
|
||||
* parameters it was started with and from what the host bridge says.
|
||||
*
|
||||
* It owns nothing about how Element Call came to be showing a call: no
|
||||
* routing, no authentication, no resolving of room aliases. Those belong to
|
||||
* whatever is hosting it — the standalone app's own shell, or an application
|
||||
* embedding Element Call as a component. Both render this.
|
||||
*/
|
||||
export const CallView: FC<Props> = (props): ReactNode => {
|
||||
// Whether the user is in the call is the call's own business, not its host's.
|
||||
// Held here rather than below so that it survives the mute state being
|
||||
// rebuilt.
|
||||
const [joined, setJoined] = useState(false);
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
if (muteStates === null) return null;
|
||||
|
||||
return (
|
||||
<LoadedCallView
|
||||
{...props}
|
||||
joined={joined}
|
||||
setJoined={setJoined}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedProps extends Props {
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
muteStates: MuteStates;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
/** {@link CallView}, once it has the mute state everything below needs. */
|
||||
const LoadedCallView: FC<LoadedProps> = ({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
@@ -136,9 +186,9 @@ export const GroupCallView: FC<Props> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info("[Lifecycle] GroupCallView Component mounted");
|
||||
logger.info("[Lifecycle] CallView Component mounted");
|
||||
return (): void => {
|
||||
logger.info("[Lifecycle] GroupCallView Component unmounted");
|
||||
logger.info("[Lifecycle] CallView Component unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/i
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { ElementCallView } from "../ElementCallView";
|
||||
import { CallView } from "./CallView";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { HomePage } from "../home/HomePage";
|
||||
@@ -102,7 +102,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<ElementCallView
|
||||
<CallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
|
||||
Reference in New Issue
Block a user