diff --git a/component/index.tsx b/component/index.tsx index 7e89f89da..06b5631d1 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -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 = ({ - = ({ - 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 ( - - ); -}; diff --git a/src/room/GroupCallView.test.tsx b/src/room/CallView.test.tsx similarity index 87% rename from src/room/GroupCallView.test.tsx rename to src/room/CallView.test.tsx index a53288c2c..522a41616 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/CallView.test.tsx @@ -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).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 = ( - ); const { getByText } = render( @@ -199,10 +190,10 @@ function createGroupCallView( {options.withErrorBoundary ? ( - {groupCallView} + {callView} ) : ( - groupCallView + callView )} @@ -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).mockImplementation(() => { const [error, setError] = useState(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), ); diff --git a/src/room/GroupCallView.tsx b/src/room/CallView.tsx similarity index 89% rename from src/room/GroupCallView.tsx rename to src/room/CallView.tsx index e3cbea32b..a88ab7b2d 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/CallView.tsx @@ -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): 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 ( + + ); +}; + +interface LoadedProps extends Props { joined: boolean; setJoined: (value: boolean) => void; muteStates: MuteStates; } -export const GroupCallView: FC = ({ +/** {@link CallView}, once it has the mute state everything below needs. */ +const LoadedCallView: FC = ({ client, isPasswordlessUser, confineToRoom, @@ -136,9 +186,9 @@ export const GroupCallView: FC = ({ }, []); 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"); }; }, []); diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 8d974ee46..71bceea76 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -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 ( -