From f6f47ede62bda74609ff96860760c1223a6e2b75 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:14:10 +0200 Subject: [PATCH] Let a host supply Element Call's client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientProvider found its own client: from the widget API, or by restoring or creating a session. A host that embeds Element Call already has one, and owns the user's session, so accept it as a prop and skip all of that. A supplied client seeds the state synchronously, since there is no session of ours to restore and so nothing to wait for. Also guard the broadcast that shuts down other instances of the app. It protects Element Call's own session and crypto stores, which is why it was already skipped in widget mode — a host's client has the same property, so without this an embedded Element Call would close down the user's other tabs on mount. Adds the first tests for ClientContext, covering both --- src/ClientContext.test.tsx | 69 ++++++++++++++++++++++++++++++++++++++ src/ClientContext.tsx | 33 ++++++++++++++---- 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 src/ClientContext.test.tsx diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx new file mode 100644 index 000000000..909635d89 --- /dev/null +++ b/src/ClientContext.test.tsx @@ -0,0 +1,69 @@ +/* +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, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { type MatrixClient } from "matrix-js-sdk"; +import { type FC } from "react"; + +import { ClientProvider, useClientState } from "./ClientContext"; + +const mockClient = (): MatrixClient => + ({ + on: vi.fn(), + removeListener: vi.fn(), + getUserId: () => "@alice:example.org", + getDeviceId: () => "AAAA", + stopClient: vi.fn(), + }) as Partial as MatrixClient; + +/** Reports what the context says, so a test can assert on it. */ +const ShowClientState: FC = () => { + const state = useClientState(); + if (state === undefined) return loading; + if (state.state === "error") return error; + return ( + {state.authenticated?.client.getUserId() ?? "unauthenticated"} + ); +}; + +test("uses a client supplied by the host without waiting", () => { + const client = mockClient(); + + const { container } = render( + + + + + , + ); + + // Available on the very first render: a supplied client needs no session + // restoring, so there is no loading state to pass through. + expect(container.textContent).toBe("@alice:example.org"); +}); + +test("does not claim exclusive use of storage when given a client", () => { + // The channel is created when the module loads, so spy on the prototype + // rather than trying to replace the global. + const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage"); + + render( + + + + + , + ); + + // The broadcast shuts down other instances to protect Element Call's own + // stores. A host's client brings its own, so there is nothing to protect. + expect(postMessage).not.toHaveBeenCalled(); + + postMessage.mockRestore(); +}); diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index a0d65e64f..6ad017f15 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -134,19 +134,36 @@ const loadChannel = interface Props { children: JSX.Element; + /** + * The client Element Call should use. + * + * When a host embeds Element Call it already has a client, and owns the + * user's session; supplying it here means Element Call neither authenticates + * anyone nor manages their session. Left out, Element Call finds a client + * itself — from the widget API, or by restoring or creating a session of its + * own. + */ + client?: MatrixClient; } -export const ClientProvider: FC = ({ children }) => { +export const ClientProvider: FC = ({ children, client }) => { const navigate = useNavigate(); const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< InitResult | null | undefined - >(undefined); + >( + client === undefined + ? undefined + : // A supplied client belongs to the host, so there is no session of ours + // to restore and nothing to wait for. + { client, passwordlessUser: false }, + ); const initializing = useRef(false); useEffect(() => { + if (client !== undefined) return; // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -161,7 +178,7 @@ export const ClientProvider: FC = ({ children }) => { }) .catch((err) => logger.error(err)) .finally(() => (initializing.current = false)); - }, []); + }, [client]); const changePassword = useCallback( async (password: string) => { @@ -228,11 +245,13 @@ export const ClientProvider: FC = ({ children }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other - // running instances of the app. This isn't necessary if the app is running in - // a widget though, since then it'll be mostly stateless. + // running instances of the app. Element Call only has storage of its own to + // protect when it created the session itself; given a client, or running as a + // widget, it is mostly stateless. + const ownsSession = client === undefined && widget === null; useEffect(() => { - if (!widget) loadChannel?.postMessage({}); - }, []); + if (ownsSession) loadChannel?.postMessage({}); + }, [ownsSession]); const [alreadyOpenedErr, setAlreadyOpenedErr] = useState( undefined,