mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Let the component own its mute state
ElementCallView took muteStates as a prop, which would have meant an embedder building one before it could show a call. It could not simply make its own: RoomPage created one on mount whichever branch it went on to render, and two would both report the user's mute state to the host, talking over each other. Move the construction into a useMuteStates hook, and give the lobby shown while waiting to be let into a room its own component. Each lobby now holds mute state only while it is on screen, so there is never a second one, and the component can own the call's. KnockLobbyView also takes the room summary and label handling that RoomPage was assembling on its behalf, leaving the page with arriving at a call rather than being in one.
This commit is contained in:
@@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk";
|
|||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { GroupCallView } from "./room/GroupCallView";
|
import { GroupCallView } from "./room/GroupCallView";
|
||||||
import { type MuteStates } from "./state/MuteStates";
|
import { useMuteStates } from "./state/useMuteStates";
|
||||||
import { type UrlParams } from "./UrlParams";
|
import { type UrlParams } from "./UrlParams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,8 +18,6 @@ interface Props {
|
|||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
/** The call to join. */
|
/** The call to join. */
|
||||||
rtcSession: MatrixRTCSession;
|
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
|
* Whether the user is signed in as a guest, and so should be offered the
|
||||||
* chance to create an account when the call ends.
|
* 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.
|
* 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
|
* Those belong to whatever is hosting it — the standalone app's own shell, or
|
||||||
* an application embedding Element Call directly.
|
* 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<Props> = ({
|
export const ElementCallView: FC<Props> = ({
|
||||||
client,
|
client,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
muteStates,
|
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
confineToRoom,
|
confineToRoom,
|
||||||
preload,
|
preload,
|
||||||
@@ -56,6 +49,9 @@ export const ElementCallView: FC<Props> = ({
|
|||||||
}): ReactNode => {
|
}): ReactNode => {
|
||||||
// Whether the user is in the call is the call's own business, not its host's.
|
// Whether the user is in the call is the call's own business, not its host's.
|
||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
|
const muteStates = useMuteStates();
|
||||||
|
|
||||||
|
if (muteStates === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupCallView
|
<GroupCallView
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
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 JSX, type ReactNode } from "react";
|
||||||
|
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
|
import { LobbyView } from "./LobbyView";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { useMuteStates } from "../state/useMuteStates";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
client: MatrixClient;
|
||||||
|
/** What we know about the room from peeking at it. */
|
||||||
|
roomSummary: RoomSummary;
|
||||||
|
/** The user's own name and avatar, to show in their own tile. */
|
||||||
|
profile: { displayName: string; avatarUrl: string };
|
||||||
|
/**
|
||||||
|
* Asks to be let in, if the room allows it. Absent once we have asked and
|
||||||
|
* are waiting for an answer.
|
||||||
|
*/
|
||||||
|
knock: (() => 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<Props> = ({
|
||||||
|
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")}
|
||||||
|
<CheckIcon />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("lobby.ask_to_join")
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LobbyView
|
||||||
|
client={client}
|
||||||
|
matrixInfo={{
|
||||||
|
userId: client.getUserId() ?? "",
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
roomAlias: null,
|
||||||
|
roomId: roomSummary.room_id,
|
||||||
|
roomName: roomSummary.name ?? "",
|
||||||
|
roomAvatar: roomSummary.avatar_url ?? null,
|
||||||
|
e2eeSystem: {
|
||||||
|
kind: roomSummary["im.nheko.summary.encryption"]
|
||||||
|
? E2eeType.PER_PARTICIPANT
|
||||||
|
: E2eeType.NONE,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onEnter={(): void => knock?.()}
|
||||||
|
enterLabel={enterLabel}
|
||||||
|
waitingForInvite={waitingForInvite}
|
||||||
|
confineToRoom={confineToRoom}
|
||||||
|
hideHeader={hideHeader}
|
||||||
|
participantCount={null}
|
||||||
|
muteStates={muteStates}
|
||||||
|
onShareClick={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+24
-91
@@ -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.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
|
||||||
type FC,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
useRef,
|
|
||||||
type JSX,
|
|
||||||
} from "react";
|
|
||||||
import { type MatrixError } from "matrix-js-sdk";
|
import { type MatrixError } from "matrix-js-sdk";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import {
|
import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
CheckIcon,
|
|
||||||
UnknownSolidIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
|
|
||||||
import { useClientLegacy } from "../ClientContext";
|
import { useClientLegacy } from "../ClientContext";
|
||||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||||
@@ -29,22 +19,15 @@ import { ElementCallView } from "../ElementCallView";
|
|||||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
import { HomePage } from "../home/HomePage";
|
import { HomePage } from "../home/HomePage";
|
||||||
import { useHostBridge } from "../HostBridge.ts";
|
|
||||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { KnockLobbyView } from "./KnockLobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
|
||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { useOptInAnalytics } from "../settings/settings";
|
import { useOptInAnalytics } from "../settings/settings";
|
||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
import { ErrorView } from "../ErrorView";
|
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 => {
|
export const RoomPage: FC = (): ReactNode => {
|
||||||
const urlParams = useUrlParams();
|
const urlParams = useUrlParams();
|
||||||
const hostBridge = useHostBridge();
|
|
||||||
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
|
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||||
@@ -63,26 +46,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
|
|
||||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||||
|
|
||||||
const devices = useMediaDevices();
|
|
||||||
const [muteStates, setMuteStates] = useState<MuteStates | null>(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(() => {
|
useEffect(() => {
|
||||||
// If we've finished loading, are not already authed and we've been given a display name as
|
// 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
|
// a URL param, automatically register a passwordless user
|
||||||
@@ -123,64 +86,34 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
switch (groupCallState.kind) {
|
switch (groupCallState.kind) {
|
||||||
case "loaded":
|
case "loaded":
|
||||||
return (
|
return (
|
||||||
muteStates && (
|
<ElementCallView
|
||||||
<ElementCallView
|
client={client!}
|
||||||
client={client!}
|
rtcSession={groupCallState.rtcSession}
|
||||||
rtcSession={groupCallState.rtcSession}
|
isPasswordlessUser={passwordlessUser}
|
||||||
isPasswordlessUser={passwordlessUser}
|
confineToRoom={confineToRoom}
|
||||||
confineToRoom={confineToRoom}
|
preload={preload}
|
||||||
preload={preload}
|
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
/>
|
||||||
muteStates={muteStates}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
case "waitForInvite":
|
case "waitForInvite":
|
||||||
case "canKnock": {
|
case "canKnock": {
|
||||||
wasInWaitForInviteState.current =
|
wasInWaitForInviteState.current =
|
||||||
wasInWaitForInviteState.current ||
|
wasInWaitForInviteState.current ||
|
||||||
groupCallState.kind === "waitForInvite";
|
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")}
|
|
||||||
<CheckIcon />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
muteStates && (
|
<KnockLobbyView
|
||||||
<LobbyView
|
client={client!}
|
||||||
client={client!}
|
roomSummary={groupCallState.roomSummary}
|
||||||
matrixInfo={{
|
profile={{
|
||||||
userId: client!.getUserId() ?? "",
|
displayName: userDisplayName ?? "",
|
||||||
displayName: userDisplayName ?? "",
|
avatarUrl: avatarUrl ?? "",
|
||||||
avatarUrl: avatarUrl ?? "",
|
}}
|
||||||
roomAlias: null,
|
knock={
|
||||||
roomId: groupCallState.roomSummary.room_id,
|
groupCallState.kind === "canKnock" ? groupCallState.knock : null
|
||||||
roomName: groupCallState.roomSummary.name ?? "",
|
}
|
||||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
confineToRoom={confineToRoom}
|
||||||
e2eeSystem: {
|
hideHeader={header !== "standard"}
|
||||||
kind: groupCallState.roomSummary[
|
/>
|
||||||
"im.nheko.summary.encryption"
|
|
||||||
]
|
|
||||||
? E2eeType.PER_PARTICIPANT
|
|
||||||
: E2eeType.NONE,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onEnter={(): void => knock?.()}
|
|
||||||
enterLabel={label}
|
|
||||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
|
||||||
confineToRoom={confineToRoom}
|
|
||||||
hideHeader={header !== "standard"}
|
|
||||||
participantCount={null}
|
|
||||||
muteStates={muteStates}
|
|
||||||
onShareClick={null}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "loading":
|
case "loading":
|
||||||
|
|||||||
@@ -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<MuteStates | null>(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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user