Compare commits

...
Author SHA1 Message Date
Quentin GliechandClaude Fable 5.1 58b9e2d983 Test the standalone knock flow in Playwright
A knock call room, created over the CS API, walks through the lobby's join
states: request to join, the waiting copy with its withdrawal link, a second
request, and an invite from the room's moderator that lands the user in the
call. A decline, a ban with its reason, and a room whose join rule the app
cannot act on are covered too. An invite-only room asserts what happens
today: Synapse serves no summary for it, so the refused join is a full-screen
error rather than a lobby state.

The `spaUser` fixture registers over the admin API and logs in through the
form, and serves `config.devenv.json` itself, since Playwright reuses a dev
server started with another config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 1c8930daa0 Join the call as soon as a request to join is accepted
An invite that replaced a request to join was read as somebody else being
mid-join. Nobody always is: a host without auto-join, a join that failed, or
a fresh load already at that membership left the user waiting with nothing to
press but "Cancel request". The lobby now takes such an invite up itself,
showing a joining state, and offers a join of the user's own if that fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 d78c204ef4 Let the lobby ask its host for membership changes
Running as a widget, Element Call has no access token of its own, so a room
it is not in can only be entered by the host: the lobby ended at a
full-screen "Room not found" for every room the user had not already joined.

Membership changes now go through a `ChangeMembership` function, which the
host supplies when it keeps the access token to itself and Element Call
builds from its own client when it does not. Either way the answer is the
membership the request resulted in, which for the client is the one an
accepted request implies: local room state still holds the previous
membership until the next sync. The host takes an `io.element.membership`
action carrying `{action: "join" | "knock" | "cancel_knock", reason?}`. An
error reply carrying `matrix_api_error` is the homeserver's own refusal; one
without it means the host does not implement the action, which leaves the
lobby saying the user cannot get in this way. Element Call also asks to
receive `m.room.join_rules`, and reads the state event rather than
`Room.getJoinRule()`, since a host that grants no such capability is
otherwise indistinguishable from an invite-only room.

