diff --git a/src/Header.tsx b/src/Header.tsx index cffc34023..a963f16bb 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -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 { @@ -142,7 +142,7 @@ export const RoomHeaderInfo: FC = ({ participantCount, }) => { const { t } = useTranslation(); - const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg"; + const size = useRootSizeMatches(({ width }) => width <= 550) ? "sm" : "lg"; return (
diff --git a/src/base.css b/src/base.css index e566fed29..8287f01d0 100644 --- a/src/base.css +++ b/src/base.css @@ -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; } } diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 3be0fd359..4a2bd8ba8 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -35,7 +35,7 @@ div.reactionPopupMenuRoot { .reactionPopupMenuRoot > div { width: fit-content; - max-width: 100vw; + max-width: 100cqw; } div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div { diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 60fb715e7..89018691b 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -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 = ({ }, [navigate]); const hangup = confineToRoom ? undefined : onLeaveClick; - const recentsButtonInFooter = useMediaQuery("(max-height: 500px)"); + const recentsButtonInFooter = useRootSizeMatches( + ({ height }) => height <= 500, + ); const recentsButton = !confineToRoom && ( {t("lobby.leave_button")} diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index 44c43faf2..4d3d0562c 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -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); diff --git a/src/useRootSize.test.tsx b/src/useRootSize.test.tsx new file mode 100644 index 000000000..6afa7adcd --- /dev/null +++ b/src/useRootSize.test.tsx @@ -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; + + 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); + }); +}); diff --git a/src/useRootSize.ts b/src/useRootSize.ts new file mode 100644 index 000000000..9db159a0d --- /dev/null +++ b/src/useRootSize.ts @@ -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; +} diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 373f08c9f..965f42474 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -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 = {}; const localStoragePolyfill = { getItem(key: string) {