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:
Timo K.
2026-09-08 15:38:59 +02:00
co-authored by Claude Fable 5.1
parent 8e8bc5ddde
commit f70fba1dd6
5 changed files with 84 additions and 112 deletions
+2 -2
View File
@@ -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}
-69
View File
@@ -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");
};
}, []);
+2 -2
View File
@@ -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}