diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index eb60506589c..34a79efded7 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -891,7 +891,6 @@ export default class MatrixChat extends React.PureComponent { } this.setStateForNewView(newState); - ThemeController.isLogin = true; this.themeWatcher.recheck(); this.notifyNewScreen('register'); } @@ -1020,7 +1019,6 @@ export default class MatrixChat extends React.PureComponent { view: Views.WELCOME, }); this.notifyNewScreen('welcome'); - ThemeController.isLogin = true; this.themeWatcher.recheck(); } @@ -1030,7 +1028,6 @@ export default class MatrixChat extends React.PureComponent { ...otherState, }); this.notifyNewScreen('login'); - ThemeController.isLogin = true; this.themeWatcher.recheck(); } @@ -1043,7 +1040,6 @@ export default class MatrixChat extends React.PureComponent { }); this.setPage(PageType.HomePage); this.notifyNewScreen('home'); - ThemeController.isLogin = false; this.themeWatcher.recheck(); } @@ -1301,7 +1297,6 @@ export default class MatrixChat extends React.PureComponent { * Called when a new logged in session has started */ private async onLoggedIn() { - ThemeController.isLogin = false; this.themeWatcher.recheck(); this.setStateForNewView({ view: Views.LOGGED_IN }); // If a specific screen is set to be shown after login, show that above diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 4a533f1f8e7..314c4912f7e 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -32,7 +32,8 @@ import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; -import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; +import { Theme } from '../../Theme'; +import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; @@ -87,8 +88,8 @@ export default class UserMenu extends React.Component { this.state = { contextMenuPosition: null, - isDarkTheme: this.isUserOnDarkTheme(), - isHighContrast: this.isUserOnHighContrastTheme(), + isDarkTheme: ThemeWatcher.isDarkTheme(), + isHighContrast: ThemeWatcher.isHighContrast(), pendingRoomJoin: new Set(), }; @@ -132,30 +133,6 @@ export default class UserMenu extends React.Component { this.forceUpdate(); // we don't have anything useful in state to update }; - private isUserOnDarkTheme(): boolean { - if (SettingsStore.getValue("use_system_theme")) { - return window.matchMedia("(prefers-color-scheme: dark)").matches; - } else { - const theme = SettingsStore.getValue("theme"); - if (theme.startsWith("custom-")) { - return getCustomTheme(theme.substring("custom-".length)).is_dark; - } - return theme === "dark"; - } - } - - private isUserOnHighContrastTheme(): boolean { - if (SettingsStore.getValue("use_system_theme")) { - return window.matchMedia("(prefers-contrast: more)").matches; - } else { - const theme = SettingsStore.getValue("theme"); - if (theme.startsWith("custom-")) { - return false; - } - return isHighContrastTheme(theme); - } - } - private onProfileUpdate = async () => { // the store triggered an update, so force a layout update. We don't // have any state to store here for that to magically happen. @@ -169,8 +146,8 @@ export default class UserMenu extends React.Component { private onThemeChanged = () => { this.setState( { - isDarkTheme: this.isUserOnDarkTheme(), - isHighContrast: this.isUserOnHighContrastTheme(), + isDarkTheme: ThemeWatcher.isDarkTheme(), + isHighContrast: ThemeWatcher.isHighContrast(), }); }; @@ -239,9 +216,9 @@ export default class UserMenu extends React.Component { // Disable system theme matching if the user hits this button SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); - let newTheme = this.state.isDarkTheme ? "light" : "dark"; + let newTheme = this.state.isDarkTheme ? SettingsStore.getValue("light_theme") : SettingsStore.getValue("dark_theme"); if (this.state.isHighContrast) { - const hcTheme = findHighContrastTheme(newTheme); + const hcTheme = new Theme(newTheme).highContrast; if (hcTheme) { newTheme = hcTheme; } diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index feb9552230a..caf4edc1217 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -import { enumerateThemes, findHighContrastTheme, findNonHighContrastTheme, isHighContrastTheme } from "../../../theme"; +import { Theme } from "../../../theme"; import ThemeWatcher from "../../../settings/watchers/ThemeWatcher"; import AccessibleButton from "../elements/AccessibleButton"; import dis from "../../../dispatcher/dispatcher"; @@ -162,13 +162,13 @@ export default class ThemeChoicePanel extends React.Component { private renderHighContrastCheckbox(): React.ReactElement { if ( !this.state.useSystemTheme && ( - findHighContrastTheme(this.state.theme) || - isHighContrastTheme(this.state.theme) + // Theme.findHighContrast(this.state.theme) || + ThemeWatcher.isHighContrast() ) ) { return
this.highContrastThemeChanged(e.target.checked)} > { _t( "Use high contrast" ) } @@ -178,11 +178,12 @@ export default class ThemeChoicePanel extends React.Component { } private highContrastThemeChanged(checked: boolean): void { + const theme = new Theme(this.state.theme) let newTheme: string; if (checked) { - newTheme = findHighContrastTheme(this.state.theme); + newTheme = theme.highContrast; } else { - newTheme = findNonHighContrastTheme(this.state.theme); + newTheme = theme.nonHighContrast; } if (newTheme) { this.onThemeChange(newTheme); @@ -190,9 +191,8 @@ export default class ThemeChoicePanel extends React.Component { } public render(): React.ReactElement { - const themeWatcher = new ThemeWatcher(); let systemThemeSection: JSX.Element; - if (themeWatcher.isSystemThemeSupported()) { + if (ThemeWatcher.supportsSystemTheme()) { systemThemeSection =
{ // 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 - .filter(p => !isHighContrastTheme(p.id)); + .filter(p => !ThemeWatcher.isHighContrast()); 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)); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index e8a63efb034..ad7073b9205 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -459,6 +459,14 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: "light", controller: new ThemeController(), }, + "dark_theme": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + default: "dark", // Must be "dark" for compatibility with, e.g., Jitsi + }, + "light_theme": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + default: "light", // Must be "light" for compatibility with, e.g., Jitsi + }, "custom_themes": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: [], diff --git a/src/settings/controllers/ThemeController.ts b/src/settings/controllers/ThemeController.ts index f564f56f54e..a0ec5d3644e 100644 --- a/src/settings/controllers/ThemeController.ts +++ b/src/settings/controllers/ThemeController.ts @@ -15,12 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { CustomTheme } from "../../Theme" import SettingController from "./SettingController"; -import { DEFAULT_THEME, enumerateThemes } from "../../theme"; import { SettingLevel } from "../SettingLevel"; +import ThemeWatcher from "../watchers/ThemeWatcher" export default class ThemeController extends SettingController { - public static isLogin = false; + + public constructor() { + super() + } public getValueOverride( level: SettingLevel, @@ -30,14 +34,112 @@ export default class ThemeController extends SettingController { ): any { if (!calculatedValue) return null; // Don't override null themes - if (ThemeController.isLogin) return 'light'; + const themes = ThemeWatcher.availableThemes; - const themes = enumerateThemes(); // Override in case some no longer supported theme is stored here if (!themes[calculatedValue]) { - return DEFAULT_THEME; + return ThemeWatcher.currentTheme(); } return null; // no override } + + /** + * Called whenever someone changes the theme + * Async function that returns once the theme has been set + * (ie. the CSS has been loaded) + * + * @param {string} theme new theme + */ + public static async setTheme(theme?: string): Promise { + if (!theme) { + theme = ThemeWatcher.currentTheme(); + } + ThemeController.clearCustomTheme(); + let stylesheetName = theme; + if (theme.startsWith("custom-")) { + stylesheetName = new CustomTheme(theme.substr(7)).is_dark ? "dark-custom" : "light-custom"; + } + + // look for the stylesheet elements. + // styleElements is a map from style name to HTMLLinkElement. + const styleElements = Object.create(null); + const themes = Array.from(document.querySelectorAll('[data-mx-theme]')); + themes.forEach(theme => { + styleElements[theme.attributes['data-mx-theme'].value.toLowerCase()] = theme; + }); + + if (!(stylesheetName in styleElements)) { + throw new Error("Unknown theme " + stylesheetName); + } + + // disable all of them first, then enable the one we want. Chrome only + // bothers to do an update on a true->false transition, so this ensures + // that we get exactly one update, at the right time. + // + // ^ This comment was true when we used to use alternative stylesheets + // for the CSS. Nowadays we just set them all as disabled in index.html + // and enable them as needed. It might be cleaner to disable them all + // at the same time to prevent loading two themes simultaneously and + // having them interact badly... but this causes a flash of unstyled app + // which is even uglier. So we don't. + + styleElements[stylesheetName].disabled = false; + + return new Promise((resolve) => { + const switchTheme = function() { + // we re-enable our theme here just in case we raced with another + // theme set request as per https://github.com/vector-im/element-web/issues/5601. + // We could alternatively lock or similar to stop the race, but + // this is probably good enough for now. + styleElements[stylesheetName].disabled = false; + Object.values(styleElements).forEach((a: HTMLStyleElement) => { + if (a == styleElements[stylesheetName]) return; + a.disabled = true; + }); + const bodyStyles = global.getComputedStyle(document.body); + if (bodyStyles.backgroundColor) { + const metaElement: HTMLMetaElement = document.querySelector('meta[name="theme-color"]'); + metaElement.content = bodyStyles.backgroundColor; + } + resolve(); + }; + + // turns out that Firefox preloads the CSS for link elements with + // the disabled attribute, but Chrome doesn't. + + let cssLoaded = false; + + styleElements[stylesheetName].onload = () => { + switchTheme(); + }; + + for (let i = 0; i < document.styleSheets.length; i++) { + const ss = document.styleSheets[i]; + if (ss && ss.href === styleElements[stylesheetName].href) { + cssLoaded = true; + break; + } + } + + if (cssLoaded) { + styleElements[stylesheetName].onload = undefined; + switchTheme(); + } + }); + } + + private static clearCustomTheme(): void { + // remove all css variables, we assume these are there because of the custom theme + const inlineStyleProps = Object.values(document.body.style); + for (const prop of inlineStyleProps) { + if (prop.startsWith("--")) { + document.body.style.removeProperty(prop); + } + } + const customFontFaceStyle = document.querySelector("head > style[title='custom-theme-font-faces']"); + if (customFontFaceStyle) { + customFontFaceStyle.remove(); + } + } } diff --git a/src/settings/watchers/ThemeWatcher.ts b/src/settings/watchers/ThemeWatcher.ts index d8f25b50756..09617ca657f 100644 --- a/src/settings/watchers/ThemeWatcher.ts +++ b/src/settings/watchers/ThemeWatcher.ts @@ -15,17 +15,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsStore from '../SettingsStore'; -import dis from '../../dispatcher/dispatcher'; import { Action } from '../../dispatcher/actions'; -import ThemeController from "../controllers/ThemeController"; -import { findHighContrastTheme, setTheme } from "../../theme"; import { ActionPayload } from '../../dispatcher/payloads'; -import { SettingLevel } from "../SettingLevel"; +import dis from '../../dispatcher/dispatcher'; +import { CustomTheme } from "../../Theme"; +import { SETTINGS } from '../Settings'; +import SettingsStore from '../SettingsStore'; +import { SettingLevel } from '../SettingLevel'; +import { _t } from '../../languageHandler'; +import { Theme } from '../../Theme'; +import ThemeController from '../controllers/ThemeController'; -import { logger } from "matrix-js-sdk/src/logger"; +import { logger } from 'matrix-js-sdk/src/logger'; + +interface IThemeWatcherOpts { + sanitized?: boolean + newInstance?: boolean +} export default class ThemeWatcher { + + private static instance_: ThemeWatcher; + private themeWatchRef: string; private systemThemeWatchRef: string; private dispatcherRef: string; @@ -34,85 +45,36 @@ export default class ThemeWatcher { private preferLight: MediaQueryList; private preferHighContrast: MediaQueryList; - private currentTheme: string; - - constructor() { - this.themeWatchRef = null; - this.systemThemeWatchRef = null; - this.dispatcherRef = null; - - // we have both here as each may either match or not match, so by having both - // we can get the tristate of dark/light/unsupported - this.preferDark = (global).matchMedia("(prefers-color-scheme: dark)"); - this.preferLight = (global).matchMedia("(prefers-color-scheme: light)"); - this.preferHighContrast = (global).matchMedia("(prefers-contrast: more)"); - - this.currentTheme = this.getEffectiveTheme(); - } + private initialTheme: string; - public start() { - this.themeWatchRef = SettingsStore.watchSetting("theme", null, this.onChange); - this.systemThemeWatchRef = SettingsStore.watchSetting("use_system_theme", null, this.onChange); - if (this.preferDark.addEventListener) { - this.preferDark.addEventListener('change', this.onChange); - this.preferLight.addEventListener('change', this.onChange); - this.preferHighContrast.addEventListener('change', this.onChange); - } - this.dispatcherRef = dis.register(this.onAction); - } - - public stop() { - if (this.preferDark.addEventListener) { - this.preferDark.removeEventListener('change', this.onChange); - this.preferLight.removeEventListener('change', this.onChange); - this.preferHighContrast.removeEventListener('change', this.onChange); - } - SettingsStore.unwatchSetting(this.systemThemeWatchRef); - SettingsStore.unwatchSetting(this.themeWatchRef); - dis.unregister(this.dispatcherRef); - } - - private onChange = () => { - this.recheck(); + static BUILTIN_THEMES = { + 'light': _t('Light'), + 'light-high-contrast': _t('Light high contrast'), + 'dark': _t('Dark'), }; - private onAction = (payload: ActionPayload) => { - if (payload.action === Action.RecheckTheme) { - // XXX forceTheme - this.recheck(payload.forceTheme); + public static get availableThemes(): {[key: string]: string} { + const customThemes = SettingsStore.getValue('custom_themes'); + const customThemeNames = {}; + for (const { name } of customThemes) { + customThemeNames[`custom-${name}`] = name; } - }; + return Object.assign({}, customThemeNames, ThemeWatcher.BUILTIN_THEMES); + } - // XXX: forceTheme param added here as local echo appears to be unreliable - // https://github.com/vector-im/element-web/issues/11443 - public recheck(forceTheme?: string) { - const oldTheme = this.currentTheme; - this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme; - if (oldTheme !== this.currentTheme) { - setTheme(this.currentTheme); - } + public static get customThemes() { + return SettingsStore.getValue('custom_themes') } - public getEffectiveTheme(): string { + public static currentTheme(opts: IThemeWatcherOpts = {}): string { // Dev note: Much of this logic is replicated in the AppearanceUserSettingsTab - // XXX: checking the isLight flag here makes checking it in the ThemeController - // itself completely redundant since we just override the result here and we're - // now effectively just using the ThemeController as a place to store the static - // variable. The system theme setting probably ought to have an equivalent - // controller that honours the same flag, although probablt better would be to - // have the theme logic in one place rather than split between however many - // different places. - if (ThemeController.isLogin) return 'light'; - // If the user has specifically enabled the system matching option (excluding default), // then use that over anything else. We pick the lowest possible level for the setting // to ensure the ordering otherwise works. - const systemThemeExplicit = SettingsStore.getValueAt( - SettingLevel.DEVICE, "use_system_theme", null, false, true); - if (systemThemeExplicit) { - logger.log("returning explicit system theme"); - const theme = this.themeBasedOnSystem(); + if (ThemeWatcher.instance(opts).useSystemTheme) { + logger.log('returning explicit system theme'); + const theme = ThemeWatcher.instance(opts).systemTheme(); if (theme) { return theme; } @@ -121,34 +83,106 @@ export default class ThemeWatcher { // If the user has specifically enabled the theme (without the system matching option being // enabled specifically and excluding the default), use that theme. We pick the lowest possible // level for the setting to ensure the ordering otherwise works. - const themeExplicit = SettingsStore.getValueAt( - SettingLevel.DEVICE, "theme", null, false, true); - if (themeExplicit) { - logger.log("returning explicit theme: " + themeExplicit); - return themeExplicit; + const settingsTheme = ThemeWatcher.instance(opts).settingsTheme(); + if (settingsTheme) { + logger.log('returning explicit theme: ' + settingsTheme); + return settingsTheme; } - // If the user hasn't really made a preference in either direction, assume the defaults of the - // settings and use those. - if (SettingsStore.getValue('use_system_theme')) { - const theme = this.themeBasedOnSystem(); - if (theme) { - return theme; + // If the above didn't work, try again sanitized + if (!opts.sanitized) { + return ThemeWatcher.currentTheme({ sanitized: true }); + } + + // You really shouldn't be able to get this far, + // since 'sanitized' returns default values + } + + public static instance(opts: IThemeWatcherOpts = {}): ThemeWatcher { + if (opts.newInstance || !ThemeWatcher.instance_) { + ThemeWatcher.instance_ = new ThemeWatcher(); + } + return ThemeWatcher.instance_; + } + + public static newInstance(opts: IThemeWatcherOpts = {}): ThemeWatcher { + return ThemeWatcher.instance({ newInstance: true }); + } + + public static isDarkTheme(opts: IThemeWatcherOpts = {}): boolean { + if (ThemeWatcher.instance(opts).useSystemTheme) { + return ThemeWatcher.instance(opts).preferDark.matches; + } else { + const theme = ThemeWatcher.instance(opts).settingsTheme(); + if (theme.startsWith('custom-')) { + return new CustomTheme(theme.substring('custom-'.length)).is_dark; } + return theme === ThemeWatcher.instance(opts).settingsDarkTheme(); } - logger.log("returning theme value"); - return SettingsStore.getValue('theme'); } - private themeBasedOnSystem() { + public static isHighContrast(opts: IThemeWatcherOpts = {}): boolean { + if (ThemeWatcher.instance(opts).useSystemTheme) { + return ThemeWatcher.instance(opts).preferHighContrast.matches; + } else { + const theme = ThemeWatcher.instance(opts).settingsTheme(); + if (theme.startsWith('custom-')) { + return false; + } + return new Theme(theme).isHighContrast; + } + } + + // This allows both: + // * deferring the default dark theme to the settings schema + // * adding alternate dark themes later + private settingsDarkTheme(opts: IThemeWatcherOpts = {}): string { + if (opts.sanitized) { + return SETTINGS['dark_theme'].default; + } + return SettingsStore.getValue('dark_theme'); + } + + // This allows both: + // * deferring the default dark theme to the settings schema + // * adding alternate dark themes later + private settingsLightTheme(opts: IThemeWatcherOpts = {}): string { + if (opts.sanitized) { + return SETTINGS['light_theme'].default; + } + return SettingsStore.getValue('light_theme'); + } + + private settingsTheme(opts: IThemeWatcherOpts = {}): string { + let theme = SettingsStore.getValueAt( + SettingLevel.DEVICE, 'theme', null, false, true); + + if (theme.startsWith('custom-')) { + if (theme.contains('light') || !new CustomTheme(theme.substr(7)).is_dark) { + return ThemeWatcher.instance(opts).settingsLightTheme(); + } else { + return ThemeWatcher.instance(opts).settingsDarkTheme(); + } + } + + return theme; + } + + public static supportsSystemTheme(opts: IThemeWatcherOpts = {}): boolean { + return ThemeWatcher.instance(opts).preferDark.matches || ThemeWatcher.instance(opts).preferLight.matches; + } + + private systemTheme(opts: IThemeWatcherOpts = {}): string { let newTheme: string; - if (this.preferDark.matches) { - newTheme = 'dark'; - } else if (this.preferLight.matches) { - newTheme = 'light'; + // default to dark for historical reasons + // preferHighContrast only supports light for now + if (ThemeWatcher.instance(opts).preferLight.matches || ThemeWatcher.instance(opts).preferHighContrast.matches) { + newTheme = ThemeWatcher.instance(opts).settingsLightTheme(); + } else { + newTheme = ThemeWatcher.instance(opts).settingsDarkTheme(); } - if (this.preferHighContrast.matches) { - const hcTheme = findHighContrastTheme(newTheme); + if (ThemeWatcher.instance(opts).preferHighContrast.matches && !opts.sanitized) { + const hcTheme = new Theme(newTheme).highContrast; if (hcTheme) { newTheme = hcTheme; } @@ -156,7 +190,64 @@ export default class ThemeWatcher { return newTheme; } - public isSystemThemeSupported() { - return this.preferDark.matches || this.preferLight.matches; + private get useSystemTheme(): boolean { + return SettingsStore.getValueAt(SettingLevel.DEVICE, 'use_system_theme', null, false, true) + } + + private constructor() { + this.themeWatchRef = null; + this.systemThemeWatchRef = null; + this.dispatcherRef = null; + + // we have both here as each may either match or not match, so by having both + // we can get the tristate of dark/light/unsupported + this.preferDark = (global).matchMedia("(prefers-color-scheme: dark)"); + this.preferLight = (global).matchMedia("(prefers-color-scheme: light)"); + this.preferHighContrast = (global).matchMedia("(prefers-contrast: more)"); + + this.initialTheme = ThemeWatcher.currentTheme(); + } + + public start(opts: IThemeWatcherOpts = {}) { + ThemeWatcher.instance(opts).themeWatchRef = SettingsStore.watchSetting('theme', null, ThemeWatcher.instance(opts).onChange); + ThemeWatcher.instance(opts).systemThemeWatchRef = SettingsStore.watchSetting('use_system_theme', null, ThemeWatcher.instance(opts).onChange); + if (ThemeWatcher.instance(opts).preferDark.addEventListener) { + ThemeWatcher.instance(opts).preferDark.addEventListener('change', ThemeWatcher.instance(opts).onChange); + ThemeWatcher.instance(opts).preferLight.addEventListener('change', ThemeWatcher.instance(opts).onChange); + ThemeWatcher.instance(opts).preferHighContrast.addEventListener('change', ThemeWatcher.instance(opts).onChange); + } + ThemeWatcher.instance(opts).dispatcherRef = dis.register(ThemeWatcher.instance(opts).onAction); + } + + public stop() { + if (ThemeWatcher.instance().preferDark.addEventListener) { + ThemeWatcher.instance().preferDark.removeEventListener('change', ThemeWatcher.instance().onChange); + ThemeWatcher.instance().preferLight.removeEventListener('change', ThemeWatcher.instance().onChange); + ThemeWatcher.instance().preferHighContrast.removeEventListener('change', ThemeWatcher.instance().onChange); + } + SettingsStore.unwatchSetting(ThemeWatcher.instance().systemThemeWatchRef); + SettingsStore.unwatchSetting(ThemeWatcher.instance().themeWatchRef); + dis.unregister(ThemeWatcher.instance().dispatcherRef); + } + + private onAction = (payload: ActionPayload) => { + if (payload.action === Action.RecheckTheme) { + // XXX forceTheme + ThemeWatcher.instance().recheck(payload.forceTheme); + } + }; + + private onChange = () => { + ThemeWatcher.instance().recheck(); + }; + + // XXX: forceTheme param added here as local echo appears to be unreliable + // https://github.com/vector-im/element-web/issues/11443 + public recheck(forceTheme?: string) { + const oldTheme = ThemeWatcher.instance().initialTheme; + ThemeWatcher.instance().initialTheme = forceTheme === undefined ? ThemeWatcher.currentTheme() : forceTheme; + if (oldTheme !== ThemeWatcher.instance().initialTheme) { + ThemeController.setTheme(ThemeWatcher.instance().initialTheme); + } } } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index b08b51a32fa..1dbf527c196 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -48,7 +48,6 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ModalWidgetStore } from "../ModalWidgetStore"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; -import { getCustomTheme } from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -112,24 +111,11 @@ export class ElementWidget extends Widget { domain = "jitsi.riot.im"; } - let theme = new ThemeWatcher().getEffectiveTheme(); - if (theme.startsWith("custom-")) { - const customTheme = getCustomTheme(theme.substr(7)); - // Jitsi only understands light/dark - theme = customTheme.is_dark ? "dark" : "light"; - } - - // only allow light/dark through, defaulting to dark as that was previously the only state - // accounts for legacy-light/legacy-dark themes too - if (theme.includes("light")) { - theme = "light"; - } else { - theme = "dark"; - } + const sanitizedTheme = ThemeWatcher.currentTheme({ sanitized: true }); return { ...super.rawData, - theme, + sanitizedTheme, conferenceId, domain, }; diff --git a/src/theme.ts b/src/theme.ts index b1eec5acedd..ba2b8421090 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -15,16 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { _t } from "./languageHandler"; - -import SettingsStore from "./settings/SettingsStore"; +import SettingsStore from "./settings/SettingsStore" import ThemeWatcher from "./settings/watchers/ThemeWatcher"; -export const DEFAULT_THEME = "light"; -const HIGH_CONTRAST_THEMES = { - "light": "light-high-contrast", -}; - interface IFontFaces { src: { format: string; @@ -33,257 +26,157 @@ interface IFontFaces { }[]; } -interface ICustomTheme { - colors: { - [key: string]: string; - }; - fonts: { - faces: IFontFaces[]; - general: string; - monospace: string; +export class Theme { + + static HIGH_CONTRAST_THEMES = { + 'light': 'light-high-contrast', }; - is_dark?: boolean; // eslint-disable-line camelcase -} -/** - * Given a non-high-contrast theme, find the corresponding high-contrast one - * if it exists, or return undefined if not. - */ -export function findHighContrastTheme(theme: string) { - return HIGH_CONTRAST_THEMES[theme]; -} + themeName: string; -/** - * Given a high-contrast theme, find the corresponding non-high-contrast one - * if it exists, or return undefined if not. - */ -export function findNonHighContrastTheme(hcTheme: string) { - for (const theme in HIGH_CONTRAST_THEMES) { - if (HIGH_CONTRAST_THEMES[theme] === hcTheme) { - return theme; - } + constructor(themeName: string) { + this.themeName = themeName; } -} - -/** - * Decide whether the supplied theme is high contrast. - */ -export function isHighContrastTheme(theme: string) { - return Object.values(HIGH_CONTRAST_THEMES).includes(theme); -} -export function enumerateThemes(): {[key: string]: string} { - const BUILTIN_THEMES = { - "light": _t("Light"), - "light-high-contrast": _t("Light high contrast"), - "dark": _t("Dark"), - }; - const customThemes = SettingsStore.getValue("custom_themes"); - const customThemeNames = {}; - for (const { name } of customThemes) { - customThemeNames[`custom-${name}`] = name; + /** + * Given a non-high-contrast theme, find the corresponding high-contrast one + * if it exists, or return undefined if not. + */ + public get highContrast(): string { + return Theme.HIGH_CONTRAST_THEMES[this.themeName]; } - return Object.assign({}, customThemeNames, BUILTIN_THEMES); -} -function clearCustomTheme(): void { - // remove all css variables, we assume these are there because of the custom theme - const inlineStyleProps = Object.values(document.body.style); - for (const prop of inlineStyleProps) { - if (prop.startsWith("--")) { - document.body.style.removeProperty(prop); - } - } - const customFontFaceStyle = document.querySelector("head > style[title='custom-theme-font-faces']"); - if (customFontFaceStyle) { - customFontFaceStyle.remove(); - } -} - -const allowedFontFaceProps = [ - "font-display", - "font-family", - "font-stretch", - "font-style", - "font-weight", - "font-variant", - "font-feature-settings", - "font-variation-settings", - "src", - "unicode-range", -]; - -function generateCustomFontFaceCSS(faces: IFontFaces[]): string { - return faces.map(face => { - const src = face.src && face.src.map(srcElement => { - let format; - if (srcElement.format) { - format = `format("${srcElement.format}")`; - } - if (srcElement.url) { - return `url("${srcElement.url}") ${format}`; - } else if (srcElement.local) { - return `local("${srcElement.local}") ${format}`; - } - return ""; - }).join(", "); - const props = Object.keys(face).filter(prop => allowedFontFaceProps.includes(prop)); - const body = props.map(prop => { - let value; - if (prop === "src") { - value = src; - } else if (prop === "font-family") { - value = `"${face[prop]}"`; - } else { - value = face[prop]; + /** + * Given a high-contrast theme, find the corresponding non-high-contrast one + * if it exists, or return undefined if not. + */ + public get nonHighContrast(): string { + for (const theme in Theme.HIGH_CONTRAST_THEMES) { + if (Theme.HIGH_CONTRAST_THEMES[theme] === this.themeName) { + return theme; } - return `${prop}: ${value}`; - }).join(";"); - return `@font-face {${body}}`; - }).join("\n"); -} - -function setCustomThemeVars(customTheme: ICustomTheme): void { - const { style } = document.body; - - function setCSSColorVariable(name, hexColor, doPct = true) { - style.setProperty(`--${name}`, hexColor); - if (doPct) { - // uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50% - style.setProperty(`--${name}-0pct`, hexColor + "00"); - style.setProperty(`--${name}-15pct`, hexColor + "26"); - style.setProperty(`--${name}-50pct`, hexColor + "7F"); } } - if (customTheme.colors) { - for (const [name, value] of Object.entries(customTheme.colors)) { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i += 1) { - setCSSColorVariable(`${name}_${i}`, value[i], false); - } - } else { - setCSSColorVariable(name, value); - } - } - } - if (customTheme.fonts) { - const { fonts } = customTheme; - if (fonts.faces) { - const css = generateCustomFontFaceCSS(fonts.faces); - const style = document.createElement("style"); - style.setAttribute("title", "custom-theme-font-faces"); - style.setAttribute("type", "text/css"); - style.appendChild(document.createTextNode(css)); - document.head.appendChild(style); - } - if (fonts.general) { - style.setProperty("--font-family", fonts.general); - } - if (fonts.monospace) { - style.setProperty("--font-family-monospace", fonts.monospace); - } + /** + * Decide whether the supplied theme is high contrast. + */ + public get isHighContrast(): boolean { + return Object.values(Theme.HIGH_CONTRAST_THEMES).includes(this.themeName); } -} -export function getCustomTheme(themeName: string): ICustomTheme { - // set css variables - const customThemes = SettingsStore.getValue("custom_themes"); - if (!customThemes) { - throw new Error(`No custom themes set, can't set custom theme "${themeName}"`); - } - const customTheme = customThemes.find(t => t.name === themeName); - if (!customTheme) { - const knownNames = customThemes.map(t => t.name).join(", "); - throw new Error(`Can't find custom theme "${themeName}", only know ${knownNames}`); - } - return customTheme; } -/** - * Called whenever someone changes the theme - * Async function that returns once the theme has been set - * (ie. the CSS has been loaded) - * - * @param {string} theme new theme - */ -export async function setTheme(theme?: string): Promise { - if (!theme) { - const themeWatcher = new ThemeWatcher(); - theme = themeWatcher.getEffectiveTheme(); - } - clearCustomTheme(); - let stylesheetName = theme; - if (theme.startsWith("custom-")) { - const customTheme = getCustomTheme(theme.substr(7)); - stylesheetName = customTheme.is_dark ? "dark-custom" : "light-custom"; - setCustomThemeVars(customTheme); - } +export class CustomTheme extends Theme { - // look for the stylesheet elements. - // styleElements is a map from style name to HTMLLinkElement. - const styleElements = Object.create(null); - const themes = Array.from(document.querySelectorAll('[data-mx-theme]')); - themes.forEach(theme => { - styleElements[theme.attributes['data-mx-theme'].value.toLowerCase()] = theme; - }); + colors: { + [key: string]: string; + }; + fonts: { + faces: IFontFaces[]; + general: string; + monospace: string; + }; + is_dark?: boolean; // eslint-disable-line camelcase - if (!(stylesheetName in styleElements)) { - throw new Error("Unknown theme " + stylesheetName); - } + public constructor(themeName: string) { + super(themeName) - // disable all of them first, then enable the one we want. Chrome only - // bothers to do an update on a true->false transition, so this ensures - // that we get exactly one update, at the right time. - // - // ^ This comment was true when we used to use alternative stylesheets - // for the CSS. Nowadays we just set them all as disabled in index.html - // and enable them as needed. It might be cleaner to disable them all - // at the same time to prevent loading two themes simultaneously and - // having them interact badly... but this causes a flash of unstyled app - // which is even uglier. So we don't. + // getCustomTheme(themeName: string): ICustomTheme + // set css variables + const customThemes = ThemeWatcher.customThemes; + if (!customThemes) { + throw new Error(`No custom themes set, can't set custom theme '${this.themeName}'`); + } + const customTheme = customThemes.find(t => t.name === this.themeName); + if (!customTheme) { + const knownNames = customThemes.map(t => t.name).join(', '); + throw new Error(`Can't find custom theme '${this.themeName}', only know ${knownNames}`); + } - styleElements[stylesheetName].disabled = false; + const { style } = document.body; - return new Promise((resolve) => { - const switchTheme = function() { - // we re-enable our theme here just in case we raced with another - // theme set request as per https://github.com/vector-im/element-web/issues/5601. - // We could alternatively lock or similar to stop the race, but - // this is probably good enough for now. - styleElements[stylesheetName].disabled = false; - Object.values(styleElements).forEach((a: HTMLStyleElement) => { - if (a == styleElements[stylesheetName]) return; - a.disabled = true; - }); - const bodyStyles = global.getComputedStyle(document.body); - if (bodyStyles.backgroundColor) { - const metaElement: HTMLMetaElement = document.querySelector('meta[name="theme-color"]'); - metaElement.content = bodyStyles.backgroundColor; + function setCSSColorVariable(name, hexColor, doPct = true) { + style.setProperty(`--${name}`, hexColor); + if (doPct) { + // uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50% + style.setProperty(`--${name}-0pct`, hexColor + "00"); + style.setProperty(`--${name}-15pct`, hexColor + "26"); + style.setProperty(`--${name}-50pct`, hexColor + "7F"); } - resolve(); - }; - - // turns out that Firefox preloads the CSS for link elements with - // the disabled attribute, but Chrome doesn't. - - let cssLoaded = false; - - styleElements[stylesheetName].onload = () => { - switchTheme(); - }; + } - for (let i = 0; i < document.styleSheets.length; i++) { - const ss = document.styleSheets[i]; - if (ss && ss.href === styleElements[stylesheetName].href) { - cssLoaded = true; - break; + if (customTheme.colors) { + for (const [name, value] of Object.entries(customTheme.colors)) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i += 1) { + setCSSColorVariable(`${name}_${i}`, value[i], false); + } + } else { + setCSSColorVariable(name, value); + } } } - - if (cssLoaded) { - styleElements[stylesheetName].onload = undefined; - switchTheme(); + if (customTheme.fonts) { + const { fonts } = customTheme; + if (fonts.faces) { + const css = CustomTheme.generateCustomFontFaceCSS(fonts.faces); + const style = document.createElement("style"); + style.setAttribute("title", "custom-theme-font-faces"); + style.setAttribute("type", "text/css"); + style.appendChild(document.createTextNode(css)); + document.head.appendChild(style); + } + if (fonts.general) { + style.setProperty("--font-family", fonts.general); + } + if (fonts.monospace) { + style.setProperty("--font-family-monospace", fonts.monospace); + } } - }); -} + } + + private static generateCustomFontFaceCSS(faces: IFontFaces[]): string { + + const allowedFontFaceProps = [ + 'font-display', + 'font-family', + 'font-stretch', + 'font-style', + 'font-weight', + 'font-variant', + 'font-feature-settings', + 'font-variation-settings', + 'src', + 'unicode-range', + ]; + + return faces.map(face => { + const src = face.src && face.src.map(srcElement => { + let format; + if (srcElement.format) { + format = `format("${srcElement.format}")`; + } + if (srcElement.url) { + return `url("${srcElement.url}") ${format}`; + } else if (srcElement.local) { + return `local("${srcElement.local}") ${format}`; + } + return ""; + }).join(", "); + const props = Object.keys(face).filter(prop => allowedFontFaceProps.includes(prop)); + const body = props.map(prop => { + let value; + if (prop === "src") { + value = src; + } else if (prop === "font-family") { + value = `"${face[prop]}"`; + } else { + value = face[prop]; + } + return `${prop}: ${value}`; + }).join(";"); + return `@font-face {${body}}`; + }).join("\n"); + } +} \ No newline at end of file diff --git a/test/settings/watchers/ThemeWatcher-test.tsx b/test/settings/watchers/ThemeWatcher-test.tsx index c97ba13a33c..f026274dff7 100644 --- a/test/settings/watchers/ThemeWatcher-test.tsx +++ b/test/settings/watchers/ThemeWatcher-test.tsx @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { SETTINGS } from '../../../src/settings/Settings'; import SettingsStore from '../../../src/settings/SettingsStore'; import ThemeWatcher from '../../../src/settings/watchers/ThemeWatcher'; import { SettingLevel } from '../../../src/settings/SettingLevel'; @@ -66,9 +67,9 @@ describe('ThemeWatcher', function() { // Given no system settings global.matchMedia = makeMatchMedia({}); - // Then getEffectiveTheme returns light - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light"); + // Then currentTheme() returns light + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe(SETTINGS["theme"].default); }); it('should choose default theme if system settings are inconclusive', () => { @@ -76,12 +77,11 @@ describe('ThemeWatcher', function() { global.matchMedia = makeMatchMedia({}); SettingsStore.getValue = makeGetValue({ "use_system_theme": true, - "theme": "light", }); - // Then getEffectiveTheme returns light - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light"); + // Then currentTheme() returns light + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("light"); }); it('should choose a dark theme if that is selected', () => { @@ -92,9 +92,9 @@ describe('ThemeWatcher', function() { }); SettingsStore.getValueAt = makeGetValueAt({ "theme": "dark" }); - // Then getEffectiveTheme returns dark - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("dark"); + // Then currentTheme() returns dark + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("dark"); }); it('should choose a light theme if that is selected', () => { @@ -105,9 +105,9 @@ describe('ThemeWatcher', function() { }); SettingsStore.getValueAt = makeGetValueAt({ "theme": "light" }); - // Then getEffectiveTheme returns light - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light"); + // Then currentTheme() returns light + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("light"); }); it('should choose a light-high-contrast theme if that is selected', () => { @@ -115,9 +115,9 @@ describe('ThemeWatcher', function() { global.matchMedia = makeMatchMedia({ "(prefers-color-scheme: dark)": true }); SettingsStore.getValueAt = makeGetValueAt({ "theme": "light-high-contrast" }); - // Then getEffectiveTheme returns light-high-contrast - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light-high-contrast"); + // Then currentTheme() returns light-high-contrast + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("light-high-contrast"); }); it('should choose a light theme if system prefers it (via default)', () => { @@ -127,9 +127,9 @@ describe('ThemeWatcher', function() { SettingsStore.getValueAt = makeGetValueAt({}); SettingsStore.getValue = makeGetValue({ "use_system_theme": true }); - // Then getEffectiveTheme returns light - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light"); + // Then currentTheme() returns light + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("light"); }); it('should choose a dark theme if system prefers it (via default)', () => { @@ -139,9 +139,9 @@ describe('ThemeWatcher', function() { SettingsStore.getValueAt = makeGetValueAt({}); SettingsStore.getValue = makeGetValue({ "use_system_theme": true }); - // Then getEffectiveTheme returns dark - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("dark"); + // Then currentTheme() returns dark + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("dark"); }); it('should choose a light theme if system prefers it (explicit)', () => { @@ -150,9 +150,9 @@ describe('ThemeWatcher', function() { SettingsStore.getValueAt = makeGetValueAt({ "use_system_theme": true }); SettingsStore.getValue = makeGetValue({ "use_system_theme": true }); - // Then getEffectiveTheme returns light - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light"); + // Then currentTheme() returns light + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("light"); }); it('should choose a dark theme if system prefers it (explicit)', () => { @@ -161,9 +161,9 @@ describe('ThemeWatcher', function() { SettingsStore.getValueAt = makeGetValueAt({ "use_system_theme": true }); SettingsStore.getValue = makeGetValue({ "use_system_theme": true }); - // Then getEffectiveTheme returns dark - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("dark"); + // Then currentTheme() returns dark + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("dark"); }); it('should choose a high-contrast theme if system prefers it', () => { @@ -175,9 +175,9 @@ describe('ThemeWatcher', function() { SettingsStore.getValueAt = makeGetValueAt({ "use_system_theme": true }); SettingsStore.getValue = makeGetValue({ "use_system_theme": true }); - // Then getEffectiveTheme returns light-high-contrast - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("light-high-contrast"); + // Then currentTheme() returns light-high-contrast + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("light-high-contrast"); }); it('should not choose a high-contrast theme if not available', () => { @@ -190,9 +190,9 @@ describe('ThemeWatcher', function() { SettingsStore.getValueAt = makeGetValueAt({ "use_system_theme": true }); SettingsStore.getValue = makeGetValue({ "use_system_theme": true }); - // Then getEffectiveTheme returns dark - const themeWatcher = new ThemeWatcher(); - expect(themeWatcher.getEffectiveTheme()).toBe("dark"); + // Then currentTheme() returns dark + ThemeWatcher.instance({ newInstance: true }); + expect(ThemeWatcher.currentTheme()).toBe("dark"); }); });