This commit is contained in:
Timo K.
2026-09-09 16:05:18 +02:00
parent 2ac95df75f
commit 59ef5f5400
4 changed files with 100 additions and 75 deletions
+39 -41
View File
@@ -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<void> => 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<void> => 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,
+47 -32
View File
@@ -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<ElementCallProps> = ({
`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 (
<I18nextProvider i18n={i18n}>
<HostBridgeProvider value={hostBridge}>
<UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}>
{container !== null &&
rtcSession !== null &&
mediaDevices !== null && (
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
)}
{ready && (
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
)}
</div>
</UrlParamsProvider>
</HostBridgeProvider>
+5
View File
@@ -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,
},
},
},
+9 -2
View File
@@ -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("/");