diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index fb3779f41..ced48cc89 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -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): WidgetHelpers { return { @@ -217,6 +224,81 @@ describe("createWidgetHostBridge", () => { }); }); + describe("changeMembership", () => { + const bridgeReplying = ( + send: ReturnType, + ): ReturnType => + 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); }); diff --git a/src/HostBridge.ts b/src/HostBridge.ts index 2ba50257c..8c317c427 100644 --- a/src/HostBridge.ts +++ b/src/HostBridge.ts @@ -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; + /** + * 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; diff --git a/src/room/membership.test.ts b/src/room/membership.test.ts new file mode 100644 index 000000000..0b5bd8f02 --- /dev/null +++ b/src/room/membership.test.ts @@ -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 as MatrixClient; +}; + +const change = ( + client: MatrixClient, +): ReturnType => + 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(); + } + }); +}); diff --git a/src/room/membership.ts b/src/room/membership.ts new file mode 100644 index 000000000..2075c927e --- /dev/null +++ b/src/room/membership.ts @@ -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; + +/** + * 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 { + 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; + } + }; +} diff --git a/src/room/useLoadGroupCall.test.tsx b/src/room/useLoadGroupCall.test.tsx index 7c5968e71..048e1b1b9 100644 --- a/src/room/useLoadGroupCall.test.tsx +++ b/src/room/useLoadGroupCall.test.tsx @@ -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): RoomSummary => ...extra, }) as RoomSummary; +/** + * Renders the hook, as a widget of `host` when one is given. + */ const renderLoad = ( client: MatrixClient, + host?: HostBridge, ): ReturnType> => - renderHook(() => useLoadGroupCall(client, roomId, viaServers)); + renderHook(() => useLoadGroupCall(client, roomId, viaServers), { + wrapper: ({ children }): ReactNode => + host === undefined ? ( + children + ) : ( + + {children} + + ), + }); 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); + }); +}); diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 817aff95b..44f7f3ee7 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -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 -): ReturnType { - 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(undefined); const { t } = useTranslation(); const { isWidget } = useUrlParams(); + const hostBridge = useHostBridge(); useEffect(() => { if (!client || !roomIdOrAlias) { @@ -257,6 +227,9 @@ export const useLoadGroupCall = ( entry: LobbyEntry, ): Promise => { 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 => { 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; }; diff --git a/src/widget.test.ts b/src/widget.test.ts index c645c3b34..e5136b16a 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -85,6 +85,7 @@ describe("widget", () => { { eventType: EventType.RoomName }, { eventType: EventType.RoomMember }, { eventType: EventType.RoomEncryption }, + { eventType: EventType.RoomJoinRules }, { eventType: EventType.GroupCallMemberPrefix }, ]; diff --git a/src/widget.ts b/src/widget.ts index b35e335bc..9a6f3a6eb 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -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 }, ];