What the lobby opens on comes from our membership, the membership it
replaced and the join rule. A restricted or knock-restricted room offers a
join, because the widget cannot evaluate `allowed_room_ids` and the host can.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 3653a7fd05 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>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 1c8a6487e9 Remove the stray page.pause() calls from the Playwright specs
They halt a headless run until the inspector is closed, which never happens
in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 1f7e930137 Name the button the room auth screen's licence caption points at
The caption said "Join call now"; the form's button reads "Continue".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 a7fdade61a Tolerate a registration flow that carries no params
`registerRequest({})` rejects with the registration flows, and a homeserver
may answer with no `params` at all, which threw a TypeError on the landing
and login screens and left them without a privacy policy link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
Quentin GliechandClaude Fable 5.1 302f6f7efa Allow overriding the dev server TLS certificate via environment
EC_DEV_TLS_KEY / EC_DEV_TLS_CERT point vite at a key pair the developer's
browser already trusts, instead of the checked-in Element Call Dev CA.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:44:59 +02:00
29 changed files with 2542 additions and 360 deletions
+7 -2
View File
@@ -152,10 +152,15 @@
"layout_switch_label": "Layout",
"lobby": {
"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_button": "Join call",
"joining": "Joining…",
"leave_button": "Back to recents",
"waiting_for_invite": "Request sent! Waiting for permission to join…"
"request_accepted": "Your request to join was accepted.",
"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",
"logging_in": "Logging in…",
@@ -190,7 +195,7 @@
"register_heading": "Create your account",
"return_home_button": "Return to home screen",
"room_auth_view_continue_button": "Continue",
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"room_auth_view_ssla_caption": "By clicking \"Continue\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"screenshare_button_label": "Share screen",
"settings": {
"advanced_camera_description": "Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
-3
View File
@@ -114,8 +114,6 @@ test("Should show error screen if call creation is restricted", async ({
}),
);
await page.pause();
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
@@ -125,7 +123,6 @@ test("Should show error screen if call creation is restricted", async ({
// Join the call
await page.getByTestId("lobby_joinCall").click();
await page.pause();
// Should fail
await expect(page.getByText("Failed to create call")).toBeVisible();
await expect(
+76
View File
@@ -0,0 +1,76 @@
/*
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 { expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
import { HOMESERVER_URL } from "../utils/matrix-api.ts";
/** A user of the Playwright backend's homeserver. */
export interface SpaUser {
/** The localpart actually registered, which carries a unique suffix. */
username: string;
mxId: string;
accessToken: string;
}
export interface SpaFixtures {
/** Registers a user over the admin API, without a browser session. */
registerUser: (username: string) => Promise<SpaUser>;
/** A user registered and logged in to Element Call on the test's page. */
spaUser: SpaUser;
}
const PASSWORD = "password1!";
// A dev server that was already running keeps whatever config.json it was
// started with, since Playwright reuses it rather than installing the one it
// wants, so the tests serve the config they assert against.
const CONFIG_PATH = fileURLToPath(
new URL("../../config/config.devenv.json", import.meta.url),
);
export const spaTest = test.extend<SpaFixtures>({
context: async ({ context }, use) => {
await context.route(
"**/config.json",
async (route) => await route.fulfill({ path: CONFIG_PATH }),
);
await use(context);
},
registerUser: async ({ browserName }, use) => {
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
await use(async (username: string): Promise<SpaUser> => {
// Every browser and worker registers against the one homeserver.
const unique = `${username}_${browserName}_${randomUUID().slice(0, 8)}`;
const registered = await admin.registerUser(unique, PASSWORD);
return {
username: unique,
mxId: registered.user_id,
accessToken: registered.access_token,
};
});
},
spaUser: async ({ page, registerUser }, use) => {
const user = await registerUser("caller");
await page.goto("/");
await page.getByTestId("home_login").click();
await page.getByTestId("login_username").fill(user.username);
await page.getByTestId("login_password").fill(PASSWORD);
await page.getByTestId("login_login").click();
await expect(
page.getByRole("heading", { name: "Start new call" }),
).toBeVisible();
await use(user);
},
});
+177
View File
@@ -0,0 +1,177 @@
/*
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 { expect } from "@playwright/test";
import { spaTest as test, type SpaUser } from "./fixtures/spa-user.ts";
import {
banUser,
createCallRoom,
inviteUser,
kickUser,
membershipOf,
} from "./utils/matrix-api.ts";
/** The app URL for a call known only by its room ID. */
const callUrl = (roomId: string): string =>
`/room/#?roomId=${encodeURIComponent(roomId)}`;
/** Reads the membership the server holds for `user`, as a moderator sees it. */
const membershipPoll =
(moderator: SpaUser, roomId: string, user: SpaUser) =>
async (): Promise<string | undefined> =>
await membershipOf(moderator.accessToken, roomId, user.mxId);
test("Ask to join a call, withdraw, ask again and be let in", async ({
page,
spaUser,
registerUser,
}) => {
test.slow();
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Knock call",
joinRule: "knock",
});
const membership = membershipPoll(moderator, roomId, spaUser);
await page.goto(callUrl(roomId));
const joinButton = page.getByTestId("lobby_joinCall");
await expect(joinButton).toHaveText("Request to join call");
await expect(joinButton).toBeEnabled();
await expect(page.locator("video")).toBeVisible();
await joinButton.click();
await expect(joinButton).toHaveText("Request to join sent");
await expect(joinButton).toBeDisabled();
await expect(page.getByTestId("lobby_joinMessage")).toContainText(
"You will receive an invite to join the call if your request is accepted.",
);
await expect.poll(membership).toBe("knock");
await page.getByTestId("lobby_cancelRequest").click();
await expect(joinButton).toHaveText("Request to join call");
await expect(joinButton).toBeEnabled();
await expect(page.getByTestId("lobby_joinMessage")).toBeHidden();
await expect.poll(membership).toBe("leave");
await joinButton.click();
await expect(joinButton).toHaveText("Request to join sent");
await expect.poll(membership).toBe("knock");
await inviteUser(moderator.accessToken, roomId, spaUser.mxId);
// The accepted request joins the room and skips a second lobby.
await expect(page.getByTestId("incall_leave")).toBeVisible({
timeout: 60_000,
});
await expect(joinButton).toBeHidden();
});
test("A declined request is shown in the lobby", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Declining call",
joinRule: "knock",
});
await page.goto(callUrl(roomId));
await page.getByTestId("lobby_joinCall").click();
await expect.poll(membershipPoll(moderator, roomId, spaUser)).toBe("knock");
await kickUser(moderator.accessToken, roomId, spaUser.mxId);
const message = page.getByTestId("lobby_joinMessage");
await expect(message).toContainText("Access denied");
await expect(message).toContainText("Your request to join was declined.");
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
// The camera preview outlives the refusal.
await expect(page.locator("video")).toBeVisible();
});
test("A ban is shown in the lobby, with its reason", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Banning call",
joinRule: "knock",
});
await page.goto(callUrl(roomId));
await page.getByTestId("lobby_joinCall").click();
await expect.poll(membershipPoll(moderator, roomId, spaUser)).toBe("knock");
await banUser(moderator.accessToken, roomId, spaUser.mxId, "Wrong call");
const message = page.getByTestId("lobby_joinMessage");
await expect(message).toContainText("Banned");
await expect(message).toContainText("You have been banned from the room.");
await expect(message).toContainText("Reason: Wrong call");
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
});
test("A call that takes no requests says so in the lobby", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
// Synapse serves an MSC3266 summary for knock and knock_restricted rooms
// only, so a `knock_restricted` room, whose rule Element Call cannot act on
// without knowing the user's other rooms, is the one shape that reaches the
// lobby with nothing to offer.
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Space call",
joinRule: "knock_restricted",
});
await page.goto(callUrl(roomId));
await expect(page.getByTestId("lobby_joinMessage")).toContainText(
"You need an invite to join this call.",
);
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
await expect(page.locator("video")).toBeVisible();
});
test("An invite-only call cannot be reached at all", async ({
page,
spaUser,
registerUser,
}) => {
const moderator = await registerUser("moderator");
const roomId = await createCallRoom({
accessToken: moderator.accessToken,
name: "Private call",
joinRule: "invite",
});
await page.goto(callUrl(roomId));
// Synapse serves no room summary for an invite-only room, so Element Call
// falls back to joining as if the room were public and the refusal lands on
// the error page rather than in the lobby.
await expect(
page.getByRole("heading", { name: "Something went wrong" }),
).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeHidden();
});
+146
View File
@@ -0,0 +1,146 @@
/*
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.
*/
/** The homeserver of the Playwright backend, as `config.devenv.json` names it. */
export const HOMESERVER_URL = "https://synapse.m.localhost";
/** MSC3417: the room type Element Call gives its calls. */
const CALL_ROOM_TYPE = "org.matrix.msc3417.call";
/** The state event every call participant sends to announce their membership. */
const RTC_MEMBER_EVENT_TYPE = "org.matrix.msc3401.call.member";
async function csApiRequest(
accessToken: string,
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<Response> {
const init: RequestInit = {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
};
if (body !== undefined) init.body = JSON.stringify(body);
return await fetch(`${HOMESERVER_URL}/_matrix/client/v3/${path}`, init);
}
async function csApi<T>(
accessToken: string,
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const response = await csApiRequest(accessToken, method, path, body);
if (!response.ok)
throw new Error(
`${method} ${path} failed: ${response.status} ${await response.text()}`,
);
return (await response.json()) as T;
}
/**
* Create a room that Element Call sees as a call, and that anyone who gets in
* may participate in. The creator is its only moderator, and `joinRule` is
* written as `m.room.join_rules` over whatever the preset would set.
*
* @returns The room's ID
*/
export async function createCallRoom({
accessToken,
name,
joinRule,
}: {
accessToken: string;
name: string;
joinRule: string;
}): Promise<string> {
const created = await csApi<{ room_id: string }>(
accessToken,
"POST",
"createRoom",
{
name,
preset: "private_chat",
creation_content: { type: CALL_ROOM_TYPE },
initial_state: [
{
type: "m.room.join_rules",
state_key: "",
content: { join_rule: joinRule },
},
],
power_level_content_override: {
events: { [RTC_MEMBER_EVENT_TYPE]: 0 },
},
},
);
return created.room_id;
}
export async function inviteUser(
accessToken: string,
roomId: string,
userId: string,
): Promise<void> {
await csApi(
accessToken,
"POST",
`rooms/${encodeURIComponent(roomId)}/invite`,
{
user_id: userId,
},
);
}
export async function kickUser(
accessToken: string,
roomId: string,
userId: string,
): Promise<void> {
await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/kick`, {
user_id: userId,
});
}
export async function banUser(
accessToken: string,
roomId: string,
userId: string,
reason?: string,
): Promise<void> {
await csApi(accessToken, "POST", `rooms/${encodeURIComponent(roomId)}/ban`, {
user_id: userId,
reason,
});
}
/**
* Read a user's membership as the server reports it, from the room state, so
* that knocks and leaves are visible and not just joins.
*
* @returns The membership, or undefined while the user has no member event
*/
export async function membershipOf(
accessToken: string,
roomId: string,
userId: string,
): Promise<string | undefined> {
const response = await csApiRequest(
accessToken,
"GET",
`rooms/${encodeURIComponent(roomId)}/state/m.room.member/${encodeURIComponent(userId)}`,
);
if (response.status === 404) return undefined;
if (!response.ok)
throw new Error(
`Reading the membership of ${userId} failed: ${response.status} ${await response.text()}`,
);
return ((await response.json()) as { membership: string }).membership;
}
-2
View File
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
rtcMode1,
-2
View File
@@ -129,8 +129,6 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
frame.getByTestId("screenshare-indicator").first(),
).toHaveAttribute("data-visible", "true");
await carol.page.pause();
// now click on next
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
await frame.getByRole("button", { name: "Next" }).click();
+87 -1
View File
@@ -6,8 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
import {
type ITransport,
type WidgetApi,
WidgetApiResponseError,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import EventEmitter from "events";
import { KnownMembership, MatrixError } from "matrix-js-sdk";
import { type Observable } from "rxjs";
@@ -17,6 +23,7 @@ import {
nullHostBridge,
} from "./HostBridge";
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
import { MembershipUnsupportedError } from "./room/membership";
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
return {
@@ -217,6 +224,81 @@ describe("createWidgetHostBridge", () => {
});
});
describe("changeMembership", () => {
const bridgeReplying = (
send: ReturnType<typeof vi.fn>,
): ReturnType<typeof createWidgetHostBridge> =>
createWidgetHostBridge(
mockWidget({ transport: { send } as unknown as ITransport }),
);
test("asks the host to knock, and reports what it ended up with", async () => {
const send = vi
.fn()
.mockResolvedValue({ membership: KnownMembership.Knock });
const bridge = bridgeReplying(send);
await expect(
bridge.changeMembership!({ action: "knock", reason: "let me in" }),
).resolves.toBe(KnownMembership.Knock);
expect(send).toHaveBeenCalledWith("io.element.membership", {
action: "knock",
reason: "let me in",
});
});
test("turns a refusal by the homeserver into a MatrixError", async () => {
const bridge = bridgeReplying(
vi.fn().mockRejectedValue(
new WidgetApiResponseError("Failed to knock", {
matrix_api_error: {
http_status: 403,
http_headers: {},
url: "/_matrix/client/v3/knock/!call:example.org",
response: {
errcode: "M_FORBIDDEN",
error: "You are not invited",
},
},
}),
),
);
await expect(
bridge.changeMembership!({ action: "knock" }),
).rejects.toSatisfy(
(error) =>
error instanceof MatrixError && error.errcode === "M_FORBIDDEN",
);
});
test("reports a host that does not implement the action", async () => {
const bridge = bridgeReplying(
vi
.fn()
.mockRejectedValue(
new WidgetApiResponseError(
"Unknown or unsupported from-widget action: io.element.membership",
{},
),
),
);
await expect(
bridge.changeMembership!({ action: "knock" }),
).rejects.toBeInstanceOf(MembershipUnsupportedError);
});
test("passes a transport failure through", async () => {
const timeout = new Error("Request timed out");
const bridge = bridgeReplying(vi.fn().mockRejectedValue(timeout));
await expect(bridge.changeMembership!({ action: "join" })).rejects.toBe(
timeout,
);
});
});
describe("close", () => {
test("asks the host to close, then stops the transport", async () => {
const transport = {
@@ -296,6 +378,10 @@ describe("nullHostBridge", () => {
expect(nullHostBridge.downloadMedia).toBeUndefined();
});
test("changes no membership, so Element Call uses its own client", () => {
expect(nullHostBridge.changeMembership).toBeUndefined();
});
test("supports reactions, since nothing is mediating its homeserver access", () => {
expect(nullHostBridge.supportsReactions).toBe(true);
});
+31
View File
@@ -10,14 +10,22 @@ import { fromEvent, map, NEVER, type Observable } from "rxjs";
import {
type IWidgetApiRequest,
type IWidgetApiRequestData,
type IWidgetApiResponseData,
WidgetApiResponseError,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import { MatrixError, type Membership } from "matrix-js-sdk";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "./widget";
import {
type ChangeMembership,
type MembershipChange,
MembershipUnsupportedError,
} from "./room/membership";
// Note: these are type aliases rather than interfaces so that they satisfy the
// widget API's index-signature payload types.
@@ -122,6 +130,12 @@ export interface HostBridge {
* media itself using its own client.
*/
downloadMedia?(mxcUri: string): Promise<Blob>;
/**
* Changes the user's membership of the room on Element Call's behalf, for
* hosts that do not give it direct access to the homeserver. Absent when
* Element Call should change the membership itself using its own client.
*/
changeMembership?: ChangeMembership;
}
/**
@@ -221,6 +235,23 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
)
);
},
changeMembership: async (change) => {
try {
const { membership } = await widget.api.transport.send<
MembershipChange & IWidgetApiRequestData,
IWidgetApiResponseData & { membership: Membership }
>(ElementWidgetActions.Membership, change);
return membership;
} catch (e) {
if (!(e instanceof WidgetApiResponseError)) throw e;
// A host that made the request on our behalf answers with the
// homeserver's own error. One without means it made no request: it
// does not know the action, or refused it outright.
throw e.data.matrix_api_error
? MatrixError.fromWidgetApiErrorData(e.data.matrix_api_error)
: new MembershipUnsupportedError(e.message);
}
},
downloadMedia: async (mxcUri) => {
const { file } = await widget.api.downloadFile(mxcUri);
if (file instanceof Blob) return file;
+7 -2
View File
@@ -50,11 +50,16 @@ export const useInteractiveRegistration = (
useEffect(() => {
if (isWidget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key.
// A 401 carrying the flow parameters is the expected answer, so this
// rejection is the success path, and some homeservers answer it with no
// params at all.
authClient.current!.registerRequest({}).catch((error) => {
logger.debug("Registration flow parameters", error);
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
error.data?.params?.["m.login.terms"]?.policies?.privacy_policy?.en
?.url,
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
setRecaptchaKey(error.data?.params?.["m.login.recaptcha"]?.public_key);
});
}, [isWidget]);
+1 -1
View File
@@ -507,7 +507,7 @@ const LoadedCallView: FC<LoadedProps> = ({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => setJoined(true)}
joinState={{ kind: "can-join", join: () => setJoined(true) }}
confineToRoom={confineToRoom}
hideHeader={header !== HeaderStyle.Standard}
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 userEvent from "@testing-library/user-event";
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 { type LobbyJoinState } from "./LobbyJoinState";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { mockMediaDevices } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { type PreJoinRoomInfo } from "./preJoinRoomInfo";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
@@ -44,22 +47,24 @@ const client = {
} as Partial<MatrixClient> as MatrixClient;
// What peeking at a room we are not in tells us about it
const roomSummary = {
room_id: "!room:example.org",
name: "Knock Room",
"im.nheko.summary.encryption": "m.megolm.v1.aes-sha2",
} as Partial<RoomSummary> as RoomSummary;
const room: PreJoinRoomInfo = {
roomId: "!room:example.org",
roomName: "Knock Room",
roomAlias: null,
roomAvatar: null,
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
};
function renderKnockLobby(knock: (() => void) | null): void {
function renderKnockLobby(joinState: LobbyJoinState): void {
render(
<LeaveToHomeProvider value={vi.fn()}>
<MediaDevicesContext value={mockMediaDevices({})}>
<TooltipProvider>
<KnockLobbyView
client={client}
roomSummary={roomSummary}
room={room}
profile={{ displayName: "Test User", avatarUrl: "" }}
knock={knock}
joinState={joinState}
confineToRoom={false}
hideHeader={false}
/>
@@ -71,8 +76,8 @@ function renderKnockLobby(knock: (() => void) | null): void {
describe("KnockLobbyView", () => {
it("offers to ask to join, with what it knows of the room", async () => {
const knock = vi.fn();
renderKnockLobby(knock);
const askToJoin = vi.fn();
renderKnockLobby({ kind: "can-ask-to-join", askToJoin });
// The mute state arrives asynchronously, and the lobby with it
const button = await screen.findByTestId("lobby_joinCall");
@@ -81,14 +86,14 @@ describe("KnockLobbyView", () => {
expect(screen.getByText("Knock Room")).toBeInTheDocument();
await userEvent.setup().click(button);
expect(knock).toHaveBeenCalledOnce();
expect(askToJoin).toHaveBeenCalledOnce();
});
it("waits once it has asked", async () => {
renderKnockLobby(null);
renderKnockLobby({ kind: "waiting-for-approval" });
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
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.
*/
import { type FC, type JSX, type ReactNode } from "react";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type FC, type ReactNode } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useMuteStates } from "../state/useMuteStates";
import { type LobbyJoinState } from "./LobbyJoinState";
import { type PreJoinRoomInfo } from "./preJoinRoomInfo";
interface Props {
client: MatrixClient;
/** What we know about the room from peeking at it. */
roomSummary: RoomSummary;
/** What we know about the room without being in it. */
room: PreJoinRoomInfo;
/** The user's own name and avatar, to show in their own tile. */
profile: { displayName: string; avatarUrl: string };
/**
* Asks to be let in, if the room allows it. Absent once we have asked and
* are waiting for an answer.
*/
knock: (() => void) | null;
/** How the user may get into the call, and what they may do about it. */
joinState: LobbyJoinState;
confineToRoom: boolean;
hideHeader: boolean;
}
@@ -41,27 +37,16 @@ interface Props {
*/
export const KnockLobbyView: FC<Props> = ({
client,
roomSummary,
room,
profile,
knock,
joinState,
confineToRoom,
hideHeader,
}): ReactNode => {
const { t } = useTranslation();
const muteStates = useMuteStates();
if (muteStates === null) return null;
const waitingForInvite = knock === null;
const enterLabel: string | JSX.Element = waitingForInvite ? (
<>
{t("lobby.waiting_for_invite")}
<CheckIcon />
</>
) : (
t("lobby.ask_to_join")
);
return (
<LobbyView
client={client}
@@ -69,19 +54,9 @@ export const KnockLobbyView: FC<Props> = ({
userId: client.getUserId() ?? "",
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
roomAlias: null,
roomId: roomSummary.room_id,
roomName: roomSummary.name ?? "",
roomAvatar: roomSummary.avatar_url ?? null,
e2eeSystem: {
kind: roomSummary["im.nheko.summary.encryption"]
? E2eeType.PER_PARTICIPANT
: E2eeType.NONE,
},
...room,
}}
onEnter={(): void => knock?.()}
enterLabel={enterLabel}
waitingForInvite={waitingForInvite}
joinState={joinState}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={null}
+40
View File
@@ -0,0 +1,40 @@
/*
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. `notice` is set when they got here
* by having a request accepted, but the join it entitles them to failed.
*/
| { kind: "can-join"; join: () => void; notice?: "request_accepted" }
/**
* 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 user's join is on its way. */
| { kind: "joining" }
/**
* 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);
}
}
.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);
}
+186 -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 { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
@@ -17,6 +18,7 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { type LobbyJoinState } from "./LobbyJoinState";
import { E2eeType } from "../e2ee/e2eeType";
import { mockMediaDevices, mockMuteStates } from "../utils/test";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -87,7 +89,7 @@ function renderLobbyView(
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
joinState={{ kind: "can-join", join: () => {} }}
confineToRoom={false}
hideHeader={hideHeader}
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 () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
{ joinState: { kind: "waiting-for-approval" } },
true,
"android",
);
@@ -157,9 +150,7 @@ describe("LobbyView", () => {
it("renders with AppBar ios", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
{ joinState: { kind: "waiting-for-approval" } },
true,
"ios",
);
@@ -177,4 +168,185 @@ describe("LobbyView", () => {
expect(container).toMatchSnapshot();
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: "joining" },
button: "Joining",
disabled: true,
message: null,
},
{
joinState: {
kind: "can-join",
join: () => {},
notice: "request_accepted",
},
button: "Join call",
disabled: false,
message: "Your request to join was accepted.",
},
{
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("does nothing while the join is on its way", async () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "joining" },
});
const button = getByTestId("lobby_joinCall");
expect(button).toHaveAttribute("aria-busy", "true");
await userEvent.click(button);
expect(button).toHaveAttribute("aria-disabled", "true");
});
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",
);
});
});
});
+137 -21
View File
@@ -7,16 +7,20 @@ Please see LICENSE in the repository root for full details.
import {
type FC,
type ReactNode,
useCallback,
useMemo,
useState,
type JSX,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
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 {
CheckIcon,
SpinnerIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/lib/logger";
import { usePreviewTracks } from "@livekit/components-react";
import {
@@ -28,6 +32,7 @@ import { useObservableEagerState } from "observable-hooks";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
import buttonStyles from "../button/Button.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "../state/MuteStates";
@@ -51,31 +56,28 @@ import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
import { type LobbyJoinState } from "./LobbyJoinState";
interface Props {
client: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
enterLabel?: JSX.Element | string;
joinState: LobbyJoinState;
confineToRoom: boolean;
hideHeader: boolean;
participantCount: number | null;
onShareClick: (() => void) | null;
waitingForInvite?: boolean;
}
export const LobbyView: FC<Props> = ({
client,
matrixInfo,
muteStates,
onEnter,
enterLabel,
joinState,
confineToRoom,
hideHeader,
participantCount,
onShareClick,
waitingForInvite,
}) => {
useEffect(() => {
logger.info("[Lifecycle] LobbyView Component mounted");
@@ -209,6 +211,127 @@ export const LobbyView: FC<Props> = ({
};
}, [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":
case "joining":
return (
<Button
className={classNames(styles.join, buttonStyles.rotate)}
size="lg"
Icon={SpinnerIcon}
disabled
aria-busy
data-testid="lobby_joinCall"
>
{t(
joinState.kind === "joining"
? "lobby.joining"
: "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-join":
return joinState.notice === undefined ? null : (
<Text size="sm">{t("lobby.request_accepted")}</Text>
);
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 "sending-request":
case "joining":
return null;
}
})();
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -236,20 +359,13 @@ export const LobbyView: FC<Props> = ({
videoEnabled={videoEnabled}
videoTrack={videoTrack}
>
<Button
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>
{joinButton}
</VideoPreview>
{joinMessage !== null && (
<div className={styles.joinMessage} data-testid="lobby_joinMessage">
{joinMessage}
</div>
)}
{!recentsButtonInFooter && recentsButton}
</div>
{footerVm !== null && (
+1 -1
View File
@@ -85,7 +85,7 @@ export const RoomAuthView: FC = () => {
</FieldRow>
<Text size="sm">
<Trans i18nKey="room_auth_view_ssla_caption">
By clicking "Join call now", you agree to our{" "}
By clicking "Continue", you agree to our{" "}
<ExternalLink href={Config.get().ssla}>
Software and Services License Agreement (SSLA)
</ExternalLink>
+12 -16
View File
@@ -56,9 +56,8 @@ export const RoomPage: FC = (): ReactNode => {
);
usePageTitle(
roomName ??
(groupCallState.kind === "canKnock" ||
groupCallState.kind === "waitForInvite"
? groupCallState.roomSummary.name
(groupCallState.kind === "lobby"
? groupCallState.room.roomName
: undefined),
);
@@ -90,10 +89,10 @@ export const RoomPage: FC = (): ReactNode => {
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
const wasInWaitForInviteState = useRef<boolean>(false);
const wasWaitingForApproval = useRef<boolean>(false);
useEffect(() => {
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
if (groupCallState.kind === "loaded" && wasWaitingForApproval.current) {
logger.log("Play join sound 'Not yet implemented'");
}
}, [groupCallState.kind]);
@@ -108,25 +107,22 @@ export const RoomPage: FC = (): ReactNode => {
isPasswordlessUser={passwordlessUser}
confineToRoom={confineToRoom}
preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current}
skipLobby={skipLobby || wasWaitingForApproval.current}
/>
);
case "waitForInvite":
case "canKnock": {
wasInWaitForInviteState.current =
wasInWaitForInviteState.current ||
groupCallState.kind === "waitForInvite";
case "lobby": {
wasWaitingForApproval.current =
wasWaitingForApproval.current ||
groupCallState.joinState.kind === "waiting-for-approval";
return (
<KnockLobbyView
client={client!}
roomSummary={groupCallState.roomSummary}
room={groupCallState.room}
profile={{
displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "",
}}
knock={
groupCallState.kind === "canKnock" ? groupCallState.knock : null
}
joinState={groupCallState.joinState}
confineToRoom={confineToRoom}
hideHeader={header !== "standard"}
/>
@@ -139,7 +135,7 @@ export const RoomPage: FC = (): ReactNode => {
</FullScreenView>
);
case "failed":
wasInWaitForInviteState.current = false;
wasWaitingForApproval.current = false;
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return (
<FullScreenView>
+96 -14
View File
@@ -7,7 +7,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
>
<header>
<button
aria-labelledby="_r_36_"
aria-labelledby="_r_22_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
@@ -80,10 +80,51 @@ exports[`LobbyView > renders with AppBar android 1`] = `
role="button"
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>
</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
class="_link_13esb_8"
data-kind="primary"
@@ -102,7 +143,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_3c_"
aria-labelledby="_r_28_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -133,7 +174,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_3h_"
aria-labelledby="_r_2d_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -158,7 +199,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3m_"
aria-labelledby="_r_2i_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -183,7 +224,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3r_"
aria-labelledby="_r_2n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -205,7 +246,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
</svg>
</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"
data-kind="primary"
data-size="lg"
@@ -239,7 +280,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
>
<header>
<button
aria-labelledby="_r_4a_"
aria-labelledby="_r_36_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
@@ -312,10 +353,51 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
role="button"
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>
</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
class="_link_13esb_8"
data-kind="primary"
@@ -334,7 +416,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_4g_"
aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -365,7 +447,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_4l_"
aria-labelledby="_r_3h_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -390,7 +472,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4q_"
aria-labelledby="_r_3m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -415,7 +497,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4v_"
aria-labelledby="_r_3r_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -437,7 +519,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
</svg>
</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"
data-kind="primary"
data-size="lg"
+95
View File
@@ -0,0 +1,95 @@
/*
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 {
KnownMembership,
MatrixError,
type MatrixClient,
type Membership,
} from "matrix-js-sdk";
import { mockMatrixRoom } from "../utils/test";
import { changeMembershipWithClient } from "./membership";
const roomId = "!call:example.org";
const viaServers = ["example.org"];
const mockClient = (membership?: Membership): MatrixClient => {
const room =
membership === undefined
? null
: mockMatrixRoom({ roomId, getMyMembership: () => membership });
return {
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
knockRoom: vi.fn().mockResolvedValue({ room_id: roomId }),
leave: vi.fn().mockResolvedValue({}),
} as Partial<MatrixClient> as MatrixClient;
};
const change = (
client: MatrixClient,
): ReturnType<typeof changeMembershipWithClient> =>
changeMembershipWithClient(client, roomId, viaServers);
describe("changeMembershipWithClient", () => {
it("joins with the servers to try", async () => {
const client = mockClient(KnownMembership.Leave);
await expect(change(client)({ action: "join" })).resolves.toBe(
KnownMembership.Join,
);
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
});
it("knocks with the reason and the servers to try", async () => {
const client = mockClient(KnownMembership.Knock);
await expect(
change(client)({ action: "knock", reason: "let me in" }),
).resolves.toBe(KnownMembership.Knock);
expect(client.knockRoom).toHaveBeenCalledWith(roomId, {
viaServers,
reason: "let me in",
});
});
it("reports a knock the room state has yet to catch up with", async () => {
// A user who withdrew a request and asked again is still `leave` locally
await expect(
change(mockClient(KnownMembership.Leave))({ action: "knock" }),
).resolves.toBe(KnownMembership.Knock);
});
it("withdraws a request by leaving", async () => {
const client = mockClient(KnownMembership.Knock);
await expect(change(client)({ action: "cancel_knock" })).resolves.toBe(
KnownMembership.Leave,
);
expect(client.leave).toHaveBeenCalledWith(roomId);
});
it("retries a join the server has yet to see the invite for", async () => {
vi.useFakeTimers();
try {
const client = mockClient(KnownMembership.Invite);
const joinRoom = vi
.spyOn(client, "joinRoom")
.mockRejectedValueOnce(
new MatrixError({ errcode: "M_FORBIDDEN", error: "Not invited" }),
)
.mockResolvedValueOnce(client.getRoom(roomId)!);
const joined = change(client)({ action: "join" });
await vi.runAllTimersAsync();
await expect(joined).resolves.toBe(KnownMembership.Join);
expect(joinRoom).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
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 {
KnownMembership,
MatrixError,
type MatrixClient,
type Membership,
} from "matrix-js-sdk";
/** A change to the local user's membership of the room the call is in. */
export type MembershipChange =
| { action: "join" }
| { action: "knock"; reason?: string }
/** Withdraws a request to join. */
| { action: "cancel_knock" };
/**
* Applies a membership change, resolving to the membership it resulted in.
*/
export type ChangeMembership = (
change: MembershipChange,
) => Promise<Membership>;
/**
* The host has no implementation of the requested membership change, so there
* is no point in offering it again.
*/
export class MembershipUnsupportedError extends Error {}
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
/**
* Joins a room the user has been invited to, retrying on M_FORBIDDEN: a
* homeserver that has not yet caught up with an invite from another server
* refuses the join.
*
* @see https://github.com/element-hq/element-call/issues/2634
*/
async function joinRoomAfterInvite(
client: MatrixClient,
roomId: string,
viaServers: string[],
attempt = 1,
): Promise<void> {
try {
await client.joinRoom(roomId, { viaServers });
} catch (error) {
if (
error instanceof MatrixError &&
error.errcode === "M_FORBIDDEN" &&
attempt < MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE
) {
await new Promise((r) => setTimeout(r, DELAY_MS_FOR_INVITE_JOIN_FAILURE));
await joinRoomAfterInvite(client, roomId, viaServers, attempt + 1);
} else {
throw error;
}
}
}
/**
* Changes the membership over the client-server API, with Element Call's own
* access token.
*/
export function changeMembershipWithClient(
client: MatrixClient,
roomId: string,
viaServers: string[],
): ChangeMembership {
return async (change) => {
// A request the server accepted decides the membership: the local room
// state still holds the previous one until the next sync brings the new
// member event, so reading it back here would report the old membership.
switch (change.action) {
case "join":
if (
client.getRoom(roomId)?.getMyMembership() === KnownMembership.Invite
)
await joinRoomAfterInvite(client, roomId, viaServers);
else await client.joinRoom(roomId, { viaServers });
return KnownMembership.Join;
case "knock":
await client.knockRoom(roomId, { viaServers, reason: change.reason });
return KnownMembership.Knock;
case "cancel_knock":
await client.leave(roomId);
return KnownMembership.Leave;
}
};
}
+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,
),
};
}
+662
View File
@@ -0,0 +1,662 @@
/*
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, type Mock } from "vitest";
import { type ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import EventEmitter from "events";
import {
ClientEvent,
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 HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../HostBridge";
import { getUrlParams, UrlParamsProvider } from "../UrlParams";
import { MembershipUnsupportedError } from "./membership";
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;
/**
* Renders the hook, as a widget of `host` when one is given.
*/
const renderLoad = (
client: MatrixClient,
host?: HostBridge,
): ReturnType<typeof renderHook<GroupCallStatus, unknown>> =>
renderHook(() => useLoadGroupCall(client, roomId, viaServers), {
wrapper: ({ children }): ReactNode =>
host === undefined ? (
children
) : (
<UrlParamsProvider value={{ ...getUrlParams(), isWidget: true }}>
<HostBridgeProvider value={host}>{children}</HostBridgeProvider>
</UrlParamsProvider>
),
});
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("joins straight away when a request was accepted before this load", async () => {
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "joining");
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.getRoomSummary).not.toHaveBeenCalled();
});
it("offers a join of our own when an accepted request cannot be taken up", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const room = mockRoom({
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
});
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockRejectedValue(new Error("offline")),
});
const { result } = renderLoad(client);
expect(await waitForJoinState(result, "can-join")).toMatchObject({
notice: "request_accepted",
});
expect(error).toHaveBeenCalled();
});
it("joins an invite that answers no request without a lobby", async () => {
const room = mockRoom({ membership: KnownMembership.Invite });
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
});
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");
});
});
describe("useLoadGroupCall as a widget", () => {
/** A host that answers membership changes with `changeMembership`. */
const host = (
changeMembership: Mock = vi.fn().mockResolvedValue(KnownMembership.Knock),
): HostBridge => ({ ...nullHostBridge, changeMembership });
it("proceeds straight to the call when the host has joined us", async () => {
const room = mockRoom({ membership: KnownMembership.Join });
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host());
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it.each([
["a ban", { membership: KnownMembership.Ban }, "banned"],
[
"a pending request",
{ membership: KnownMembership.Knock },
"waiting-for-approval",
],
[
"an invite that replaced a request",
{
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
},
"joining",
],
["a plain invite", { membership: KnownMembership.Invite }, "can-join"],
[
"a declined request",
{
membership: KnownMembership.Leave,
prevMembership: KnownMembership.Knock,
},
"denied",
],
[
"a public room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Public },
"can-join",
],
[
"a restricted room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Restricted },
"can-join",
],
[
"a knock_restricted room",
{ membership: KnownMembership.Leave, joinRule: "knock_restricted" },
"can-join",
],
[
"a knock room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Knock },
"can-ask-to-join",
],
[
"a knock room with no membership",
{ joinRule: JoinRule.Knock },
"can-ask-to-join",
],
[
"an invite-only room",
{ membership: KnownMembership.Leave, joinRule: JoinRule.Invite },
"not-allowed",
],
[
"a room with no join rule",
{ membership: KnownMembership.Leave },
"not-allowed",
],
])("opens the lobby of %s", async (_case, spec, expected) => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(mockRoom(spec)),
});
// A request the host never answers, so that the state a lobby opens on is
// the one under test.
const { result } = renderLoad(
client,
host(vi.fn().mockReturnValue(new Promise(() => {}))),
);
await waitForJoinState(result, expected as LobbyJoinState["kind"]);
});
it("warns when the host has not shared the join rule", async () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const client = mockClient({
getRoom: vi
.fn()
.mockReturnValue(mockRoom({ membership: KnownMembership.Leave })),
});
const { result } = renderLoad(client, host());
await waitForJoinState(result, "not-allowed");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("m.room.join_rules"),
);
});
it("waits for approval when the host knocked instead of joining", async () => {
const changeMembership = vi.fn().mockResolvedValue(KnownMembership.Knock);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: "knock_restricted",
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canJoin = await waitForJoinState(result, "can-join");
act(() => canJoin.join());
await waitForJoinState(result, "waiting-for-approval");
expect(changeMembership).toHaveBeenCalledWith({ action: "join" });
});
it("returns to the lobby when the host does not answer", async () => {
const changeMembership = vi
.fn()
.mockRejectedValue(new Error("Request timed out"));
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: JoinRule.Knock,
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canAsk = await waitForJoinState(result, "can-ask-to-join");
act(() => canAsk.askToJoin("let me in"));
expect(await waitForJoinState(result, "can-ask-to-join")).toMatchObject({
error: "request_failed",
});
expect(changeMembership).toHaveBeenCalledWith({
action: "knock",
reason: "let me in",
});
});
it("gives up when the host has no membership action", async () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: JoinRule.Knock,
}),
),
});
const { result } = renderLoad(
client,
host(vi.fn().mockRejectedValue(new MembershipUnsupportedError())),
);
const canAsk = await waitForJoinState(result, "can-ask-to-join");
act(() => canAsk.askToJoin());
await waitForJoinState(result, "not-allowed");
});
it("asks the host to join us as soon as a request is accepted", async () => {
const changeMembership = vi.fn().mockReturnValue(new Promise(() => {}));
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host(changeMembership));
await waitForJoinState(result, "waiting-for-approval");
spec.membership = KnownMembership.Invite;
emitMembership(client, room, KnownMembership.Invite, KnownMembership.Knock);
await waitForJoinState(result, "joining");
expect(changeMembership).toHaveBeenCalledWith({ action: "join" });
});
it("enters the call when the host reports us joined", async () => {
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
// The host joins us as it answers, so no membership event of our own
// follows the reply.
const joinAsHost = vi.fn(async (): Promise<Membership> => {
spec.membership = KnownMembership.Join;
return await Promise.resolve(KnownMembership.Join);
});
const { result } = renderLoad(client, host(joinAsHost));
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it("stays in the call when the host answers our join late", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
let refuse!: (error: Error) => void;
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(
client,
host(
vi.fn().mockReturnValue(
new Promise((_resolve, reject) => {
refuse = reject;
}),
),
),
);
await waitForJoinState(result, "joining");
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
const loaded = result.current;
refuse(new Error("Request timed out"));
await waitFor(() => expect(error).toHaveBeenCalled());
expect(result.current).toBe(loaded);
});
it("offers a join of our own when the host cannot answer", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const changeMembership = vi
.fn()
.mockRejectedValue(new Error("Request timed out"));
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canJoin = await waitForJoinState(result, "can-join");
expect(canJoin).toMatchObject({ notice: "request_accepted" });
expect(error).toHaveBeenCalled();
act(() => canJoin.join());
await waitForJoinState(result, "joining");
expect(await waitForJoinState(result, "can-join")).toMatchObject({
notice: "request_accepted",
});
expect(changeMembership).toHaveBeenCalledTimes(2);
});
it("resolves when the host joined us before we could listen", async () => {
const room = mockRoom({ membership: KnownMembership.Knock });
(room as { getMyMembership: () => Membership }).getMyMembership = vi
.fn()
.mockReturnValueOnce(KnownMembership.Knock)
.mockReturnValue(KnownMembership.Join);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host());
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it("adds no listener as the join state changes, and removes them all on unmount", async () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Leave,
joinRule: JoinRule.Knock,
}),
),
});
const { result, unmount } = renderLoad(client, host());
const canAsk = await waitForJoinState(result, "can-ask-to-join");
const listeners = client.listenerCount(RoomEvent.MyMembership);
act(() => canAsk.askToJoin());
await waitForJoinState(result, "waiting-for-approval");
expect(client.listenerCount(RoomEvent.MyMembership)).toBe(listeners);
unmount();
expect(client.listenerCount(RoomEvent.MyMembership)).toBe(0);
expect(client.listenerCount(ClientEvent.Sync)).toBe(0);
});
});
+453 -228
View File
@@ -1,5 +1,6 @@
/*
Copyright 2022-2024 New Vector Ltd.
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.
@@ -9,7 +10,6 @@ import {
useState,
useEffect,
useRef,
useCallback,
type ComponentType,
type SVGAttributes,
} from "react";
@@ -19,6 +19,7 @@ import {
SyncState,
MatrixError,
KnownMembership,
type Membership,
ClientEvent,
type MatrixClient,
type RoomSummary,
@@ -30,11 +31,21 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useTranslation } from "react-i18next";
import {
AdminIcon,
CloseIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useUrlParams } from "../UrlParams";
import { useHostBridge } from "../HostBridge";
import { type LobbyJoinState } from "./LobbyJoinState";
import {
preJoinRoomInfoFromRoom,
preJoinRoomInfoFromSummary,
type PreJoinRoomInfo,
} from "./preJoinRoomInfo";
import {
changeMembershipWithClient,
MembershipUnsupportedError,
} from "./membership";
export type GroupCallLoaded = {
kind: "loaded";
@@ -50,59 +61,21 @@ export type GroupCallLoading = {
kind: "loading";
};
export type GroupCallWaitForInvite = {
kind: "waitForInvite";
roomSummary: RoomSummary;
};
export type GroupCallCanKnock = {
kind: "canKnock";
roomSummary: RoomSummary;
knock: () => void;
/**
* The call cannot be entered yet, so the lobby is shown with whatever the user
* can do about it.
*/
export type GroupCallLobby = {
kind: "lobby";
room: PreJoinRoomInfo;
joinState: LobbyJoinState;
};
export type GroupCallStatus =
| GroupCallLoaded
| GroupCallLoadFailed
| GroupCallLoading
| GroupCallWaitForInvite
| GroupCallCanKnock;
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
/**
* Join a room, and retry on M_FORBIDDEN error in order to work
* around a potential race when joining rooms over federation.
*
* Will wait up to to `DELAY_MS_FOR_INVITE_JOIN_FAILURE` per attempt.
* Will try up to `MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE` times.
*
* @see https://github.com/element-hq/element-call/issues/2634
* @param client The matrix client
* @param attempt Number of attempts made.
* @param params Parameters to pass to client.joinRoom
*/
async function joinRoomAfterInvite(
client: MatrixClient,
attempt = 0,
...params: Parameters<MatrixClient["joinRoom"]>
): ReturnType<MatrixClient["joinRoom"]> {
try {
return await client.joinRoom(...params);
} catch (ex) {
if (
ex instanceof MatrixError &&
ex.errcode === "M_FORBIDDEN" &&
attempt < MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE
) {
// If we were invited and got a M_FORBIDDEN, it's highly likely the server hasn't caught up yet.
await new Promise((r) => setTimeout(r, DELAY_MS_FOR_INVITE_JOIN_FAILURE));
return joinRoomAfterInvite(client, attempt + 1, ...params);
}
throw ex;
}
}
| GroupCallLobby;
export class CallTerminatedMessage extends Error {
/**
@@ -124,6 +97,23 @@ export class CallTerminatedMessage extends Error {
}
}
/** The membership the local user's current one replaced, if any. */
function previousMembership(room: Room): Membership | undefined {
return room.currentState
.getStateEvents(EventType.RoomMember, room.myUserId)
?.getPrevContent().membership as Membership | undefined;
}
/** The join state a lobby opens on, before the user acts on it. */
type LobbyEntry =
| "can-join"
| "can-ask-to-join"
| "joining"
| "waiting-for-approval"
| "denied"
| "banned"
| "not-allowed";
export const useLoadGroupCall = (
client: MatrixClient | undefined,
roomIdOrAlias: string | null,
@@ -133,47 +123,64 @@ export const useLoadGroupCall = (
const activeRoom = useRef<Room | undefined>(undefined);
const { t } = useTranslation();
const { isWidget } = useUrlParams();
const bannedError = useCallback(
(): CallTerminatedMessage =>
new CallTerminatedMessage(
AdminIcon,
t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"),
leaveReason(),
),
[t],
);
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(
EndCallIcon,
t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"),
leaveReason(),
),
[t],
);
const leaveReason = (): string =>
activeRoom.current?.currentState
.getStateEvents(EventType.RoomMember, activeRoom.current?.myUserId)
?.getContent().reason;
const hostBridge = useHostBridge();
useEffect(() => {
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(
AdminIcon,
t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"),
leaveReason(),
);
const removeNoticeError = (): CallTerminatedMessage =>
new CallTerminatedMessage(
EndCallIcon,
t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"),
leaveReason(),
);
/**
* Wait for the room to be usable for group calls. Widget mode has no
* equivalent gate, because the host owns the sync.
*/
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;
};
const getRoomByAlias = async (alias: string): Promise<Room> => {
// 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
@@ -181,150 +188,371 @@ export const useLoadGroupCall = (
// 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
// room you're already joined to (which it probably ought not to).
let room: Room | null = null;
const lookupResult = await client.getRoomIdForAlias(alias.toLowerCase());
logger.info(`${alias} resolved to ${lookupResult.room_id}`);
room = client.getRoom(lookupResult.room_id);
if (!room) {
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
room = await client.joinRoom(lookupResult.room_id, {
viaServers: lookupResult.servers,
});
} else {
const room = client.getRoom(lookupResult.room_id);
if (room) {
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 (
/**
* Watch for the local user joining the room. `check` re-tests the room as
* it stands, for a join that happened without an event we saw, such as one
* the host had already applied.
*/
const watchForJoin = (
roomId: string,
viaServers: string[],
onKnockSent: () => void,
): Promise<Room> => {
await client.knockRoom(roomId, { viaServers });
onKnockSent();
return await new Promise<Room>((resolve, reject) => {
client.on(
RoomEvent.MyMembership,
(room, membership, prevMembership): void => {
if (roomId !== room.roomId) return;
activeRoom.current = room;
if (
membership === KnownMembership.Invite &&
prevMembership === KnownMembership.Knock
) {
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());
},
);
): { reached: Promise<Room>; check: () => void } => {
let check = (): void => {};
const reached = new Promise<Room>((resolve) => {
const test = (room: Room | null): boolean => {
if (room?.getMyMembership() !== KnownMembership.Join) return false;
activeRoom.current = room;
resolve(room);
return true;
};
const off = onMyMembership((room) => {
if (room.roomId === roomId && test(room)) off();
});
check = (): void => {
if (test(client.getRoom(roomId))) off();
};
check();
});
return { reached, check };
};
/**
* 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;
const changeMembership =
hostBridge.changeMembership ??
changeMembershipWithClient(client, roomId, viaServers);
let requestInFlight = false;
let withdrawing = false;
let entered = false;
// Listening before anything is sent, so that a join landing while a
// request is in flight is not missed.
const { reached, check } = watchForJoin(roomId);
/** Sets the lobby state, unless the user is already through the lobby. */
const setLobby = (joinState: LobbyJoinState): void => {
if (!entered && !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 });
/** The request was accepted, but the join it entitles us to failed. */
const acceptedButNotJoined = (): void =>
setLobby({
kind: "can-join",
join: joinOnceAccepted,
notice: "request_accepted",
});
const onRequestResult = (
membership: Membership,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A membership the room already holds reaches us in the reply rather
// than in an event of its own, so the join this lobby is parked on is
// re-tested here.
if (membership === KnownMembership.Join) return check();
if (membership === KnownMembership.Knock) return waitForApproval();
logger.error(
`${operation} on ${roomId} left us with membership ${membership}`,
);
onFailure();
};
const onRequestError = (
error: unknown,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A refusal by the server, or a host with no implementation of the
// operation, means this user cannot get in this way at all.
if (
error instanceof MembershipUnsupportedError ||
(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;
setLobby({ kind: "joining" });
changeMembership({ action: "join" }).then(
(membership) => onRequestResult(membership, "Joining", canJoin),
(error) => onRequestError(error, "Joining", canJoin),
);
};
/** Takes up the invite an accepted request has earned us. */
const joinOnceAccepted = (): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "joining" });
changeMembership({ action: "join" }).then(
(membership) =>
onRequestResult(
membership,
"Joining once accepted",
acceptedButNotJoined,
),
(error) =>
onRequestError(
error,
"Joining once accepted",
acceptedButNotJoined,
),
);
};
const askToJoin = (reason?: string): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "sending-request" });
const failed = (): void => canAskToJoin("request_failed");
changeMembership({ action: "knock", reason }).then(
(membership) => onRequestResult(membership, "Asking to join", failed),
(error) => onRequestError(error, "Asking to join", 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" });
changeMembership({ action: "cancel_knock" }).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:
// An invite that replaced a request is ours to take up: nothing
// else is going to turn it into a join.
if (prevMembership === KnownMembership.Knock) joinOnceAccepted();
else canJoin();
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 "joining":
joinOnceAccepted();
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 reached;
entered = true;
offTransitions();
return joined;
};
/**
* What the lobby of a room the host has pushed to us offers, from our
* membership, the membership it replaced, and the join rule.
*/
const widgetLobbyEntry = (
room: Room,
membership: Membership | undefined,
): LobbyEntry => {
const prevMembership = previousMembership(room);
if (membership === KnownMembership.Ban) return "banned";
if (membership === KnownMembership.Knock) return "waiting-for-approval";
if (membership === KnownMembership.Invite)
return prevMembership === KnownMembership.Knock
? "joining"
: "can-join";
if (prevMembership === KnownMembership.Knock) return "denied";
// An absent join rule is not `invite`: it means the host granted no
// capability to read the state event.
const joinRules = room.currentState.getStateEvents(
EventType.RoomJoinRules,
"",
);
if (joinRules === null) {
logger.warn(
`No ${EventType.RoomJoinRules} state in ${room.roomId}; the host has not shared the join rule`,
);
return "not-allowed";
}
const joinRule = joinRules.getContent().join_rule as string;
switch (joinRule) {
// The widget cannot evaluate `allowed_room_ids`, since it does not know
// the user's other rooms, so it offers a join and lets the host decide.
case JoinRule.Public:
case JoinRule.Restricted:
// `JoinRule` in the js-sdk has no member for knock_restricted yet.
case "knock_restricted":
return "can-join";
case JoinRule.Knock:
return "can-ask-to-join";
default:
logger.info(
`Room ${room.roomId} takes neither joins nor knocks (join rule ${joinRule})`,
);
return "not-allowed";
}
};
const fetchOrCreateRoom = async (): Promise<Room> => {
let room: Room | null = null;
if (roomIdOrAlias[0] === "#") {
const alias = roomIdOrAlias;
// The call uses a room alias
room = await getRoomByAlias(alias);
const room = await getRoomByAlias(roomIdOrAlias);
activeRoom.current = room;
} else {
// The call uses a room_id
const roomId = roomIdOrAlias;
return room;
}
const roomId = roomIdOrAlias;
// first try if the room already exists
// - in widget mode
// - in SPA mode if the user already joined the room
room = client.getRoom(roomId);
activeRoom.current = room ?? undefined;
const membership = room?.getMyMembership();
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.)
// The room already exists in widget mode, and in SPA mode if the user
// has joined it before.
const room = client.getRoom(roomId);
activeRoom.current = room ?? undefined;
const membership = room?.getMyMembership();
if (membership === KnownMembership.Join) return room!;
if (isWidget) {
if (!room)
// 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) {
throw bannedError();
} else if (membership === KnownMembership.Invite) {
room = await client.joinRoom(roomId, {
viaServers,
});
} else {
// If the room does not exist we first search for it with viaServers
let roomSummary: RoomSummary | undefined = undefined;
try {
roomSummary = await client.getRoomSummary(roomId, viaServers);
} catch (error) {
// 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.
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
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.`,
);
}
}
return await enterFromLobby(
preJoinRoomInfoFromRoom(room),
widgetLobbyEntry(room, membership),
);
}
if (room && membership === KnownMembership.Ban)
return await enterFromLobby(preJoinRoomInfoFromRoom(room), "banned");
if (room && membership === KnownMembership.Invite)
return await readyForGroupCalls(
previousMembership(room) === KnownMembership.Knock
? await enterFromLobby(preJoinRoomInfoFromRoom(room), "joining")
: await client.joinRoom(roomId, { viaServers }),
);
// If the room does not exist we first search for it with viaServers
let roomSummary: RoomSummary | undefined = undefined;
try {
roomSummary = await client.getRoomSummary(roomId, viaServers);
} catch (error) {
// 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.
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
error,
);
}
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(
`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);
logger.info(`${roomIdOrAlias}, is ready for group calls`);
return room;
return await enterFromLobby(roomInfo, "not-allowed");
};
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
@@ -349,6 +577,9 @@ export const useLoadGroupCall = (
}
};
client.on(ClientEvent.Sync, onSync);
signal.addEventListener("abort", () => {
client.off(ClientEvent.Sync, onSync);
});
});
logger.debug("useLoadGroupCall: client is now syncing.");
}
@@ -356,32 +587,26 @@ export const useLoadGroupCall = (
const observeMyMembership = async (): Promise<void> => {
await new Promise((_, reject) => {
client.on(RoomEvent.MyMembership, (_, membership) => {
onMyMembership((_room, membership) => {
if (membership === KnownMembership.Leave) reject(removeNoticeError());
if (membership === KnownMembership.Ban) reject(bannedError());
});
});
};
if (state.kind === "loading") {
logger.log("Start loading group call");
waitForClientSyncing()
.then(fetchOrCreateGroupCall)
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
.then(observeMyMembership)
.catch((error) => setState({ kind: "failed", error }));
}
}, [
bannedError,
client,
isWidget,
knockRejectError,
removeNoticeError,
roomIdOrAlias,
state,
t,
viaServers,
]);
logger.log("Start loading group call");
waitForClientSyncing()
.then(fetchOrCreateGroupCall)
.then((rtcSession) => {
if (!signal.aborted) setState({ kind: "loaded", rtcSession });
})
.then(observeMyMembership)
.catch((error) => {
if (!signal.aborted) setState({ kind: "failed", error });
});
return (): void => controller.abort();
}, [client, hostBridge, isWidget, roomIdOrAlias, viaServers, t]);
return state;
};
+1
View File
@@ -85,6 +85,7 @@ describe("widget", () => {
{ eventType: EventType.RoomName },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.RoomJoinRules },
{ eventType: EventType.GroupCallMemberPrefix },
];
+8
View File
@@ -44,6 +44,13 @@ export enum ElementWidgetActions {
// video_enabled?: boolean
// }
DeviceMute = "io.element.device_mute",
// fromWidget: asks the host to change our membership of its room, for hosts
// that keep the access token to themselves.
// Data: `{ action: "join" | "knock" | "cancel_knock", reason?: string }`,
// reply `{ membership }`, the membership the request resulted in. An error
// reply carries `matrix_api_error` when the homeserver is the one that
// refused it.
Membership = "io.element.membership",
}
export interface JoinCallData {
@@ -147,6 +154,7 @@ export const initializeWidget = (
{ eventType: EventType.RoomName },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.RoomJoinRules },
{ eventType: EventType.GroupCallMemberPrefix },
];
+8 -2
View File
@@ -125,8 +125,14 @@ export default ({
port: 3000,
fs: { allow },
https: {
key: fs.readFileSync("./backend/dev_tls_m.localhost.key"),
cert: fs.readFileSync("./backend/dev_tls_m.localhost.crt"),
// EC_DEV_TLS_KEY / EC_DEV_TLS_CERT let a developer serve a certificate
// their browser already trusts (e.g. one signed by a local mkcert root).
key: fs.readFileSync(
process.env.EC_DEV_TLS_KEY ?? "./backend/dev_tls_m.localhost.key",
),
cert: fs.readFileSync(
process.env.EC_DEV_TLS_CERT ?? "./backend/dev_tls_m.localhost.crt",
),
},
},
worker: {