scaling based on component size instead webview size

This commit is contained in:
Timo K.
2026-09-04 14:16:40 +02:00
parent f5458bb03e
commit 38c28a1c8f
11 changed files with 293 additions and 14 deletions
+4
View File
@@ -230,6 +230,10 @@ Element Call behaves when it does not own the page — the size it is given,
whether it stays inside its container, and what it says to its host, which is
logged along the bottom.
The call lays itself out for the size of the element it is mounted in, not the
window: a host that shrinks the container to a corner of its page gets the
picture-in-picture layout, just as a host that shrank the whole iframe used to.
The component's stylesheet is confined to the element it is mounted in: the
build rewrites every selector so that it matches only Element Call's root or
what is inside it, with `html`, `body` and `:root` standing for that root (see
@@ -132,3 +132,36 @@ test("tells its host what it is doing", async ({ page }) => {
timeout: 30_000,
});
});
test("lays itself out for the space it is given, not the page", async ({
page,
}) => {
const { username, roomId } = await createUserAndRoom("containersize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
const call = pane.locator("[data-layout]");
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
await expect(call).toBeVisible({ timeout: 60_000 });
await expect(call).not.toHaveAttribute("data-layout", "pip");
// As a widget, Element Call's container and its window were one and the same:
// a host wanting a picture-in-picture made the iframe small, and Element Call
// saw the window shrink. A component gets no such signal from the window,
// which stays as large as it ever was; only the container changes.
const resize = async (width: number, height: number): Promise<void> =>
container.evaluate(
(element, size) => {
element.style.width = `${size.width}px`;
element.style.height = `${size.height}px`;
},
{ width, height },
);
await resize(300, 300);
await expect(call).toHaveAttribute("data-layout", "pip");
await resize(900, 700);
await expect(call).not.toHaveAttribute("data-layout", "pip");
});
+3
View File
@@ -60,6 +60,7 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { initializeWidget } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { createWidgetHostBridge } from "../src/HostBridge";
import { observeElementSize$ } from "../src/utils/elementSize";
interface MatrixRTCSdk {
/**
@@ -151,6 +152,8 @@ export async function createMatrixRTCSdk(
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
// The SDK owns its page, so the body is the space it has
windowSize$: scope.behavior(observeElementSize$(document.body)),
},
of({}),
of({}),
+69 -1
View File
@@ -14,7 +14,7 @@ import {
type MockedFunction,
vi,
} from "vitest";
import { render, type RenderResult } from "@testing-library/react";
import { act, render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
@@ -51,6 +51,7 @@ import { AppBar } from "../AppBar";
import { type MatrixInfo } from "./VideoPreview";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { initializeWidget } from "../widget";
import { RootElementProvider } from "../RootElementContext";
initializeWidget();
vi.hoisted(
@@ -263,4 +264,71 @@ describe("ActiveCall", () => {
// Rendering at all proves ActiveCall created all of its view models
expect(await findByTestId("incall_leave")).toBeVisible();
});
it("lays the call out for the size of its root element", async () => {
// jsdom has no layout and no ResizeObserver: the root reports whatever
// size we say, and the observer notifies whenever we tell it to
let size = { width: 1000, height: 800 };
const root = document.createElement("div");
Object.defineProperty(root, "clientWidth", { get: () => size.width });
Object.defineProperty(root, "clientHeight", { get: () => size.height });
document.body.appendChild(root);
const observers: (() => void)[] = [];
const originalResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
public constructor(private readonly callback: ResizeObserverCallback) {}
public observe(): void {
observers.push(() => this.callback([], this as ResizeObserver));
}
public unobserve(): void {}
public disconnect(): void {}
} as unknown as typeof ResizeObserver;
try {
const mediaDevices = mockMediaDevices({});
const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]);
const { findByTestId, container } = render(
<BrowserRouter>
<RootElementProvider value={root}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<TooltipProvider>
<RoomContext value={mockLivekitRoom({ localParticipant })}>
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
e2eeSystem={{ kind: E2eeType.NONE }}
onLeft={(): void => {}}
/>
</RoomContext>
</TooltipProvider>
</ProcessorProvider>
</MediaDevicesContext>
</RootElementProvider>
</BrowserRouter>,
{ container: root },
);
await findByTestId("incall_leave");
const call = container.querySelector("[data-layout]")!;
expect(call.getAttribute("data-layout")).not.toBe("pip");
// The host shrinks the container to a corner of its page. The window has
// not changed at all — what matters is the element we were given.
size = { width: 300, height: 300 };
act(() => observers.forEach((notify) => notify()));
expect(call.getAttribute("data-layout")).toBe("pip");
size = { width: 1000, height: 800 };
act(() => observers.forEach((notify) => notify()));
expect(call.getAttribute("data-layout")).not.toBe("pip");
} finally {
window.ResizeObserver = originalResizeObserver;
root.remove();
}
});
});
+10
View File
@@ -29,6 +29,8 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { useHostBridge } from "../HostBridge.ts";
import { useRootElement } from "../RootElementContext";
import { observeElementSize$ } from "../utils/elementSize";
import styles from "./InCallView.module.css";
import { GridTile } from "../tile/GridTile";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
@@ -120,6 +122,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const hostBridge = useHostBridge();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
useEffect(() => {
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
@@ -140,6 +145,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -165,6 +171,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
trackProcessorState$,
props.client,
rootElement,
]);
useEffect(() => {
@@ -625,6 +632,9 @@ export const InCallView: FC<InCallViewProps> = ({
[styles.overflowing]: overflowing,
})}
ref={containerRef}
// Which layout the call has settled on, for tests and for anyone
// wondering why the call looks the way it does at the size it was given
data-layout={layout.type}
onPointerUp={onViewPointerUp}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
@@ -4,6 +4,7 @@ exports[`InCallView > rendering > renders 1`] = `
<div>
<div
class="_inRoom_4e7ff8"
data-layout="one-on-one-desktop"
>
<header
class="_header_e4b327 _header_4e7ff8"
+17 -13
View File
@@ -204,8 +204,15 @@ export interface CallViewModelOptions {
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
connectionState$?: Behavior<ConnectionState>;
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
windowSize$?: Behavior<{ width: number; height: number }>;
/**
* The size of the space the call is drawn in: the page when Element Call
* owns it, or the container a host mounted it in when it is a component.
* The layout — whether the call is shown full size, flat, narrow or as a
* picture-in-picture — follows this rather than the size of the window, so
* that a component shrunk by its host adapts even though the window has not
* changed.
*/
windowSize$: Behavior<{ width: number; height: number }>;
/** Optional value overriding the local transport, for testing purposes. */
localTransport?: LocalTransport;
/** Optional value overriding the connection factory, for testing purposes. */
@@ -263,6 +270,11 @@ const smallMobileCallThreshold = 3;
// with the interface
const showFooterMs = 4000;
/**
* The general shape of the space the call is drawn in. Called a window because
* that is what it is in the standalone app; for a component it is the container
* the host gave us, which may be a small corner of a large window.
*/
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
interface LayoutScanState {
@@ -1097,18 +1109,10 @@ export function createCallViewModel$(
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
const windowSize$ =
options.windowSize$ ??
scope.behavior<{ width: number; height: number }>(
fromEvent(window, "resize").pipe(
startWith(null),
map(() => ({ width: window.innerWidth, height: window.innerHeight })),
),
);
// A guess at what the window's mode should be based on its size and shape.
// A guess at what the window's mode should be based on the size and shape of
// the space we have to draw in.
const naturalWindowMode$ = scope.behavior<WindowMode>(
windowSize$.pipe(
options.windowSize$.pipe(
map(({ width, height }) => {
if (height <= 400 && width <= 340) return "pip";
// Our layouts for flat windows are better at adapting to a small width
+104
View File
@@ -0,0 +1,104 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { observeElementSize$ } from "./elementSize";
/**
* A ResizeObserver whose notifications the test triggers itself, and which
* keeps track of what it is watching. jsdom does not ship one.
*/
class MockResizeObserver {
public static instances: MockResizeObserver[] = [];
public observed: Element[] = [];
public disconnected = false;
public constructor(private readonly callback: ResizeObserverCallback) {
MockResizeObserver.instances.push(this);
}
public observe(element: Element): void {
this.observed.push(element);
// Real observers report the initial size once observation starts
this.fire();
}
public unobserve(): void {}
public disconnect(): void {
this.disconnected = true;
}
public fire(): void {
this.callback([], this as unknown as ResizeObserver);
}
}
describe("observeElementSize$", () => {
const originalResizeObserver = window.ResizeObserver;
let element: HTMLDivElement;
let size: { width: number; height: number };
beforeEach(() => {
MockResizeObserver.instances = [];
window.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
// jsdom does no layout, so the element reports whatever we say it does
size = { width: 800, height: 600 };
element = document.createElement("div");
Object.defineProperty(element, "clientWidth", { get: () => size.width });
Object.defineProperty(element, "clientHeight", { get: () => size.height });
});
afterEach(() => {
window.ResizeObserver = originalResizeObserver;
});
it("emits the current size synchronously on subscription", () => {
const next = vi.fn();
observeElementSize$(element).subscribe(next);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith({ width: 800, height: 600 });
expect(MockResizeObserver.instances[0].observed).toEqual([element]);
});
it("emits again whenever the element is resized", () => {
const next = vi.fn();
observeElementSize$(element).subscribe(next);
const [observer] = MockResizeObserver.instances;
size = { width: 300, height: 300 };
observer.fire();
size = { width: 1000, height: 700 };
observer.fire();
expect(next.mock.calls.map(([s]) => s)).toEqual([
{ width: 800, height: 600 },
{ width: 300, height: 300 },
{ width: 1000, height: 700 },
]);
});
it("does not repeat a size that has not changed", () => {
const next = vi.fn();
observeElementSize$(element).subscribe(next);
// The initial report the observer makes on observe() carried the same size
// as the synchronous measurement, and so did not count
expect(next).toHaveBeenCalledTimes(1);
MockResizeObserver.instances[0].fire();
expect(next).toHaveBeenCalledTimes(1);
});
it("stops observing when unsubscribed", () => {
const subscription = observeElementSize$(element).subscribe();
const [observer] = MockResizeObserver.instances;
expect(observer.disconnected).toBe(false);
subscription.unsubscribe();
expect(observer.disconnected).toBe(true);
});
});
+42
View File
@@ -0,0 +1,42 @@
/*
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 { Observable, distinctUntilChanged } from "rxjs";
export interface ElementSize {
width: number;
height: number;
}
/**
* Observes the size of an element, starting with the size it currently has and
* following it through every change for as long as the subscription lasts.
*
* Measured with `clientWidth`/`clientHeight`, so borders, scrollbars and any
* transforms a host might animate the element with are left out: this is the
* space there is to draw in, not where the element happens to be painted.
*/
export function observeElementSize$(element: Element): Observable<ElementSize> {
return new Observable<ElementSize>((subscriber) => {
const measure = (): void =>
subscriber.next({
width: element.clientWidth,
height: element.clientHeight,
});
// Synchronously, so that a Behavior built on this has an initial value
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return (): void => observer.disconnect();
}).pipe(
// A ResizeObserver reports once on observe(), which repeats the first
// measurement above, and again for changes to dimensions we do not read
distinctUntilChanged(
(a, b) => a.width === b.width && a.height === b.height,
),
);
}
+1
View File
@@ -176,6 +176,7 @@ export function getBasicCallViewModelEnvironment(
}),
connectionState$: constant(ConnectionState.Connected),
matrixRTCMode$: constant(MatrixRTCMode.Compatibility),
windowSize$: constant({ width: 1000, height: 800 }),
...callViewModelOptions,
},
handRaisedSubject$,
+9
View File
@@ -53,6 +53,15 @@ window.matchMedia = global.matchMedia = (): MediaQueryList =>
removeEventListener: () => {},
}) as Partial<MediaQueryList> as MediaQueryList;
// jsdom does no layout and has no ResizeObserver. The call view observes the
// size of its root element; this one reports nothing, so that element stays at
// whatever size jsdom says it is (zero) unless a test says otherwise.
window.ResizeObserver ??= class ResizeObserver {
public observe(): void {}
public unobserve(): void {}
public disconnect(): void {}
};
const storage: Record<string, string> = {};
const localStoragePolyfill = {
getItem(key: string) {