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
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
139
playwright/e2e/settings/appearance-user-settings-tab/index.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 9 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 64 KiB |
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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();
|
||||||
|
|