New theme ui in user settings (#12576)

* Add hook to get the theme

* Adapt subsection settings to new ui

* WIP new theme subsection

* Add theme selection

* Fix test types

* Disabled theme selector when system theme is used

* Update compound to `4.4.1`

* Add custom theme support

* Remove old ThemChoicePanel

* Fix QuickThemeSwitcher-test.tsx

* Fix AppearanceUserSettingsTab-test.tsx

* Update i18n

* Fix ThemeChoicePanel-test.tsx

* Update `@vector-im/compound-web`

* Small tweaks

* Fix CSS comments and use compound variable

* Remove custom theme title

* i18n: update

* test: add tests to theme selection

* test: update AppearanceUserSettingsTab-test snapshot

* test: rework custom theme

* playwright: fix audio-player.spec.ts

* playwright: appearance tab

* test: update snapshot

* playright: add custom theme

* i18n: use correct char for ellipsis

* a11y: add missing aria-label to delete button

* dialog: update close button tooltip

* theme: remove local state and handle custom delete

* theme: don't add twice the same custom theme

* test: update snapshot

* playwright: update snapshot

* custom theme: add background to custom theme list

* update compound web

* Use new destructive property on `IconButton` of theme panel

* test: update snapshots

* rename new ui into legacy

* remove wrong constructor doc

* fix theme selector padding

* theme selector: fix key

* test: fix e2e
This commit is contained in:
Florian Duros 2024-06-26 17:47:01 +02:00 committed by GitHub
parent 8ede89101a
commit 33a017b528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1749 additions and 477 deletions

View file

@ -160,7 +160,7 @@ test.describe("Audio player", () => {
// Enable high contrast manually // Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance"); const settings = await app.settings.openUserSettings("Appearance");
await settings.getByTestId("mx_ThemeChoicePanel").getByText("Use high contrast").click(); await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog(); await app.closeDialog();

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { test, expect } from "../../element-web-test"; import { expect, test } from ".";
import { SettingLevel } from "../../../src/settings/SettingLevel";
test.describe("Appearance user settings tab", () => { test.describe("Appearance user settings tab", () => {
test.use({ test.use({
@ -151,69 +150,68 @@ test.describe("Appearance user settings tab", () => {
}); });
test.describe("Theme Choice Panel", () => { test.describe("Theme Choice Panel", () => {
test.beforeEach(async ({ app, user }) => { 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
await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false); await util.disableSystemTheme();
await util.openAppearanceTab();
}); });
test("should be rendered with the light theme selected", async ({ page, app }) => { test("should be rendered with the light theme selected", async ({ page, app, util }) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
const useSystemTheme = themePanel.getByTestId("checkbox-use-system-theme");
await expect(useSystemTheme.getByText("Match system theme")).toBeVisible();
// Assert that 'Match system theme' is not checked // Assert that 'Match system theme' is not checked
// Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked();
await expect(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible();
const selectors = themePanel.getByTestId("theme-choice-panel-selectors");
await expect(selectors.locator(".mx_ThemeSelector_light")).toBeVisible();
await expect(selectors.locator(".mx_ThemeSelector_dark")).toBeVisible();
// Assert that the light theme is selected // Assert that the light theme is selected
await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible(); await expect(util.getLightTheme()).toBeChecked();
// Assert that the buttons for the light and dark theme are not enabled // Assert that the dark and high contrast themes are not selected
await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible(); await expect(util.getDarkTheme()).not.toBeChecked();
await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible(); await expect(util.getHighContrastTheme()).not.toBeChecked();
// Assert that the checkbox for the high contrast theme is rendered await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png");
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
}); });
test("should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for the system theme is clicked", async ({ test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => {
page, await util.getMatchSystemThemeCheckbox().click();
app,
}) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
await themePanel.locator(".mx_Checkbox", { hasText: "Match system theme" }).click(); // Assert that the themes are disabled
await expect(util.getLightTheme()).toBeDisabled();
await expect(util.getDarkTheme()).toBeDisabled();
await expect(util.getHighContrastTheme()).toBeDisabled();
// Assert that the labels for the light theme and dark theme are disabled await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png");
await expect(themePanel.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).toBeVisible();
await expect(themePanel.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).toBeVisible();
// Assert that there does not exist a label for an enabled theme
await expect(themePanel.locator("label.mx_StyledRadioButton_enabled")).not.toBeVisible();
// Assert that the checkbox and label to enable the high contrast theme should not exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
}); });
test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({ test("should change the theme to dark", async ({ page, app, util }) => {
page, // Assert that the light theme is selected
app, await expect(util.getLightTheme()).toBeChecked();
}) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
// Assert that the checkbox and the label to enable the high contrast theme should exist await util.getDarkTheme().click();
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
// Enable the dark theme // Assert that the light and high contrast themes are not selected
await themePanel.locator(".mx_ThemeSelector_dark").click(); await expect(util.getLightTheme()).not.toBeChecked();
await expect(util.getDarkTheme()).toBeChecked();
await expect(util.getHighContrastTheme()).not.toBeChecked();
// Assert that the checkbox and the label should not exist await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png");
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible(); });
test.describe("custom theme", () => {
test.use({
labsFlags: ["feature_custom_themes"],
});
test("should render the custom theme section", async ({ page, app, util }) => {
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
});
test("should be able to add and remove a custom theme", async ({ page, app, util }) => {
await util.addCustomTheme();
await expect(util.getCustomTheme()).not.toBeChecked();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png");
await util.removeCustomTheme();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
});
}); });
}); });
}); });

View file

