diff --git a/playwright/e2e/audio-player/audio-player.spec.ts b/playwright/e2e/audio-player/audio-player.spec.ts index 4581801db5..e60d273299 100644 --- a/playwright/e2e/audio-player/audio-player.spec.ts +++ b/playwright/e2e/audio-player/audio-player.spec.ts @@ -160,7 +160,7 @@ test.describe("Audio player", () => { // Enable high contrast manually 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(); diff --git a/playwright/e2e/settings/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts similarity index 64% rename from playwright/e2e/settings/appearance-user-settings-tab.spec.ts rename to playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts index 7e16d73955..efbdb777fd 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { test, expect } from "../../element-web-test"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { expect, test } from "."; test.describe("Appearance user settings tab", () => { test.use({ @@ -151,69 +150,68 @@ test.describe("Appearance user settings tab", () => { }); 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 - 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 }) => { - 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(); + test("should be rendered with the light theme selected", async ({ page, app, util }) => { // 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(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible(); + await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked(); - 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 - await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible(); - // Assert that the buttons for the light and dark theme are not enabled - await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible(); - await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible(); + await expect(util.getLightTheme()).toBeChecked(); + // Assert that the dark and high contrast themes are not selected + await expect(util.getDarkTheme()).not.toBeChecked(); + await expect(util.getHighContrastTheme()).not.toBeChecked(); - // Assert that the checkbox for the high contrast theme is rendered - await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png"); }); - 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 ({ - page, - app, - }) => { - await app.settings.openUserSettings("Appearance"); - const themePanel = page.getByTestId("mx_ThemeChoicePanel"); + test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => { + await util.getMatchSystemThemeCheckbox().click(); - 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(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(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png"); }); - test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({ - page, - app, - }) => { - await app.settings.openUserSettings("Appearance"); - const themePanel = page.getByTestId("mx_ThemeChoicePanel"); + test("should change the theme to dark", async ({ page, app, util }) => { + // Assert that the light theme is selected + await expect(util.getLightTheme()).toBeChecked(); - // Assert that the checkbox and the label to enable the high contrast theme should exist - await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible(); + await util.getDarkTheme().click(); - // Enable the dark theme - await themePanel.locator(".mx_ThemeSelector_dark").click(); + // Assert that the light and high contrast themes are not selected + 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(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png"); + }); + + 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"); + }); }); }); }); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/index.ts b/playwright/e2e/settings/appearance-user-settings-tab/index.ts new file mode 100644 index 0000000000..6eabfe11ef --- /dev/null +++ b/playwright/e2e/settings/appearance-user-settings-tab/index.ts @@ -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(); + } +} diff --git a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png index 9f46fce516..bfbfccbaeb 100644 Binary files a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png and b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png new file mode 100644 index 0000000000..98ece4d4d9 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png new file mode 100644 index 0000000000..ffde3eb92e Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png new file mode 100644 index 0000000000..0a24053a1c Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png new file mode 100644 index 0000000000..3b9c243138 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png new file mode 100644 index 0000000000..ca90917116 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png new file mode 100644 index 0000000000..1aed777c8d Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png new file mode 100644 index 0000000000..a1ca72b398 Binary files /dev/null and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 32a2610c1f..54dedf9199 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -604,7 +604,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ), + ):not(.mx_ThemeChoicePanel_CustomTheme button), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -624,14 +624,14 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):last-child { + ):not(.mx_ThemeChoicePanel_CustomTheme button):last-child { margin-right: 0px; } .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):focus, + ):not(.mx_ThemeChoicePanel_CustomTheme button):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -643,7 +643,7 @@ legend { .mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ), + ):not(.mx_ThemeChoicePanel_CustomTheme button), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); 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 input[type="submit"].danger, .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 { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -670,7 +672,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):disabled, + ):not(.mx_ThemeChoicePanel_CustomTheme button):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss index 44d0a34426..f0e31285cb 100644 --- a/res/css/components/views/settings/shared/_SettingsSubsection.pcss +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -17,6 +17,12 @@ limitations under the License. .mx_SettingsSubsection { width: 100%; box-sizing: border-box; + + &.mx_SettingsSubsection_newUi { + display: flex; + flex-direction: column; + gap: var(--cpd-space-8x); + } } .mx_SettingsSubsection_description { @@ -54,4 +60,8 @@ limitations under the License. &.mx_SettingsSubsection_noHeading { margin-top: 0; } + &.mx_SettingsSubsection_content_newUi { + gap: var(--cpd-space-6x); + margin-top: 0; + } } diff --git a/res/css/views/settings/_ThemeChoicePanel.pcss b/res/css/views/settings/_ThemeChoicePanel.pcss index 8616668224..f70cdf92e3 100644 --- a/res/css/views/settings/_ThemeChoicePanel.pcss +++ b/res/css/views/settings/_ThemeChoicePanel.pcss @@ -14,48 +14,72 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ThemeChoicePanel_themeSelectors { - color: $primary-content; +.mx_ThemeChoicePanel_ThemeSelectors { display: flex; - flex-direction: row; flex-wrap: wrap; + /* Override form default style */ + flex-direction: row !important; + gap: var(--cpd-space-4x) !important; - > .mx_StyledRadioButton { - align-items: center; - padding: $font-16px; - box-sizing: border-box; - border-radius: 10px; - width: 180px; + .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); - background: $accent-200; - opacity: 0.4; - - flex-shrink: 1; - flex-grow: 0; - - 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_ThemeChoicePanel_themeSelector_enabled { + border-color: var(--cpd-color-border-interactive-primary); } - &.mx_ThemeSelector_dark { - /* 5% lightened version of 181b21 */ - background-color: #25282e; - color: #f3f8fd; + &.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; + background-color: var(--cpd-color-gray-200); + padding: var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-4x); + + .mx_ThemeChoicePanel_CustomThemeList_name { + font: var(--cpd-font-body-sm-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } } } diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 4ed8543ee2..653c6327fa 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -128,7 +128,8 @@ export default class BaseDialog extends React.Component { onClick={this.onCancelClick} className="mx_Dialog_cancelButton" aria-label={_t("dialog_close_label")} - title={_t("dialog_close_label")} + title={_t("action|close")} + placement="bottom" /> ); } diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index c7166fe829..e3df157d61 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -1,285 +1,340 @@ /* -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"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; +import 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 { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import { findHighContrastTheme, findNonHighContrastTheme, getOrderedThemes, isHighContrastTheme } from "../../../theme"; +import SettingsSubsection from "./shared/SettingsSubsection"; 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 { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload"; import { Action } from "../../../dispatcher/actions"; -import StyledCheckbox from "../elements/StyledCheckbox"; -import Field from "../elements/Field"; -import StyledRadioGroup from "../elements/StyledRadioGroup"; -import { SettingLevel } from "../../../settings/SettingLevel"; -import PosthogTrackers from "../../../PosthogTrackers"; -import SettingsSubsection from "./shared/SettingsSubsection"; +import { useTheme } from "../../../hooks/useTheme"; +import { findHighContrastTheme, getOrderedThemes, CustomTheme as CustomThemeType, ITheme } from "../../../theme"; +import { useSettingValue } from "../../../hooks/useSettings"; -interface IProps {} +/** + * Panel to choose the theme + */ +export function ThemeChoicePanel(): JSX.Element { + const themeState = useTheme(); + const themeWatcher = useRef(new ThemeWatcher()); + const customThemeEnabled = useSettingValue("feature_custom_themes"); -interface IThemeState { + return ( + + {themeWatcher.current.isSystemThemeSupported() && ( + + )} + + {customThemeEnabled && } + + ); +} + +/** + * Component to toggle the system theme + */ +interface SystemThemeProps { + /* Whether the system theme is activated */ + systemThemeActivated: boolean; +} + +/** + * Component to toggle the system theme + */ +function SystemTheme({ systemThemeActivated }: SystemThemeProps): JSX.Element { + return ( + { + const checked = new FormData(evt.currentTarget).get("systemTheme") === "on"; + await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked); + dis.dispatch({ action: Action.RecheckTheme }); + }} + > + } + > + + + + ); +} + +/** + * Component to select the theme + */ +interface ThemeSelectorProps { + /* The current theme */ theme: string; - useSystemTheme: boolean; + /* The theme can't be selected */ + disabled: boolean; } -export interface CustomThemeMessage { - isError: boolean; - text: string; -} +/** + * Component to select the theme + */ +function ThemeSelectors({ theme, disabled }: ThemeSelectorProps): JSX.Element { + const themes = useThemes(); -interface IState extends IThemeState { - customThemeUrl: string; - customThemeMessage: CustomThemeMessage; -} + return ( + { + // 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; -export default class ThemeChoicePanel extends React.Component { - private themeTimer?: number; + // Do nothing if the same theme is selected + if (!newTheme || theme === newTheme) return; - 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) { - return { - theme: themeChoice, - useSystemTheme: false, - }; - } - - // Otherwise assume the defaults for the settings - return { - theme: themeChoice, - useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"), - }; - } - - private onThemeChange = (newTheme: string): void => { - if (this.state.theme === newTheme) return; - - PosthogTrackers.trackInteraction("WebSettingsAppearanceTabThemeSelector"); - - // 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(() => { - dis.dispatch({ action: Action.RecheckTheme }); - this.setState({ theme: oldTheme }); - }); - this.setState({ theme: newTheme }); - // 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 - // do the dispatch now - // XXX: The local echoed value appears to be unreliable, in particular - // when settings custom themes(!) so adding forceTheme to override - // the value from settings. - dis.dispatch({ action: Action.RecheckTheme, forceTheme: newTheme }); - }; - - private onUseSystemThemeChanged = (checked: boolean): void => { - this.setState({ useSystemTheme: checked }); - SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked); - dis.dispatch({ action: Action.RecheckTheme }); - }; - - private onAddCustomTheme = async (): Promise => { - let currentThemes: string[] = SettingsStore.getValue("custom_themes"); - if (!currentThemes) currentThemes = []; - currentThemes = currentThemes.map((c) => c); // cheap clone - - if (this.themeTimer) { - clearTimeout(this.themeTimer); - } - - try { - const r = await fetch(this.state.customThemeUrl); - // XXX: need some schema for this - const themeInfo = await r.json(); - if (!themeInfo || typeof themeInfo["name"] !== "string" || typeof themeInfo["colors"] !== "object") { - this.setState({ - customThemeMessage: { text: _t("settings|appearance|custom_theme_invalid"), isError: true }, + // doing getValue in the .catch will still return the value we failed to set, + SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => { + dis.dispatch({ action: Action.RecheckTheme }); }); - return; - } - currentThemes.push(themeInfo); - } catch (e) { - logger.error(e); - this.setState({ - customThemeMessage: { text: _t("settings|appearance|custom_theme_error_downloading"), isError: true }, - }); - return; // Don't continue on error - } - await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes); - this.setState({ - customThemeUrl: "", - customThemeMessage: { text: _t("settings|appearance|custom_theme_success"), isError: false }, - }); - - this.themeTimer = window.setTimeout(() => { - this.setState({ customThemeMessage: { text: "", isError: false } }); - }, 3000); - }; - - private onCustomThemeChange = (e: React.ChangeEvent): void => { - this.setState({ customThemeUrl: e.target.value }); - }; - - private renderHighContrastCheckbox(): React.ReactElement | undefined { - if ( - !this.state.useSystemTheme && - (findHighContrastTheme(this.state.theme) || isHighContrastTheme(this.state.theme)) - ) { - return ( -
- this.highContrastThemeChanged(e.target.checked)} + // 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 + // do the dispatch now + // XXX: The local echoed value appears to be unreliable, in particular + // when settings custom themes(!) so adding forceTheme to override + // the value from settings. + dis.dispatch({ action: Action.RecheckTheme, forceTheme: newTheme }); + }} + > + {themes.map((_theme) => { + const isChecked = theme === _theme.id; + return ( + + } > - {_t("settings|appearance|use_high_contrast")} - -
- ); - } - } + + + ); + })} +
+ ); +} - 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 { - const themeWatcher = new ThemeWatcher(); - let systemThemeSection: JSX.Element | undefined; - if (themeWatcher.isSystemThemeSupported()) { - systemThemeSection = ( -
- this.onUseSystemThemeChanged(e.target.checked)} - > - {SettingsStore.getDisplayName("use_system_theme")} - -
- ); - } - - 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 =
{this.state.customThemeMessage.text}
; - } else { - messageElement =
{this.state.customThemeMessage.text}
; - } - } - customThemeForm = ( -
-
- - - {_t("settings|appearance|custom_theme_add_button")} - - {messageElement} - -
- ); - } - - const orderedThemes = getOrderedThemes(); - return ( - - {systemThemeSection} -
- ({ - value: t.id, - label: t.name, - disabled: this.state.useSystemTheme, - className: "mx_ThemeSelector_" + t.id, - }))} - onChange={this.onThemeChange} - value={this.apparentSelectedThemeId()} - outlined - /> -
- {this.renderHighContrastCheckbox()} - {customThemeForm} -
+/** + * Return all the available themes + */ +function useThemes(): Array { + const customThemes = useSettingValue("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(), ); - } - public apparentSelectedThemeId(): string | undefined { - if (this.state.useSystemTheme) { - return undefined; - } - const nonHighContrast = findNonHighContrastTheme(this.state.theme); - return nonHighContrast ? nonHighContrast : this.state.theme; + 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(""); + const [error, setError] = useState(); + const clear = useCallback(() => { + setError(undefined); + setCustomTheme(""); + }, [setError, setCustomTheme]); + + return ( +
+ ) => { + 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("custom_themes").map((t) => t) || []; + + try { + const r = await fetch(customTheme); + // XXX: need some schema for this + const themeInfo = await r.json(); + if ( + !themeInfo || + typeof themeInfo["name"] !== "string" || + typeof themeInfo["colors"] !== "object" + ) { + setError(_t("settings|appearance|custom_theme_invalid")); + 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); + } catch (e) { + logger.error(e); + setError(_t("settings|appearance|custom_theme_error_downloading")); + return; + } + + // Reset the error + clear(); + await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes); + }} + onCancel={clear} + /> + +
+ ); +} + +interface CustomThemeListProps { + /* + * The current theme + */ + theme: string; +} + +/** + * List of the custom themes + */ +function CustomThemeList({ theme: currentTheme }: CustomThemeListProps): JSX.Element { + const customThemes = useSettingValue("custom_themes") || []; + + return ( +
    + {customThemes.map((theme) => { + return ( +
  • + {theme.name} + { + // Get the custom themes and do a cheap clone + // To avoid to mutate the original array in the settings + const currentThemes = + SettingsStore.getValue("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({ + action: Action.RecheckTheme, + }); + } + }} + > + + +
  • + ); + })} +
+ ); +} diff --git a/src/components/views/settings/shared/SettingsSubsection.tsx b/src/components/views/settings/shared/SettingsSubsection.tsx index 035306f5f3..afcf92dea3 100644 --- a/src/components/views/settings/shared/SettingsSubsection.tsx +++ b/src/components/views/settings/shared/SettingsSubsection.tsx @@ -16,6 +16,7 @@ limitations under the License. import classNames from "classnames"; import React, { HTMLAttributes } from "react"; +import { Separator } from "@vector-im/compound-web"; import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading"; @@ -25,6 +26,11 @@ export interface SettingsSubsectionProps extends HTMLAttributes children?: React.ReactNode; // when true content will be justify-items: stretch, which will make items within the section stretch to full width. stretchContent?: boolean; + /* + * When true, the legacy UI style will be applied to the subsection. + * @default true + */ + legacy?: boolean; } export const SettingsSubsectionText: React.FC> = ({ children, ...rest }) => ( @@ -38,10 +44,16 @@ export const SettingsSubsection: React.FC = ({ description, children, stretchContent, + legacy = true, ...rest }) => ( -
- {typeof heading === "string" ? : <>{heading}} +
+ {typeof heading === "string" ? : <>{heading}} {!!description && (
{description} @@ -52,11 +64,13 @@ export const SettingsSubsection: React.FC = ({ className={classNames("mx_SettingsSubsection_content", { mx_SettingsSubsection_contentStretch: !!stretchContent, mx_SettingsSubsection_noHeading: !heading && !description, + mx_SettingsSubsection_content_newUi: !legacy, })} > {children}
)} + {!legacy && }
); diff --git a/src/components/views/settings/shared/SettingsSubsectionHeading.tsx b/src/components/views/settings/shared/SettingsSubsectionHeading.tsx index 262b9f4d37..936b11dc37 100644 --- a/src/components/views/settings/shared/SettingsSubsectionHeading.tsx +++ b/src/components/views/settings/shared/SettingsSubsectionHeading.tsx @@ -20,14 +20,24 @@ import Heading from "../../typography/Heading"; export interface SettingsSubsectionHeadingProps extends HTMLAttributes { heading: string; + legacy?: boolean; children?: React.ReactNode; } -export const SettingsSubsectionHeading: React.FC = ({ heading, children, ...rest }) => ( -
- - {heading} - - {children} -
-); +export const SettingsSubsectionHeading: React.FC = ({ + heading, + legacy = true, + children, + ...rest +}) => { + const size = legacy ? "4" : "3"; + + return ( +
+ + {heading} + + {children} +
+ ); +}; diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 99f5a51c3b..cc76a7b2c1 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -28,7 +28,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { Layout } from "../../../../../settings/enums/Layout"; import LayoutSwitcher from "../../LayoutSwitcher"; import FontScalingPanel from "../../FontScalingPanel"; -import ThemeChoicePanel from "../../ThemeChoicePanel"; +import { ThemeChoicePanel } from "../../ThemeChoicePanel"; import ImageSizePanel from "../../ImageSizePanel"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; diff --git a/src/components/views/spaces/QuickThemeSwitcher.tsx b/src/components/views/spaces/QuickThemeSwitcher.tsx index 66f3d2c040..1a6ee738ba 100644 --- a/src/components/views/spaces/QuickThemeSwitcher.tsx +++ b/src/components/views/spaces/QuickThemeSwitcher.tsx @@ -20,13 +20,13 @@ import { _t } from "../../../languageHandler"; import { Action } from "../../../dispatcher/actions"; import { findNonHighContrastTheme, getOrderedThemes } from "../../../theme"; import Dropdown from "../elements/Dropdown"; -import ThemeChoicePanel from "../settings/ThemeChoicePanel"; import SettingsStore from "../../../settings/SettingsStore"; import { SettingLevel } from "../../../settings/SettingLevel"; import dis from "../../../dispatcher/dispatcher"; import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload"; import PosthogTrackers from "../../../PosthogTrackers"; import { NonEmptyArray } from "../../../@types/common"; +import { useTheme } from "../../../hooks/useTheme"; type Props = { requestClose: () => void; @@ -37,10 +37,10 @@ const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID"; const QuickThemeSwitcher: React.FC = ({ requestClose }) => { const orderedThemes = useMemo(getOrderedThemes, []); - const themeState = ThemeChoicePanel.calculateThemeState(); + const themeState = useTheme(); const nonHighContrast = findNonHighContrastTheme(themeState.theme); const theme = nonHighContrast ? nonHighContrast : themeState.theme; - const { useSystemTheme } = themeState; + const { systemThemeActivated } = themeState; const themeOptions = [ { @@ -50,7 +50,7 @@ const QuickThemeSwitcher: React.FC = ({ requestClose }) => { ...orderedThemes, ]; - const selectedTheme = useSystemTheme ? MATCH_SYSTEM_THEME_ID : theme; + const selectedTheme = systemThemeActivated ? MATCH_SYSTEM_THEME_ID : theme; const onOptionChange = async (newTheme: string): Promise => { PosthogTrackers.trackInteraction("WebQuickSettingsThemeDropdown"); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index cabd4cbee9..23ce891b1e 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -17,6 +17,7 @@ limitations under the License. import { useEffect, useState } from "react"; import SettingsStore from "../settings/SettingsStore"; +import { SettingLevel } from "../settings/SettingLevel"; // Hook to fetch the value of a setting and dynamically update when it changes export const useSettingValue = (settingName: string, roomId: string | null = null, excludeDefault = false): T => { @@ -35,6 +36,39 @@ export const useSettingValue = (settingName: string, roomId: string | null = 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 = ( + level: SettingLevel, + settingName: string, + roomId: string | null = null, + explicit = false, + excludeDefault = false, +): T => { + const [value, setValue] = useState( + SettingsStore.getValueAt(level, settingName, roomId, explicit, excludeDefault), + ); + + useEffect(() => { + const ref = SettingsStore.watchSetting(settingName, roomId, () => { + setValue(SettingsStore.getValueAt(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 export const useFeatureEnabled = (featureName: string, roomId: string | null = null): boolean => { const [enabled, setEnabled] = useState(SettingsStore.getValue(featureName, roomId)); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000000..fcf77d4c67 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -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("theme"); + const systemThemeExplicit = useSettingValueAt(SettingLevel.DEVICE, "use_system_theme", null, false, true); + const themeExplicit = useSettingValueAt(SettingLevel.DEVICE, "theme", null, false, true); + const systemThemeActivated = useSettingValue("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, + }; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 06d0d1d449..de8e33927f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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_name": "System font name", "custom_font_size": "Use custom size", - "custom_theme_add_button": "Add theme", - "custom_theme_error_downloading": "Error downloading theme information.", + "custom_theme_add": "Add custom theme", + "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_success": "Theme added!", - "custom_theme_url": "Custom theme URL", "dialog_title": "Settings: Appearance", "font_size": "Font size", "font_size_default": "%(fontSize)s (default)", + "high_contrast": "High contrast", "image_size_default": "Default", "image_size_large": "Large", "layout_bubbles": "Message bubbles", "layout_irc": "IRC (Experimental)", "match_system_theme": "Match system theme", - "timeline_image_size": "Image size in the timeline", - "use_high_contrast": "Use high contrast" + "timeline_image_size": "Image size in the timeline" }, "automatic_language_detection_syntax_highlight": "Enable automatic language detection for syntax highlighting", "autoplay_gifs": "Autoplay GIFs", diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 2a52021618..276f00282b 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -355,7 +355,7 @@ export default class SettingsStore { const setting = SETTINGS[settingName]; const levelOrder = getLevelOrder(setting); - return SettingsStore.getValueAt(levelOrder[0], settingName, roomId, false, excludeDefault); + return SettingsStore.getValueAt(levelOrder[0], settingName, roomId, false, excludeDefault); } /** @@ -369,13 +369,13 @@ export default class SettingsStore { * @param {boolean} excludeDefault True to disable using the default value. * @return {*} The value, or null if not found. */ - public static getValueAt( + public static getValueAt( level: SettingLevel, settingName: string, roomId: string | null = null, explicit = false, excludeDefault = false, - ): any { + ): T { // Verify that the setting is actually a setting const setting = SETTINGS[settingName]; if (!setting) { diff --git a/src/theme.ts b/src/theme.ts index 8e2e893334..3245d72b76 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -103,7 +103,7 @@ export function enumerateThemes(): { [key: string]: string } { return Object.assign({}, customThemeNames, BUILTIN_THEMES); } -interface ITheme { +export interface ITheme { id: string; name: string; } diff --git a/test/components/views/settings/ThemeChoicePanel-test.tsx b/test/components/views/settings/ThemeChoicePanel-test.tsx index 03bbdbafdb..6c5482a553 100644 --- a/test/components/views/settings/ThemeChoicePanel-test.tsx +++ b/test/components/views/settings/ThemeChoicePanel-test.tsx @@ -15,15 +15,177 @@ limitations under the License. */ 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("", () => { + /** + * 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; + }); + + await enableSystemTheme(false); + await setTheme("light"); + }); -describe("ThemeChoicePanel", () => { it("renders the theme choice UI", () => { - TestUtils.stubClient(); const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); + + describe("theme selection", () => { + describe("system theme", () => { + it("should disable Match system theme", async () => { + render(); + expect(screen.getByRole("checkbox", { name: "Match system theme" })).not.toBeChecked(); + }); + + it("should enable Match system theme", async () => { + await enableSystemTheme(true); + + render(); + expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked(); + }); + + it("should change the system theme when clicked", async () => { + jest.spyOn(SettingsStore, "setValue"); + + render(); + 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(); + + // 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(); + + // 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(); + + // 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(); + + 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(); + 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(); + + // 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(); + + expect(screen.getByRole("radio", { name: aliceTheme.name })).toBeInTheDocument(); + expect(screen.getByRole("listitem", { name: aliceTheme.name })).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + }); }); diff --git a/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap b/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap index dad019c6c5..ecbd1e38a4 100644 --- a/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap @@ -1,73 +1,774 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ThemeChoicePanel renders the theme choice UI 1`] = ` +exports[` custom theme should display custom theme 1`] = `

Theme

-
-