New layout selector ui in user settings (#12676)

* feat: reworked the layout switcher

* feat: make the classname optional in EventTilePreview.tsx

* test: add tests to LayoutSwitcher

* feat: change appearance tab

* test: update appearance snapshot

* e2e: add tests

* css: add comment for gap overriding
pull/28217/head
Florian Duros 2024-07-05 09:30:31 +02:00 committed by GitHub
parent 6f5d21fedb
commit 2f953f1d0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1289 additions and 429 deletions

View File

@ -33,43 +33,6 @@ test.describe("Appearance user settings tab", () => {
await expect(tab).toMatchScreenshot("appearance-tab.png");
});
test("should support switching layouts", async ({ page, user, app }) => {
// Create and view a room first
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
await app.settings.openUserSettings("Appearance");
const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
// Assert that the layout selected by default is "Modern"
await expect(
buttons.locator(".mx_StyledRadioButton_enabled", {
hasText: "Modern",
}),
).toBeVisible();
// Assert that the room layout is set to group (modern) layout
await expect(page.locator(".mx_RoomView_body[data-layout='group']")).toBeVisible();
// Select the first layout
await buttons.first().click();
// Assert that the layout selected is "IRC (Experimental)"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
// Assert that the room layout is set to IRC layout
await expect(page.locator(".mx_RoomView_body[data-layout='irc']")).toBeVisible();
// Select the last layout
await buttons.last().click();
// Assert that the layout selected is "Message bubbles"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
// Assert that the room layout is set to bubble layout
await expect(page.locator(".mx_RoomView_body[data-layout='bubble']")).toBeVisible();
});
test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
@ -84,57 +47,6 @@ test.describe("Appearance user settings tab", () => {
await expect(page).toMatchScreenshot("window-12px.png");
});
test("should support enabling compact group (modern) layout", async ({ page, app, user }) => {
// Create and view a room first
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
await app.settings.openUserSettings("Appearance");
// Click "Show advanced" link button
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.locator("label", { hasText: "Use a more compact 'Modern' layout" }).click();
// Assert that the room layout is set to compact group (modern) layout
await expect(page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout")).toBeVisible();
});
test("should disable compact group (modern) layout option on IRC layout and bubble layout", async ({
page,
app,
user,
}) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
const checkDisabled = async () => {
await expect(tab.getByRole("checkbox", { name: "Use a more compact 'Modern' layout" })).toBeDisabled();
};
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
// Enable IRC layout
await buttons.first().click();
// Assert that the layout selected is "IRC (Experimental)"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
await checkDisabled();
// Enable bubble layout
await buttons.last().click();
// Assert that the layout selected is "IRC (Experimental)"
await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
await checkDisabled();
});
test("should support enabling system font", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
@ -149,6 +61,49 @@ test.describe("Appearance user settings tab", () => {
await expect(page.locator("body")).toHaveCSS("font-family", '""');
});
test.describe("Message Layout Panel", () => {
test.beforeEach(async ({ app, user, util }) => {
await util.createAndDisplayRoom();
await util.assertModernLayout();
await util.openAppearanceTab();
});
test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => {
await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png");
await util.getBubbleLayout().click();
// Assert that modern are irc layout are not selected
await expect(util.getBubbleLayout()).toBeChecked();
await expect(util.getModernLayout()).not.toBeChecked();
await expect(util.getIRCLayout()).not.toBeChecked();
// Assert that the room layout is set to bubble layout
await util.assertBubbleLayout();
await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png");
});
test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => {
await expect(util.getCompactLayoutCheckbox()).not.toBeChecked();
await util.getCompactLayoutCheckbox().click();
await util.assertCompactLayout();
});
test("should disable compact layout when the modern layout is not selected", async ({
page,
app,
user,
util,
}) => {
await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled();
// Select the bubble layout, which should disable the compact layout checkbox
await util.getBubbleLayout().click();
await expect(util.getCompactLayoutCheckbox()).toBeDisabled();
});
});
test.describe("Theme Choice Panel", () => {
test.beforeEach(async ({ app, user, util }) => {
// Disable the default theme for consistency in case ThemeWatcher automatically chooses it

View File

@ -14,11 +14,12 @@
* limitations under the License.
*/
import { Page } from "@playwright/test";
import { Locator, Page } from "@playwright/test";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Layout } from "../../../../src/settings/enums/Layout";
export { expect };
@ -57,6 +58,21 @@ class Helpers {
return this.app.settings.openUserSettings("Appearance");
}
/**
* Compare screenshot and hide the matrix chat
* @param locator
* @param screenshot
*/
assertScreenshot(locator: Locator, screenshot: `${string}.png`) {
return expect(locator).toMatchScreenshot(screenshot, {
css: `
#matrixchat {
display: none;
}
`,
});
}
// Theme Panel
/**
@ -136,4 +152,90 @@ class Helpers {
removeCustomTheme() {
return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click();
}
// Message layout Panel
/**
* Create and display a room named Test Room
*/
async createAndDisplayRoom() {
await this.app.client.createRoom({ name: "Test Room" });
await this.app.viewRoomByName("Test Room");
}
/**
* Assert the room layout
* @param layout
* @private
*/
private assertRoomLayout(layout: Layout) {
return expect(this.page.locator(`.mx_RoomView_body[data-layout=${layout}]`)).toBeVisible();
}
/**
* Assert the room layout is modern
*/
assertModernLayout() {
return this.assertRoomLayout(Layout.Group);
}
/**
* Assert the room layout is bubble
*/
assertBubbleLayout() {
return this.assertRoomLayout(Layout.Bubble);
}
/**
* Return the layout panel
*/
getMessageLayoutPanel() {
return this.page.getByTestId("layoutPanel");
}
/**
* Return the layout radio button
* @param layoutName
* @private
*/
private getLayout(layoutName: string) {
return this.getMessageLayoutPanel().getByRole("radio", { name: layoutName });
}
/**
* Return the message bubbles layout radio button
*/
getBubbleLayout() {
return this.getLayout("Message bubbles");
}
/**
* Return the modern layout radio button
*/
getModernLayout() {
return this.getLayout("Modern");
}
/**
* Return the IRC layout radio button
*/
getIRCLayout() {
return this.getLayout("IRC (experimental)");
}
/**
* Return the compact layout checkbox
*/
getCompactLayoutCheckbox() {
return this.getMessageLayoutPanel().getByRole("checkbox", { name: "Show compact text and messages" });
}
/**
* Assert the compact layout is enabled
*/
assertCompactLayout() {
return expect(
this.page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout"),
).toBeVisible();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -15,79 +15,80 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_LayoutSwitcher_RadioButtons {
.mx_LayoutSwitcher_LayoutSelector {
display: flex;
flex-direction: row;
gap: 24px;
width: 100%;
flex-direction: column;
/**
* The settings form has a default gap of 10px
* We want to have a bigger gap between the layout options
*/
gap: var(--cpd-space-4x) !important;
color: $primary-content;
.mxLayoutSwitcher_LayoutSelector_LayoutRadio {
border: 1px solid var(--cpd-color-border-interactive-primary);
border-radius: var(--cpd-space-2x);
> .mx_LayoutSwitcher_RadioButton {
flex-grow: 0;
flex-shrink: 1;
display: flex;
flex-direction: column;
overflow: hidden;
flex-basis: 33%;
min-width: 0;
border: 1px solid $quinary-content;
border-radius: 10px;
.mx_EventTile_msgOption,
.mx_MessageActionBar {
display: none;
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline {
display: flex;
/*
* 10px
*/
gap: calc(var(--cpd-space-2x) + var(--cpd-space-0-5x));
align-items: center;
}
.mx_LayoutSwitcher_RadioButton_preview {
flex-grow: 1;
display: flex;
align-items: center;
padding: 10px;
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline,
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview {
margin: var(--cpd-space-3x);
}
/**
* Override the event tile style to make it fit in the selector
* Tweak also hover style and remove action bar
*/
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview {
pointer-events: none;
.mx_EventTile[data-layout="bubble"] .mx_EventTile_line {
padding-right: 11px;
.mx_EventTile {
margin: 0;
/**
* Hide the message options and message action bar in the preview
*/
.mx_EventTile_msgOption,
.mx_MessageActionBar {
display: none;
}
.mx_EventTile_content {
margin-right: 0;
}
&[data-layout="group"] {
margin-top: calc(var(--cpd-space-3x) * -1);
}
/**
* Add margin to center the bubble
*/
&[data-layout="bubble"] {
/**
* Add the layout margin and the margin to vertically center the bubble
*/
margin-top: var(--cpd-space-6x);
margin-right: 34px;
flex-shrink: 1;
}
.mx_EventTile_line {
max-width: 100%;
}
}
}
.mx_StyledRadioButton {
flex-grow: 0;
padding: 10px;
}
.mx_EventTile_content {
margin-right: 0;
}
&.mx_LayoutSwitcher_RadioButton_selected {
border-color: var(--cpd-color-bg-accent-rest);
}
}
.mx_StyledRadioButton {
border-top: 1px solid $quinary-content;
}
.mx_StyledRadioButton_checked {
background-color: var(--cpd-color-bg-subtle-secondary);
}
.mx_EventTile {
margin: 0;
&[data-layout="bubble"] {
margin-right: 40px;
flex-shrink: 1;
}
&[data-layout="irc"] {
> a {
display: none;
}
}
.mx_EventTile_line {
max-width: 90%;
.mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator {
border-top: 0;
border-bottom: 1px solid var(--cpd-color-border-interactive-secondary);
}
}
}

View File

@ -37,7 +37,7 @@ interface IProps {
/**
* classnames to apply to the wrapper of the preview
*/
className: string;
className?: string;
/**
* The ID of the displayed user

View File

@ -1,131 +1,170 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
import React, { JSX, useEffect, useState } from "react";
import { Field, HelpMessage, InlineField, Label, RadioControl, Root, ToggleControl } from "@vector-im/compound-web";
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import SettingsStore from "../../../settings/SettingsStore";
import EventTilePreview from "../elements/EventTilePreview";
import StyledRadioButton from "../elements/StyledRadioButton";
import { _t } from "../../../languageHandler";
import { Layout } from "../../../settings/enums/Layout";
import { SettingLevel } from "../../../settings/SettingLevel";
import SettingsSubsection from "./shared/SettingsSubsection";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import { useSettingValue } from "../../../hooks/useSettings";
import { Layout } from "../../../settings/enums/Layout";
import EventTilePreview from "../elements/EventTilePreview";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
interface IProps {
userId?: string;
displayName?: string;
avatarUrl?: string;
messagePreviewText: string;
onLayoutChanged: (layout: Layout) => void;
/**
* A section to switch between different message layouts.
*/
export function LayoutSwitcher(): JSX.Element {
return (
<SettingsSubsection heading={_t("common|message_layout")} legacy={false} data-testid="layoutPanel">
<LayoutSelector />
<ToggleCompactLayout />
</SettingsSubsection>
);
}
interface IState {
/**
* A selector to choose the layout of the messages.
*/
function LayoutSelector(): JSX.Element {
return (
<Root
className="mx_LayoutSwitcher_LayoutSelector"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newLayout = new FormData(evt.currentTarget).get("layout") as string | null;
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, newLayout);
}}
>
<LayoutRadio layout={Layout.Group} label={_t("common|modern")} />
<LayoutRadio layout={Layout.Bubble} label={_t("settings|appearance|layout_bubbles")} />
<LayoutRadio layout={Layout.IRC} label={_t("settings|appearance|layout_irc")} />
</Root>
);
}
/**
* A radio button to select a layout.
*/
interface LayoutRadioProps {
/**
* The value of the layout.
*/
layout: Layout;
/**
* The label to display for the layout.
*/
label: string;
}
export default class LayoutSwitcher extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
/**
* A radio button to select a layout.
* @param layout
* @param label
*/
function LayoutRadio({ layout, label }: LayoutRadioProps): JSX.Element {
const currentLayout = useSettingValue<Layout>("layout");
const eventTileInfo = useEventTileInfo();
this.state = {
layout: SettingsStore.getValue("layout"),
};
}
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const layout = e.target.value as Layout;
this.setState({ layout: layout });
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
this.props.onLayoutChanged(layout);
};
public render(): React.ReactNode {
const ircClasses = classNames("mx_LayoutSwitcher_RadioButton", {
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.IRC,
});
const groupClasses = classNames("mx_LayoutSwitcher_RadioButton", {
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.Group,
});
const bubbleClasses = classNames("mx_LayoutSwitcher_RadioButton", {
mx_LayoutSwitcher_RadioButton_selected: this.state.layout === Layout.Bubble,
});
return (
<SettingsSubsection heading={_t("common|message_layout")}>
<div className="mx_LayoutSwitcher_RadioButtons">
<label className={ircClasses}>
<EventTilePreview
className="mx_LayoutSwitcher_RadioButton_preview"
message={this.props.messagePreviewText}
layout={Layout.IRC}
userId={this.props.userId}
displayName={this.props.displayName}
avatarUrl={this.props.avatarUrl}
/>
<StyledRadioButton
name="layout"
value={Layout.IRC}
checked={this.state.layout === Layout.IRC}
onChange={this.onLayoutChange}
>
{_t("settings|appearance|layout_irc")}
</StyledRadioButton>
</label>
<label className={groupClasses}>
<EventTilePreview
className="mx_LayoutSwitcher_RadioButton_preview"
message={this.props.messagePreviewText}
layout={Layout.Group}
userId={this.props.userId}
displayName={this.props.displayName}
avatarUrl={this.props.avatarUrl}
/>
<StyledRadioButton
name="layout"
value={Layout.Group}
checked={this.state.layout == Layout.Group}
onChange={this.onLayoutChange}
>
{_t("common|modern")}
</StyledRadioButton>
</label>
<label className={bubbleClasses}>
<EventTilePreview
className="mx_LayoutSwitcher_RadioButton_preview"
message={this.props.messagePreviewText}
layout={Layout.Bubble}
userId={this.props.userId}
displayName={this.props.displayName}
avatarUrl={this.props.avatarUrl}
/>
<StyledRadioButton
name="layout"
value={Layout.Bubble}
checked={this.state.layout == Layout.Bubble}
onChange={this.onLayoutChange}
>
{_t("settings|appearance|layout_bubbles")}
</StyledRadioButton>
</label>
return (
<Field name="layout" className="mxLayoutSwitcher_LayoutSelector_LayoutRadio">
<Label aria-label={label}>
<div className="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline">
<RadioControl name="layout" value={layout} defaultChecked={currentLayout === layout} />
<span>{label}</span>
</div>
</SettingsSubsection>
);
}
<hr className="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator" />
<EventTilePreview
message={_t("common|preview_message")}
layout={layout}
className="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
{...eventTileInfo}
/>
</Label>
</Field>
);
}
type EventTileInfo = {
/**
* The ID of the user to display.
*/
userId: string;
/**
* The display name of the user to display.
*/
displayName?: string;
/**
* The avatar URL of the user to display.
*/
avatarUrl?: string;
};
/**
* Fetch the information to display in the event tile preview.
*/
function useEventTileInfo(): EventTileInfo {
const matrixClient = useMatrixClientContext();
const userId = matrixClient.getSafeUserId();
const [eventTileInfo, setEventTileInfo] = useState<EventTileInfo>({ userId });
useEffect(() => {
const run = async (): Promise<void> => {
const profileInfo = await matrixClient.getProfileInfo(userId);
setEventTileInfo({
userId,
displayName: profileInfo.displayname,
avatarUrl: profileInfo.avatar_url,
});
};
run();
}, [userId, matrixClient, setEventTileInfo]);
return eventTileInfo;
}
/**
* A toggleable setting to enable or disable the compact layout.
*/
function ToggleCompactLayout(): JSX.Element {
const compactLayoutEnabled = useSettingValue<boolean>("useCompactLayout");
const layout = useSettingValue<Layout>("layout");
return (
<Root
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("compactLayout") === "on";
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, checked);
}}
>
<InlineField
name="compactLayout"
control={
<ToggleControl
disabled={layout !== Layout.Group}
name="compactLayout"
defaultChecked={compactLayoutEnabled}
/>
}
>
<Label>{_t("settings|appearance|compact_layout")}</Label>
<HelpMessage>{_t("settings|appearance|compact_layout_description")}</HelpMessage>
</InlineField>
</Root>
);
}

View File

@ -25,15 +25,13 @@ import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/enums/Layout";
import LayoutSwitcher from "../../LayoutSwitcher";
import { LayoutSwitcher } from "../../LayoutSwitcher";
import FontScalingPanel from "../../FontScalingPanel";
import { ThemeChoicePanel } from "../../ThemeChoicePanel";
import ImageSizePanel from "../../ImageSizePanel";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
interface IProps {}
@ -42,21 +40,9 @@ interface IState {
useSystemFont: boolean;
systemFont: string;
showAdvanced: boolean;
layout: Layout;
// User profile data for the message preview
userId?: string;
displayName?: string;
avatarUrl?: string;
}
export default class AppearanceUserSettingsTab extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
private readonly MESSAGE_PREVIEW_TEXT = _t("common|preview_message");
private unmounted = false;
public constructor(props: IProps) {
super(props);
@ -65,32 +51,9 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
useSystemFont: SettingsStore.getValue("useSystemFont"),
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
layout: SettingsStore.getValue("layout"),
};
}
public async componentDidMount(): Promise<void> {
// Fetch the current user profile for the message preview
const client = this.context;
const userId = client.getUserId()!;
const profileInfo = await client.getProfileInfo(userId);
if (this.unmounted) return;
this.setState({
userId,
displayName: profileInfo.displayname,
avatarUrl: profileInfo.avatar_url,
});
}
public componentWillUnmount(): void {
this.unmounted = true;
}
private onLayoutChanged = (layout: Layout): void => {
this.setState({ layout: layout });
};
private renderAdvancedSection(): ReactNode {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
@ -156,13 +119,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
<SettingsTab data-testid="mx_AppearanceUserSettingsTab">
<SettingsSection>
<ThemeChoicePanel />
<LayoutSwitcher
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
messagePreviewText={this.MESSAGE_PREVIEW_TEXT}
onLayoutChanged={this.onLayoutChanged}
/>
<LayoutSwitcher />
<FontScalingPanel />
{this.renderAdvancedSection()}
<ImageSizePanel />

View File

@ -2416,6 +2416,8 @@
"always_show_message_timestamps": "Always show message timestamps",
"appearance": {
"bundled_emoji_font": "Use bundled emoji font",
"compact_layout": "Show compact text and messages",
"compact_layout_description": "Modern layout must be selected to use this feature.",
"custom_font": "Use a system font",
"custom_font_description": "Set the name of a font installed on your system & %(brand)s will attempt to use it.",
"custom_font_name": "System font name",
@ -2432,7 +2434,7 @@
"image_size_default": "Default",
"image_size_large": "Large",
"layout_bubbles": "Message bubbles",
"layout_irc": "IRC (Experimental)",
"layout_irc": "IRC (experimental)",
"match_system_theme": "Match system theme",
"timeline_image_size": "Image size in the timeline"
},

View File

@ -0,0 +1,97 @@
/*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { mocked } from "jest-mock";
import { LayoutSwitcher } from "../../../../src/components/views/settings/LayoutSwitcher";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { stubClient } from "../../../test-utils";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Layout } from "../../../../src/settings/enums/Layout";
describe("<LayoutSwitcher />", () => {
const matrixClient = stubClient();
const profileInfo = {
displayname: "Alice",
};
async function renderLayoutSwitcher() {
const renderResult = render(
<MatrixClientContext.Provider value={matrixClient}>
<LayoutSwitcher />
</MatrixClientContext.Provider>,
);
// Wait for the profile info to be displayed in the event tile preview
// Also avoid act warning
await waitFor(() => expect(screen.getAllByText(profileInfo.displayname).length).toBe(3));
return renderResult;
}
beforeEach(async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
mocked(matrixClient).getProfileInfo.mockResolvedValue(profileInfo);
});
it("should render", async () => {
const { asFragment } = await renderLayoutSwitcher();
expect(asFragment()).toMatchSnapshot();
});
describe("layout selection", () => {
it("should display the modern layout", async () => {
await renderLayoutSwitcher();
expect(screen.getByRole("radio", { name: "Modern" })).toBeChecked();
});
it("should change the layout when selected", async () => {
await renderLayoutSwitcher();
act(() => screen.getByRole("radio", { name: "Message bubbles" }).click());
expect(screen.getByRole("radio", { name: "Message bubbles" })).toBeChecked();
await waitFor(() => expect(SettingsStore.getValue<boolean>("layout")).toBe(Layout.Bubble));
});
});
describe("compact layout", () => {
beforeEach(async () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, false);
});
it("should be enabled", async () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
await renderLayoutSwitcher();
expect(screen.getByRole("checkbox", { name: "Show compact text and messages" })).toBeChecked();
});
it("should change the setting when toggled", async () => {
await renderLayoutSwitcher();
act(() => screen.getByRole("checkbox", { name: "Show compact text and messages" }).click());
await waitFor(() => expect(SettingsStore.getValue<boolean>("useCompactLayout")).toBe(true));
});
it("should be disabled when the modern layout is not enabled", async () => {
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
await renderLayoutSwitcher();
expect(screen.getByRole("checkbox", { name: "Show compact text and messages" })).toBeDisabled();
});
});
});

View File

@ -0,0 +1,426 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LayoutSwitcher /> should render 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="layoutPanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Message layout
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24 mx_LayoutSwitcher_LayoutSelector"
>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Modern"
class="_label_dgy0u_67"
for="radix-0"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-0"
name="layout"
title=""
type="radio"
value="group"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Modern
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="group"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
A
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
Hey you. You're the best!
</span>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Message bubbles"
class="_label_dgy0u_67"
for="radix-1"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-1"
name="layout"
title=""
type="radio"
value="bubble"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Message bubbles
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="bubble"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
A
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
Hey you. You're the best!
</span>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="IRC (experimental)"
class="_label_dgy0u_67"
for="radix-2"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-2"
name="layout"
title=""
type="radio"
value="irc"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
IRC (experimental)
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview mx_IRCLayout"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="irc"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
Alice
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 14px;"
title="@userId:matrix.org"
>
A
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
Hey you. You're the best!
</span>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
</form>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
aria-describedby="radix-3"
class="_input_qnvru_32"
id="radix-4"
name="compactLayout"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-4"
>
Show compact text and messages
</label>
<span
class="_message_dgy0u_98 _help-message_dgy0u_104"
id="radix-3"
>
Modern layout must be selected to use this feature.
</span>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;

View File

@ -146,143 +146,424 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
/>
</div>
<div
class="mx_SettingsSubsection"
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="layoutPanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Message layout
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<div
class="mx_LayoutSwitcher_RadioButtons"
<form
class="_root_dgy0u_24 mx_LayoutSwitcher_LayoutSelector"
>
<label
class="mx_LayoutSwitcher_RadioButton"
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<div
class="mx_LayoutSwitcher_RadioButton_preview mx_IRCLayout mx_EventTilePreview_loader"
<label
aria-label="Modern"
class="_label_dgy0u_67"
for="radix-3"
>
<div
class="mx_Spinner"
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-3"
name="layout"
title=""
type="radio"
value="group"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Modern
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="group"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
@userId:matrix.org
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
Hey you. You're the best!
</span>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="Message bubbles"
class="_label_dgy0u_67"
for="radix-4"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-4"
name="layout"
title=""
type="radio"
value="bubble"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
Message bubbles
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="bubble"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
@userId:matrix.org
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 30px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
Hey you. You're the best!
</span>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label
aria-label="IRC (experimental)"
class="_label_dgy0u_67"
for="radix-5"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-5"
name="layout"
title=""
type="radio"
value="irc"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
<span>
IRC (experimental)
</span>
</div>
<hr
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator"
/>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview mx_IRCLayout"
role="presentation"
>
<div
aria-atomic="true"
aria-live="off"
class="mx_EventTile"
data-event-id="$9999999999999999999999999999999999999999999"
data-has-reply="false"
data-layout="irc"
data-scroll-tokens="$9999999999999999999999999999999999999999999"
data-self="true"
tabindex="-1"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class="mx_Username_color2 mx_DisambiguatedProfile_displayName"
dir="auto"
>
@userId:matrix.org
</span>
</div>
<div
class="mx_EventTile_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 14px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EventTile_line"
>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
Hey you. You're the best!
</span>
</div>
<div
aria-label="Message Actions"
aria-live="off"
class="mx_MessageActionBar"
role="toolbar"
>
<div
aria-label="Edit"
class="mx_AccessibleButton mx_MessageActionBar_iconButton"
role="button"
tabindex="0"
>
<div />
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
>
<div />
</div>
</div>
</div>
</div>
</div>
</label>
</div>
</form>
<form
class="_root_dgy0u_24"
>
<div
class="_inline-field_dgy0u_40"
>
<div
class="_inline-field-control_dgy0u_52"
>
<div
class="_container_qnvru_18"
>
<input
aria-describedby="radix-6"
class="_input_qnvru_32"
id="radix-7"
name="compactLayout"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled"
>
<input
name="layout"
type="radio"
value="irc"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
IRC (Experimental)
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</label>
<label
class="mx_LayoutSwitcher_RadioButton mx_LayoutSwitcher_RadioButton_selected"
>
<div
class="mx_LayoutSwitcher_RadioButton_preview mx_EventTilePreview_loader"
class="_inline-field-body_dgy0u_46"
>
<div
class="mx_Spinner"
<label
class="_label_dgy0u_67"
for="radix-7"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
Show compact text and messages
</label>
<span
class="_message_dgy0u_98 _help-message_dgy0u_104"
id="radix-6"
>
Modern layout must be selected to use this feature.
</span>
</div>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled mx_StyledRadioButton_checked"
>
<input
checked=""
name="layout"
type="radio"
value="group"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Modern
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</label>
<label
class="mx_LayoutSwitcher_RadioButton"
>
<div
class="mx_LayoutSwitcher_RadioButton_preview mx_EventTilePreview_loader"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled"
>
<input
name="layout"
type="radio"
value="bubble"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
>
Message bubbles
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
<div
class="mx_SettingsSubsection"