@ -0,0 +1,139 @@
/*
* 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 { Page } from "@playwright/test";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
export { expect };
/**
* Set up for the appearance tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app }, use) => {
await use(new Helpers(page, app));
},
});
/**
* A collection of helper functions for the appearance tab test
* The goal is to make easier to get and interact with the button, input, or other elements of the appearance tab
*/
class Helpers {
private CUSTOM_THEME_URL = "http://custom.theme";
private CUSTOM_THEME = {
name: "Custom theme",
isDark: false,
colors: {},
};
constructor(
private page: Page,
private app: ElementAppPage,
) {}
/**
* Open the appearance tab
*/
openAppearanceTab() {
return this.app.settings.openUserSettings("Appearance");
}
// Theme Panel
/**
* Disable in the settings the system theme
*/
disableSystemTheme() {
return this.app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
}
/**
* Return the theme section
*/
getThemePanel() {
return this.page.getByTestId("themePanel");
}
/**
* Return the system theme toggle
*/
getMatchSystemThemeCheckbox() {
return this.getThemePanel().getByRole("checkbox");
}
/**
* Return the theme radio button
* @param theme - the theme to select
* @private
*/
private getThemeRadio(theme: string) {
return this.getThemePanel().getByRole("radio", { name: theme });
}
/**
* Return the light theme radio button
*/
getLightTheme() {
return this.getThemeRadio("Light");
}
/**
* Return the dark theme radio button
*/
getDarkTheme() {
return this.getThemeRadio("Dark");
}
/**
* Return the custom theme radio button
*/
getCustomTheme() {
return this.getThemeRadio(this.CUSTOM_THEME.name);
}
/**
* Return the high contrast theme radio button
*/
getHighContrastTheme() {
return this.getThemeRadio("High contrast");
}
/**
* Add a custom theme
* Mock the request to the custom and return a fake local custom theme
*/
async addCustomTheme() {
await this.page.route(this.CUSTOM_THEME_URL, (route) =>
route.fulfill({ body: JSON.stringify(this.CUSTOM_THEME) }),
);
await this.page.getByRole("textbox", { name: "Add custom theme" }).fill(this.CUSTOM_THEME_URL);
await this.page.getByRole("button", { name: "Add custom theme" }).click();
await this.page.unroute(this.CUSTOM_THEME_URL);
}
/**
* Remove the custom theme
*/
removeCustomTheme() {
return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Before After
Before After

View file

@ -604,7 +604,7 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
), ):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog input[type="submit"], .mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] { .mx_Dialog_buttons input[type="submit"] {
@ -624,14 +624,14 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):last-child { ):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
margin-right: 0px; margin-right: 0px;
} }
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):focus, ):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
.mx_Dialog input[type="submit"]:focus, .mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus { .mx_Dialog_buttons input[type="submit"]:focus {
@ -643,7 +643,7 @@ legend {
.mx_Dialog_buttons .mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
), ):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary); color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest); background-color: var(--cpd-color-bg-action-primary-rest);
@ -654,7 +654,9 @@ legend {
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].danger, .mx_Dialog input[type="submit"].danger,
.mx_Dialog_buttons .mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button), button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button
),
.mx_Dialog_buttons input[type="submit"].danger { .mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary); background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary);
@ -670,7 +672,7 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):disabled, ):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
.mx_Dialog input[type="submit"]:disabled, .mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled { .mx_Dialog_buttons input[type="submit"]:disabled {

View file

@ -17,6 +17,12 @@ limitations under the License.
.mx_SettingsSubsection { .mx_SettingsSubsection {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
&.mx_SettingsSubsection_newUi {
display: flex;
flex-direction: column;
gap: var(--cpd-space-8x);
}
} }
.mx_SettingsSubsection_description { .mx_SettingsSubsection_description {
@ -54,4 +60,8 @@ limitations under the License.
&.mx_SettingsSubsection_noHeading { &.mx_SettingsSubsection_noHeading {
margin-top: 0; margin-top: 0;
} }
&.mx_SettingsSubsection_content_newUi {
gap: var(--cpd-space-6x);
margin-top: 0;
}
} }

View file

@ -14,48 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_ThemeChoicePanel_themeSelectors { .mx_ThemeChoicePanel_ThemeSelectors {
color: $primary-content;
display: flex; display: flex;
flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
/* Override form default style */
flex-direction: row !important;
gap: var(--cpd-space-4x) !important;
> .mx_StyledRadioButton { .mx_ThemeChoicePanel_themeSelector {
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-1-5x);
padding: var(--cpd-space-3x) var(--cpd-space-5x) var(--cpd-space-3x) var(--cpd-space-3x);
gap: var(--cpd-space-2x);
background-color: var(--cpd-color-bg-canvas-default);
&.mx_ThemeChoicePanel_themeSelector_enabled {
border-color: var(--cpd-color-border-interactive-primary);
}
&.mx_ThemeChoicePanel_themeSelector_disabled {
border-color: var(--cpd-color-border-disabled);
}
.mx_ThemeChoicePanel_themeSelector_Label {
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-semibold);
}
}
}
.mx_ThemeChoicePanel_CustomTheme {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
.mx_ThemeChoicePanel_CustomTheme_EditInPlace input:focus {
/*
* When the input is focused, the border is growing
* We need to move it a bit to avoid the left border to be under the left panel
*/
margin-left: var(--cpd-space-0-5x);
}
.mx_ThemeChoicePanel_CustomThemeList {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
/*
* Override the default padding/margin of the list
*/
padding: 0;
margin: 0;
.mx_ThemeChoicePanel_CustomThemeList_theme {
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: $font-16px; background-color: var(--cpd-color-gray-200);
box-sizing: border-box; padding: var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-4x);
border-radius: 10px;
width: 180px;
background: $accent-200; .mx_ThemeChoicePanel_CustomThemeList_name {
opacity: 0.4; font: var(--cpd-font-body-sm-semibold);
overflow: hidden;
flex-shrink: 1; text-overflow: ellipsis;
flex-grow: 0; white-space: nowrap;
}
margin-right: 15px;
margin-top: 10px;
font-weight: var(--cpd-font-weight-semibold);
> span {
justify-content: center;
}
}
> .mx_StyledRadioButton_enabled {
opacity: 1;
/* These colors need to be hardcoded because they don't change with the theme */
&.mx_ThemeSelector_light {
background-color: #f3f8fd;
color: #2e2f32;
}
&.mx_ThemeSelector_dark {
/* 5% lightened version of 181b21 */
background-color: #25282e;
color: #f3f8fd;
} }
} }
} }

View file

@ -128,7 +128,8 @@ export default class BaseDialog extends React.Component<IProps> {
onClick={this.onCancelClick} onClick={this.onCancelClick}
className="mx_Dialog_cancelButton" className="mx_Dialog_cancelButton"
aria-label={_t("dialog_close_label")} aria-label={_t("dialog_close_label")}
title={_t("dialog_close_label")} title={_t("action|close")}
placement="bottom"
/> />
); );
} }

View file

