From 59ef5f5400c235b398f99f20a31a7fdc169fc8a9 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Wed, 9 Sep 2026 16:05:18 +0200 Subject: [PATCH] ci --- component/host.ts | 80 ++++++++++----------- component/index.tsx | 79 +++++++++++--------- playwright.config.ts | 5 ++ playwright/component/component-call.spec.ts | 11 ++- 4 files changed, 100 insertions(+), 75 deletions(-) diff --git a/component/host.ts b/component/host.ts index 2a83a5978..c0b2db89b 100644 --- a/component/host.ts +++ b/component/host.ts @@ -141,47 +141,45 @@ export function useComponentHostBridge( requests.themeChange$.next({ data: { name: theme }, reply: () => {} }); }, [requests, theme]); - const bridge = useInitial( - (): HostBridge => ({ - setAlwaysOnScreen: async (alwaysOnScreen) => { - await latest.current.setAlwaysOnScreen?.(alwaysOnScreen); - }, - contentLoaded: async () => { - await latest.current.contentLoaded?.(); - }, - notifyJoined: async () => { - await latest.current.notifyJoined?.(); - }, - notifyHungUp: async () => { - await latest.current.notifyHungUp?.(); - }, - notifyDeviceMute: async (state) => { - await latest.current.notifyDeviceMute?.(state); - }, - // Whether these exist is itself information, so they are read through - // rather than wrapped unconditionally - get close() { - const close = latest.current.close; - return close === undefined - ? undefined - : async (): Promise => await close(); - }, - // Not offered to a component host: the client it hands over holds the - // credentials to fetch media itself. A widget's client does not, which - // is what the internal bridge's `downloadMedia` is for. - get supportsReactions(): boolean { - return latest.current.supportsReactions ?? true; - }, - get allowJoinUnmutedViaIntent(): boolean { - return latest.current.allowJoinUnmutedViaIntent ?? false; - }, - // Whatever the host says or does not say, the account is its own: it - // signed the user in and handed us the client. So Element Call never - // offers to edit the profile from inside a component. - supportsProfileChanges: false, - ...requests, - }), - ); + const bridge = useInitial((): HostBridge => ({ + setAlwaysOnScreen: async (alwaysOnScreen) => { + await latest.current.setAlwaysOnScreen?.(alwaysOnScreen); + }, + contentLoaded: async () => { + await latest.current.contentLoaded?.(); + }, + notifyJoined: async () => { + await latest.current.notifyJoined?.(); + }, + notifyHungUp: async () => { + await latest.current.notifyHungUp?.(); + }, + notifyDeviceMute: async (state) => { + await latest.current.notifyDeviceMute?.(state); + }, + // Whether these exist is itself information, so they are read through + // rather than wrapped unconditionally + get close() { + const close = latest.current.close; + return close === undefined + ? undefined + : async (): Promise => await close(); + }, + // Not offered to a component host: the client it hands over holds the + // credentials to fetch media itself. A widget's client does not, which + // is what the internal bridge's `downloadMedia` is for. + get supportsReactions(): boolean { + return latest.current.supportsReactions ?? true; + }, + get allowJoinUnmutedViaIntent(): boolean { + return latest.current.allowJoinUnmutedViaIntent ?? false; + }, + // Whatever the host says or does not say, the account is its own: it + // signed the user in and handed us the client. So Element Call never + // offers to edit the profile from inside a component. + supportsProfileChanges: false, + ...requests, + })); useImperativeHandle( ref, diff --git a/component/index.tsx b/component/index.tsx index 39bb1d29b..b3f04e00c 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -40,6 +40,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState, } from "react"; import { type MatrixClient } from "matrix-js-sdk"; @@ -298,43 +299,57 @@ export const ElementCall: FC = ({ `Element Call was asked to call in ${roomId}, which its host's client does not know about`, ); + // Everything the call needs is in hand once these exist, and the first + // render with them is where the call itself appears: the moment the host + // is told that Element Call has loaded, as the widget tells its client once + // its own initialisation is over. Once per mount, however often the pieces + // are later swapped out. + const ready = + container !== null && rtcSession !== null && mediaDevices !== null; + const announcedLoaded = useRef(false); + useEffect(() => { + if (!ready || announcedLoaded.current) return; + announcedLoaded.current = true; + hostBridge + .contentLoaded() + .catch((e) => logger.error("Could not tell the host we had loaded", e)); + }, [ready, hostBridge]); + return (
- {container !== null && - rtcSession !== null && - mediaDevices !== null && ( - - {/* Whatever goes wrong in here is shown in here. Left to - propagate, an error would unmount the host's own tree. */} - } - // A broken call should not hold the host on screen - onError={() => void hostBridge.setAlwaysOnScreen(false)} - > - - - - - - - - - - - - - - )} + {ready && ( + + {/* Whatever goes wrong in here is shown in here. Left to + propagate, an error would unmount the host's own tree. */} + } + // A broken call should not hold the host on screen + onError={() => void hostBridge.setAlwaysOnScreen(false)} + > + + + + + + + + + + + + + + )}
diff --git a/playwright.config.ts b/playwright.config.ts index 73112c7ad..e6dcc5249 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -86,6 +86,11 @@ export default defineConfig({ // enumerateDevices work on CI runners without real hardware. "media.navigator.streams.fake": true, "media.navigator.permission.disabled": true, + // Vite serves HTTPS over HTTP/2, and Firefox intermittently stalls + // on Node's HTTP/2 server with a page that never finishes loading + // (one run in five or so, locally). Every server in the suite + // still speaks HTTP/1.1, so nothing is lost by insisting on it. + "network.http.http2.enabled": false, }, }, }, diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 54e1a22a9..a611ddc1d 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -25,6 +25,11 @@ import { SpaHelpers } from "../spa-helpers.ts"; * guaranteed both. */ +// Each test signs in twice, sets up crypto twice and syncs twice before +// anything is on screen, and then waits for media to connect; the waits below +// are sized for that, so the tests have to be too +test.describe.configure({ timeout: 180_000 }); + /** The settings button, whichever of the two the footer is currently showing. */ function settingsButton(pane: Locator): Locator { return pane @@ -206,16 +211,18 @@ test("looks the same in a small container as in a small window", async ({ browser, }) => { // Two calls to set up, one of them through the harness's two logins - test.setTimeout(240_000); + test.setTimeout(300_000); const size = { width: 300, height: 300 }; // The reference is Element Call owning a window of that size, which is what // a mobile app's webview or a browser's picture-in-picture gives it, and // what its small-window styling was written for. + // No permissions to grant: each browser is launched with fake media that is + // handed out without asking (see playwright.config.ts), and Firefox rejects + // a request for `camera` or `microphone` outright const referenceContext = await browser.newContext({ viewport: size, ignoreHTTPSErrors: true, - permissions: ["microphone", "camera"], }); const referencePage = await referenceContext.newPage(); await referencePage.goto("/");