Ensure interface gradually reduces visible buttons when viewport shrinks (#33477)

* Rewrite Measured to be a functional component

* Add tests to cover narrow viewports

* lint

* breakpoint is optional

* Cleanup

* Provide default value

* Fixup

* fix two snaps

* Update screenshot

* and the other one

* unfake CIDER

* Update snaps AGAIN
This commit is contained in:
Will Hunt 2026-05-14 16:15:43 +01:00 committed by GitHub
parent cff2c4cd25
commit ab904bb6ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 66 additions and 51 deletions

View File

@ -77,6 +77,12 @@ test.describe("Composer", () => {
await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
});
test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => {
// Shrink the viewport
await page.setViewportSize({ width: 500, height: 1080 });
await expect(app.getComposer()).toMatchScreenshot("narrow.png");
});
test.describe("render emoji picker with larger viewport height", async () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => {
@ -201,12 +207,6 @@ test.describe("Composer", () => {
});
test("can paste a file", async ({ page, bot, app }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bot.credentials.userId],
});
await app.viewRoomByName("Bob");
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
await expect(page.locator(".mx_ImageBody")).toBeVisible();
});

View File

@ -216,6 +216,12 @@ test.describe("Composer", () => {
await expect(page.locator(".mx_ImageBody")).toBeVisible();
});
test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => {
// Shrink the viewport
await page.setViewportSize({ width: 750, height: 1080 });
await expect(page.locator(".mx_MessageComposer_wrapper")).toMatchScreenshot("narrow.png");
});
test.describe("when Control+Enter is required to send", () => {
test.beforeEach(async ({ app }) => {
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);

View File

@ -327,19 +327,19 @@ test.describe("Threads", () => {
test.describe("with larger viewport", async () => {
// Increase viewport size so that voice messages fit
test.use({ viewport: { width: 1280, height: 720 } });
test.use({ viewport: { width: 1440, height: 720 } });
test.beforeEach(async ({ page }) => {
// Increase right-panel size, so that voice messages fit
await page.addInitScript(() => {
window.localStorage.setItem("mx_rhs_size", "600");
window.localStorage.setItem("mx_rhs_size", "700");
});
});
test("can send voice messages", { tag: ["@no-firefox", "@no-webkit"] }, async ({ page, app, user }) => {
// Increase right-panel size, so that voice messages fit
await page.evaluate(() => {
window.localStorage.setItem("mx_rhs_size", "600");
window.localStorage.setItem("mx_rhs_size", "700");
});
const roomId = await app.client.createRoom({});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -435,7 +435,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<Measured breakpoint={400} sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (

View File

@ -1,4 +1,5 @@
/*
Copyright (C) 2026 Element Creations Ltd
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
@ -6,57 +7,65 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type RefObject } from "react";
import { useEffect, useRef, type RefObject } from "react";
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
import UIStore from "../../../stores/UIStore";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
interface IProps {
/**
* Element to watch for resize changes on.
*/
sensor: RefObject<Element | null>;
breakpoint: number;
onMeasurement(narrow: boolean): void;
/**
* Minimum width of element to be considered full-size.
* Defaults to `500px`
*/
breakpoint?: number;
/**
* Callback for when the narrowness property changes.
* @param narrow
* @returns
*/
onMeasurement: (narrow: boolean) => void;
}
export default class Measured extends React.PureComponent<IProps> {
private static instanceCount = 0;
private readonly instanceId: number;
let instanceCount = 0;
public static defaultProps = {
breakpoint: 500,
};
/**
* This component can watch a single element for width changes, and will fire
* a callback if the width changes to be lower or higher than the `breakpoint`.
*/
export default function Measured({ sensor, breakpoint = 500, onMeasurement }: IProps): null {
const instanceIdRef = useRef(instanceCount++);
const instanceId = instanceIdRef.current;
public constructor(props: IProps) {
super(props);
this.instanceId = Measured.instanceCount++;
}
public componentDidMount(): void {
UIStore.instance.on(`Measured${this.instanceId}`, this.onResize);
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
const previous = prevProps.sensor.current;
const current = this.props.sensor.current;
if (previous === current) return;
if (previous) {
UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`);
useEffect(() => {
if (sensor.current) {
UIStore.instance.trackElementDimensions(`Measured${instanceId}`, sensor.current);
}
if (current) {
UIStore.instance.trackElementDimensions(`Measured${this.instanceId}`, current);
}
}
public componentWillUnmount(): void {
UIStore.instance.off(`Measured${this.instanceId}`, this.onResize);
UIStore.instance.stopTrackingElementDimensions(`Measured${this.instanceId}`);
}
return () => {
UIStore.instance.stopTrackingElementDimensions(`Measured${instanceId}`);
};
}, [sensor, instanceId]);
private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry): void => {
if (type !== UI_EVENTS.Resize) return;
this.props.onMeasurement(entry.contentRect.width <= this.props.breakpoint);
};
const narrow = useEventEmitterState<boolean>(
UIStore.instance,
`Measured${instanceId}`,
(_type: unknown, entry: ResizeObserverEntry) => {
if (!entry) {
return false;
}
// N.B there is only one `_type` of resize event.
return entry.contentRect.width <= breakpoint;
},
);
public render(): React.ReactNode {
return null;
}
// Only fire when the state changes.
useEffect(() => {
onMeasurement(narrow);
}, [onMeasurement, narrow]);
return null;
}