diff --git a/src/ElementCallView.tsx b/src/ElementCallView.tsx index 72cf9ddaf..d0b3e4f34 100644 --- a/src/ElementCallView.tsx +++ b/src/ElementCallView.tsx @@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { GroupCallView } from "./room/GroupCallView"; -import { type MuteStates } from "./state/MuteStates"; +import { useMuteStates } from "./state/useMuteStates"; import { type UrlParams } from "./UrlParams"; interface Props { @@ -18,8 +18,6 @@ interface Props { client: MatrixClient; /** The call to join. */ rtcSession: MatrixRTCSession; - /** The audio and video mute state to start from, and keep in step with. */ - muteStates: MuteStates; /** * Whether the user is signed in as a guest, and so should be offered the * chance to create an account when the call ends. @@ -40,15 +38,10 @@ interface Props { * 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. - * - * TODO: `muteStates` is still passed in, because the standalone shell shares - * one with the lobby it shows while waiting to be let into a room. Ownership - * moves here once that lobby has its own. */ export const ElementCallView: FC = ({ client, rtcSession, - muteStates, isPasswordlessUser, confineToRoom, preload, @@ -56,6 +49,9 @@ export const ElementCallView: FC = ({ }): 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 ( void) | null; + confineToRoom: boolean; + hideHeader: boolean; +} + +/** + * The lobby shown while the user is outside a room they want to call in — + * either able to ask to join, or waiting for someone to answer. + * + * This belongs to the app shell rather than to the call: it exists precisely + * because there is no call to be in yet. It keeps its own mute state, which is + * why it is a component rather than part of the page — so that the call's mute + * state and this one are never alive at the same time, reporting over each + * other to the host. + */ +export const KnockLobbyView: FC = ({ + client, + roomSummary, + profile, + knock, + confineToRoom, + hideHeader, +}): ReactNode => { + const { t } = useTranslation(); + const muteStates = useMuteStates(); + + if (muteStates === null) return null; + + const waitingForInvite = knock === null; + const enterLabel: string | JSX.Element = waitingForInvite ? ( + <> + {t("lobby.waiting_for_invite")} + + + ) : ( + t("lobby.ask_to_join") + ); + + return ( + knock?.()} + enterLabel={enterLabel} + waitingForInvite={waitingForInvite} + confineToRoom={confineToRoom} + hideHeader={hideHeader} + participantCount={null} + muteStates={muteStates} + onShareClick={null} + /> + ); +}; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 604840e5f..f49509e30 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -6,21 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type FC, - useEffect, - useState, - type ReactNode, - useRef, - type JSX, -} from "react"; +import { type FC, useEffect, useState, type ReactNode, useRef } from "react"; import { type MatrixError } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; import { Trans, useTranslation } from "react-i18next"; -import { - CheckIcon, - UnknownSolidIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useClientLegacy } from "../ClientContext"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; @@ -29,22 +19,15 @@ import { ElementCallView } from "../ElementCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; -import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; -import { LobbyView } from "./LobbyView"; -import { E2eeType } from "../e2ee/e2eeType"; +import { KnockLobbyView } from "./KnockLobbyView"; import { useProfile } from "../profile/useProfile"; import { useOptInAnalytics } from "../settings/settings"; import { Link } from "../button/Link"; import { ErrorView } from "../ErrorView"; -import { useMediaDevices } from "../MediaDevicesContext"; -import { MuteStates } from "../state/MuteStates"; -import { ObservableScope } from "../state/ObservableScope"; -import { calculateInitialMuteState } from "../state/initialMuteState.ts"; export const RoomPage: FC = (): ReactNode => { const urlParams = useUrlParams(); - const hostBridge = useHostBridge(); const { confineToRoom, preload, header, displayName, skipLobby } = urlParams; const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -63,26 +46,6 @@ export const RoomPage: FC = (): ReactNode => { const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); - const devices = useMediaDevices(); - const [muteStates, setMuteStates] = useState(null); - - useEffect(() => { - const scope = new ObservableScope(); - setMuteStates( - new MuteStates( - scope, - devices, - calculateInitialMuteState( - urlParams.skipLobby, - urlParams.callIntent, - urlParams.isWidget, - ), - hostBridge, - ), - ); - return (): void => scope.end(); - }, [devices, urlParams, hostBridge]); - useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user @@ -123,64 +86,34 @@ export const RoomPage: FC = (): ReactNode => { switch (groupCallState.kind) { case "loaded": return ( - muteStates && ( - - ) + ); case "waitForInvite": case "canKnock": { wasInWaitForInviteState.current = wasInWaitForInviteState.current || groupCallState.kind === "waitForInvite"; - const knock = - groupCallState.kind === "canKnock" ? groupCallState.knock : null; - const label: string | JSX.Element = - groupCallState.kind === "canKnock" ? ( - t("lobby.ask_to_join") - ) : ( - <> - {t("lobby.waiting_for_invite")} - - - ); return ( - muteStates && ( - knock?.()} - enterLabel={label} - waitingForInvite={groupCallState.kind === "waitForInvite"} - confineToRoom={confineToRoom} - hideHeader={header !== "standard"} - participantCount={null} - muteStates={muteStates} - onShareClick={null} - /> - ) + ); } case "loading": diff --git a/src/state/useMuteStates.ts b/src/state/useMuteStates.ts new file mode 100644 index 000000000..ab35169f0 --- /dev/null +++ b/src/state/useMuteStates.ts @@ -0,0 +1,51 @@ +/* +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 { useEffect, useState } from "react"; + +import { MuteStates } from "./MuteStates"; +import { ObservableScope } from "./ObservableScope"; +import { calculateInitialMuteState } from "./initialMuteState"; +import { useMediaDevices } from "../MediaDevicesContext"; +import { useHostBridge } from "../HostBridge"; +import { useUrlParams } from "../UrlParams"; + +/** + * Audio and video mute state, kept in step with the host. + * + * `null` until the media devices have been looked at, since what the user + * starts muted depends on what they have. + * + * Whoever shows the user their own camera owns one of these. Note there should + * only ever be one alive at a time: each reports the user's mute state to the + * host, so two would have them talking over each other. + */ +export function useMuteStates(): MuteStates | null { + const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); + const devices = useMediaDevices(); + const [muteStates, setMuteStates] = useState(null); + + useEffect(() => { + const scope = new ObservableScope(); + setMuteStates( + new MuteStates( + scope, + devices, + calculateInitialMuteState( + urlParams.skipLobby, + urlParams.callIntent, + urlParams.isWidget, + ), + hostBridge, + ), + ); + return (): void => scope.end(); + }, [devices, urlParams, hostBridge]); + + return muteStates; +}