mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
scaling based on component size instead webview size
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -176,6 +176,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
}),
|
||||
connectionState$: constant(ConnectionState.Connected),
|
||||
matrixRTCMode$: constant(MatrixRTCMode.Compatibility),
|
||||
windowSize$: constant({ width: 1000, height: 800 }),
|
||||
...callViewModelOptions,
|
||||
},
|
||||
handRaisedSubject$,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user