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
|
whether it stays inside its container, and what it says to its host, which is
|
||||||
logged along the bottom.
|
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
|
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
|
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
|
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,
|
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 { initializeWidget } from "../src/widget";
|
||||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||||
|
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||||
|
|
||||||
interface MatrixRTCSdk {
|
interface MatrixRTCSdk {
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +152,8 @@ export async function createMatrixRTCSdk(
|
|||||||
...callViewModelOptionsFromParams(urlParams),
|
...callViewModelOptionsFromParams(urlParams),
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
hostBridge,
|
hostBridge,
|
||||||
|
// The SDK owns its page, so the body is the space it has
|
||||||
|
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||||
},
|
},
|
||||||
of({}),
|
of({}),
|
||||||
of({}),
|
of({}),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type MockedFunction,
|
type MockedFunction,
|
||||||
vi,
|
vi,
|
||||||
} from "vitest";
|
} 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 { type LocalParticipant } from "livekit-client";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
@@ -51,6 +51,7 @@ import { AppBar } from "../AppBar";
|
|||||||
import { type MatrixInfo } from "./VideoPreview";
|
import { type MatrixInfo } from "./VideoPreview";
|
||||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||||
import { initializeWidget } from "../widget";
|
import { initializeWidget } from "../widget";
|
||||||
|
import { RootElementProvider } from "../RootElementContext";
|
||||||
|
|
||||||
initializeWidget();
|
initializeWidget();
|
||||||
vi.hoisted(
|
vi.hoisted(
|
||||||
@@ -263,4 +264,71 @@ describe("ActiveCall", () => {
|
|||||||
// Rendering at all proves ActiveCall created all of its view models
|
// Rendering at all proves ActiveCall created all of its view models
|
||||||
expect(await findByTestId("incall_leave")).toBeVisible();
|
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 { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { useHostBridge } from "../HostBridge.ts";
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
|
import { useRootElement } from "../RootElementContext";
|
||||||
|
import { observeElementSize$ } from "../utils/elementSize";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -120,6 +122,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
const hostBridge = useHostBridge();
|
const hostBridge = useHostBridge();
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
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(() => {
|
useEffect(() => {
|
||||||
rootLogger.info("START CALL VIEW SCOPE");
|
rootLogger.info("START CALL VIEW SCOPE");
|
||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
@@ -140,6 +145,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
autoLeaveWhenOthersLeft,
|
autoLeaveWhenOthersLeft,
|
||||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||||
matrixRTCMode$: matrixRTCModeSetting.value$,
|
matrixRTCMode$: matrixRTCModeSetting.value$,
|
||||||
|
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||||
},
|
},
|
||||||
reactionsReader.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reactionsReader.reactions$,
|
reactionsReader.reactions$,
|
||||||
@@ -165,6 +171,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
mediaDevices,
|
mediaDevices,
|
||||||
trackProcessorState$,
|
trackProcessorState$,
|
||||||
props.client,
|
props.client,
|
||||||
|
rootElement,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -625,6 +632,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
[styles.overflowing]: overflowing,
|
[styles.overflowing]: overflowing,
|
||||||
})}
|
})}
|
||||||
ref={containerRef}
|
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}
|
onPointerUp={onViewPointerUp}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerOut={onPointerOut}
|
onPointerOut={onPointerOut}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="_inRoom_4e7ff8"
|
class="_inRoom_4e7ff8"
|
||||||
|
data-layout="one-on-one-desktop"
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
class="_header_e4b327 _header_4e7ff8"
|
class="_header_e4b327 _header_4e7ff8"
|
||||||
|
|||||||
@@ -204,8 +204,15 @@ export interface CallViewModelOptions {
|
|||||||
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
|
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
|
||||||
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
|
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
|
||||||
connectionState$?: Behavior<ConnectionState>;
|
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. */
|
/** Optional value overriding the local transport, for testing purposes. */
|
||||||
localTransport?: LocalTransport;
|
localTransport?: LocalTransport;
|
||||||
/** Optional value overriding the connection factory, for testing purposes. */
|
/** Optional value overriding the connection factory, for testing purposes. */
|
||||||
@@ -263,6 +270,11 @@ const smallMobileCallThreshold = 3;
|
|||||||
// with the interface
|
// with the interface
|
||||||
const showFooterMs = 4000;
|
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";
|
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||||
|
|
||||||
interface LayoutScanState {
|
interface LayoutScanState {
|
||||||
@@ -1097,18 +1109,10 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
||||||
|
|
||||||
const windowSize$ =
|
// A guess at what the window's mode should be based on the size and shape of
|
||||||
options.windowSize$ ??
|
// the space we have to draw in.
|
||||||
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.
|
|
||||||
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
||||||
windowSize$.pipe(
|
options.windowSize$.pipe(
|
||||||
map(({ width, height }) => {
|
map(({ width, height }) => {
|
||||||
if (height <= 400 && width <= 340) return "pip";
|
if (height <= 400 && width <= 340) return "pip";
|
||||||
// Our layouts for flat windows are better at adapting to a small width
|
// 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),
|
connectionState$: constant(ConnectionState.Connected),
|
||||||
matrixRTCMode$: constant(MatrixRTCMode.Compatibility),
|
matrixRTCMode$: constant(MatrixRTCMode.Compatibility),
|
||||||
|
windowSize$: constant({ width: 1000, height: 800 }),
|
||||||
...callViewModelOptions,
|
...callViewModelOptions,
|
||||||
},
|
},
|
||||||
handRaisedSubject$,
|
handRaisedSubject$,
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ window.matchMedia = global.matchMedia = (): MediaQueryList =>
|
|||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
}) as Partial<MediaQueryList> as MediaQueryList;
|
}) 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 storage: Record<string, string> = {};
|
||||||
const localStoragePolyfill = {
|
const localStoragePolyfill = {
|
||||||
getItem(key: string) {
|
getItem(key: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user