Give the lobby a join state instead of an enter callback

`LobbyView` took `onEnter`, `enterLabel` and `waitingForInvite`, which cannot
express a declined, banned or invite-only room, so each of those ended at a
full-screen error and took the camera preview with it.

It now takes a `LobbyJoinState`, one of seven kinds, and renders both the
button and a message under the preview from it. `KnockLobbyView` passes
through what the loader offers, which is a single `lobby` state in place of
`canKnock` and `waitForInvite`, so a decline, a ban, or a room that takes
neither joins nor knocks keeps the user in the lobby. Waiting says what it is
waiting for and offers a way to withdraw the request.

The loader drives that state from the lobby's own callbacks rather than from
the load promise, so a refused request leaves the user where they were, and
every membership listener it adds is dropped when the effect is cleaned up.
What it knows of a room nobody has joined is a `PreJoinRoomInfo`, built from
a summary or from a `Room`, so a ban Element Call finds at load time is a
lobby state as well.

Denied and banned reuse the translated `group_call_loader.*` copy, so four
strings are new.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Quentin Gliech
2026-09-09 22:44:59 +02:00
co-authored by Claude Fable 5.1
parent 1c8a6487e9
commit 3653a7fd05
14 changed files with 1286 additions and 303 deletions
+4 -1
View File
@@ -152,10 +152,13 @@
"layout_switch_label": "Layout", "layout_switch_label": "Layout",
"lobby": { "lobby": {
"ask_to_join": "Request to join call", "ask_to_join": "Request to join call",
"cancel_request": "Cancel request",
"invite_only_body": "You need an invite to join this call.",
"join_as_guest": "Join as guest", "join_as_guest": "Join as guest",
"join_button": "Join call", "join_button": "Join call",
"leave_button": "Back to recents", "leave_button": "Back to recents",
"waiting_for_invite": "Request sent! Waiting for permission to join…" "request_sent": "Request to join sent",
"request_sent_body": "You will receive an invite to join the call if your request is accepted."
}, },
"log_in": "Log In", "log_in": "Log In",
"logging_in": "Logging in…", "logging_in": "Logging in…",
+1 -1
View File
@@ -507,7 +507,7 @@ const LoadedCallView: FC<LoadedProps> = ({
client={client} client={client}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} muteStates={muteStates}
onEnter={() => setJoined(true)} joinState={{ kind: "can-join", join: () => setJoined(true) }}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={header !== HeaderStyle.Standard} hideHeader={header !== HeaderStyle.Standard}
participantCount={participantCount} participantCount={participantCount}
+19 -14
View File
@@ -9,13 +9,16 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { KnockLobbyView } from "./KnockLobbyView"; import { KnockLobbyView } from "./KnockLobbyView";
import { type LobbyJoinState } from "./LobbyJoinState";
import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext"; import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { mockMediaDevices } from "../utils/test"; import { mockMediaDevices } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { type PreJoinRoomInfo } from "./preJoinRoomInfo";
vi.mock("@livekit/components-react", () => ({ vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [], usePreviewTracks: (): unknown[] => [],
@@ -44,22 +47,24 @@ const client = {
} as Partial<MatrixClient> as MatrixClient; } as Partial<MatrixClient> as MatrixClient;
// What peeking at a room we are not in tells us about it // What peeking at a room we are not in tells us about it
const roomSummary = { const room: PreJoinRoomInfo = {
room_id: "!room:example.org", roomId: "!room:example.org",
name: "Knock Room", roomName: "Knock Room",
"im.nheko.summary.encryption": "m.megolm.v1.aes-sha2", roomAlias: null,
} as Partial<RoomSummary> as RoomSummary; roomAvatar: null,
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
};
function renderKnockLobby(knock: (() => void) | null): void { function renderKnockLobby(joinState: LobbyJoinState): void {
render( render(
<LeaveToHomeProvider value={vi.fn()}> <LeaveToHomeProvider value={vi.fn()}>
<MediaDevicesContext value={mockMediaDevices({})}> <MediaDevicesContext value={mockMediaDevices({})}>
<TooltipProvider> <TooltipProvider>
<KnockLobbyView <KnockLobbyView
client={client} client={client}
roomSummary={roomSummary} room={room}
profile={{ displayName: "Test User", avatarUrl: "" }} profile={{ displayName: "Test User", avatarUrl: "" }}
knock={knock} joinState={joinState}
confineToRoom={false} confineToRoom={false}
hideHeader={false} hideHeader={false}
/> />
@@ -71,8 +76,8 @@ function renderKnockLobby(knock: (() => void) | null): void {
describe("KnockLobbyView", () => { describe("KnockLobbyView", () => {
it("offers to ask to join, with what it knows of the room", async () => { it("offers to ask to join, with what it knows of the room", async () => {
const knock = vi.fn(); const askToJoin = vi.fn();
renderKnockLobby(knock); renderKnockLobby({ kind: "can-ask-to-join", askToJoin });
// The mute state arrives asynchronously, and the lobby with it // The mute state arrives asynchronously, and the lobby with it
const button = await screen.findByTestId("lobby_joinCall"); const button = await screen.findByTestId("lobby_joinCall");
@@ -81,14 +86,14 @@ describe("KnockLobbyView", () => {
expect(screen.getByText("Knock Room")).toBeInTheDocument(); expect(screen.getByText("Knock Room")).toBeInTheDocument();
await userEvent.setup().click(button); await userEvent.setup().click(button);
expect(knock).toHaveBeenCalledOnce(); expect(askToJoin).toHaveBeenCalledOnce();
}); });
it("waits once it has asked", async () => { it("waits once it has asked", async () => {
renderKnockLobby(null); renderKnockLobby({ kind: "waiting-for-approval" });
const button = await screen.findByTestId("lobby_joinCall"); const button = await screen.findByTestId("lobby_joinCall");
expect(button).toHaveTextContent("Request sent!"); expect(button).toHaveTextContent("Request to join sent");
// Compound's button keeps focusable, saying so through ARIA instead // Compound's button keeps focusable, saying so through ARIA instead
expect(button).toHaveAttribute("aria-disabled", "true"); expect(button).toHaveAttribute("aria-disabled", "true");
}); });
+12 -37
View File
@@ -5,26 +5,22 @@ 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 { type FC, type JSX, type ReactNode } from "react"; import { type FC, type ReactNode } from "react";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk"; import { type MatrixClient } 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 { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useMuteStates } from "../state/useMuteStates"; import { useMuteStates } from "../state/useMuteStates";
import { type LobbyJoinState } from "./LobbyJoinState";
import { type PreJoinRoomInfo } from "./preJoinRoomInfo";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
/** What we know about the room from peeking at it. */ /** What we know about the room without being in it. */
roomSummary: RoomSummary; room: PreJoinRoomInfo;
/** The user's own name and avatar, to show in their own tile. */ /** The user's own name and avatar, to show in their own tile. */
profile: { displayName: string; avatarUrl: string }; profile: { displayName: string; avatarUrl: string };
/** /** How the user may get into the call, and what they may do about it. */
* Asks to be let in, if the room allows it. Absent once we have asked and joinState: LobbyJoinState;
* are waiting for an answer.
*/
knock: (() => void) | null;
confineToRoom: boolean; confineToRoom: boolean;
hideHeader: boolean; hideHeader: boolean;
} }
@@ -41,27 +37,16 @@ interface Props {
*/ */
export const KnockLobbyView: FC<Props> = ({ export const KnockLobbyView: FC<Props> = ({
client, client,
roomSummary, room,
profile, profile,
knock, joinState,
confineToRoom, confineToRoom,
hideHeader, hideHeader,
}): ReactNode => { }): ReactNode => {
const { t } = useTranslation();
const muteStates = useMuteStates(); const muteStates = useMuteStates();
if (muteStates === null) return null; 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 ( return (
<LobbyView <LobbyView
client={client} client={client}
@@ -69,19 +54,9 @@ export const KnockLobbyView: FC<Props> = ({
userId: client.getUserId() ?? "", userId: client.getUserId() ?? "",
displayName: profile.displayName, displayName: profile.displayName,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
roomAlias: null, ...room,
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?.()} joinState={joinState}
enterLabel={enterLabel}
waitingForInvite={waitingForInvite}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={hideHeader}
participantCount={null} participantCount={null}
+35
View File
@@ -0,0 +1,35 @@
/*
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.
*/
/**
* How the local user may enter the call they are looking at, and therefore what
* the lobby offers them. Everything the lobby needs to know about membership.
*/
export type LobbyJoinState =
/** The user may enter the call right away. */
| { kind: "can-join"; join: () => void }
/**
* The room only takes knocks. `error` is set when a previous request failed
* to send.
*/
| {
kind: "can-ask-to-join";
askToJoin: (reason?: string) => void;
error?: "request_failed";
}
/** The request is on its way to the server. */
| { kind: "sending-request" }
/**
* The request is with the room's moderators. `cancelRequest` is absent while
* a withdrawal is on its way, and where withdrawing is not supported.
*/
| { kind: "waiting-for-approval"; cancelRequest?: () => void }
/** The request was declined. There is no way to ask again. */
| { kind: "denied" }
| { kind: "banned"; reason?: string }
/** The room takes neither joins nor knocks from this user. */
| { kind: "not-allowed" };
+16
View File
@@ -39,3 +39,19 @@ Please see LICENSE in the repository root for full details.
gap: var(--cpd-space-10x); gap: var(--cpd-space-10x);
} }
} }
.joinMessage {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--cpd-space-2x);
max-inline-size: 40ch;
text-align: center;
color: var(--cpd-color-text-secondary);
}
.waiting {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
}
+160 -14
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
@@ -17,6 +18,7 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { type LobbyJoinState } from "./LobbyJoinState";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { mockMediaDevices, mockMuteStates } from "../utils/test"; import { mockMediaDevices, mockMuteStates } from "../utils/test";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -87,7 +89,7 @@ function renderLobbyView(
client={mockClient} client={mockClient}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
muteStates={muteStates} muteStates={muteStates}
onEnter={() => {}} joinState={{ kind: "can-join", join: () => {} }}
confineToRoom={false} confineToRoom={false}
hideHeader={hideHeader} hideHeader={hideHeader}
participantCount={3} participantCount={3}
@@ -125,18 +127,9 @@ describe("LobbyView", () => {
} }
}); });
it("renders with waiting for invite state", () => {
const { getByTestId } = renderLobbyView({
waitingForInvite: true,
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
it("renders with AppBar android", async () => { it("renders with AppBar android", async () => {
const { container, getByRole } = renderLobbyView( const { container, getByRole } = renderLobbyView(
{ { joinState: { kind: "waiting-for-approval" } },
waitingForInvite: true,
},
true, true,
"android", "android",
); );
@@ -157,9 +150,7 @@ describe("LobbyView", () => {
it("renders with AppBar ios", async () => { it("renders with AppBar ios", async () => {
const { container, getByRole } = renderLobbyView( const { container, getByRole } = renderLobbyView(
{ { joinState: { kind: "waiting-for-approval" } },
waitingForInvite: true,
},
true, true,
"ios", "ios",
); );
@@ -177,4 +168,159 @@ describe("LobbyView", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations(); expect(await axe(container)).toHaveNoViolations();
}); });
describe("join states", () => {
const cases: {
joinState: LobbyJoinState;
button: string | null;
disabled: boolean;
message: string | null;
}[] = [
{
joinState: { kind: "can-join", join: () => {} },
button: "Join call",
disabled: false,
message: null,
},
{
joinState: { kind: "can-ask-to-join", askToJoin: () => {} },
button: "Request to join call",
disabled: false,
message: null,
},
{
joinState: {
kind: "can-ask-to-join",
askToJoin: () => {},
error: "request_failed",
},
button: "Request to join call",
disabled: false,
message: "Something went wrong",
},
{
joinState: { kind: "sending-request" },
button: "Request to join call",
disabled: true,
message: null,
},
{
joinState: { kind: "waiting-for-approval" },
button: "Request to join sent",
disabled: true,
message: "You will receive an invite",
},
{
joinState: { kind: "denied" },
button: null,
disabled: false,
message: "Your request to join was declined.",
},
{
joinState: { kind: "banned" },
button: null,
disabled: false,
message: "You have been banned from the room.",
},
{
joinState: { kind: "not-allowed" },
button: null,
disabled: false,
message: "You need an invite to join this call.",
},
];
it.each(cases)(
"renders $joinState.kind",
async ({ joinState, button, disabled, message }) => {
const { container, queryByTestId } = renderLobbyView({ joinState });
const joinButton = queryByTestId("lobby_joinCall");
if (button === null) {
expect(joinButton).toBeNull();
} else {
expect(joinButton).toHaveTextContent(button);
// Compound buttons are soft-disabled: they keep focus and expose
// `aria-disabled` rather than the DOM `disabled` attribute.
if (disabled) {
expect(joinButton).toHaveAttribute("aria-disabled", "true");
} else {
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
}
}
const messageBlock = queryByTestId("lobby_joinMessage");
if (message === null) {
expect(messageBlock).toBeNull();
} else {
expect(messageBlock).toHaveTextContent(message);
}
expect(await axe(container)).toHaveNoViolations();
},
);
it("only marks the waiting button as waiting", () => {
const waiting = renderLobbyView({
joinState: { kind: "waiting-for-approval" },
});
expect(waiting.getByTestId("lobby_joinCall")).toHaveClass(
lobbyStyles.wait,
);
waiting.unmount();
const canJoin = renderLobbyView();
expect(canJoin.getByTestId("lobby_joinCall")).not.toHaveClass(
lobbyStyles.wait,
);
});
it("joins when the join button is pressed", async () => {
const join = vi.fn();
const { getByTestId } = renderLobbyView({
joinState: { kind: "can-join", join },
});
await userEvent.click(getByTestId("lobby_joinCall"));
expect(join).toHaveBeenCalled();
});
it("asks to join when the request button is pressed", async () => {
const askToJoin = vi.fn();
const { getByTestId } = renderLobbyView({
joinState: { kind: "can-ask-to-join", askToJoin },
});
await userEvent.click(getByTestId("lobby_joinCall"));
expect(askToJoin).toHaveBeenCalled();
});
it("does nothing while the request is being sent", async () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "sending-request" },
});
const button = getByTestId("lobby_joinCall");
expect(button).toHaveAttribute("aria-busy", "true");
await userEvent.click(button);
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("withdraws the request when cancel is pressed", async () => {
const cancelRequest = vi.fn();
const { getByTestId } = renderLobbyView({
joinState: { kind: "waiting-for-approval", cancelRequest },
});
await userEvent.click(getByTestId("lobby_cancelRequest"));
expect(cancelRequest).toHaveBeenCalled();
});
it("offers no cancel link when withdrawing is unsupported", () => {
const { queryByTestId } = renderLobbyView({
joinState: { kind: "waiting-for-approval" },
});
expect(queryByTestId("lobby_cancelRequest")).toBeNull();
});
it("shows the ban reason", () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "banned", reason: "Not today" },
});
expect(getByTestId("lobby_joinMessage")).toHaveTextContent(
"Reason: Not today",
);
});
});
}); });
+128 -21
View File
@@ -7,16 +7,20 @@ Please see LICENSE in the repository root for full details.
import { import {
type FC, type FC,
type ReactNode,
useCallback, useCallback,
useMemo, useMemo,
useState, useState,
type JSX,
useEffect, useEffect,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { Button } from "@vector-im/compound-web"; import { Button, Heading, InlineSpinner, Text } from "@vector-im/compound-web";
import classNames from "classnames"; import classNames from "classnames";
import {
CheckIcon,
SpinnerIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { usePreviewTracks } from "@livekit/components-react"; import { usePreviewTracks } from "@livekit/components-react";
import { import {
@@ -28,6 +32,7 @@ import { useObservableEagerState } from "observable-hooks";
import inCallStyles from "./InCallView.module.css"; import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
import buttonStyles from "../button/Button.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview"; import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "../state/MuteStates"; import { type MuteStates } from "../state/MuteStates";
@@ -51,31 +56,28 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel"; import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel"; import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar"; import { useAppBarPrimaryButtonIconKind } from "../AppBar";
import { type LobbyJoinState } from "./LobbyJoinState";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; muteStates: MuteStates;
onEnter: () => void; joinState: LobbyJoinState;
enterLabel?: JSX.Element | string;
confineToRoom: boolean; confineToRoom: boolean;
hideHeader: boolean; hideHeader: boolean;
participantCount: number | null; participantCount: number | null;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
waitingForInvite?: boolean;
} }
export const LobbyView: FC<Props> = ({ export const LobbyView: FC<Props> = ({
client, client,
matrixInfo, matrixInfo,
muteStates, muteStates,
onEnter, joinState,
enterLabel,
confineToRoom, confineToRoom,
hideHeader, hideHeader,
participantCount, participantCount,
onShareClick, onShareClick,
waitingForInvite,
}) => { }) => {
useEffect(() => { useEffect(() => {
logger.info("[Lifecycle] LobbyView Component mounted"); logger.info("[Lifecycle] LobbyView Component mounted");
@@ -209,6 +211,118 @@ export const LobbyView: FC<Props> = ({
}; };
}, [devices, hangup, hideHeader, muteStates, openSettings]); }, [devices, hangup, hideHeader, muteStates, openSettings]);
const joinButton = ((): ReactNode => {
switch (joinState.kind) {
case "can-join":
return (
<Button
className={styles.join}
size="lg"
onClick={joinState.join}
data-testid="lobby_joinCall"
>
{t("lobby.join_button")}
</Button>
);
case "can-ask-to-join":
return (
<Button
className={styles.join}
size="lg"
onClick={() => joinState.askToJoin()}
data-testid="lobby_joinCall"
>
{t("lobby.ask_to_join")}
</Button>
);
case "sending-request":
return (
<Button
className={classNames(styles.join, buttonStyles.rotate)}
size="lg"
Icon={SpinnerIcon}
disabled
aria-busy
data-testid="lobby_joinCall"
>
{t("lobby.ask_to_join")}
</Button>
);
case "waiting-for-approval":
return (
<Button
className={classNames(styles.join, styles.wait)}
size="md"
disabled
data-testid="lobby_joinCall"
>
{t("lobby.request_sent")}
<CheckIcon />
</Button>
);
case "denied":
case "banned":
case "not-allowed":
return null;
}
})();
const joinMessage = ((): ReactNode => {
switch (joinState.kind) {
case "can-ask-to-join":
return joinState.error === undefined ? null : (
<Text size="sm">{t("error.generic")}</Text>
);
case "waiting-for-approval":
return (
<>
<div className={styles.waiting}>
<InlineSpinner aria-label={t("common.loading")} />
<Text size="sm">{t("lobby.request_sent_body")}</Text>
</div>
{joinState.cancelRequest !== undefined && (
<Button
kind="tertiary"
size="md"
onClick={joinState.cancelRequest}
data-testid="lobby_cancelRequest"
>
{t("lobby.cancel_request")}
</Button>
)}
</>
);
case "denied":
return (
<>
<Heading as="h2" weight="semibold" size="sm">
{t("group_call_loader.knock_reject_heading")}
</Heading>
<Text size="sm">{t("group_call_loader.knock_reject_body")}</Text>
</>
);
case "banned":
return (
<>
<Heading as="h2" weight="semibold" size="sm">
{t("group_call_loader.banned_heading")}
</Heading>
<Text size="sm">{t("group_call_loader.banned_body")}</Text>
{joinState.reason !== undefined && (
<Text size="sm">
{t("group_call_loader.reason", { reason: joinState.reason })}
</Text>
)}
</>
);
case "not-allowed":
return <Text size="sm">{t("lobby.invite_only_body")}</Text>;
case "can-join":
case "sending-request":
return null;
}
})();
// TODO: Unify this component with InCallView, so we can get slick joining // TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS // animations and don't have to feel bad about reusing its CSS
return ( return (
@@ -236,20 +350,13 @@ export const LobbyView: FC<Props> = ({
videoEnabled={videoEnabled} videoEnabled={videoEnabled}
videoTrack={videoTrack} videoTrack={videoTrack}
> >
<Button {joinButton}
className={classNames(styles.join, {
[styles.wait]: waitingForInvite,
})}
size={waitingForInvite ? "md" : "lg"}
disabled={waitingForInvite}
onClick={() => {
if (!waitingForInvite) onEnter();
}}
data-testid="lobby_joinCall"
>
{enterLabel ?? t("lobby.join_button")}
</Button>
</VideoPreview> </VideoPreview>
{joinMessage !== null && (
<div className={styles.joinMessage} data-testid="lobby_joinMessage">
{joinMessage}
</div>
)}
{!recentsButtonInFooter && recentsButton} {!recentsButtonInFooter && recentsButton}
</div> </div>
{footerVm !== null && ( {footerVm !== null && (
+12 -16
View File
@@ -56,9 +56,8 @@ export const RoomPage: FC = (): ReactNode => {
); );
usePageTitle( usePageTitle(
roomName ?? roomName ??
(groupCallState.kind === "canKnock" || (groupCallState.kind === "lobby"
groupCallState.kind === "waitForInvite" ? groupCallState.room.roomName
? groupCallState.roomSummary.name
: undefined), : undefined),
); );
@@ -90,10 +89,10 @@ export const RoomPage: FC = (): ReactNode => {
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true); if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]); }, [optInAnalytics, setOptInAnalytics]);
const wasInWaitForInviteState = useRef<boolean>(false); const wasWaitingForApproval = useRef<boolean>(false);
useEffect(() => { useEffect(() => {
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) { if (groupCallState.kind === "loaded" && wasWaitingForApproval.current) {
logger.log("Play join sound 'Not yet implemented'"); logger.log("Play join sound 'Not yet implemented'");
} }
}, [groupCallState.kind]); }, [groupCallState.kind]);
@@ -108,25 +107,22 @@ export const RoomPage: FC = (): ReactNode => {
isPasswordlessUser={passwordlessUser} isPasswordlessUser={passwordlessUser}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
preload={preload} preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current} skipLobby={skipLobby || wasWaitingForApproval.current}
/> />
); );
case "waitForInvite": case "lobby": {
case "canKnock": { wasWaitingForApproval.current =
wasInWaitForInviteState.current = wasWaitingForApproval.current ||
wasInWaitForInviteState.current || groupCallState.joinState.kind === "waiting-for-approval";
groupCallState.kind === "waitForInvite";
return ( return (
<KnockLobbyView <KnockLobbyView
client={client!} client={client!}
roomSummary={groupCallState.roomSummary} room={groupCallState.room}
profile={{ profile={{
displayName: userDisplayName ?? "", displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "", avatarUrl: avatarUrl ?? "",
}} }}
knock={ joinState={groupCallState.joinState}
groupCallState.kind === "canKnock" ? groupCallState.knock : null
}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={header !== "standard"} hideHeader={header !== "standard"}
/> />
@@ -139,7 +135,7 @@ export const RoomPage: FC = (): ReactNode => {
</FullScreenView> </FullScreenView>
); );
case "failed": case "failed":
wasInWaitForInviteState.current = false; wasWaitingForApproval.current = false;
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") { if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return ( return (
<FullScreenView> <FullScreenView>
+96 -14
View File
@@ -7,7 +7,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
> >
<header> <header>
<button <button
aria-labelledby="_r_36_" aria-labelledby="_r_22_"
class="_icon-button_1215g_8 _primaryButton_221541" class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary" data-kind="primary"
role="button" role="button"
@@ -80,10 +80,51 @@ exports[`LobbyView > renders with AppBar android 1`] = `
role="button" role="button"
tabindex="0" tabindex="0"
> >
Join call Request to join sent
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div
class="_joinMessage_f9ee84"
data-testid="lobby_joinMessage"
>
<div
class="_waiting_f9ee84"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
You will receive an invite to join the call if your request is accepted.
</p>
</div>
</div>
<a <a
class="_link_13esb_8" class="_link_13esb_8"
data-kind="primary" data-kind="primary"
@@ -102,7 +143,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_settingsLogoContainer_20b7b4" class="_settingsLogoContainer_20b7b4"
> >
<button <button
aria-labelledby="_r_3c_" aria-labelledby="_r_28_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4" class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary" data-kind="secondary"
data-testid="settings-bottom-left" data-testid="settings-bottom-left"
@@ -133,7 +174,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_buttons_20b7b4" class="_buttons_20b7b4"
> >
<button <button
aria-labelledby="_r_3h_" aria-labelledby="_r_2d_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -158,7 +199,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_3m_" aria-labelledby="_r_2i_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -183,7 +224,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_3r_" aria-labelledby="_r_2n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -205,7 +246,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="_r_40_" aria-labelledby="_r_2s_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -239,7 +280,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
> >
<header> <header>
<button <button
aria-labelledby="_r_4a_" aria-labelledby="_r_36_"
class="_icon-button_1215g_8 _primaryButton_221541" class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary" data-kind="primary"
role="button" role="button"
@@ -312,10 +353,51 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
role="button" role="button"
tabindex="0" tabindex="0"
> >
Join call Request to join sent
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div
class="_joinMessage_f9ee84"
data-testid="lobby_joinMessage"
>
<div
class="_waiting_f9ee84"
>
<svg
aria-label="Loading…"
class="_icon_1855a_18"
fill="currentColor"
height="1em"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31"
>
You will receive an invite to join the call if your request is accepted.
</p>
</div>
</div>
<a <a
class="_link_13esb_8" class="_link_13esb_8"
data-kind="primary" data-kind="primary"
@@ -334,7 +416,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_settingsLogoContainer_20b7b4" class="_settingsLogoContainer_20b7b4"
> >
<button <button
aria-labelledby="_r_4g_" aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4" class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary" data-kind="secondary"
data-testid="settings-bottom-left" data-testid="settings-bottom-left"
@@ -365,7 +447,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_buttons_20b7b4" class="_buttons_20b7b4"
> >
<button <button
aria-labelledby="_r_4l_" aria-labelledby="_r_3h_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -390,7 +472,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_4q_" aria-labelledby="_r_3m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -415,7 +497,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_4v_" aria-labelledby="_r_3r_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -437,7 +519,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="_r_54_" aria-labelledby="_r_40_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
+104
View File
@@ -0,0 +1,104 @@
/*
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 { describe, expect, it } from "vitest";
import {
EventType,
MatrixEvent,
type Room,
type RoomState,
type RoomSummary,
} from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { mockMatrixRoom } from "../utils/test";
import {
preJoinRoomInfoFromRoom,
preJoinRoomInfoFromSummary,
} from "./preJoinRoomInfo";
const roomId = "!call:example.org";
const summary = (extra: Partial<RoomSummary> = {}): RoomSummary =>
({
room_id: roomId,
name: "Weekly sync",
canonical_alias: "#sync:example.org",
avatar_url: "mxc://example.org/avatar",
world_readable: false,
guest_can_join: false,
num_joined_members: 3,
...extra,
}) as RoomSummary;
const room = (extra: { encryption?: string } = {}): Room =>
mockMatrixRoom({
roomId,
name: "Weekly sync",
getCanonicalAlias: () => "#sync:example.org",
getMxcAvatarUrl: () => "mxc://example.org/avatar",
currentState: {
getStateEvents: (type: string) =>
type === EventType.RoomEncryption && extra.encryption !== undefined
? new MatrixEvent({
type: EventType.RoomEncryption,
state_key: "",
content: { algorithm: extra.encryption },
})
: null,
} as unknown as RoomState,
});
describe("preJoinRoomInfoFromSummary", () => {
it("reads the stable encryption field", () => {
expect(
preJoinRoomInfoFromSummary(
summary({ encryption: "m.megolm.v1.aes-sha2" } as Partial<RoomSummary>),
),
).toEqual({
roomId,
roomName: "Weekly sync",
roomAlias: "#sync:example.org",
roomAvatar: "mxc://example.org/avatar",
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
});
});
it("falls back to the unstable encryption field", () => {
expect(
preJoinRoomInfoFromSummary(
summary({ "im.nheko.summary.encryption": "m.megolm.v1.aes-sha2" }),
).e2eeSystem,
).toEqual({ kind: E2eeType.PER_PARTICIPANT });
});
it("is unencrypted when neither field is set", () => {
expect(preJoinRoomInfoFromSummary(summary()).e2eeSystem).toEqual({
kind: E2eeType.NONE,
});
});
});
describe("preJoinRoomInfoFromRoom", () => {
it("reads the encryption state event", () => {
expect(
preJoinRoomInfoFromRoom(room({ encryption: "m.megolm.v1.aes-sha2" })),
).toEqual({
roomId,
roomName: "Weekly sync",
roomAlias: "#sync:example.org",
roomAvatar: "mxc://example.org/avatar",
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
});
});
it("is unencrypted with no encryption state event", () => {
expect(preJoinRoomInfoFromRoom(room()).e2eeSystem).toEqual({
kind: E2eeType.NONE,
});
});
});
+64
View File
@@ -0,0 +1,64 @@
/*
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 { EventType, type Room, type RoomSummary } from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
/**
* The subset of MatrixInfo that is knowable before the room is joined, from
* either a room summary or the room state a widget host pushes.
*/
export interface PreJoinRoomInfo {
roomId: string;
roomName: string;
roomAlias: string | null;
roomAvatar: string | null;
e2eeSystem: EncryptionSystem;
}
// `RoomSummary` in the js-sdk omits `canonical_alias` and has no stable
// `encryption` field.
type WidenedRoomSummary = RoomSummary & {
canonical_alias?: string;
encryption?: string;
};
const e2eeSystem = (encryption: string | undefined): EncryptionSystem =>
encryption === undefined
? { kind: E2eeType.NONE }
: { kind: E2eeType.PER_PARTICIPANT };
export function preJoinRoomInfoFromSummary(
summary: RoomSummary,
): PreJoinRoomInfo {
const widened = summary as WidenedRoomSummary;
return {
roomId: summary.room_id,
roomName: summary.name ?? "",
roomAlias: widened.canonical_alias ?? null,
roomAvatar: summary.avatar_url ?? null,
e2eeSystem: e2eeSystem(
widened.encryption ?? summary["im.nheko.summary.encryption"],
),
};
}
export function preJoinRoomInfoFromRoom(room: Room): PreJoinRoomInfo {
return {
roomId: room.roomId,
roomName: room.name,
roomAlias: room.getCanonicalAlias(),
roomAvatar: room.getMxcAvatarUrl(),
e2eeSystem: e2eeSystem(
room.currentState
.getStateEvents(EventType.RoomEncryption, "")
?.getContent().algorithm,
),
};
}
+319
View File
@@ -0,0 +1,319 @@
/*
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 { describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import EventEmitter from "events";
import {
EventType,
JoinRule,
KnownMembership,
MatrixEvent,
RoomEvent,
SyncState,
type MatrixClient,
type Membership,
type Room,
type RoomState,
type RoomSummary,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { mockMatrixRoom } from "../utils/test";
import { type LobbyJoinState } from "./LobbyJoinState";
import {
useLoadGroupCall,
type GroupCallLobby,
type GroupCallStatus,
} from "./useLoadGroupCall";
const roomId = "!call:example.org";
const userId = "@gwen:example.org";
const viaServers = ["example.org"];
interface RoomSpec {
membership?: Membership;
prevMembership?: Membership;
joinRule?: string;
reason?: string;
}
/** A room whose membership follows `spec`, so a test can change it. */
function mockRoom(spec: RoomSpec = {}): Room {
const state = new Map<string, MatrixEvent>([
[
EventType.RoomMember,
new MatrixEvent({
type: EventType.RoomMember,
state_key: userId,
content: {
membership: spec.membership ?? KnownMembership.Leave,
reason: spec.reason,
},
unsigned:
spec.prevMembership === undefined
? undefined
: { prev_content: { membership: spec.prevMembership } },
}),
],
]);
if (spec.joinRule !== undefined)
state.set(
EventType.RoomJoinRules,
new MatrixEvent({
type: EventType.RoomJoinRules,
state_key: "",
content: { join_rule: spec.joinRule },
}),
);
return mockMatrixRoom({
roomId,
myUserId: userId,
name: "Weekly sync",
getCanonicalAlias: () => null,
getMxcAvatarUrl: () => null,
getMyMembership: () => spec.membership!,
currentState: {
getStateEvents: (type: string) => state.get(type) ?? null,
} as unknown as RoomState,
});
}
type TestClient = MatrixClient & { events: EventEmitter };
function mockClient(overrides: Partial<MatrixClient> = {}): TestClient {
const events = new EventEmitter();
return {
events,
on: events.on.bind(events),
off: events.off.bind(events),
emit: events.emit.bind(events),
listenerCount: events.listenerCount.bind(events),
getUserId: () => userId,
getSyncState: () => SyncState.Syncing,
getRoom: vi.fn().mockReturnValue(null),
getRoomIdForAlias: vi.fn(),
getRoomSummary: vi.fn(),
joinRoom: vi.fn(),
knockRoom: vi.fn().mockResolvedValue({ room_id: roomId }),
leave: vi.fn().mockResolvedValue({}),
waitUntilRoomReadyForGroupCalls: vi.fn().mockResolvedValue(undefined),
matrixRTC: { getRoomSession: (room: Room) => ({ room }) },
...overrides,
} as unknown as TestClient;
}
const summary = (extra: Record<string, unknown>): RoomSummary =>
({
room_id: roomId,
name: "Weekly sync",
world_readable: false,
guest_can_join: false,
num_joined_members: 2,
...extra,
}) as RoomSummary;
const renderLoad = (
client: MatrixClient,
): ReturnType<typeof renderHook<GroupCallStatus, unknown>> =>
renderHook(() => useLoadGroupCall(client, roomId, viaServers));
const lobby = (state: GroupCallStatus): GroupCallLobby => {
expect(state.kind).toBe("lobby");
return state as GroupCallLobby;
};
async function waitForJoinState<K extends LobbyJoinState["kind"]>(
result: { current: GroupCallStatus },
kind: K,
): Promise<Extract<LobbyJoinState, { kind: K }>> {
await waitFor(() => expect(lobby(result.current).joinState.kind).toBe(kind));
return lobby(result.current).joinState as Extract<
LobbyJoinState,
{ kind: K }
>;
}
const emitMembership = (
client: TestClient,
room: Room,
membership: Membership,
prevMembership?: Membership,
): void =>
act(() => {
client.events.emit(
RoomEvent.MyMembership,
room,
membership,
prevMembership,
);
});
describe("useLoadGroupCall in the standalone app", () => {
it("joins a public room", async () => {
const room = mockRoom({ membership: KnownMembership.Join });
const client = mockClient({
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Public })),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
expect(client.waitUntilRoomReadyForGroupCalls).toHaveBeenCalledWith(roomId);
});
it("treats a room with no summary as public", async () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const room = mockRoom({ membership: KnownMembership.Join });
const client = mockClient({
getRoomSummary: vi.fn().mockRejectedValue(new Error("no /summary here")),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
expect(warn).toHaveBeenCalled();
});
it("asks to join a knock room, then joins once accepted", async () => {
const spec: RoomSpec = {};
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Knock })),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
const canAsk = await waitForJoinState(result, "can-ask-to-join");
expect(canAsk.error).toBeUndefined();
act(() => canAsk.askToJoin());
spec.membership = KnownMembership.Knock;
await waitForJoinState(result, "waiting-for-approval");
expect(client.knockRoom).toHaveBeenCalledWith(roomId, {
viaServers,
reason: undefined,
});
spec.membership = KnownMembership.Invite;
emitMembership(client, room, KnownMembership.Invite, KnownMembership.Knock);
await waitFor(() =>
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers }),
);
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.waitUntilRoomReadyForGroupCalls).toHaveBeenCalledWith(roomId);
});
it("shows a declined request in the lobby", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi.fn().mockResolvedValue(
summary({
join_rule: JoinRule.Knock,
membership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "waiting-for-approval");
spec.membership = KnownMembership.Leave;
emitMembership(client, room, KnownMembership.Leave, KnownMembership.Knock);
await waitForJoinState(result, "denied");
});
it("shows a ban in the lobby", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi.fn().mockResolvedValue(
summary({
join_rule: JoinRule.Knock,
membership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "waiting-for-approval");
emitMembership(
client,
mockRoom({ membership: KnownMembership.Ban, reason: "spam" }),
KnownMembership.Ban,
);
expect(await waitForJoinState(result, "banned")).toMatchObject({
reason: "spam",
});
});
it("shows a ban found before any request in the lobby", async () => {
const room = mockRoom({ membership: KnownMembership.Ban, reason: "spam" });
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client);
expect(await waitForJoinState(result, "banned")).toMatchObject({
reason: "spam",
});
expect(client.getRoomSummary).not.toHaveBeenCalled();
});
it("keeps the user in the lobby when the request fails to send", async () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(mockRoom()),
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Knock })),
knockRoom: vi.fn().mockRejectedValue(new Error("offline")),
});
const { result } = renderLoad(client);
const canAsk = await waitForJoinState(result, "can-ask-to-join");
act(() => canAsk.askToJoin());
await waitFor(() => expect(client.knockRoom).toHaveBeenCalled());
expect(await waitForJoinState(result, "can-ask-to-join")).toMatchObject({
error: "request_failed",
});
});
it("withdraws a request and offers to ask again", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
getRoomSummary: vi.fn().mockResolvedValue(
summary({
join_rule: JoinRule.Knock,
membership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client);
const waiting = await waitForJoinState(result, "waiting-for-approval");
act(() => waiting.cancelRequest!());
await waitFor(() => expect(client.leave).toHaveBeenCalledWith(roomId));
spec.membership = KnownMembership.Leave;
emitMembership(client, room, KnownMembership.Leave, KnownMembership.Knock);
expect(await waitForJoinState(result, "can-ask-to-join")).toMatchObject({
error: undefined,
});
});
it("offers nothing for an invite-only room", async () => {
const client = mockClient({
getRoomSummary: vi
.fn()
.mockResolvedValue(summary({ join_rule: JoinRule.Invite })),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "not-allowed");
});
});
+316 -185
View File
@@ -1,5 +1,6 @@
/* /*
Copyright 2022-2024 New Vector Ltd. Copyright 2022-2024 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial 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.
@@ -9,7 +10,6 @@ import {
useState, useState,
useEffect, useEffect,
useRef, useRef,
useCallback,
type ComponentType, type ComponentType,
type SVGAttributes, type SVGAttributes,
} from "react"; } from "react";
@@ -19,6 +19,7 @@ import {
SyncState, SyncState,
MatrixError, MatrixError,
KnownMembership, KnownMembership,
type Membership,
ClientEvent, ClientEvent,
type MatrixClient, type MatrixClient,
type RoomSummary, type RoomSummary,
@@ -30,11 +31,16 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
AdminIcon, AdminIcon,
CloseIcon,
EndCallIcon, EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
import { type LobbyJoinState } from "./LobbyJoinState";
import {
preJoinRoomInfoFromRoom,
preJoinRoomInfoFromSummary,
type PreJoinRoomInfo,
} from "./preJoinRoomInfo";
export type GroupCallLoaded = { export type GroupCallLoaded = {
kind: "loaded"; kind: "loaded";
@@ -50,23 +56,21 @@ export type GroupCallLoading = {
kind: "loading"; kind: "loading";
}; };
export type GroupCallWaitForInvite = { /**
kind: "waitForInvite"; * The call cannot be entered yet, so the lobby is shown with whatever the user
roomSummary: RoomSummary; * can do about it.
}; */
export type GroupCallLobby = {
export type GroupCallCanKnock = { kind: "lobby";
kind: "canKnock"; room: PreJoinRoomInfo;
roomSummary: RoomSummary; joinState: LobbyJoinState;
knock: () => void;
}; };
export type GroupCallStatus = export type GroupCallStatus =
| GroupCallLoaded | GroupCallLoaded
| GroupCallLoadFailed | GroupCallLoadFailed
| GroupCallLoading | GroupCallLoading
| GroupCallWaitForInvite | GroupCallLobby;
| GroupCallCanKnock;
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3; const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000; const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
@@ -124,6 +128,15 @@ export class CallTerminatedMessage extends Error {
} }
} }
/** The join state a lobby opens on, before the user acts on it. */
type LobbyEntry =
| "can-join"
| "can-ask-to-join"
| "waiting-for-approval"
| "denied"
| "banned"
| "not-allowed";
export const useLoadGroupCall = ( export const useLoadGroupCall = (
client: MatrixClient | undefined, client: MatrixClient | undefined,
roomIdOrAlias: string | null, roomIdOrAlias: string | null,
@@ -134,46 +147,62 @@ export const useLoadGroupCall = (
const { t } = useTranslation(); const { t } = useTranslation();
const { isWidget } = useUrlParams(); const { isWidget } = useUrlParams();
const bannedError = useCallback( useEffect(() => {
(): CallTerminatedMessage => if (!client || !roomIdOrAlias) {
return;
}
const controller = new AbortController();
const { signal } = controller;
/** Adds a client listener that is removed when the effect is cleaned up. */
const onMyMembership = (
listener: (
room: Room,
membership: Membership,
prevMembership?: Membership,
) => void,
): (() => void) => {
client.on(RoomEvent.MyMembership, listener);
const off = (): void => {
client.off(RoomEvent.MyMembership, listener);
};
signal.addEventListener("abort", off);
return off;
};
const leaveReason = (): string =>
activeRoom.current?.currentState
.getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId)
?.getContent().reason;
const bannedError = (): CallTerminatedMessage =>
new CallTerminatedMessage( new CallTerminatedMessage(
AdminIcon, AdminIcon,
t("group_call_loader.banned_heading"), t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"), t("group_call_loader.banned_body"),
leaveReason(), leaveReason(),
), );
[t], const removeNoticeError = (): CallTerminatedMessage =>
);
const knockRejectError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
CloseIcon,
t("group_call_loader.knock_reject_heading"),
t("group_call_loader.knock_reject_body"),
leaveReason(),
),
[t],
);
const removeNoticeError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage( new CallTerminatedMessage(
EndCallIcon, EndCallIcon,
t("group_call_loader.call_ended_heading"), t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"), t("group_call_loader.call_ended_body"),
leaveReason(), leaveReason(),
), );
[t],
);
const leaveReason = (): string => /**
activeRoom.current?.currentState * Wait for the room to be usable for group calls. Widget mode has no
.getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId) * equivalent gate, because the host owns the sync.
?.getContent().reason; */
const readyForGroupCalls = async (room: Room): Promise<Room> => {
logger.info(
`Joined ${room.roomId}, waiting for the room to be ready for group calls`,
);
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
logger.info(`${room.roomId} is ready for group calls`);
return room;
};
useEffect(() => {
if (!client || !roomIdOrAlias) {
return;
}
const getRoomByAlias = async (alias: string): Promise<Room> => { const getRoomByAlias = async (alias: string): Promise<Room> => {
// We lowercase the localpart when we create the room, so we must lowercase // We lowercase the localpart when we create the room, so we must lowercase
// it here too (we just do the whole alias). We can't do the same to room IDs // it here too (we just do the whole alias). We can't do the same to room IDs
@@ -181,150 +210,255 @@ export const useLoadGroupCall = (
// Also, we explicitly look up the room alias here. We previously just tried to // Also, we explicitly look up the room alias here. We previously just tried to
// join anyway but the js-sdk recreates the room if you pass the alias for a // join anyway but the js-sdk recreates the room if you pass the alias for a
// room you're already joined to (which it probably ought not to). // room you're already joined to (which it probably ought not to).
let room: Room | null = null;
const lookupResult = await client.getRoomIdForAlias(alias.toLowerCase()); const lookupResult = await client.getRoomIdForAlias(alias.toLowerCase());
logger.info(`${alias} resolved to ${lookupResult.room_id}`); logger.info(`${alias} resolved to ${lookupResult.room_id}`);
room = client.getRoom(lookupResult.room_id); const room = client.getRoom(lookupResult.room_id);
if (!room) { if (room) {
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
room = await client.joinRoom(lookupResult.room_id, {
viaServers: lookupResult.servers,
});
} else {
logger.info(`Already in room ${lookupResult.room_id}, not rejoining.`); logger.info(`Already in room ${lookupResult.room_id}, not rejoining.`);
return room;
} }
return room; logger.info(`Room ${lookupResult.room_id} not found, joining.`);
return await readyForGroupCalls(
await client.joinRoom(lookupResult.room_id, {
viaServers: lookupResult.servers,
}),
);
}; };
const getRoomByKnocking = async ( /**
roomId: string, * Resolve once the local user has joined the room, including when they
viaServers: string[], * already have: the host may have joined us before we could listen.
onKnockSent: () => void, */
): Promise<Room> => { const waitForJoin = async (roomId: string): Promise<Room> =>
await client.knockRoom(roomId, { viaServers }); await new Promise<Room>((resolve) => {
onKnockSent(); const reached = (room: Room | null): boolean => {
return await new Promise<Room>((resolve, reject) => { if (room?.getMyMembership() !== KnownMembership.Join) return false;
client.on( activeRoom.current = room;
RoomEvent.MyMembership, resolve(room);
(room, membership, prevMembership): void => { return true;
if (roomId !== room.roomId) return; };
activeRoom.current = room; const off = onMyMembership((room) => {
if ( if (room.roomId === roomId && reached(room)) off();
membership === KnownMembership.Invite && });
prevMembership === KnownMembership.Knock if (reached(client.getRoom(roomId))) off();
) {
joinRoomAfterInvite(client, 0, room.roomId, { viaServers }).then(
(room) => {
logger.log("Auto-joined %s", room.roomId);
resolve(room);
},
reject,
);
}
if (membership === KnownMembership.Ban) reject(bannedError());
if (membership === KnownMembership.Leave)
reject(knockRejectError());
},
);
}); });
/**
* Show the lobby and resolve once the local user has joined. Denial, ban
* and a room that takes nothing from this user are lobby states rather
* than errors, so the promise never resolves and the render follows the
* join state.
*
* @param room The room as far as it is knowable before joining
* @param entry The join state to open on
*/
const enterFromLobby = async (
room: PreJoinRoomInfo,
entry: LobbyEntry,
): Promise<Room> => {
const roomId = room.roomId;
let requestInFlight = false;
let withdrawing = false;
const setLobby = (joinState: LobbyJoinState): void => {
if (!signal.aborted) setState({ kind: "lobby", room, joinState });
};
const waitForApproval = (): void =>
setLobby({ kind: "waiting-for-approval", cancelRequest });
const canAskToJoin = (error?: "request_failed"): void =>
setLobby({ kind: "can-ask-to-join", askToJoin, error });
const canJoin = (): void => setLobby({ kind: "can-join", join });
const onRequestError = (
error: unknown,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A refusal by the server means this user cannot get in this way at all.
if (error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
logger.warn(`${operation} on ${roomId} was refused`, error);
setLobby({ kind: "not-allowed" });
} else {
logger.error(`${operation} on ${roomId} failed`, error);
onFailure();
}
};
const join = (): void => {
if (requestInFlight) return;
requestInFlight = true;
// A join of our own resolves the promise this lobby is parked on, so
// there is nothing to show on success.
client.joinRoom(roomId, { viaServers }).then(
() => {
requestInFlight = false;
},
(error: unknown) => onRequestError(error, "Joining", canJoin),
);
};
const askToJoin = (reason?: string): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "sending-request" });
client.knockRoom(roomId, { viaServers, reason }).then(
() => {
requestInFlight = false;
waitForApproval();
},
(error: unknown) =>
onRequestError(error, "Asking to join", () =>
canAskToJoin("request_failed"),
),
);
};
const cancelRequest = (): void => {
if (withdrawing) return;
withdrawing = true;
// Drop the link while the withdrawal is on its way, so it cannot be
// pressed twice.
setLobby({ kind: "waiting-for-approval" });
client.leave(roomId).catch((error: unknown) => {
withdrawing = false;
logger.error("Failed to withdraw the request to join", error);
waitForApproval();
});
};
const offTransitions = onMyMembership(
(changed, membership, prevMembership) => {
if (changed.roomId !== roomId) return;
activeRoom.current = changed;
switch (membership) {
case KnownMembership.Invite:
if (prevMembership !== KnownMembership.Knock) canJoin();
else
joinRoomAfterInvite(client, 0, roomId, { viaServers }).then(
() => logger.info(`Joined ${roomId} once accepted`),
(error: unknown) =>
logger.error(
`Joining ${roomId} once accepted failed`,
error,
),
);
break;
case KnownMembership.Ban:
setLobby({ kind: "banned", reason: leaveReason() });
break;
case KnownMembership.Leave:
// Withdrawing a request produces the same membership as a decline,
// so the two are told apart by who initiated it.
if (withdrawing) {
withdrawing = false;
canAskToJoin();
} else {
setLobby({ kind: "denied" });
}
break;
}
},
);
switch (entry) {
case "can-join":
canJoin();
break;
case "can-ask-to-join":
canAskToJoin();
break;
case "waiting-for-approval":
waitForApproval();
break;
case "denied":
setLobby({ kind: "denied" });
break;
case "banned":
setLobby({ kind: "banned", reason: leaveReason() });
break;
case "not-allowed":
setLobby({ kind: "not-allowed" });
break;
}
const joined = await waitForJoin(roomId);
offTransitions();
return joined;
}; };
const fetchOrCreateRoom = async (): Promise<Room> => { const fetchOrCreateRoom = async (): Promise<Room> => {
let room: Room | null = null;
if (roomIdOrAlias[0] === "#") { if (roomIdOrAlias[0] === "#") {
const alias = roomIdOrAlias; const room = await getRoomByAlias(roomIdOrAlias);
// The call uses a room alias
room = await getRoomByAlias(alias);
activeRoom.current = room; activeRoom.current = room;
} else { return room;
// The call uses a room_id }
const roomId = roomIdOrAlias; const roomId = roomIdOrAlias;
// first try if the room already exists // The room already exists in widget mode, and in SPA mode if the user
// - in widget mode // has joined it before.
// - in SPA mode if the user already joined the room const room = client.getRoom(roomId);
room = client.getRoom(roomId); activeRoom.current = room ?? undefined;
activeRoom.current = room ?? undefined; const membership = room?.getMyMembership();
const membership = room?.getMyMembership(); if (membership === KnownMembership.Join) return room!;
if (membership === KnownMembership.Join) {
// room already joined so we are done here already.
return room!;
}
if (isWidget)
// in widget mode we never should reach this point. (getRoom should return the room.)
throw new Error(
"Room not found. The widget-api did not pass over the relevant room events/information.",
);
if (membership === KnownMembership.Ban) { if (isWidget)
throw bannedError(); // in widget mode we never should reach this point. (getRoom should return the room.)
} else if (membership === KnownMembership.Invite) { throw new Error(
room = await client.joinRoom(roomId, { "Room not found. The widget-api did not pass over the relevant room events/information.",
viaServers, );
});
} else { if (room && membership === KnownMembership.Ban)
// If the room does not exist we first search for it with viaServers return await enterFromLobby(preJoinRoomInfoFromRoom(room), "banned");
let roomSummary: RoomSummary | undefined = undefined; if (membership === KnownMembership.Invite)
try { return await readyForGroupCalls(
roomSummary = await client.getRoomSummary(roomId, viaServers); await client.joinRoom(roomId, { viaServers }),
} catch (error) { );
// If the room summary endpoint is not supported we let it be undefined and treat this case like
// `JoinRule.Public`. // If the room does not exist we first search for it with viaServers
// This is how the logic was done before: "we expect any room id passed to EC let roomSummary: RoomSummary | undefined = undefined;
// to be for a public call" Which is definitely not ideal but worth a try if fetching try {
// the summary crashes. roomSummary = await client.getRoomSummary(roomId, viaServers);
logger.warn( } catch (error) {
`Could not load room summary to decide whether we want to join or knock. // If the room summary endpoint is not supported we let it be undefined and treat this case like
// `JoinRule.Public`.
// This is how the logic was done before: "we expect any room id passed to EC
// to be for a public call" Which is definitely not ideal but worth a try if fetching
// the summary crashes.
logger.warn(
`Could not load room summary to decide whether we want to join or knock.
EC will fallback to join as if this would be a public room. EC will fallback to join as if this would be a public room.
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`, Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
error, error,
); );
}
if (
roomSummary?.join_rule === undefined ||
roomSummary.join_rule === JoinRule.Public
) {
room = await client.joinRoom(roomId, {
viaServers,
});
} else if (roomSummary.join_rule === JoinRule.Knock) {
// bind room summary in this scope so we have it stored in a binding of type `RoomSummary`
// instead of `RoomSummary | undefined`. Because we use it in a promise the linter does not accept
// the type check from the if condition above.
const _roomSummary = roomSummary;
let knock: () => void = () => {};
const userPressedAskToJoinPromise: Promise<void> = new Promise(
(resolve) => {
if (_roomSummary.membership !== KnownMembership.Knock) {
knock = resolve;
} else {
// resolve immediately if the user already knocked
resolve();
}
},
);
setState({ kind: "canKnock", roomSummary: _roomSummary, knock });
await userPressedAskToJoinPromise;
room = await getRoomByKnocking(
roomSummary.room_id,
viaServers,
() =>
setState({ kind: "waitForInvite", roomSummary: _roomSummary }),
);
} else {
throw new Error(
`Room ${roomSummary.room_id} is not joinable. This likely means, that the conference owner has changed the room settings to private.`,
);
}
}
} }
if (
roomSummary?.join_rule === undefined ||
roomSummary.join_rule === JoinRule.Public
)
return await readyForGroupCalls(
await client.joinRoom(roomId, { viaServers }),
);
const roomInfo = preJoinRoomInfoFromSummary(roomSummary);
if (roomSummary.membership === KnownMembership.Ban)
return await enterFromLobby(roomInfo, "banned");
if (roomSummary.join_rule === JoinRule.Knock)
return await readyForGroupCalls(
await enterFromLobby(
roomInfo,
roomSummary.membership === KnownMembership.Knock
? "waiting-for-approval"
: "can-ask-to-join",
),
);
logger.info( logger.info(
`Joined ${roomIdOrAlias}, waiting room to be ready for group calls`, `Room ${roomSummary.room_id} takes neither joins nor knocks (join rule ${roomSummary.join_rule})`,
); );
await client.waitUntilRoomReadyForGroupCalls(room.roomId); return await enterFromLobby(roomInfo, "not-allowed");
logger.info(`${roomIdOrAlias}, is ready for group calls`);
return room;
}; };
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => { const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
@@ -349,6 +483,9 @@ export const useLoadGroupCall = (
} }
}; };
client.on(ClientEvent.Sync, onSync); client.on(ClientEvent.Sync, onSync);
signal.addEventListener("abort", () => {
client.off(ClientEvent.Sync, onSync);
});
}); });
logger.debug("useLoadGroupCall: client is now syncing."); logger.debug("useLoadGroupCall: client is now syncing.");
} }
@@ -356,32 +493,26 @@ export const useLoadGroupCall = (
const observeMyMembership = async (): Promise<void> => { const observeMyMembership = async (): Promise<void> => {
await new Promise((_, reject) => { await new Promise((_, reject) => {
client.on(RoomEvent.MyMembership, (_, membership) => { onMyMembership((_room, membership) => {
if (membership === KnownMembership.Leave) reject(removeNoticeError()); if (membership === KnownMembership.Leave) reject(removeNoticeError());
if (membership === KnownMembership.Ban) reject(bannedError()); if (membership === KnownMembership.Ban) reject(bannedError());
}); });
}); });
}; };
if (state.kind === "loading") { logger.log("Start loading group call");
logger.log("Start loading group call"); waitForClientSyncing()
waitForClientSyncing() .then(fetchOrCreateGroupCall)
.then(fetchOrCreateGroupCall) .then((rtcSession) => {
.then((rtcSession) => setState({ kind: "loaded", rtcSession })) if (!signal.aborted) setState({ kind: "loaded", rtcSession });
.then(observeMyMembership) })
.catch((error) => setState({ kind: "failed", error })); .then(observeMyMembership)
} .catch((error) => {
}, [ if (!signal.aborted) setState({ kind: "failed", error });
bannedError, });
client,
isWidget, return (): void => controller.abort();
knockRejectError, }, [client, isWidget, roomIdOrAlias, viaServers, t]);
removeNoticeError,
roomIdOrAlias,
state,
t,
viaServers,
]);
return state; return state;
}; };