merge: upstream Element Call v0.28.0 (cinny #201) #61
@@ -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<Props> = ({
|
||||
client,
|
||||
rtcSession,
|
||||
muteStates,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
@@ -56,6 +49,9 @@ export const ElementCallView: FC<Props> = ({
|
||||
}): 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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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<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(() => {
|
||||
// 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 && (
|
||||
<ElementCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
)
|
||||
<ElementCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
/>
|
||||
);
|
||||
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")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
muteStates && (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
<KnockLobbyView
|
||||
client={client!}
|
||||
roomSummary={groupCallState.roomSummary}
|
||||
profile={{
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
}}
|
||||
knock={
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null
|
||||
}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={header !== "standard"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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