@ -1,117 +1,125 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. * Copyright 2024 The Matrix.org Foundation C.I.C.
*
Licensed under the Apache License, Version 2.0 (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 not use this file except in compliance with the License.
You may obtain a copy of the License at * 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 * Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
limitations under the License. * limitations under the License.
*/ */
import React from "react"; import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react";
import {
InlineField,
ToggleControl,
Label,
Root,
RadioControl,
EditInPlace,
IconButton,
} from "@vector-im/compound-web";
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsSubsection from "./shared/SettingsSubsection";
import { findHighContrastTheme, findNonHighContrastTheme, getOrderedThemes, isHighContrastTheme } from "../../../theme";
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher"; import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
import AccessibleButton from "../elements/AccessibleButton"; import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload"; import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import StyledCheckbox from "../elements/StyledCheckbox"; import { useTheme } from "../../../hooks/useTheme";
import Field from "../elements/Field"; import { findHighContrastTheme, getOrderedThemes, CustomTheme as CustomThemeType, ITheme } from "../../../theme";
import StyledRadioGroup from "../elements/StyledRadioGroup"; import { useSettingValue } from "../../../hooks/useSettings";
import { SettingLevel } from "../../../settings/SettingLevel";
import PosthogTrackers from "../../../PosthogTrackers";
import SettingsSubsection from "./shared/SettingsSubsection";
interface IProps {} /**
* Panel to choose the theme
*/
export function ThemeChoicePanel(): JSX.Element {
const themeState = useTheme();
const themeWatcher = useRef(new ThemeWatcher());
const customThemeEnabled = useSettingValue<boolean>("feature_custom_themes");
interface IThemeState { return (
theme: string; <SettingsSubsection heading={_t("common|theme")} legacy={false} data-testid="themePanel">
useSystemTheme: boolean; {themeWatcher.current.isSystemThemeSupported() && (
} <SystemTheme systemThemeActivated={themeState.systemThemeActivated} />
)}
export interface CustomThemeMessage { <ThemeSelectors theme={themeState.theme} disabled={themeState.systemThemeActivated} />
isError: boolean; {customThemeEnabled && <CustomTheme theme={themeState.theme} />}
text: string; </SettingsSubsection>
}
interface IState extends IThemeState {
customThemeUrl: string;
customThemeMessage: CustomThemeMessage;
}
export default class ThemeChoicePanel extends React.Component<IProps, IState> {
private themeTimer?: number;
public constructor(props: IProps) {
super(props);
this.state = {
...ThemeChoicePanel.calculateThemeState(),
customThemeUrl: "",
customThemeMessage: { isError: false, text: "" },
};
}
public static calculateThemeState(): IThemeState {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things.
const themeChoice: string = SettingsStore.getValue("theme");
const systemThemeExplicit: boolean = SettingsStore.getValueAt(
SettingLevel.DEVICE,
"use_system_theme",
null,
false,
true,
); );
const themeExplicit: string = SettingsStore.getValueAt(SettingLevel.DEVICE, "theme", null, false, true);
// If the user has enabled system theme matching, use that.
if (systemThemeExplicit) {
return {
theme: themeChoice,
useSystemTheme: true,
};
} }
// If the user has set a theme explicitly, use that (no system theme matching) /**
if (themeExplicit) { * Component to toggle the system theme
return { */
theme: themeChoice, interface SystemThemeProps {
useSystemTheme: false, /* Whether the system theme is activated */
}; systemThemeActivated: boolean;
} }
// Otherwise assume the defaults for the settings /**
return { * Component to toggle the system theme
theme: themeChoice, */
useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"), function SystemTheme({ systemThemeActivated }: SystemThemeProps): JSX.Element {
}; return (
<Root
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("systemTheme") === "on";
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
}}
>
<InlineField
name="systemTheme"
control={<ToggleControl name="systemTheme" defaultChecked={systemThemeActivated} />}
>
<Label>{SettingsStore.getDisplayName("use_system_theme")}</Label>
</InlineField>
</Root>
);
} }
private onThemeChange = (newTheme: string): void => { /**
if (this.state.theme === newTheme) return; * Component to select the theme
*/
interface ThemeSelectorProps {
/* The current theme */
theme: string;
/* The theme can't be selected */
disabled: boolean;
}
PosthogTrackers.trackInteraction("WebSettingsAppearanceTabThemeSelector"); /**
* Component to select the theme
*/
function ThemeSelectors({ theme, disabled }: ThemeSelectorProps): JSX.Element {
const themes = useThemes();
return (
<Root
className="mx_ThemeChoicePanel_ThemeSelectors"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newTheme = new FormData(evt.currentTarget).get("themeSelector") as string | null;
// Do nothing if the same theme is selected
if (!newTheme || theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set, // doing getValue in the .catch will still return the value we failed to set,
// so remember what the value was before we tried to set it so we can revert
const oldTheme: string = SettingsStore.getValue("theme");
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => { SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => {
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme }); dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
this.setState({ theme: oldTheme });
}); });
this.setState({ theme: newTheme });
// The settings watcher doesn't fire until the echo comes back from the // The settings watcher doesn't fire until the echo comes back from the
// server, so to make the theme change immediately we need to manually // server, so to make the theme change immediately we need to manually
// do the dispatch now // do the dispatch now
@ -119,167 +127,214 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
// when settings custom themes(!) so adding forceTheme to override // when settings custom themes(!) so adding forceTheme to override
// the value from settings. // the value from settings.
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme }); dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme });
}; }}
>
private onUseSystemThemeChanged = (checked: boolean): void => { {themes.map((_theme) => {
this.setState({ useSystemTheme: checked }); const isChecked = theme === _theme.id;
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked); return (
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme }); <InlineField
}; className={classNames("mx_ThemeChoicePanel_themeSelector", {
[`mx_ThemeChoicePanel_themeSelector_enabled`]: !disabled && theme === _theme.id,
private onAddCustomTheme = async (): Promise<void> => { [`mx_ThemeChoicePanel_themeSelector_disabled`]: disabled,
let currentThemes: string[] = SettingsStore.getValue("custom_themes"); // We need to force the compound theme to be light or dark
if (!currentThemes) currentThemes = []; // The theme selection doesn't depend on the current theme
currentThemes = currentThemes.map((c) => c); // cheap clone // For example when the light theme is used, the dark theme selector should be dark
"cpd-theme-light": !_theme.isDark,
if (this.themeTimer) { "cpd-theme-dark": _theme.isDark,
clearTimeout(this.themeTimer); })}
name="themeSelector"
key={_theme.id}
control={
<RadioControl
name="themeSelector"
checked={!disabled && isChecked}
disabled={disabled}
value={_theme.id}
/>
} }
>
<Label className="mx_ThemeChoicePanel_themeSelector_Label">{_theme.name}</Label>
</InlineField>
);
})}
</Root>
);
}
/**
* Return all the available themes
*/
function useThemes(): Array<ITheme & { isDark: boolean }> {
const customThemes = useSettingValue<CustomThemeType[] | undefined>("custom_themes");
return useMemo(() => {
// Put the custom theme into a map
// To easily find the theme by name when going through the themes list
const checkedCustomThemes = customThemes || [];
const customThemeMap = checkedCustomThemes.reduce(
(map, theme) => map.set(theme.name, theme),
new Map<string, CustomThemeType>(),
);
const themes = getOrderedThemes();
// Separate the built-in themes from the custom themes
// To insert the high contrast theme between them
const builtInThemes = themes.filter((theme) => !customThemeMap.has(theme.name));
const otherThemes = themes.filter((theme) => customThemeMap.has(theme.name));
const highContrastTheme = makeHighContrastTheme();
if (highContrastTheme) builtInThemes.push(highContrastTheme);
const allThemes = builtInThemes.concat(otherThemes);
// Check if the themes are dark
return allThemes.map((theme) => {
const customTheme = customThemeMap.get(theme.name);
const isDark = (customTheme ? customTheme.is_dark : theme.id.includes("dark")) || false;
return { ...theme, isDark };
});
}, [customThemes]);
}
/**
* Create the light high contrast theme
*/
function makeHighContrastTheme(): ITheme | undefined {
const lightHighContrastId = findHighContrastTheme("light");
if (lightHighContrastId) {
return {
name: _t("settings|appearance|high_contrast"),
id: lightHighContrastId,
};
}
}
interface CustomThemeProps {
/**
* The current theme
*/
theme: string;
}
/**
* Add and manager custom themes
*/
function CustomTheme({ theme }: CustomThemeProps): JSX.Element {
const [customTheme, setCustomTheme] = useState<string>("");
const [error, setError] = useState<string>();
const clear = useCallback(() => {
setError(undefined);
setCustomTheme("");
}, [setError, setCustomTheme]);
return (
<div className="mx_ThemeChoicePanel_CustomTheme">
<EditInPlace
className="mx_ThemeChoicePanel_CustomTheme_EditInPlace"
label={_t("settings|appearance|custom_theme_add")}
saveButtonLabel={_t("settings|appearance|custom_theme_add")}
savingLabel={_t("settings|appearance|custom_theme_downloading")}
helpLabel={_t("settings|appearance|custom_theme_help")}
error={error}
value={customTheme}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setError(undefined);
setCustomTheme(e.target.value);
}}
onSave={async () => {
// The field empty is empty
if (!customTheme) return;
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
try { try {
const r = await fetch(this.state.customThemeUrl); const r = await fetch(customTheme);
// XXX: need some schema for this // XXX: need some schema for this
const themeInfo = await r.json(); const themeInfo = await r.json();
if (!themeInfo || typeof themeInfo["name"] !== "string" || typeof themeInfo["colors"] !== "object") { if (
this.setState({ !themeInfo ||
customThemeMessage: { text: _t("settings|appearance|custom_theme_invalid"), isError: true }, typeof themeInfo["name"] !== "string" ||
}); typeof themeInfo["colors"] !== "object"
) {
setError(_t("settings|appearance|custom_theme_invalid"));
return; return;
} }
// Check if the theme is already existing
const isAlreadyExisting = Boolean(currentThemes.find((t) => t.name === themeInfo.name));
if (isAlreadyExisting) {
clear();
return;
}
currentThemes.push(themeInfo); currentThemes.push(themeInfo);
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);
this.setState({ setError(_t("settings|appearance|custom_theme_error_downloading"));
customThemeMessage: { text: _t("settings|appearance|custom_theme_error_downloading"), isError: true }, return;
});
return; // Don't continue on error
} }
// Reset the error
clear();
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes); await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
this.setState({ }}
customThemeUrl: "", onCancel={clear}
customThemeMessage: { text: _t("settings|appearance|custom_theme_success"), isError: false }, />
<CustomThemeList theme={theme} />
</div>
);
}
interface CustomThemeListProps {
/*
* The current theme
*/
theme: string;
}
/**
* List of the custom themes
*/
function CustomThemeList({ theme: currentTheme }: CustomThemeListProps): JSX.Element {
const customThemes = useSettingValue<CustomThemeType[]>("custom_themes") || [];
return (
<ul className="mx_ThemeChoicePanel_CustomThemeList">
{customThemes.map((theme) => {
return (
<li key={theme.name} className="mx_ThemeChoicePanel_CustomThemeList_theme" aria-label={theme.name}>
<span className="mx_ThemeChoicePanel_CustomThemeList_name">{theme.name}</span>
<IconButton
destructive={true}
aria-label={_t("action|delete")}
tooltip={_t("action|delete")}
onClick={async () => {
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
// Remove the theme from the list
const newThemes = currentThemes.filter((t) => t.name !== theme.name);
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, newThemes);
// If the delete custom theme is the current theme, reset the theme to the default theme
// By settings the theme at null at the device level, we are getting the default theme
if (currentTheme === `custom-${theme.name}`) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, null);
dis.dispatch<RecheckThemePayload>({
action: Action.RecheckTheme,
}); });
}
this.themeTimer = window.setTimeout(() => { }}
this.setState({ customThemeMessage: { text: "", isError: false } });
}, 3000);
};
private onCustomThemeChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>): void => {
this.setState({ customThemeUrl: e.target.value });
};
private renderHighContrastCheckbox(): React.ReactElement<HTMLDivElement> | undefined {
if (
!this.state.useSystemTheme &&
(findHighContrastTheme(this.state.theme) || isHighContrastTheme(this.state.theme))
) {
return (
<div>
<StyledCheckbox
checked={isHighContrastTheme(this.state.theme)}
onChange={(e) => this.highContrastThemeChanged(e.target.checked)}
> >
{_t("settings|appearance|use_high_contrast")} <DeleteIcon />
</StyledCheckbox> </IconButton>
</div> </li>
);
})}
</ul>
); );
} }
}
private highContrastThemeChanged(checked: boolean): void {
let newTheme: string | undefined;
if (checked) {
newTheme = findHighContrastTheme(this.state.theme);
} else {
newTheme = findNonHighContrastTheme(this.state.theme);
}
if (newTheme) {
this.onThemeChange(newTheme);
}
}
public render(): React.ReactElement<HTMLDivElement> {
const themeWatcher = new ThemeWatcher();
let systemThemeSection: JSX.Element | undefined;
if (themeWatcher.isSystemThemeSupported()) {
systemThemeSection = (
<div data-testid="checkbox-use-system-theme">
<StyledCheckbox
checked={this.state.useSystemTheme}
onChange={(e) => this.onUseSystemThemeChanged(e.target.checked)}
>
{SettingsStore.getDisplayName("use_system_theme")}
</StyledCheckbox>
</div>
);
}
let customThemeForm: JSX.Element | undefined;
if (SettingsStore.getValue("feature_custom_themes")) {
let messageElement: JSX.Element | undefined;
if (this.state.customThemeMessage.text) {
if (this.state.customThemeMessage.isError) {
messageElement = <div className="text-error">{this.state.customThemeMessage.text}</div>;
} else {
messageElement = <div className="text-success">{this.state.customThemeMessage.text}</div>;
}
}
customThemeForm = (
<div className="mx_SettingsTab_section">
<form onSubmit={this.onAddCustomTheme}>
<Field
label={_t("settings|appearance|custom_theme_url")}
type="text"
id="mx_GeneralUserSettingsTab_customThemeInput"
autoComplete="off"
onChange={this.onCustomThemeChange}
value={this.state.customThemeUrl}
/>
<AccessibleButton
onClick={this.onAddCustomTheme}
type="submit"
kind="primary_sm"
disabled={!this.state.customThemeUrl.trim()}
>
{_t("settings|appearance|custom_theme_add_button")}
</AccessibleButton>
{messageElement}
</form>
</div>
);
}
const orderedThemes = getOrderedThemes();
return (
<SettingsSubsection heading={_t("common|theme")} data-testid="mx_ThemeChoicePanel">
{systemThemeSection}
<div className="mx_ThemeChoicePanel_themeSelectors" data-testid="theme-choice-panel-selectors">
<StyledRadioGroup
name="theme"
definitions={orderedThemes.map((t) => ({
value: t.id,
label: t.name,
disabled: this.state.useSystemTheme,
className: "mx_ThemeSelector_" + t.id,
}))}
onChange={this.onThemeChange}
value={this.apparentSelectedThemeId()}
outlined
/>
</div>
{this.renderHighContrastCheckbox()}
{customThemeForm}
</SettingsSubsection>
);
}
public apparentSelectedThemeId(): string | undefined {
if (this.state.useSystemTheme) {
return undefined;
}
const nonHighContrast = findNonHighContrastTheme(this.state.theme);
return nonHighContrast ? nonHighContrast : this.state.theme;
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import classNames from "classnames"; import classNames from "classnames";
import React, { HTMLAttributes } from "react"; import React, { HTMLAttributes } from "react";
import { Separator } from "@vector-im/compound-web";
import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading"; import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
@ -25,6 +26,11 @@ export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement>
children?: React.ReactNode; children?: React.ReactNode;
// when true content will be justify-items: stretch, which will make items within the section stretch to full width. // when true content will be justify-items: stretch, which will make items within the section stretch to full width.
stretchContent?: boolean; stretchContent?: boolean;
/*
* When true, the legacy UI style will be applied to the subsection.
* @default true
*/
legacy?: boolean;
} }
export const SettingsSubsectionText: React.FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...rest }) => ( export const SettingsSubsectionText: React.FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...rest }) => (
@ -38,10 +44,16 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
description, description,
children, children,
stretchContent, stretchContent,
legacy = true,
...rest ...rest
}) => ( }) => (
<div {...rest} className="mx_SettingsSubsection"> <div
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} /> : <>{heading}</>} {...rest}
className={classNames("mx_SettingsSubsection", {
mx_SettingsSubsection_newUi: !legacy,
})}
>
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} legacy={legacy} /> : <>{heading}</>}
{!!description && ( {!!description && (
<div className="mx_SettingsSubsection_description"> <div className="mx_SettingsSubsection_description">
<SettingsSubsectionText>{description}</SettingsSubsectionText> <SettingsSubsectionText>{description}</SettingsSubsectionText>
@ -52,11 +64,13 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
className={classNames("mx_SettingsSubsection_content", { className={classNames("mx_SettingsSubsection_content", {
mx_SettingsSubsection_contentStretch: !!stretchContent, mx_SettingsSubsection_contentStretch: !!stretchContent,
mx_SettingsSubsection_noHeading: !heading && !description, mx_SettingsSubsection_noHeading: !heading && !description,
mx_SettingsSubsection_content_newUi: !legacy,
})} })}
> >
{children} {children}
</div> </div>
)} )}
{!legacy && <Separator />}
</div> </div>
); );

