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.themeChange$.next({ data: { name: theme }, reply: () => {} });
}, [requests, theme]); }, [requests, theme]);
const bridge = useInitial( const bridge = useInitial((): HostBridge => ({
(): HostBridge => ({ setAlwaysOnScreen: async (alwaysOnScreen) => {
setAlwaysOnScreen: async (alwaysOnScreen) => { await latest.current.setAlwaysOnScreen?.(alwaysOnScreen);
await latest.current.setAlwaysOnScreen?.(alwaysOnScreen); },
}, contentLoaded: async () => {
contentLoaded: async () => { await latest.current.contentLoaded?.();
await latest.current.contentLoaded?.(); },
}, notifyJoined: async () => {
notifyJoined: async () => { await latest.current.notifyJoined?.();
await latest.current.notifyJoined?.(); },
}, notifyHungUp: async () => {
notifyHungUp: async () => { await latest.current.notifyHungUp?.();
await latest.current.notifyHungUp?.(); },
}, notifyDeviceMute: async (state) => {
notifyDeviceMute: async (state) => { await latest.current.notifyDeviceMute?.(state);
await latest.current.notifyDeviceMute?.(state); },
}, // Whether these exist is itself information, so they are read through
// Whether these exist is itself information, so they are read through // rather than wrapped unconditionally
// rather than wrapped unconditionally get close() {
get close() { const close = latest.current.close;
const close = latest.current.close; return close === undefined
return close === undefined ? undefined
? undefined : async (): Promise<void> => await close();
: async (): Promise<void> => await close(); },
}, // Not offered to a component host: the client it hands over holds the
// 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
// credentials to fetch media itself. A widget's client does not, which // is what the internal bridge's `downloadMedia` is for.
// is what the internal bridge's `downloadMedia` is for. get supportsReactions(): boolean {
get supportsReactions(): boolean { return latest.current.supportsReactions ?? true;
return latest.current.supportsReactions ?? true; },
}, get allowJoinUnmutedViaIntent(): boolean {
get allowJoinUnmutedViaIntent(): boolean { return latest.current.allowJoinUnmutedViaIntent ?? false;
return latest.current.allowJoinUnmutedViaIntent ?? false; },
}, // Whatever the host says or does not say, the account is its own: it
// 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
// signed the user in and handed us the client. So Element Call never // offers to edit the profile from inside a component.
// offers to edit the profile from inside a component. supportsProfileChanges: false,
supportsProfileChanges: false, ...requests,
...requests, }));
}),
);
useImperativeHandle( useImperativeHandle(
ref, ref,
+47 -32
View File
@@ -40,6 +40,7 @@ import {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { type MatrixClient } from "matrix-js-sdk"; 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`, `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 ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<HostBridgeProvider value={hostBridge}> <HostBridgeProvider value={hostBridge}>
<UrlParamsProvider value={params}> <UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}> <div ref={setContainer} className={styles.root}>
{container !== null && {ready && (
rtcSession !== null && <RootElementProvider value={container}>
mediaDevices !== null && ( {/* Whatever goes wrong in here is shown in here. Left to
<RootElementProvider value={container}> propagate, an error would unmount the host's own tree. */}
{/* Whatever goes wrong in here is shown in here. Left to <ErrorBoundary
propagate, an error would unmount the host's own tree. */} fallback={(error) => <ErrorPage error={error} />}
<ErrorBoundary // A broken call should not hold the host on screen
fallback={(error) => <ErrorPage error={error} />} onError={() => void hostBridge.setAlwaysOnScreen(false)}
// A broken call should not hold the host on screen >
onError={() => void hostBridge.setAlwaysOnScreen(false)} <Decoration>
> <TooltipProvider>
<Decoration> <ClientProvider client={client}>
<TooltipProvider> <MediaDevicesContext value={mediaDevices}>
<ClientProvider client={client}> <ProcessorProvider>
<MediaDevicesContext value={mediaDevices}> <CallView
<ProcessorProvider> client={client}
<CallView rtcSession={rtcSession}
client={client} isPasswordlessUser={false}
rtcSession={rtcSession} confineToRoom={params.confineToRoom}
isPasswordlessUser={false} preload={params.preload}
confineToRoom={params.confineToRoom} skipLobby={params.skipLobby}
preload={params.preload} />
skipLobby={params.skipLobby} </ProcessorProvider>
/> </MediaDevicesContext>
</ProcessorProvider> </ClientProvider>
</MediaDevicesContext> </TooltipProvider>
</ClientProvider> </Decoration>
</TooltipProvider> </ErrorBoundary>
</Decoration> </RootElementProvider>
</ErrorBoundary> )}
</RootElementProvider>
)}
</div> </div>
</UrlParamsProvider> </UrlParamsProvider>
</HostBridgeProvider> </HostBridgeProvider>
+5
View File
@@ -86,6 +86,11 @@ export default defineConfig({
// enumerateDevices work on CI runners without real hardware. // enumerateDevices work on CI runners without real hardware.
"media.navigator.streams.fake": true, "media.navigator.streams.fake": true,
"media.navigator.permission.disabled": 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. * 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. */ /** The settings button, whichever of the two the footer is currently showing. */
function settingsButton(pane: Locator): Locator { function settingsButton(pane: Locator): Locator {
return pane return pane
@@ -206,16 +211,18 @@ test("looks the same in a small container as in a small window", async ({
browser, browser,
}) => { }) => {
// Two calls to set up, one of them through the harness's two logins // 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 }; const size = { width: 300, height: 300 };
// The reference is Element Call owning a window of that size, which is what // 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 // a mobile app's webview or a browser's picture-in-picture gives it, and
// what its small-window styling was written for. // 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({ const referenceContext = await browser.newContext({
viewport: size, viewport: size,
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
permissions: ["microphone", "camera"],
}); });
const referencePage = await referenceContext.newPage(); const referencePage = await referenceContext.newPage();
await referencePage.goto("/"); await referencePage.goto("/");