diff --git a/README.md b/README.md index 248609e53..48b9bbc84 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index d679fbb24..97a002675 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -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 => + 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"); +}); diff --git a/sdk/main.ts b/sdk/main.ts index 6347d1421..6cd37032d 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -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({}), diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 3113c0727..357bc186e 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -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( + + + + + + + {}} + /> + + + + + + , + { 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(); + } + }); }); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 97fb75438..e2df75295 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 = (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 = (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 = (props) => { mediaDevices, trackProcessorState$, props.client, + rootElement, ]); useEffect(() => { @@ -625,6 +632,9 @@ export const InCallView: FC = ({ [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} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 984e2b12e..cdf5d17b3 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -4,6 +4,7 @@ exports[`InCallView > rendering > renders 1`] = `
LivekitRoom; /** Optional behavior overriding the local connection state, mainly for testing purposes. */ connectionState$?: Behavior; - /** 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( - 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 diff --git a/src/utils/elementSize.test.ts b/src/utils/elementSize.test.ts new file mode 100644 index 000000000..854da6f1d --- /dev/null +++ b/src/utils/elementSize.test.ts @@ -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); + }); +}); diff --git a/src/utils/elementSize.ts b/src/utils/elementSize.ts new file mode 100644 index 000000000..c2d5e921b --- /dev/null +++ b/src/utils/elementSize.ts @@ -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 { + return new Observable((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, + ), + ); +} diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 8e88e720c..c1feb03ee 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -176,6 +176,7 @@ export function getBasicCallViewModelEnvironment( }), connectionState$: constant(ConnectionState.Connected), matrixRTCMode$: constant(MatrixRTCMode.Compatibility), + windowSize$: constant({ width: 1000, height: 800 }), ...callViewModelOptions, }, handRaisedSubject$, diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 00d289b54..373f08c9f 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -53,6 +53,15 @@ window.matchMedia = global.matchMedia = (): MediaQueryList => removeEventListener: () => {}, }) as Partial 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 = {}; const localStoragePolyfill = { getItem(key: string) {