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
@@ -77,6 +77,12 @@ test.describe("Composer", () => {
|
|||||||
await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible();
|
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.describe("render emoji picker with larger viewport height", async () => {
|
||||||
test.use({ viewport: { width: 1280, height: 720 } });
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
test("render emoji picker", { tag: "@screenshot" }, async ({ page, app }) => {
|
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 }) => {
|
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 app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
||||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -216,6 +216,12 @@ test.describe("Composer", () => {
|
|||||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
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.describe("when Control+Enter is required to send", () => {
|
||||||
test.beforeEach(async ({ app }) => {
|
test.beforeEach(async ({ app }) => {
|
||||||
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
|
await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
|
||||||
|
|||||||
@@ -327,19 +327,19 @@ test.describe("Threads", () => {
|
|||||||
|
|
||||||
test.describe("with larger viewport", async () => {
|
test.describe("with larger viewport", async () => {
|
||||||
// Increase viewport size so that voice messages fit
|
// 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 }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Increase right-panel size, so that voice messages fit
|
// Increase right-panel size, so that voice messages fit
|
||||||
await page.addInitScript(() => {
|
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 }) => {
|
test("can send voice messages", { tag: ["@no-firefox", "@no-webkit"] }, async ({ page, app, user }) => {
|
||||||
// Increase right-panel size, so that voice messages fit
|
// Increase right-panel size, so that voice messages fit
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.localStorage.setItem("mx_rhs_size", "600");
|
window.localStorage.setItem("mx_rhs_size", "700");
|
||||||
});
|
});
|
||||||
|
|
||||||
const roomId = await app.client.createRoom({});
|
const roomId = await app.client.createRoom({});
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -435,7 +435,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
|||||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
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>
|
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||||
|
|
||||||
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright (C) 2026 Element Creations Ltd
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
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.
|
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 {
|
interface IProps {
|
||||||
|
/**
|
||||||
|
* Element to watch for resize changes on.
|
||||||
|
*/
|
||||||
sensor: RefObject<Element | null>;
|
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> {
|
let instanceCount = 0;
|
||||||
private static instanceCount = 0;
|
|
||||||
private readonly instanceId: number;
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sensor.current) {
|
||||||
|
UIStore.instance.trackElementDimensions(`Measured${instanceId}`, sensor.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
UIStore.instance.stopTrackingElementDimensions(`Measured${instanceId}`);
|
||||||
};
|
};
|
||||||
|
}, [sensor, instanceId]);
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
const narrow = useEventEmitterState<boolean>(
|
||||||
super(props);
|
UIStore.instance,
|
||||||
|
`Measured${instanceId}`,
|
||||||
this.instanceId = Measured.instanceCount++;
|
(_type: unknown, entry: ResizeObserverEntry) => {
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
// N.B there is only one `_type` of resize event.
|
||||||
|
return entry.contentRect.width <= breakpoint;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
public componentDidMount(): void {
|
// Only fire when the state changes.
|
||||||
UIStore.instance.on(`Measured${this.instanceId}`, this.onResize);
|
useEffect(() => {
|
||||||
}
|
onMeasurement(narrow);
|
||||||
|
}, [onMeasurement, narrow]);
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry): void => {
|
|
||||||
if (type !== UI_EVENTS.Resize) return;
|
|
||||||
this.props.onMeasurement(entry.contentRect.width <= this.props.breakpoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||