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
This commit is contained in:
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"); 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 }) => { test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance"); await app.settings.openUserSettings("Appearance");
@ -84,57 +47,6 @@ test.describe("Appearance user settings tab", () => {
await expect(page).toMatchScreenshot("window-12px.png"); 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 }) => { test("should support enabling system font", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance"); await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab"); const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
@ -149,6 +61,49 @@ test.describe("Appearance user settings tab", () => {
await expect(page.locator("body")).toHaveCSS("font-family", '""'); 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.describe("Theme Choice Panel", () => {
test.beforeEach(async ({ app, user, util }) => { test.beforeEach(async ({ app, user, util }) => {
// Disable the default theme for consistency in case ThemeWatcher automatically chooses it // Disable the default theme for consistency in case ThemeWatcher automatically chooses it

View file

@ -14,11 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
import { Page } from "@playwright/test"; import { Locator, Page } from "@playwright/test";
import { ElementAppPage } from "../../../pages/ElementAppPage"; import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test"; import { test as base, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Layout } from "../../../../src/settings/enums/Layout";
export { expect }; export { expect };
@ -57,6 +58,21 @@ class Helpers {
return this.app.settings.openUserSettings("Appearance"); 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 // Theme Panel
/** /**
@ -136,4 +152,90 @@ class Helpers {
removeCustomTheme() { removeCustomTheme() {
return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click(); 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

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

View file

@ -15,79 +15,80 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_LayoutSwitcher_RadioButtons { .mx_LayoutSwitcher_LayoutSelector {
display: flex;
flex-direction: row;
gap: 24px;
width: 100%;
color: $primary-content;
> .mx_LayoutSwitcher_RadioButton {
flex-grow: 0;
flex-shrink: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; /**
* 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;
flex-basis: 33%; .mxLayoutSwitcher_LayoutSelector_LayoutRadio {
min-width: 0; border: 1px solid var(--cpd-color-border-interactive-primary);
border-radius: var(--cpd-space-2x);
border: 1px solid $quinary-content; .mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline {
border-radius: 10px; display: flex;
/*
* 10px
*/
gap: calc(var(--cpd-space-2x) + var(--cpd-space-0-5x));
align-items: center;
}
.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 {
margin: 0;
/**
* Hide the message options and message action bar in the preview
*/
.mx_EventTile_msgOption, .mx_EventTile_msgOption,
.mx_MessageActionBar { .mx_MessageActionBar {
display: none; display: none;
} }
.mx_LayoutSwitcher_RadioButton_preview {
flex-grow: 1;
display: flex;
align-items: center;
padding: 10px;
pointer-events: none;
.mx_EventTile[data-layout="bubble"] .mx_EventTile_line {
padding-right: 11px;
}
}
.mx_StyledRadioButton {
flex-grow: 0;
padding: 10px;
}
.mx_EventTile_content { .mx_EventTile_content {
margin-right: 0; margin-right: 0;
} }
&.mx_LayoutSwitcher_RadioButton_selected { &[data-layout="group"] {
border-color: var(--cpd-color-bg-accent-rest); margin-top: calc(var(--cpd-space-3x) * -1);
}
} }
.mx_StyledRadioButton { /**
border-top: 1px solid $quinary-content; * Add margin to center the bubble
} */
.mx_StyledRadioButton_checked {
background-color: var(--cpd-color-bg-subtle-secondary);
}
.mx_EventTile {
margin: 0;
&[data-layout="bubble"] { &[data-layout="bubble"] {
margin-right: 40px; /**
* Add the layout margin and the margin to vertically center the bubble
*/
margin-top: var(--cpd-space-6x);
margin-right: 34px;
flex-shrink: 1; flex-shrink: 1;
} }
&[data-layout="irc"] {
> a {
display: none;
}
}
.mx_EventTile_line { .mx_EventTile_line {
max-width: 90%; max-width: 100%;
}
}
}
.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 * classnames to apply to the wrapper of the preview
*/ */
className: string; className?: string;
/** /**
* The ID of the displayed user * The ID of the displayed user

View file

@ -1,131 +1,170 @@
/* /*
Copyright 2019 New Vector Ltd * Copyright 2024 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. *
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Licensed under the Apache License, Version 2.0 (the "License"); * You may obtain a copy of the License at
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
*
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,
Unless required by applicable law or agreed to in writing, software * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
distributed under the License is distributed on an "AS IS" BASIS, * See the License for the specific language governing permissions and
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * limitations under the License.
See the License for the specific language governing permissions and
limitations under the License.
*/ */
import React from "react"; import React, { JSX, useEffect, useState } from "react";
import classNames from "classnames"; import { Field, HelpMessage, InlineField, Label, RadioControl, Root, ToggleControl } from "@vector-im/compound-web";
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 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; * A section to switch between different message layouts.
displayName?: string; */
avatarUrl?: string; export function LayoutSwitcher(): JSX.Element {
messagePreviewText: string;
onLayoutChanged: (layout: Layout) => void;
}
interface IState {
layout: Layout;
}
export default class LayoutSwitcher extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
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 ( return (
<SettingsSubsection heading={_t("common|message_layout")}> <SettingsSubsection heading={_t("common|message_layout")} legacy={false} data-testid="layoutPanel">
<div className="mx_LayoutSwitcher_RadioButtons"> <LayoutSelector />
<label className={ircClasses}> <ToggleCompactLayout />
<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>
</div>
</SettingsSubsection> </SettingsSubsection>
); );
} }
/**
* 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;
}
/**
* 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();
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>
<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 AccessibleButton from "../../../elements/AccessibleButton";
import { SettingLevel } from "../../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature"; import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/enums/Layout"; import { LayoutSwitcher } from "../../LayoutSwitcher";
import LayoutSwitcher from "../../LayoutSwitcher";
import FontScalingPanel from "../../FontScalingPanel"; import FontScalingPanel from "../../FontScalingPanel";
import { ThemeChoicePanel } from "../../ThemeChoicePanel"; import { ThemeChoicePanel } from "../../ThemeChoicePanel";
import ImageSizePanel from "../../ImageSizePanel"; import ImageSizePanel from "../../ImageSizePanel";
import SettingsTab from "../SettingsTab"; import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection"; import SettingsSubsection from "../../shared/SettingsSubsection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
interface IProps {} interface IProps {}
@ -42,21 +40,9 @@ interface IState {
useSystemFont: boolean; useSystemFont: boolean;
systemFont: string; systemFont: string;
showAdvanced: boolean; 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> { 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) { public constructor(props: IProps) {
super(props); super(props);
@ -65,32 +51,9 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
useSystemFont: SettingsStore.getValue("useSystemFont"), useSystemFont: SettingsStore.getValue("useSystemFont"),
systemFont: SettingsStore.getValue("systemFont"), systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false, 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 { private renderAdvancedSection(): ReactNode {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null; 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"> <SettingsTab data-testid="mx_AppearanceUserSettingsTab">
<SettingsSection> <SettingsSection>
<ThemeChoicePanel /> <ThemeChoicePanel />
<LayoutSwitcher <LayoutSwitcher />
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
messagePreviewText={this.MESSAGE_PREVIEW_TEXT}
onLayoutChanged={this.onLayoutChanged}
/>
<FontScalingPanel /> <FontScalingPanel />
{this.renderAdvancedSection()} {this.renderAdvancedSection()}
<ImageSizePanel /> <ImageSizePanel />

View file

@ -2416,6 +2416,8 @@
"always_show_message_timestamps": "Always show message timestamps", "always_show_message_timestamps": "Always show message timestamps",
"appearance": { "appearance": {
"bundled_emoji_font": "Use bundled emoji font", "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": "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_description": "Set the name of a font installed on your system & %(brand)s will attempt to use it.",
"custom_font_name": "System font name", "custom_font_name": "System font name",
@ -2432,7 +2434,7 @@
"image_size_default": "Default", "image_size_default": "Default",
"image_size_large": "Large", "image_size_large": "Large",
"layout_bubbles": "Message bubbles", "layout_bubbles": "Message bubbles",
"layout_irc": "IRC (Experimental)", "layout_irc": "IRC (experimental)",
"match_system_theme": "Match system theme", "match_system_theme": "Match system theme",
"timeline_image_size": "Image size in the timeline" "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,144 +146,425 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
/> />
</div> </div>
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="layoutPanel"
> >
<div <div
class="mx_SettingsSubsectionHeading" class="mx_SettingsSubsectionHeading"
> >
<h3 <h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading" class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
> >
Message layout Message layout
</h3> </h3>
</div> </div>
<div <div
class="mx_SettingsSubsection_content" class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_dgy0u_24 mx_LayoutSwitcher_LayoutSelector"
> >
<div <div
class="mx_LayoutSwitcher_RadioButtons" class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
> >
<label <label
class="mx_LayoutSwitcher_RadioButton" aria-label="Modern"
class="_label_dgy0u_67"
for="radix-3"
> >
<div <div
class="mx_LayoutSwitcher_RadioButton_preview mx_IRCLayout mx_EventTilePreview_loader" class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
> >
<div <div
class="mx_Spinner" class="_container_1vw5h_18"
>
<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="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"
>
<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 mx_StyledRadioButton_checked"
> >
<input <input
checked="" checked=""
class="_input_1vw5h_26"
id="radix-3"
name="layout" name="layout"
title=""
type="radio" type="radio"
value="group" value="group"
/> />
<div> <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> </div>
<div <div
class="mx_StyledRadioButton_content" aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
> >
Modern <div />
</div>
</div>
</div>
</div>
</div> </div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label> </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> </div>
<div
class="_field_dgy0u_34 mxLayoutSwitcher_LayoutSelector_LayoutRadio"
>
<label <label
class="mx_StyledRadioButton mx_StyledRadioButton_enabled" aria-label="Message bubbles"
class="_label_dgy0u_67"
for="radix-4"
>
<div
class="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline"
>
<div
class="_container_1vw5h_18"
> >
<input <input
class="_input_1vw5h_26"
id="radix-4"
name="layout" name="layout"
title=""
type="radio" type="radio"
value="bubble" value="bubble"
/> />
<div> <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> </div>
<div <div
class="mx_StyledRadioButton_content" aria-expanded="false"
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton"
role="button"
tabindex="-1"
> >
Message bubbles <div />
</div>
</div>
</div>
</div>
</div>
</label>
</div> </div>
<div <div
class="mx_StyledRadioButton_spacer" 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"
/> />
</label> <div
</label> 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> </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>
<div
class="_inline-field-body_dgy0u_46"
>
<label
class="_label_dgy0u_67"
for="radix-7"
>
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>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="mx_FontScalingPanel" data-testid="mx_FontScalingPanel"