View file

@ -20,14 +20,24 @@ import Heading from "../../typography/Heading";
export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivElement> { export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivElement> {
heading: string; heading: string;
legacy?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({ heading, children, ...rest }) => ( export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({
heading,
legacy = true,
children,
...rest
}) => {
const size = legacy ? "4" : "3";
return (
<div {...rest} className="mx_SettingsSubsectionHeading"> <div {...rest} className="mx_SettingsSubsectionHeading">
<Heading className="mx_SettingsSubsectionHeading_heading" size="4" as="h3"> <Heading className="mx_SettingsSubsectionHeading_heading" size={size} as="h3">
{heading} {heading}
</Heading> </Heading>
{children} {children}
</div> </div>
); );
};

View file

@ -28,7 +28,7 @@ import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/enums/Layout"; 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";

View file

@ -20,13 +20,13 @@ import { _t } from "../../../languageHandler";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { findNonHighContrastTheme, getOrderedThemes } from "../../../theme"; import { findNonHighContrastTheme, getOrderedThemes } from "../../../theme";
import Dropdown from "../elements/Dropdown"; import Dropdown from "../elements/Dropdown";
import ThemeChoicePanel from "../settings/ThemeChoicePanel";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload"; import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import { NonEmptyArray } from "../../../@types/common"; import { NonEmptyArray } from "../../../@types/common";
import { useTheme } from "../../../hooks/useTheme";
type Props = { type Props = {
requestClose: () => void; requestClose: () => void;
@ -37,10 +37,10 @@ const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID";
const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => { const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
const orderedThemes = useMemo(getOrderedThemes, []); const orderedThemes = useMemo(getOrderedThemes, []);
const themeState = ThemeChoicePanel.calculateThemeState(); const themeState = useTheme();
const nonHighContrast = findNonHighContrastTheme(themeState.theme); const nonHighContrast = findNonHighContrastTheme(themeState.theme);
const theme = nonHighContrast ? nonHighContrast : themeState.theme; const theme = nonHighContrast ? nonHighContrast : themeState.theme;
const { useSystemTheme } = themeState; const { systemThemeActivated } = themeState;
const themeOptions = [ const themeOptions = [
{ {
@ -50,7 +50,7 @@ const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
...orderedThemes, ...orderedThemes,
]; ];
const selectedTheme = useSystemTheme ? MATCH_SYSTEM_THEME_ID : theme; const selectedTheme = systemThemeActivated ? MATCH_SYSTEM_THEME_ID : theme;
const onOptionChange = async (newTheme: string): Promise<void> => { const onOptionChange = async (newTheme: string): Promise<void> => {
PosthogTrackers.trackInteraction("WebQuickSettingsThemeDropdown"); PosthogTrackers.trackInteraction("WebQuickSettingsThemeDropdown");

View file

@ -17,6 +17,7 @@ limitations under the License.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
// Hook to fetch the value of a setting and dynamically update when it changes // Hook to fetch the value of a setting and dynamically update when it changes
export const useSettingValue = <T>(settingName: string, roomId: string | null = null, excludeDefault = false): T => { export const useSettingValue = <T>(settingName: string, roomId: string | null = null, excludeDefault = false): T => {
@ -35,6 +36,39 @@ export const useSettingValue = <T>(settingName: string, roomId: string | null =
return value; return value;
}; };
/**
* Hook to fetch the value of a setting at a specific level and dynamically update when it changes
* @see SettingsStore.getValueAt
* @param level
* @param settingName
* @param roomId
* @param explicit
* @param excludeDefault
*/
export const useSettingValueAt = <T>(
level: SettingLevel,
settingName: string,
roomId: string | null = null,
explicit = false,
excludeDefault = false,
): T => {
const [value, setValue] = useState(
SettingsStore.getValueAt<T>(level, settingName, roomId, explicit, excludeDefault),
);
useEffect(() => {
const ref = SettingsStore.watchSetting(settingName, roomId, () => {
setValue(SettingsStore.getValueAt<T>(level, settingName, roomId, explicit, excludeDefault));
});
// clean-up
return () => {
SettingsStore.unwatchSetting(ref);
};
}, [level, settingName, roomId, explicit, excludeDefault]);
return value;
};
// Hook to fetch whether a feature is enabled and dynamically update when that changes // Hook to fetch whether a feature is enabled and dynamically update when that changes
export const useFeatureEnabled = (featureName: string, roomId: string | null = null): boolean => { export const useFeatureEnabled = (featureName: string, roomId: string | null = null): boolean => {
const [enabled, setEnabled] = useState(SettingsStore.getValue<boolean>(featureName, roomId)); const [enabled, setEnabled] = useState(SettingsStore.getValue<boolean>(featureName, roomId));

53
src/hooks/useTheme.ts Normal file
View file

@ -0,0 +1,53 @@
/*
* 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 { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue, useSettingValueAt } from "./useSettings";
/**
* Hook to fetch the current theme and whether system theme matching is enabled.
*/
export function useTheme(): { theme: string; systemThemeActivated: boolean } {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things.
const themeChoice = useSettingValue<string>("theme");
const systemThemeExplicit = useSettingValueAt<string>(SettingLevel.DEVICE, "use_system_theme", null, false, true);
const themeExplicit = useSettingValueAt<string>(SettingLevel.DEVICE, "theme", null, false, true);
const systemThemeActivated = useSettingValue<boolean>("use_system_theme");
// If the user has enabled system theme matching, use that.
if (systemThemeExplicit) {
return {
theme: themeChoice,
systemThemeActivated: true,
};
}
// If the user has set a theme explicitly, use that (no system theme matching)
if (themeExplicit) {
return {
theme: themeChoice,
systemThemeActivated: false,
};
}
// Otherwise assume the defaults for the settings
return {
theme: themeChoice,
systemThemeActivated,
};
}

View file

@ -2420,21 +2420,21 @@
"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",
"custom_font_size": "Use custom size", "custom_font_size": "Use custom size",
"custom_theme_add_button": "Add theme", "custom_theme_add": "Add custom theme",
"custom_theme_error_downloading": "Error downloading theme information.", "custom_theme_downloading": "Downloading custom theme…",
"custom_theme_error_downloading": "Error downloading theme",
"custom_theme_help": "Enter the URL of a custom theme you want to apply.",
"custom_theme_invalid": "Invalid theme schema.", "custom_theme_invalid": "Invalid theme schema.",
"custom_theme_success": "Theme added!",
"custom_theme_url": "Custom theme URL",
"dialog_title": "<strong>Settings:</strong> Appearance", "dialog_title": "<strong>Settings:</strong> Appearance",
"font_size": "Font size", "font_size": "Font size",
"font_size_default": "%(fontSize)s (default)", "font_size_default": "%(fontSize)s (default)",
"high_contrast": "High contrast",
"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"
"use_high_contrast": "Use high contrast"
}, },
"automatic_language_detection_syntax_highlight": "Enable automatic language detection for syntax highlighting", "automatic_language_detection_syntax_highlight": "Enable automatic language detection for syntax highlighting",
"autoplay_gifs": "Autoplay GIFs", "autoplay_gifs": "Autoplay GIFs",

View file

@ -355,7 +355,7 @@ export default class SettingsStore {
const setting = SETTINGS[settingName]; const setting = SETTINGS[settingName];
const levelOrder = getLevelOrder(setting); const levelOrder = getLevelOrder(setting);
return SettingsStore.getValueAt(levelOrder[0], settingName, roomId, false, excludeDefault); return SettingsStore.getValueAt<T>(levelOrder[0], settingName, roomId, false, excludeDefault);
} }
/** /**
@ -369,13 +369,13 @@ export default class SettingsStore {
* @param {boolean} excludeDefault True to disable using the default value. * @param {boolean} excludeDefault True to disable using the default value.
* @return {*} The value, or null if not found. * @return {*} The value, or null if not found.
*/ */
public static getValueAt( public static getValueAt<T = any>(
level: SettingLevel, level: SettingLevel,
settingName: string, settingName: string,
roomId: string | null = null, roomId: string | null = null,
explicit = false, explicit = false,
excludeDefault = false, excludeDefault = false,
): any { ): T {
// Verify that the setting is actually a setting // Verify that the setting is actually a setting
const setting = SETTINGS[settingName]; const setting = SETTINGS[settingName];
if (!setting) { if (!setting) {

View file

@ -103,7 +103,7 @@ export function enumerateThemes(): { [key: string]: string } {
return Object.assign({}, customThemeNames, BUILTIN_THEMES); return Object.assign({}, customThemeNames, BUILTIN_THEMES);
} }
interface ITheme { export interface ITheme {
id: string; id: string;
name: string; name: string;
} }

View file

@ -15,15 +15,177 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { render } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import { mocked, MockedObject } from "jest-mock";
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock-jest";
import * as TestUtils from "../../../test-utils"; import { ThemeChoicePanel } from "../../../../src/components/views/settings/ThemeChoicePanel";
import ThemeChoicePanel from "../../../../src/components/views/settings/ThemeChoicePanel"; import SettingsStore from "../../../../src/settings/SettingsStore";
import ThemeWatcher from "../../../../src/settings/watchers/ThemeWatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
jest.mock("../../../../src/settings/watchers/ThemeWatcher");
describe("<ThemeChoicePanel />", () => {
/**
* Enable or disable the system theme
* @param enable
*/
async function enableSystemTheme(enable: boolean) {
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, enable);
}
/**
* Set the theme
* @param theme
*/
async function setTheme(theme: string) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, theme);
}
beforeEach(async () => {
mocked(ThemeWatcher).mockImplementation(() => {
return {
isSystemThemeSupported: jest.fn().mockReturnValue(true),
} as unknown as MockedObject<ThemeWatcher>;
});
await enableSystemTheme(false);
await setTheme("light");
});
describe("ThemeChoicePanel", () => {
it("renders the theme choice UI", () => { it("renders the theme choice UI", () => {
TestUtils.stubClient();
const { asFragment } = render(<ThemeChoicePanel />); const { asFragment } = render(<ThemeChoicePanel />);
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
describe("theme selection", () => {
describe("system theme", () => {
it("should disable Match system theme", async () => {
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).not.toBeChecked();
});
it("should enable Match system theme", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
});
it("should change the system theme when clicked", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
act(() => screen.getByRole("checkbox", { name: "Match system theme" }).click());
// The system theme should be enabled
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
expect(SettingsStore.setValue).toHaveBeenCalledWith("use_system_theme", null, "device", true);
});
});
describe("theme selection", () => {
it("should disable theme selection when system theme is enabled", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
// We expect all the themes to be disabled
const themes = screen.getAllByRole("radio");
themes.forEach((theme) => {
expect(theme).toBeDisabled();
});
});
it("should enable theme selection when system theme is disabled", async () => {
render(<ThemeChoicePanel />);
// We expect all the themes to be disabled
const themes = screen.getAllByRole("radio");
themes.forEach((theme) => {
expect(theme).not.toBeDisabled();
});
});
it("should have light theme selected", async () => {
render(<ThemeChoicePanel />);
// We expect the light theme to be selected
const lightTheme = screen.getByRole("radio", { name: "Light" });
expect(lightTheme).toBeChecked();
// And the dark theme shouldn't be selected
const darkTheme = screen.getByRole("radio", { name: "Dark" });
expect(darkTheme).not.toBeChecked();
});
it("should switch to dark theme", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
const darkTheme = screen.getByRole("radio", { name: "Dark" });
const lightTheme = screen.getByRole("radio", { name: "Light" });
expect(darkTheme).not.toBeChecked();
// Switch to the dark theme
act(() => darkTheme.click());
expect(SettingsStore.setValue).toHaveBeenCalledWith("theme", null, "device", "dark");
// Dark theme is now selected
await waitFor(() => expect(darkTheme).toBeChecked());
// Light theme is not selected anymore
expect(lightTheme).not.toBeChecked();
// The setting should be updated
expect(SettingsStore.setValue).toHaveBeenCalledWith("theme", null, "device", "dark");
});
});
});
describe("custom theme", () => {
const aliceTheme = { name: "Alice theme", is_dark: true, colors: {} };
const bobTheme = { name: "Bob theme", is_dark: false, colors: {} };
beforeEach(async () => {
await SettingsStore.setValue("feature_custom_themes", null, SettingLevel.DEVICE, true);
await SettingsStore.setValue("custom_themes", null, SettingLevel.DEVICE, [aliceTheme]);
});
it("should render the custom theme section", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(asFragment()).toMatchSnapshot();
});
it("should add a custom theme", async () => {
jest.spyOn(SettingsStore, "setValue");
// Respond to the theme request
fetchMock.get("http://bob.theme", {
body: bobTheme,
});
render(<ThemeChoicePanel />);
// Add the new custom theme
const customThemeInput = screen.getByRole("textbox", { name: "Add custom theme" });
await userEvent.type(customThemeInput, "http://bob.theme");
screen.getByRole("button", { name: "Add custom theme" }).click();
// The new custom theme is added to the user's themes
await waitFor(() =>
expect(SettingsStore.setValue).toHaveBeenCalledWith("custom_themes", null, "account", [
aliceTheme,
bobTheme,
]),
);
});
it("should display custom theme", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(screen.getByRole("radio", { name: aliceTheme.name })).toBeInTheDocument();
expect(screen.getByRole("listitem", { name: aliceTheme.name })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
}); });

View file

@ -1,73 +1,774 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThemeChoicePanel renders the theme choice UI 1`] = ` exports[`<ThemeChoicePanel /> custom theme should display custom theme 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="mx_ThemeChoicePanel" data-testid="themePanel"
> >
<div <div
class="mx_SettingsSubsectionHeading" class="mx_SettingsSubsectionHeading"
> >
<h3 <h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading" class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
> >
Theme Theme
</h3> </h3>
</div> </div>
<div <div
class="mx_SettingsSubsection_content" class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_148br_24"
> >
<div <div
class="mx_ThemeChoicePanel_themeSelectors" class="_inline-field_148br_40"
data-testid="theme-choice-panel-selectors"
> >
<label <div
class="mx_StyledRadioButton mx_ThemeSelector_light mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined" class="_inline-field-control_148br_52"
>
<div
class="_container_qnvru_18"
> >
<input <input
disabled="" class="_input_qnvru_32"
id="theme-light" id="radix-42"
name="theme" name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67"
for="radix-42"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-43"
name="themeSelector"
title=""
type="radio" type="radio"
value="light" value="light"
/> />
<div> <div
<div /> class="_ui_1vw5h_27"
/>
</div>
</div> </div>
<div <div
class="mx_StyledRadioButton_content" class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-43"
> >
Light Light
</label>
</div>
</div> </div>
<div <div
class="mx_StyledRadioButton_spacer" class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
/> >
</label> <div
<label class="_inline-field-control_148br_52"
class="mx_StyledRadioButton mx_ThemeSelector_dark mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined" >
<div
class="_container_1vw5h_18"
> >
<input <input
disabled="" class="_input_1vw5h_26"
id="theme-dark" id="radix-44"
name="theme" name="themeSelector"
title=""
type="radio" type="radio"
value="dark" value="dark"
/> />
<div> <div
<div /> class="_ui_1vw5h_27"
/>
</div>
</div> </div>
<div <div
class="mx_StyledRadioButton_content" class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-44"
> >
Dark Dark
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label> </label>
</div> </div>
</div> </div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-45"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-45"
>
High contrast
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-46"
name="themeSelector"
title=""
type="radio"
value="custom-Alice theme"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-46"
>
Alice theme
</label>
</div>
</div>
</form>
<div
class="mx_ThemeChoicePanel_CustomTheme"
>
<form
class="_container_zfn7i_17 mx_ThemeChoicePanel_CustomTheme_EditInPlace"
id=":r7:"
>
<div
class="_label_zfn7i_21"
id=":r8:"
>
Add custom theme
</div>
<div
class="_controls_zfn7i_27"
>
<input
aria-invalid="false"
aria-labelledby=":r8:"
class="_control_9gon8_18 _control_zfn7i_27"
value=""
/>
<div
class="_button-group_zfn7i_32"
>
<button
aria-controls=":r7:"
aria-label="Add custom theme"
class="_button_zfn7i_32 _primary-button_zfn7i_51"
type="submit"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575c-.133 0-.258-.02-.375-.063a.878.878 0 0 1-.325-.212L4.55 13c-.183-.183-.27-.42-.263-.713.009-.291.105-.529.288-.712a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275L9.55 15.15l8.475-8.475c.183-.183.42-.275.712-.275s.53.092.713.275c.183.183.275.42.275.712s-.092.53-.275.713l-9.2 9.2c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063Z"
/>
</svg>
</button>
<button
aria-controls=":r7:"
class="_button_zfn7i_32"
role="button"
type="button"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</button>
</div>
</div>
<span
class="_caption-line_zfn7i_92 _caption-text_zfn7i_130 _caption-text-help_zfn7i_147"
>
Enter the URL of a custom theme you want to apply.
</span>
</form>
<ul
class="mx_ThemeChoicePanel_CustomThemeList"
>
<li
aria-label="Alice theme"
class="mx_ThemeChoicePanel_CustomThemeList_theme"
>
<span
class="mx_ThemeChoicePanel_CustomThemeList_name"
>
Alice theme
</span>
<button
aria-label="Delete"
class="_icon-button_rijzz_17 _destructive_rijzz_78"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</li>
</ul>
</div>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;
exports[`<ThemeChoicePanel /> custom theme should render the custom theme section 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_148br_24"
>
<div
class="_inline-field_148br_40"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-32"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67"
for="radix-32"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-33"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-33"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-34"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-34"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-35"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-35"
>
High contrast
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-36"
name="themeSelector"
title=""
type="radio"
value="custom-Alice theme"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-36"
>
Alice theme
</label>
</div>
</div>
</form>
<div
class="mx_ThemeChoicePanel_CustomTheme"
>
<form
class="_container_zfn7i_17 mx_ThemeChoicePanel_CustomTheme_EditInPlace"
id=":r1:"
>
<div
class="_label_zfn7i_21"
id=":r2:"
>
Add custom theme
</div>
<div
class="_controls_zfn7i_27"
>
<input
aria-invalid="false"
aria-labelledby=":r2:"
class="_control_9gon8_18 _control_zfn7i_27"
value=""
/>
<div
class="_button-group_zfn7i_32"
>
<button
aria-controls=":r1:"
aria-label="Add custom theme"
class="_button_zfn7i_32 _primary-button_zfn7i_51"
type="submit"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575c-.133 0-.258-.02-.375-.063a.878.878 0 0 1-.325-.212L4.55 13c-.183-.183-.27-.42-.263-.713.009-.291.105-.529.288-.712a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275L9.55 15.15l8.475-8.475c.183-.183.42-.275.712-.275s.53.092.713.275c.183.183.275.42.275.712s-.092.53-.275.713l-9.2 9.2c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063Z"
/>
</svg>
</button>
<button
aria-controls=":r1:"
class="_button_zfn7i_32"
role="button"
type="button"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</button>
</div>
</div>
<span
class="_caption-line_zfn7i_92 _caption-text_zfn7i_130 _caption-text-help_zfn7i_147"
>
Enter the URL of a custom theme you want to apply.
</span>
</form>
<ul
class="mx_ThemeChoicePanel_CustomThemeList"
>
<li
aria-label="Alice theme"
class="mx_ThemeChoicePanel_CustomThemeList_theme"
>
<span
class="mx_ThemeChoicePanel_CustomThemeList_name"
>
Alice theme
</span>
<button
aria-label="Delete"
class="_icon-button_rijzz_17 _destructive_rijzz_78"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</li>
</ul>
</div>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;
exports[`<ThemeChoicePanel /> renders the theme choice UI 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_148br_24"
>
<div
class="_inline-field_148br_40"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-0"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67"
for="radix-0"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-1"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-1"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-2"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-2"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-3"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-3"
>
High contrast
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div> </div>
</DocumentFragment> </DocumentFragment>
`; `;

View file

@ -64,8 +64,13 @@ describe("PreferencesUserSettingsTab", () => {
const mockGetValue = (val: boolean) => { const mockGetValue = (val: boolean) => {
const copyOfGetValueAt = SettingsStore.getValueAt; const copyOfGetValueAt = SettingsStore.getValueAt;
SettingsStore.getValueAt = (level: SettingLevel, name: string, roomId?: string, isExplicit?: boolean) => { SettingsStore.getValueAt = <T,>(
if (name === "sendReadReceipts") return val; level: SettingLevel,
name: string,
roomId?: string,
isExplicit?: boolean,
): T => {
if (name === "sendReadReceipts") return val as T;
return copyOfGetValueAt(level, name, roomId, isExplicit); return copyOfGetValueAt(level, name, roomId, isExplicit);
}; };
}; };

View file

@ -16,71 +16,134 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
class="mx_SettingsSection_subSections" class="mx_SettingsSection_subSections"
> >
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="mx_ThemeChoicePanel" data-testid="themePanel"
> >
<div <div
class="mx_SettingsSubsectionHeading" class="mx_SettingsSubsectionHeading"
> >
<h3 <h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading" class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
> >
Theme Theme
</h3> </h3>
</div> </div>
<div <div
class="mx_SettingsSubsection_content" class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
> >
<div <div
class="mx_ThemeChoicePanel_themeSelectors" class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-light"
data-testid="theme-choice-panel-selectors"
> >
<label <div
class="mx_StyledRadioButton mx_ThemeSelector_light mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined" class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
> >
<input <input
class="_input_1vw5h_26"
disabled="" disabled=""
id="theme-light" id="radix-0"
name="theme" name="themeSelector"
title=""
type="radio" type="radio"
value="light" value="light"
/> />
<div> <div
<div /> class="_ui_1vw5h_27"
/>
</div>
</div> </div>
<div <div
class="mx_StyledRadioButton_content" class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-0"
> >
Light Light
</label>
</div>
</div> </div>
<div <div
class="mx_StyledRadioButton_spacer" class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-dark"
/> >
</label> <div
<label class="_inline-field-control_148br_52"
class="mx_StyledRadioButton mx_ThemeSelector_dark mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined" >
<div
class="_container_1vw5h_18"
> >
<input <input
class="_input_1vw5h_26"
disabled="" disabled=""
id="theme-dark" id="radix-1"
name="theme" name="themeSelector"
title=""
type="radio" type="radio"
value="dark" value="dark"
/> />
<div> <div
<div /> class="_ui_1vw5h_27"
/>
</div>
</div> </div>
<div <div
class="mx_StyledRadioButton_content" class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-1"
> >
Dark Dark
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label> </label>
</div> </div>
</div> </div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-2"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-2"
>
High contrast
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div> </div>
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection"

View file

@ -21,17 +21,17 @@ import { mocked } from "jest-mock";
import QuickThemeSwitcher from "../../../../src/components/views/spaces/QuickThemeSwitcher"; import QuickThemeSwitcher from "../../../../src/components/views/spaces/QuickThemeSwitcher";
import { getOrderedThemes } from "../../../../src/theme"; import { getOrderedThemes } from "../../../../src/theme";
import ThemeChoicePanel from "../../../../src/components/views/settings/ThemeChoicePanel";
import SettingsStore from "../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../../src/settings/SettingLevel";
import dis from "../../../../src/dispatcher/dispatcher"; import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions"; import { Action } from "../../../../src/dispatcher/actions";
import { mockPlatformPeg } from "../../../test-utils/platform"; import { mockPlatformPeg } from "../../../test-utils/platform";
import { useTheme } from "../../../../src/hooks/useTheme";
jest.mock("../../../../src/theme"); jest.mock("../../../../src/hooks/useTheme", () => ({
jest.mock("../../../../src/components/views/settings/ThemeChoicePanel", () => ({ useTheme: jest.fn(),
calculateThemeState: jest.fn(),
})); }));
jest.mock("../../../../src/theme");
jest.mock("../../../../src/settings/SettingsStore", () => ({ jest.mock("../../../../src/settings/SettingsStore", () => ({
setValue: jest.fn(), setValue: jest.fn(),
getValue: jest.fn(), getValue: jest.fn(),
@ -59,9 +59,10 @@ describe("<QuickThemeSwitcher />", () => {
{ id: "light", name: "Light" }, { id: "light", name: "Light" },
{ id: "dark", name: "Dark" }, { id: "dark", name: "Dark" },
]); ]);
mocked(ThemeChoicePanel).calculateThemeState.mockClear().mockReturnValue({
mocked(useTheme).mockClear().mockReturnValue({
theme: "light", theme: "light",
useSystemTheme: false, systemThemeActivated: false,
}); });
mocked(SettingsStore).setValue.mockClear().mockResolvedValue(); mocked(SettingsStore).setValue.mockClear().mockResolvedValue();
mocked(dis).dispatch.mockClear(); mocked(dis).dispatch.mockClear();
@ -85,9 +86,9 @@ describe("<QuickThemeSwitcher />", () => {
}); });
it("renders dropdown correctly when use system theme is truthy", () => { it("renders dropdown correctly when use system theme is truthy", () => {
mocked(ThemeChoicePanel).calculateThemeState.mockClear().mockReturnValue({ mocked(useTheme).mockClear().mockReturnValue({
theme: "light", theme: "light",
useSystemTheme: true, systemThemeActivated: true,
}); });
renderComponent(); renderComponent();
expect(screen.getByText("Match system")).toBeInTheDocument(); expect(screen.getByText("Match system")).toBeInTheDocument();