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

@ -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"
},