From 4b903b9fbdd32f53402f1e9c18337bab1785d4ec Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 20 Oct 2021 15:58:27 +0100 Subject: [PATCH] Break ThemeChoicePanel into a separate component (#6998) * Break ThemeChoicePanel into a separate component * Tests for ThemeChoicePanel * i18n fixes * Fix copyright for ThemeChoicePanel --- res/css/_components.scss | 1 + res/css/views/settings/_ThemeChoicePanel.scss | 88 +++++++ .../tabs/user/_AppearanceUserSettingsTab.scss | 72 ------ .../views/settings/ThemeChoicePanel.tsx | 240 ++++++++++++++++++ .../tabs/user/AppearanceUserSettingsTab.tsx | 154 +---------- src/i18n/strings/en_EN.json | 12 +- .../views/settings/ThemeChoicePanel-test.tsx | 60 +++++ .../ThemeChoicePanel-test.tsx.snap | 111 ++++++++ 8 files changed, 508 insertions(+), 230 deletions(-) create mode 100644 res/css/views/settings/_ThemeChoicePanel.scss create mode 100644 src/components/views/settings/ThemeChoicePanel.tsx create mode 100644 test/components/views/settings/ThemeChoicePanel-test.tsx create mode 100644 test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap diff --git a/res/css/_components.scss b/res/css/_components.scss index 8ea46c4243..26e36b8cdd 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -259,6 +259,7 @@ @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; @import "./views/settings/_SpellCheckLanguages.scss"; +@import "./views/settings/_ThemeChoicePanel.scss"; @import "./views/settings/_UpdateCheckButton.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; diff --git a/res/css/views/settings/_ThemeChoicePanel.scss b/res/css/views/settings/_ThemeChoicePanel.scss new file mode 100644 index 0000000000..39b73e7837 --- /dev/null +++ b/res/css/views/settings/_ThemeChoicePanel.scss @@ -0,0 +1,88 @@ +/* +Copyright 2021 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. +*/ + +.mx_ThemeChoicePanel { + $radio-bg-color: $input-darker-bg-color; + color: $primary-content; + + > .mx_ThemeSelectors { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + margin-top: 4px; + margin-bottom: 30px; + + > .mx_RadioButton { + padding: $font-16px; + box-sizing: border-box; + border-radius: 10px; + width: 180px; + + background: $radio-bg-color; + opacity: 0.4; + + flex-shrink: 1; + flex-grow: 0; + + margin-right: 15px; + margin-top: 10px; + + font-weight: 600; + color: $muted-fg-color; + + > span { + justify-content: center; + } + } + + > .mx_RadioButton_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; + + > input > div { + border-color: $input-darker-bg-color; + > div { + border-color: $input-darker-bg-color; + } + } + } + + &.mx_ThemeSelector_black { + background-color: #000000; + color: #f3f8fd; + + > input > div { + border-color: $input-darker-bg-color; + > div { + border-color: $input-darker-bg-color; + } + } + } + } + } +} + diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index 21082786e9..c237856e60 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -24,78 +24,6 @@ limitations under the License. } } -.mx_AppearanceUserSettingsTab_themeSection { - $radio-bg-color: $input-darker-bg-color; - color: $primary-content; - - > .mx_ThemeSelectors { - display: flex; - flex-direction: row; - flex-wrap: wrap; - - margin-top: 4px; - margin-bottom: 30px; - - > .mx_RadioButton { - padding: $font-16px; - box-sizing: border-box; - border-radius: 10px; - width: 180px; - - background: $radio-bg-color; - opacity: 0.4; - - flex-shrink: 1; - flex-grow: 0; - - margin-right: 15px; - margin-top: 10px; - - font-weight: 600; - color: $muted-fg-color; - - > span { - justify-content: center; - } - } - - > .mx_RadioButton_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; - - > input > div { - border-color: $input-darker-bg-color; - > div { - border-color: $input-darker-bg-color; - } - } - } - - &.mx_ThemeSelector_black { - background-color: #000000; - color: #f3f8fd; - - > input > div { - border-color: $input-darker-bg-color; - > div { - border-color: $input-darker-bg-color; - } - } - } - } - } -} - .mx_AppearanceUserSettingsTab_Advanced { color: $primary-content; diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx new file mode 100644 index 0000000000..caa07bd0ad --- /dev/null +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -0,0 +1,240 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { _t } from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; +import { enumerateThemes } from "../../../theme"; +import ThemeWatcher from "../../../settings/watchers/ThemeWatcher"; +import AccessibleButton from "../elements/AccessibleButton"; +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 { replaceableComponent } from "../../../utils/replaceableComponent"; +import { compare } from "../../../utils/strings"; + +import { logger } from "matrix-js-sdk/src/logger"; + +interface IProps { +} + +interface IThemeState { + theme: string; + useSystemTheme: boolean; +} + +export interface CustomThemeMessage { + isError: boolean; + text: string; +} + +interface IState extends IThemeState { + customThemeUrl: string; + customThemeMessage: CustomThemeMessage; +} + +@replaceableComponent("views.settings.tabs.user.ThemeChoicePanel") +export default class ThemeChoicePanel extends React.Component { + private themeTimer: number; + + constructor(props: IProps) { + super(props); + + this.state = { + ...this.calculateThemeState(), + customThemeUrl: "", + customThemeMessage: { isError: false, text: "" }, + }; + } + + private 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; + + // 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("Invalid theme schema."), isError: true } }); + return; + } + currentThemes.push(themeInfo); + } catch (e) { + logger.error(e); + this.setState({ customThemeMessage: { text: _t("Error downloading theme information."), isError: true } }); + return; // Don't continue on error + } + + await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes); + this.setState({ customThemeUrl: "", customThemeMessage: { text: _t("Theme added!"), isError: false } }); + + this.themeTimer = setTimeout(() => { + this.setState({ customThemeMessage: { text: "", isError: false } }); + }, 3000); + }; + + private onCustomThemeChange = (e: React.ChangeEvent): void => { + this.setState({ customThemeUrl: e.target.value }); + }; + + public render() { + const themeWatcher = new ThemeWatcher(); + let systemThemeSection: JSX.Element; + if (themeWatcher.isSystemThemeSupported()) { + systemThemeSection =
+ this.onUseSystemThemeChanged(e.target.checked)} + > + { SettingsStore.getDisplayName("use_system_theme") } + +
; + } + + let customThemeForm: JSX.Element; + if (SettingsStore.getValue("feature_custom_themes")) { + let messageElement = null; + if (this.state.customThemeMessage.text) { + if (this.state.customThemeMessage.isError) { + messageElement =
{ this.state.customThemeMessage.text }
; + } else { + messageElement =
{ this.state.customThemeMessage.text }
; + } + } + customThemeForm = ( +
+
+ + + { _t("Add theme") } + + { messageElement } + +
+ ); + } + + // XXX: replace any type here + const themes = Object.entries(enumerateThemes()) + .map(p => ({ id: p[0], name: p[1] })); // convert pairs to objects for code readability + const builtInThemes = themes.filter(p => !p.id.startsWith("custom-")); + const customThemes = themes.filter(p => !builtInThemes.includes(p)) + .sort((a, b) => compare(a.name, b.name)); + const orderedThemes = [...builtInThemes, ...customThemes]; + return ( +
+ { _t("Theme") } + { systemThemeSection } +
+ ({ + value: t.id, + label: t.name, + disabled: this.state.useSystemTheme, + className: "mx_ThemeSelector_" + t.id, + }))} + onChange={this.onThemeChange} + value={this.state.useSystemTheme ? undefined : this.state.theme} + outlined + /> +
+ { customThemeForm } +
+ ); + } +} diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 3abb90d2a9..404fa9504f 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -20,25 +20,17 @@ import { _t } from "../../../../../languageHandler"; import SdkConfig from "../../../../../SdkConfig"; import { MatrixClientPeg } from '../../../../../MatrixClientPeg'; import SettingsStore from "../../../../../settings/SettingsStore"; -import { enumerateThemes } from "../../../../../theme"; -import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher"; -import AccessibleButton from "../../../elements/AccessibleButton"; -import dis from "../../../../../dispatcher/dispatcher"; -import { RecheckThemePayload } from '../../../../../dispatcher/payloads/RecheckThemePayload'; -import { Action } from '../../../../../dispatcher/actions'; import StyledCheckbox from '../../../elements/StyledCheckbox'; import SettingsFlag from '../../../elements/SettingsFlag'; import Field from '../../../elements/Field'; -import StyledRadioGroup from "../../../elements/StyledRadioGroup"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { UIFeature } from "../../../../../settings/UIFeature"; import { Layout } from "../../../../../settings/Layout"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; -import { compare } from "../../../../../utils/strings"; import LayoutSwitcher from "../../LayoutSwitcher"; -import { logger } from "matrix-js-sdk/src/logger"; import FontScalingPanel from '../../FontScalingPanel'; +import ThemeChoicePanel from '../../ThemeChoicePanel'; interface IProps { } @@ -70,7 +62,6 @@ interface IState extends IThemeState { export default class AppearanceUserSettingsTab extends React.Component { private readonly MESSAGE_PREVIEW_TEXT = _t("Hey you. You're the best!"); - private themeTimer: number; private unmounted = false; constructor(props: IProps) { @@ -141,68 +132,6 @@ export default class AppearanceUserSettingsTab extends React.Component { - if (this.state.theme === newTheme) return; - - // 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("Invalid theme schema."), isError: true } }); - return; - } - currentThemes.push(themeInfo); - } catch (e) { - logger.error(e); - this.setState({ customThemeMessage: { text: _t("Error downloading theme information."), isError: true } }); - return; // Don't continue on error - } - - await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes); - this.setState({ customThemeUrl: "", customThemeMessage: { text: _t("Theme added!"), isError: false } }); - - this.themeTimer = setTimeout(() => { - this.setState({ customThemeMessage: { text: "", isError: false } }); - }, 3000); - }; - - private onCustomThemeChange = (e: React.ChangeEvent): void => { - this.setState({ customThemeUrl: e.target.value }); - }; - private onLayoutChanged = (layout: Layout): void => { this.setState({ layout: layout }); }; @@ -217,85 +146,6 @@ export default class AppearanceUserSettingsTab extends React.Component - this.onUseSystemThemeChanged(e.target.checked)} - > - { SettingsStore.getDisplayName("use_system_theme") } - - ; - } - - let customThemeForm: JSX.Element; - if (SettingsStore.getValue("feature_custom_themes")) { - let messageElement = null; - if (this.state.customThemeMessage.text) { - if (this.state.customThemeMessage.isError) { - messageElement =
{ this.state.customThemeMessage.text }
; - } else { - messageElement =
{ this.state.customThemeMessage.text }
; - } - } - customThemeForm = ( -
-
- - - { _t("Add theme") } - - { messageElement } - -
- ); - } - - // XXX: replace any type here - const themes = Object.entries(enumerateThemes()) - .map(p => ({ id: p[0], name: p[1] })); // convert pairs to objects for code readability - const builtInThemes = themes.filter(p => !p.id.startsWith("custom-")); - const customThemes = themes.filter(p => !builtInThemes.includes(p)) - .sort((a, b) => compare(a.name, b.name)); - const orderedThemes = [...builtInThemes, ...customThemes]; - return ( -
- { _t("Theme") } - { systemThemeSection } -
- ({ - value: t.id, - label: t.name, - disabled: this.state.useSystemTheme, - className: "mx_ThemeSelector_" + t.id, - }))} - onChange={this.onThemeChange} - value={this.state.useSystemTheme ? undefined : this.state.theme} - outlined - /> -
- { customThemeForm } -
- ); - } - private renderAdvancedSection() { if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null; @@ -382,7 +232,7 @@ export default class AppearanceUserSettingsTab extends React.Component { _t("Appearance Settings only affect this %(brand)s session.", { brand }) } - { this.renderThemeSection() } + { layoutSection } { this.renderAdvancedSection() } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b8252dde83..68f4fca183 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1288,18 +1288,18 @@ "Manage integrations": "Manage integrations", "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.", "Add": "Add", - "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", - "Checking for an update...": "Checking for an update...", - "No update available.": "No update available.", - "Downloading update...": "Downloading update...", - "New version available. Update now.": "New version available. Update now.", - "Check for update": "Check for update", "Invalid theme schema.": "Invalid theme schema.", "Error downloading theme information.": "Error downloading theme information.", "Theme added!": "Theme added!", "Custom theme URL": "Custom theme URL", "Add theme": "Add theme", "Theme": "Theme", + "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", + "Checking for an update...": "Checking for an update...", + "No update available.": "No update available.", + "Downloading update...": "Downloading update...", + "New version available. Update now.": "New version available. Update now.", + "Check for update": "Check for update", "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.", "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout", "Customise your appearance": "Customise your appearance", diff --git a/test/components/views/settings/ThemeChoicePanel-test.tsx b/test/components/views/settings/ThemeChoicePanel-test.tsx new file mode 100644 index 0000000000..db342f8df8 --- /dev/null +++ b/test/components/views/settings/ThemeChoicePanel-test.tsx @@ -0,0 +1,60 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { mount } from "enzyme"; + +import '../../../skinned-sdk'; +import * as TestUtils from "../../../test-utils"; +import _ThemeChoicePanel from '../../../../src/components/views/settings/ThemeChoicePanel'; + +const ThemeChoicePanel = TestUtils.wrapInMatrixClientContext(_ThemeChoicePanel); + +// Avoid errors about global.matchMedia. See: +// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +// Fake random strings to give a predictable snapshot +jest.mock( + 'matrix-js-sdk/src/randomstring', + () => { + return { + randomString: () => "abdefghi", + }; + }, +); + +describe('ThemeChoicePanel', () => { + it('renders the theme choice UI', () => { + TestUtils.stubClient(); + const wrapper = mount( + , + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap b/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap new file mode 100644 index 0000000000..a760e9d466 --- /dev/null +++ b/test/components/views/settings/__snapshots__/ThemeChoicePanel-test.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ThemeChoicePanel renders the theme choice UI 1`] = ` + + +
+ + Theme + +
+ + +