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
+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) {