mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-25 22:35:49 +00:00
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:
co-authored by
Claude Fable 5.1
parent
1c8a6487e9
commit
3653a7fd05
+4
-1
@@ -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…",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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" };
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user