Let the lobby ask its host for membership changes

Running as a widget, Element Call has no access token of its own, so a room
it is not in can only be entered by the host: the lobby ended at a
full-screen "Room not found" for every room the user had not already joined.

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Quentin Gliech
2026-09-09 22:44:59 +02:00
co-authored by Claude Fable 5.1
parent 3653a7fd05
commit d78c204ef4
8 changed files with 633 additions and 65 deletions
+87 -1
View File
@@ -6,8 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
import {
type ITransport,
type WidgetApi,
WidgetApiResponseError,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import EventEmitter from "events";
import { KnownMembership, MatrixError } from "matrix-js-sdk";
import { type Observable } from "rxjs";
@@ -17,6 +23,7 @@ import {
nullHostBridge,
} from "./HostBridge";
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
import { MembershipUnsupportedError } from "./room/membership";
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
return {
@@ -217,6 +224,81 @@ describe("createWidgetHostBridge", () => {
});
});
describe("changeMembership", () => {
const bridgeReplying = (
send: ReturnType<typeof vi.fn>,
): ReturnType<typeof createWidgetHostBridge> =>
createWidgetHostBridge(
mockWidget({ transport: { send } as unknown as ITransport }),
);
test("asks the host to knock, and reports what it ended up with", async () => {
const send = vi
.fn()
.mockResolvedValue({ membership: KnownMembership.Knock });
const bridge = bridgeReplying(send);
await expect(
bridge.changeMembership!({ action: "knock", reason: "let me in" }),
).resolves.toBe(KnownMembership.Knock);
expect(send).toHaveBeenCalledWith("io.element.membership", {
action: "knock",
reason: "let me in",
});
});
test("turns a refusal by the homeserver into a MatrixError", async () => {
const bridge = bridgeReplying(
vi.fn().mockRejectedValue(
new WidgetApiResponseError("Failed to knock", {
matrix_api_error: {
http_status: 403,
http_headers: {},
url: "/_matrix/client/v3/knock/!call:example.org",
response: {
errcode: "M_FORBIDDEN",
error: "You are not invited",
},
},
}),
),
);
await expect(
bridge.changeMembership!({ action: "knock" }),
).rejects.toSatisfy(
(error) =>
error instanceof MatrixError && error.errcode === "M_FORBIDDEN",
);
});
test("reports a host that does not implement the action", async () => {
const bridge = bridgeReplying(
vi
.fn()
.mockRejectedValue(
new WidgetApiResponseError(
"Unknown or unsupported from-widget action: io.element.membership",
{},
),
),
);
await expect(
bridge.changeMembership!({ action: "knock" }),
).rejects.toBeInstanceOf(MembershipUnsupportedError);
});
test("passes a transport failure through", async () => {
const timeout = new Error("Request timed out");
const bridge = bridgeReplying(vi.fn().mockRejectedValue(timeout));
await expect(bridge.changeMembership!({ action: "join" })).rejects.toBe(
timeout,
);
});
});
describe("close", () => {
test("asks the host to close, then stops the transport", async () => {
const transport = {
@@ -296,6 +378,10 @@ describe("nullHostBridge", () => {
expect(nullHostBridge.downloadMedia).toBeUndefined();
});
test("changes no membership, so Element Call uses its own client", () => {
expect(nullHostBridge.changeMembership).toBeUndefined();
});
test("supports reactions, since nothing is mediating its homeserver access", () => {
expect(nullHostBridge.supportsReactions).toBe(true);
});
+31
View File
@@ -10,14 +10,22 @@ import { fromEvent, map, NEVER, type Observable } from "rxjs";
import {
type IWidgetApiRequest,
type IWidgetApiRequestData,
type IWidgetApiResponseData,
WidgetApiResponseError,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import { MatrixError, type Membership } from "matrix-js-sdk";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "./widget";
import {
type ChangeMembership,
type MembershipChange,
MembershipUnsupportedError,
} from "./room/membership";
// Note: these are type aliases rather than interfaces so that they satisfy the
// widget API's index-signature payload types.
@@ -122,6 +130,12 @@ export interface HostBridge {
* media itself using its own client.
*/
downloadMedia?(mxcUri: string): Promise<Blob>;
/**
* Changes the user's membership of the room on Element Call's behalf, for
* hosts that do not give it direct access to the homeserver. Absent when
* Element Call should change the membership itself using its own client.
*/
changeMembership?: ChangeMembership;
}
/**
@@ -221,6 +235,23 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
)
);
},
changeMembership: async (change) => {
try {
const { membership } = await widget.api.transport.send<
MembershipChange & IWidgetApiRequestData,
IWidgetApiResponseData & { membership: Membership }
>(ElementWidgetActions.Membership, change);
return membership;
} catch (e) {
if (!(e instanceof WidgetApiResponseError)) throw e;
// A host that made the request on our behalf answers with the
// homeserver's own error. One without means it made no request: it
// does not know the action, or refused it outright.
throw e.data.matrix_api_error
? MatrixError.fromWidgetApiErrorData(e.data.matrix_api_error)
: new MembershipUnsupportedError(e.message);
}
},
downloadMedia: async (mxcUri) => {
const { file } = await widget.api.downloadFile(mxcUri);
if (file instanceof Blob) return file;
+95
View File
@@ -0,0 +1,95 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import {
KnownMembership,
MatrixError,
type MatrixClient,
type Membership,
} from "matrix-js-sdk";
import { mockMatrixRoom } from "../utils/test";
import { changeMembershipWithClient } from "./membership";
const roomId = "!call:example.org";
const viaServers = ["example.org"];
const mockClient = (membership?: Membership): MatrixClient => {
const room =
membership === undefined
? null
: mockMatrixRoom({ roomId, getMyMembership: () => membership });
return {
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
knockRoom: vi.fn().mockResolvedValue({ room_id: roomId }),
leave: vi.fn().mockResolvedValue({}),
} as Partial<MatrixClient> as MatrixClient;
};
const change = (
client: MatrixClient,
): ReturnType<typeof changeMembershipWithClient> =>
changeMembershipWithClient(client, roomId, viaServers);
describe("changeMembershipWithClient", () => {
it("joins with the servers to try", async () => {
const client = mockClient(KnownMembership.Leave);
await expect(change(client)({ action: "join" })).resolves.toBe(
KnownMembership.Join,
);
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
});
it("knocks with the reason and the servers to try", async () => {
const client = mockClient(KnownMembership.Knock);
await expect(
change(client)({ action: "knock", reason: "let me in" }),
).resolves.toBe(KnownMembership.Knock);
expect(client.knockRoom).toHaveBeenCalledWith(roomId, {
viaServers,
reason: "let me in",
});
});
it("reports a knock the room state has yet to catch up with", async () => {
// A user who withdrew a request and asked again is still `leave` locally
await expect(
change(mockClient(KnownMembership.Leave))({ action: "knock" }),
).resolves.toBe(KnownMembership.Knock);
});
it("withdraws a request by leaving", async () => {
const client = mockClient(KnownMembership.Knock);
await expect(change(client)({ action: "cancel_knock" })).resolves.toBe(
KnownMembership.Leave,
);
expect(client.leave).toHaveBeenCalledWith(roomId);
});
it("retries a join the server has yet to see the invite for", async () => {
vi.useFakeTimers();
try {
const client = mockClient(KnownMembership.Invite);
const joinRoom = vi
.spyOn(client, "joinRoom")
.mockRejectedValueOnce(
new MatrixError({ errcode: "M_FORBIDDEN", error: "Not invited" }),
)
.mockResolvedValueOnce(client.getRoom(roomId)!);
const joined = change(client)({ action: "join" });
await vi.runAllTimersAsync();
await expect(joined).resolves.toBe(KnownMembership.Join);
expect(joinRoom).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
KnownMembership,
MatrixError,
type MatrixClient,
type Membership,
} from "matrix-js-sdk";
/** A change to the local user's membership of the room the call is in. */
export type MembershipChange =
| { action: "join" }
| { action: "knock"; reason?: string }
/** Withdraws a request to join. */
| { action: "cancel_knock" };
/**
* Applies a membership change, resolving to the membership it resulted in.
*/
export type ChangeMembership = (
change: MembershipChange,
) => Promise<Membership>;
/**
* The host has no implementation of the requested membership change, so there
* is no point in offering it again.
*/
export class MembershipUnsupportedError extends Error {}
const MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE = 3;
const DELAY_MS_FOR_INVITE_JOIN_FAILURE = 3000;
/**
* Joins a room the user has been invited to, retrying on M_FORBIDDEN: a
* homeserver that has not yet caught up with an invite from another server
* refuses the join.
*
* @see https://github.com/element-hq/element-call/issues/2634
*/
async function joinRoomAfterInvite(
client: MatrixClient,
roomId: string,
viaServers: string[],
attempt = 1,
): Promise<void> {
try {
await client.joinRoom(roomId, { viaServers });
} catch (error) {
if (
error instanceof MatrixError &&
error.errcode === "M_FORBIDDEN" &&
attempt < MAX_ATTEMPTS_FOR_INVITE_JOIN_FAILURE
) {
await new Promise((r) => setTimeout(r, DELAY_MS_FOR_INVITE_JOIN_FAILURE));
await joinRoomAfterInvite(client, roomId, viaServers, attempt + 1);
} else {
throw error;
}
}
}
/**
* Changes the membership over the client-server API, with Element Call's own
* access token.
*/
export function changeMembershipWithClient(
client: MatrixClient,
roomId: string,
viaServers: string[],
): ChangeMembership {
return async (change) => {
// A request the server accepted decides the membership: the local room
// state still holds the previous one until the next sync brings the new
// member event, so reading it back here would report the old membership.
switch (change.action) {
case "join":
if (
client.getRoom(roomId)?.getMyMembership() === KnownMembership.Invite
)
await joinRoomAfterInvite(client, roomId, viaServers);
else await client.joinRoom(roomId, { viaServers });
return KnownMembership.Join;
case "knock":
await client.knockRoom(roomId, { viaServers, reason: change.reason });
return KnownMembership.Knock;
case "cancel_knock":
await client.leave(roomId);
return KnownMembership.Leave;
}
};
}
+209 -2
View File
@@ -5,10 +5,12 @@ 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 { 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,
@@ -24,6 +26,13 @@ import {
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,
@@ -117,10 +126,23 @@ const summary = (extra: Record<string, unknown>): RoomSummary =>
...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));
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");
@@ -317,3 +339,188 @@ describe("useLoadGroupCall in the standalone app", () => {
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,
},
"waiting-for-approval",
],
["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)),
});
const { result } = renderLoad(client, host());
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("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);
});
});
+106 -62
View File
@@ -35,12 +35,17 @@ import {
} 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";
@@ -72,42 +77,6 @@ export type GroupCallStatus =
| GroupCallLoading
| GroupCallLobby;
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;
}
}
export class CallTerminatedMessage extends Error {
/**
* Creates a new CallTerminatedMessage.
@@ -146,6 +115,7 @@ export const useLoadGroupCall = (
const activeRoom = useRef<Room | undefined>(undefined);
const { t } = useTranslation();
const { isWidget } = useUrlParams();
const hostBridge = useHostBridge();
useEffect(() => {
if (!client || !roomIdOrAlias) {
@@ -257,6 +227,9 @@ export const useLoadGroupCall = (
entry: LobbyEntry,
): Promise<Room> => {
const roomId = room.roomId;
const changeMembership =
hostBridge.changeMembership ??
changeMembershipWithClient(client, roomId, viaServers);
let requestInFlight = false;
let withdrawing = false;
@@ -272,14 +245,34 @@ export const useLoadGroupCall = (
const canJoin = (): void => setLobby({ kind: "can-join", join });
const onRequestResult = (
membership: Membership,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A join resolves the promise this lobby is parked on, so there is
// nothing left to show.
if (membership === KnownMembership.Join) return;
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 means this user cannot get in this way at all.
if (error instanceof MatrixError && error.errcode === "M_FORBIDDEN") {
// 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 {
@@ -291,13 +284,9 @@ export const useLoadGroupCall = (
const join = (): void => {
if (requestInFlight) return;
requestInFlight = true;
// A join of our own resolves the promise this lobby is parked on, so
// there is nothing to show on success.
client.joinRoom(roomId, { viaServers }).then(
() => {
requestInFlight = false;
},
(error: unknown) => onRequestError(error, "Joining", canJoin),
changeMembership({ action: "join" }).then(
(membership) => onRequestResult(membership, "Joining", canJoin),
(error) => onRequestError(error, "Joining", canJoin),
);
};
@@ -305,15 +294,10 @@ export const useLoadGroupCall = (
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "sending-request" });
client.knockRoom(roomId, { viaServers, reason }).then(
() => {
requestInFlight = false;
waitForApproval();
},
(error: unknown) =>
onRequestError(error, "Asking to join", () =>
canAskToJoin("request_failed"),
),
const failed = (): void => canAskToJoin("request_failed");
changeMembership({ action: "knock", reason }).then(
(membership) => onRequestResult(membership, "Asking to join", failed),
(error) => onRequestError(error, "Asking to join", failed),
);
};
@@ -323,7 +307,7 @@ export const useLoadGroupCall = (
// Drop the link while the withdrawal is on its way, so it cannot be
// pressed twice.
setLobby({ kind: "waiting-for-approval" });
client.leave(roomId).catch((error: unknown) => {
changeMembership({ action: "cancel_knock" }).catch((error: unknown) => {
withdrawing = false;
logger.error("Failed to withdraw the request to join", error);
waitForApproval();
@@ -336,9 +320,11 @@ export const useLoadGroupCall = (
activeRoom.current = changed;
switch (membership) {
case KnownMembership.Invite:
// A host that changes memberships on our behalf also performs the
// join an accepted request entitles us to.
if (prevMembership !== KnownMembership.Knock) canJoin();
else
joinRoomAfterInvite(client, 0, roomId, { viaServers }).then(
else if (hostBridge.changeMembership === undefined)
changeMembership({ action: "join" }).then(
() => logger.info(`Joined ${roomId} once accepted`),
(error: unknown) =>
logger.error(
@@ -390,6 +376,58 @@ export const useLoadGroupCall = (
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 = room.currentState
.getStateEvents(EventType.RoomMember, room.myUserId)
?.getPrevContent().membership as Membership | undefined;
if (membership === KnownMembership.Ban) return "banned";
if (membership === KnownMembership.Knock) return "waiting-for-approval";
if (membership === KnownMembership.Invite)
// An invite that replaced a request means the host is mid-join.
return prevMembership === KnownMembership.Knock
? "waiting-for-approval"
: "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> => {
if (roomIdOrAlias[0] === "#") {
const room = await getRoomByAlias(roomIdOrAlias);
@@ -405,11 +443,17 @@ export const useLoadGroupCall = (
const membership = room?.getMyMembership();
if (membership === KnownMembership.Join) return room!;
if (isWidget)
// in widget mode we never should reach this point. (getRoom should return the room.)
throw new Error(
"Room not found. The widget-api did not pass over the relevant room events/information.",
if (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.",
);
return await enterFromLobby(
preJoinRoomInfoFromRoom(room),
widgetLobbyEntry(room, membership),
);
}
if (room && membership === KnownMembership.Ban)
return await enterFromLobby(preJoinRoomInfoFromRoom(room), "banned");
@@ -512,7 +556,7 @@ export const useLoadGroupCall = (
});
return (): void => controller.abort();
}, [client, isWidget, roomIdOrAlias, viaServers, t]);
}, [client, hostBridge, isWidget, roomIdOrAlias, viaServers, t]);
return state;
};
+1
View File
@@ -85,6 +85,7 @@ describe("widget", () => {
{ eventType: EventType.RoomName },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.RoomJoinRules },
{ eventType: EventType.GroupCallMemberPrefix },
];
+8
View File
@@ -44,6 +44,13 @@ export enum ElementWidgetActions {
// video_enabled?: boolean
// }
DeviceMute = "io.element.device_mute",
// fromWidget: asks the host to change our membership of its room, for hosts
// that keep the access token to themselves.
// Data: `{ action: "join" | "knock" | "cancel_knock", reason?: string }`,
// reply `{ membership }`, the membership the request resulted in. An error
// reply carries `matrix_api_error` when the homeserver is the one that
// refused it.
Membership = "io.element.membership",
}
export interface JoinCallData {
@@ -147,6 +154,7 @@ export const initializeWidget = (
{ eventType: EventType.RoomName },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.RoomJoinRules },
{ eventType: EventType.GroupCallMemberPrefix },
];