merge: upstream Element Call v0.28.0 (cinny #201) #61
+2
-2
@@ -16,7 +16,7 @@ import styles from "./Header.module.css";
|
||||
import Logo from "./icons/Logo.svg?react";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { EncryptionLock } from "./room/EncryptionLock";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { useRootSizeMatches } from "./useRootSize";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
@@ -142,7 +142,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
participantCount,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg";
|
||||
const size = useRootSizeMatches(({ width }) => width <= 550) ? "sm" : "lg";
|
||||
|
||||
return (
|
||||
<div className={styles.roomHeaderInfo} data-size={size}>
|
||||
|
||||
+15
-7
@@ -46,21 +46,23 @@ the app and for the component build. */
|
||||
--font-size-headline: calc(32px * var(--font-scale));
|
||||
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
96px for typical desktop windows, and accounts for the safe area. */
|
||||
/* The distance to inset non-full-width content from the edge of Element
|
||||
Call's root along the inline axis. This ramps up from 16px for typical mobile
|
||||
windows, to 96px for typical desktop windows, and accounts for the safe area.
|
||||
Container units resolve where the property is used, so this must only be used
|
||||
by elements whose nearest query container is the root. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
max(var(--cpd-space-4x), calc((100cqw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
max(var(--cpd-space-4x), calc((100cqw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
@@ -80,7 +82,13 @@ the app and for the component build. */
|
||||
this element rather than media queries against the viewport. For the standalone
|
||||
app the two are the same thing, since the root is the page; for a host that
|
||||
embeds Element Call in a corner of its own page they are not, and it is the
|
||||
corner that the layout has to fit. */
|
||||
corner that the layout has to fit.
|
||||
|
||||
For the same reason, lengths that were once a share of the viewport (`100vw`)
|
||||
are a share of the nearest query container (`100cqw`). Container units cannot
|
||||
name their container, so they only mean this element where no other query
|
||||
container — a spotlight layout, a media tile — lies in between; check that
|
||||
before using one further down the tree. */
|
||||
[data-element-call-root] {
|
||||
container: element-call / size;
|
||||
}
|
||||
@@ -107,7 +115,7 @@ corner that the layout has to fit. */
|
||||
|
||||
[data-element-call-root][data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-size: max(1440px, 100cqw) max(1440px, 100cqh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ div.reactionPopupMenuRoot {
|
||||
|
||||
.reactionPopupMenuRoot > div {
|
||||
width: fit-content;
|
||||
max-width: 100vw;
|
||||
max-width: 100cqw;
|
||||
}
|
||||
|
||||
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { useRootSizeMatches } from "../useRootSize";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { Link } from "../button/Link";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
@@ -117,7 +117,9 @@ export const LobbyView: FC<Props> = ({
|
||||
}, [navigate]);
|
||||
const hangup = confineToRoom ? undefined : onLeaveClick;
|
||||
|
||||
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
||||
const recentsButtonInFooter = useRootSizeMatches(
|
||||
({ height }) => height <= 500,
|
||||
);
|
||||
const recentsButton = !confineToRoom && (
|
||||
<Link className={styles.recents} to="/">
|
||||
{t("lobby.leave_button")}
|
||||
|
||||
@@ -9,7 +9,9 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-left: var(--content-inset-left);
|
||||
margin-right: var(--content-inset-right);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
/* Half the height of Element Call's root, not of the viewport: embedded,
|
||||
the two differ (see the note on container units in base.css) */
|
||||
block-size: 50cqh;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-width: 100%;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
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 { act, renderHook } from "@testing-library/react";
|
||||
import { createElement, type FC, type PropsWithChildren } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useRootSizeMatches } from "./useRootSize";
|
||||
import { RootElementProvider } from "./RootElementContext";
|
||||
|
||||
/** A ResizeObserver the test fires itself. jsdom does not ship one. */
|
||||
class MockResizeObserver {
|
||||
public static instances: MockResizeObserver[] = [];
|
||||
|
||||
public constructor(private readonly callback: ResizeObserverCallback) {
|
||||
MockResizeObserver.instances.push(this);
|
||||
}
|
||||
|
||||
public observe(): void {
|
||||
this.fire();
|
||||
}
|
||||
|
||||
public unobserve(): void {}
|
||||
|
||||
public disconnect(): void {}
|
||||
|
||||
public fire(): void {
|
||||
this.callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
}
|
||||
|
||||
describe("useRootSizeMatches", () => {
|
||||
const originalResizeObserver = window.ResizeObserver;
|
||||
let root: HTMLDivElement;
|
||||
let size: { width: number; height: number };
|
||||
let Wrapper: FC<PropsWithChildren>;
|
||||
|
||||
beforeEach(() => {
|
||||
MockResizeObserver.instances = [];
|
||||
window.ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
size = { width: 800, height: 600 };
|
||||
root = document.createElement("div");
|
||||
Object.defineProperty(root, "clientWidth", { get: () => size.width });
|
||||
Object.defineProperty(root, "clientHeight", { get: () => size.height });
|
||||
Wrapper = ({ children }) =>
|
||||
createElement(RootElementProvider, { value: root }, children);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
const resize = (to: { width: number; height: number }): void => {
|
||||
size = to;
|
||||
act(() => {
|
||||
for (const observer of MockResizeObserver.instances) observer.fire();
|
||||
});
|
||||
};
|
||||
|
||||
test("answers for the root's current size straight away", () => {
|
||||
const { result } = renderHook(
|
||||
() => useRootSizeMatches(({ width }) => width <= 550),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
test("follows the root as it is resized", () => {
|
||||
const { result } = renderHook(
|
||||
() => useRootSizeMatches(({ width }) => width <= 550),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
resize({ width: 300, height: 600 });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
resize({ width: 900, height: 600 });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
test("only re-renders when the answer changes", () => {
|
||||
const renders = vi.fn();
|
||||
renderHook(
|
||||
() => {
|
||||
renders();
|
||||
return useRootSizeMatches(({ height }) => height <= 500);
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
const before = renders.mock.calls.length;
|
||||
|
||||
// Still tall enough either way
|
||||
resize({ width: 800, height: 700 });
|
||||
resize({ width: 500, height: 650 });
|
||||
expect(renders.mock.calls.length).toBe(before);
|
||||
|
||||
resize({ width: 500, height: 400 });
|
||||
expect(renders.mock.calls.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
test("keeps one subscription across renders with an inline predicate", () => {
|
||||
const { rerender } = renderHook(
|
||||
() => useRootSizeMatches(({ width }) => width <= 550),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
rerender();
|
||||
rerender();
|
||||
expect(MockResizeObserver.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 { useEffect, useState } from "react";
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import { useLatest } from "./useLatest";
|
||||
import { type ElementSize, observeElementSize$ } from "./utils/elementSize";
|
||||
|
||||
/**
|
||||
* Whether the space Element Call is drawn in satisfies a condition on its
|
||||
* size: the counterpart of {@link useMediaQuery} for the container rather than
|
||||
* the viewport, and of the `@container element-call` queries in the
|
||||
* stylesheets. The two are the same thing standalone, where the root is the
|
||||
* page; for a component in a corner of a host's page they are not, and it is
|
||||
* the corner that matters.
|
||||
*
|
||||
* Re-renders only when the answer changes, not on every pixel of resize.
|
||||
*/
|
||||
export function useRootSizeMatches(
|
||||
matches: (size: ElementSize) => boolean,
|
||||
): boolean {
|
||||
const rootElement = useRootElement();
|
||||
// The latest predicate, so that an inline arrow does not resubscribe on
|
||||
// every render
|
||||
const latestMatches = useLatest(matches);
|
||||
const [result, setResult] = useState(() =>
|
||||
matches({
|
||||
width: rootElement.clientWidth,
|
||||
height: rootElement.clientHeight,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map((size) => latestMatches.current(size)),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setResult);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [rootElement, latestMatches]);
|
||||
|
||||
return result;
|
||||
}
|
||||
+15
-1
@@ -55,13 +55,27 @@ window.matchMedia = global.matchMedia = (): 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.
|
||||
// whatever size jsdom says it is unless a test says otherwise.
|
||||
window.ResizeObserver ??= class ResizeObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
public disconnect(): void {}
|
||||
};
|
||||
|
||||
// And what jsdom says is zero, for everything — which would have every size
|
||||
// query against Element Call's root (the body, with no provider) read as a tiny
|
||||
// window. Give the body the size of a typical desktop window instead, the same
|
||||
// answer the media query mock above gives. A test that wants another size
|
||||
// supplies a root element of its own.
|
||||
for (const [property, value] of [
|
||||
["clientWidth", 1024],
|
||||
["clientHeight", 768],
|
||||
] as const)
|
||||
Object.defineProperty(document.body, property, {
|
||||
get: () => value,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const storage: Record<string, string> = {};
|
||||
const localStoragePolyfill = {
|
||||
getItem(key: string) {
|
||||
|
||||
Reference in New Issue
Block a user