mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b9e2d983 | ||
|
|
1c8930daa0 | ||
|
|
d78c204ef4 | ||
|
|
3653a7fd05 | ||
|
|
1c8a6487e9 | ||
|
|
1f7e930137 | ||
|
|
a7fdade61a | ||
|
|
302f6f7efa |
+7
-2
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
rtcMode1,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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" };
|
||||
@@ -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
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
type RoomState,
|
||||
type RoomSummary,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { mockMatrixRoom } from "../utils/test";
|
||||
import {
|
||||
preJoinRoomInfoFromRoom,
|
||||
preJoinRoomInfoFromSummary,
|
||||
} from "./preJoinRoomInfo";
|
||||
|
||||
const roomId = "!call:example.org";
|
||||
|
||||
const summary = (extra: Partial<RoomSummary> = {}): RoomSummary =>
|
||||
({
|
||||
room_id: roomId,
|
||||
name: "Weekly sync",
|
||||
canonical_alias: "#sync:example.org",
|
||||
avatar_url: "mxc://example.org/avatar",
|
||||
world_readable: false,
|
||||
guest_can_join: false,
|
||||
num_joined_members: 3,
|
||||
...extra,
|
||||
}) as RoomSummary;
|
||||
|
||||
const room = (extra: { encryption?: string } = {}): Room =>
|
||||
mockMatrixRoom({
|
||||
roomId,
|
||||
name: "Weekly sync",
|
||||
getCanonicalAlias: () => "#sync:example.org",
|
||||
getMxcAvatarUrl: () => "mxc://example.org/avatar",
|
||||
currentState: {
|
||||
getStateEvents: (type: string) =>
|
||||
type === EventType.RoomEncryption && extra.encryption !== undefined
|
||||
? new MatrixEvent({
|
||||
type: EventType.RoomEncryption,
|
||||
state_key: "",
|
||||
content: { algorithm: extra.encryption },
|
||||
})
|
||||
: null,
|
||||
} as unknown as RoomState,
|
||||
});
|
||||
|
||||
describe("preJoinRoomInfoFromSummary", () => {
|
||||
it("reads the stable encryption field", () => {
|
||||
expect(
|
||||
preJoinRoomInfoFromSummary(
|
||||
summary({ encryption: "m.megolm.v1.aes-sha2" } as Partial<RoomSummary>),
|
||||
),
|
||||
).toEqual({
|
||||
roomId,
|
||||
roomName: "Weekly sync",
|
||||
roomAlias: "#sync:example.org",
|
||||
roomAvatar: "mxc://example.org/avatar",
|
||||
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the unstable encryption field", () => {
|
||||
expect(
|
||||
preJoinRoomInfoFromSummary(
|
||||
summary({ "im.nheko.summary.encryption": "m.megolm.v1.aes-sha2" }),
|
||||
).e2eeSystem,
|
||||
).toEqual({ kind: E2eeType.PER_PARTICIPANT });
|
||||
});
|
||||
|
||||
it("is unencrypted when neither field is set", () => {
|
||||
expect(preJoinRoomInfoFromSummary(summary()).e2eeSystem).toEqual({
|
||||
kind: E2eeType.NONE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("preJoinRoomInfoFromRoom", () => {
|
||||
it("reads the encryption state event", () => {
|
||||
expect(
|
||||
preJoinRoomInfoFromRoom(room({ encryption: "m.megolm.v1.aes-sha2" })),
|
||||
).toEqual({
|
||||
roomId,
|
||||
roomName: "Weekly sync",
|
||||
roomAlias: "#sync:example.org",
|
||||
roomAvatar: "mxc://example.org/avatar",
|
||||
e2eeSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
});
|
||||
});
|
||||
|
||||
it("is unencrypted with no encryption state event", () => {
|
||||
expect(preJoinRoomInfoFromRoom(room()).e2eeSystem).toEqual({
|
||||
kind: E2eeType.NONE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventType, type Room, type RoomSummary } from "matrix-js-sdk";
|
||||
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
/**
|
||||
* The subset of MatrixInfo that is knowable before the room is joined, from
|
||||
* either a room summary or the room state a widget host pushes.
|
||||
*/
|
||||
export interface PreJoinRoomInfo {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
roomAlias: string | null;
|
||||
roomAvatar: string | null;
|
||||
e2eeSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
// `RoomSummary` in the js-sdk omits `canonical_alias` and has no stable
|
||||
// `encryption` field.
|
||||
type WidenedRoomSummary = RoomSummary & {
|
||||
canonical_alias?: string;
|
||||
encryption?: string;
|
||||
};
|
||||
|
||||
const e2eeSystem = (encryption: string | undefined): EncryptionSystem =>
|
||||
encryption === undefined
|
||||
? { kind: E2eeType.NONE }
|
||||
: { kind: E2eeType.PER_PARTICIPANT };
|
||||
|
||||
export function preJoinRoomInfoFromSummary(
|
||||
summary: RoomSummary,
|
||||
): PreJoinRoomInfo {
|
||||
const widened = summary as WidenedRoomSummary;
|
||||
return {
|
||||
roomId: summary.room_id,
|
||||
roomName: summary.name ?? "",
|
||||
roomAlias: widened.canonical_alias ?? null,
|
||||
roomAvatar: summary.avatar_url ?? null,
|
||||
e2eeSystem: e2eeSystem(
|
||||
widened.encryption ?? summary["im.nheko.summary.encryption"],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function preJoinRoomInfoFromRoom(room: Room): PreJoinRoomInfo {
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
roomName: room.name,
|
||||
roomAlias: room.getCanonicalAlias(),
|
||||
roomAvatar: room.getMxcAvatarUrl(),
|
||||
e2eeSystem: e2eeSystem(
|
||||
room.currentState
|
||||
.getStateEvents(EventType.RoomEncryption, "")
|
||||
?.getContent().algorithm,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ describe("widget", () => {
|
||||
{ eventType: EventType.RoomName },
|
||||
{ eventType: EventType.RoomMember },
|
||||
{ eventType: EventType.RoomEncryption },
|
||||
{ eventType: EventType.RoomJoinRules },
|
||||
{ eventType: EventType.GroupCallMemberPrefix },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user