diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index edb36ec4..43a21623 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -246,6 +246,9 @@ module.exports = function buildThemes(options) { }, generateBundle(_, bundle) { + // assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo + // chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo + // types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle); const manifestLocations = []; for (const [location, chunkArray] of chunkMap) { @@ -254,10 +257,6 @@ module.exports = function buildThemes(options) { const derivedVariables = compiledVariables["derived-variables"]; const icon = compiledVariables["icon"]; const builtAssets = {}; - /** - * Generate a mapping from theme name to asset hashed location of said theme in build output. - * This can be used to enumerate themes during runtime. - */ for (const chunk of chunkArray) { const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 5c89236f..eed18953 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -131,18 +131,14 @@ export class SettingsViewModel extends ViewModel { return this._formatBytes(this._estimate?.usage); } - get themes() { - return this.platform.themeLoader.themes; + get themeMapping() { + return this.platform.themeLoader.themeMapping; } get activeTheme() { return this._activeTheme; } - setTheme(name) { - this.platform.themeLoader.setTheme(name); - } - _formatBytes(n) { if (typeof n === "number") { return Math.round(n / (1024 * 1024)).toFixed(1) + " MB"; @@ -185,5 +181,15 @@ export class SettingsViewModel extends ViewModel { this.emitChange("pushNotifications.serverError"); } } + + changeThemeOption(themeName, themeVariant) { + this.platform.themeLoader.setTheme(themeName, themeVariant); + // emit so that radio-buttons become displayed/hidden + this.emitChange("themeOption"); + } + + get preferredColorScheme() { + return this.platform.themeLoader.preferredColorScheme; + } } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 8e079c85..dfb26b0f 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -187,9 +187,13 @@ export class Platform { this._serviceWorkerHandler, this._config.push ); - const manifests = this.config["themeManifests"]; - await this._themeLoader?.init(manifests); - this._themeLoader?.setTheme(await this._themeLoader.getActiveTheme(), log); + if (this._themeLoader) { + const manifests = this.config["themeManifests"]; + await this._themeLoader?.init(manifests, log); + const { themeName, themeVariant } = await this._themeLoader.getActiveTheme(); + log.log({ l: "Active theme", name: themeName, variant: themeVariant }); + this._themeLoader.setTheme(themeName, themeVariant, log); + } }); } catch (err) { this._container.innerText = err.message; diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index d9aaabb6..6a73c565 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -17,59 +17,192 @@ limitations under the License. import type {ILogItem} from "../../logging/types.js"; import type {Platform} from "./Platform.js"; +type NormalVariant = { + id: string; + cssLocation: string; +}; + +type DefaultVariant = { + dark: { + id: string; + cssLocation: string; + variantName: string; + }; + light: { + id: string; + cssLocation: string; + variantName: string; + }; + default: { + id: string; + cssLocation: string; + variantName: string; + }; +} + +type ThemeInformation = NormalVariant | DefaultVariant; + +export enum ColorSchemePreference { + Dark, + Light +}; + export class ThemeLoader { private _platform: Platform; - private _themeMapping: Record = {}; + private _themeMapping: Record; constructor(platform: Platform) { this._platform = platform; } - async init(manifestLocations: string[]): Promise { - for (const manifestLocation of manifestLocations) { - const { body } = await this._platform - .request(manifestLocation, { - method: "GET", - format: "json", - cache: true, - }) - .response(); - /* - After build has finished, the source section of each theme manifest - contains `built-assets` which is a mapping from the theme-name to the - location of the css file in build. - */ - Object.assign(this._themeMapping, body["source"]["built-assets"]); - } + async init(manifestLocations: string[], log?: ILogItem): Promise { + await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { + this._themeMapping = {}; + const results = await Promise.all( + manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) + ); + results.forEach(({ body }) => this._populateThemeMap(body, log)); + }); } - setTheme(themeName: string, log?: ILogItem) { - this._platform.logger.wrapOrRun(log, {l: "change theme", id: themeName}, () => { - const themeLocation = this._themeMapping[themeName]; - if (!themeLocation) { - throw new Error( `Cannot find theme location for theme "${themeName}"!`); + private _populateThemeMap(manifest, log: ILogItem) { + log.wrap("populateThemeMap", (l) => { + /* + After build has finished, the source section of each theme manifest + contains `built-assets` which is a mapping from the theme-id to + cssLocation of theme + */ + const builtAssets: Record = manifest.source?.["built-assets"]; + const themeName = manifest.name; + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (const [themeId, cssLocation] of Object.entries(builtAssets)) { + const variant = themeId.match(/.+-(.+)/)?.[1]; + const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId + defaultVariant.cssLocation = cssLocation; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId + }; } - this._platform.replaceStylesheet(themeLocation); - this._platform.settingsStorage.setString("theme", themeName); + if (defaultDarkVariant.id && defaultLightVariant.id) { + /** + * As mentioned above, if there's both a default dark and a default light variant, + * add them to themeMapping separately. + */ + const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + /** + * If only one default variant is found (i.e only dark default or light default but not both), + * treat it like any other variant. + */ + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + //Add the default-theme as an additional option to the mapping + const defaultThemeId = this.getDefaultTheme(); + if (defaultThemeId) { + const themeDetails = this._findThemeDetailsFromId(defaultThemeId); + if (themeDetails) { + this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation }; + } + } + l.log({ l: "Default Theme", theme: defaultThemeId}); + l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); + l.log({ l: "Result", themeMapping: this._themeMapping }); }); } - get themes(): string[] { - return Object.keys(this._themeMapping); + setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { + this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { + let cssLocation: string; + let themeDetails = this._themeMapping[themeName]; + if ("id" in themeDetails) { + cssLocation = themeDetails.cssLocation; + } + else { + if (!themeVariant) { + throw new Error("themeVariant is undefined!"); + } + cssLocation = themeDetails[themeVariant].cssLocation; + } + this._platform.replaceStylesheet(cssLocation); + this._platform.settingsStorage.setString("theme-name", themeName); + if (themeVariant) { + this._platform.settingsStorage.setString("theme-variant", themeVariant); + } + else { + this._platform.settingsStorage.remove("theme-variant"); + } + }); } - async getActiveTheme(): Promise { - // check if theme is set via settings - let theme = await this._platform.settingsStorage.getString("theme"); - if (theme) { - return theme; + /** Maps theme display name to theme information */ + get themeMapping(): Record { + return this._themeMapping; + } + + async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> { + let themeName = await this._platform.settingsStorage.getString("theme-name"); + let themeVariant = await this._platform.settingsStorage.getString("theme-variant"); + if (!themeName || !this._themeMapping[themeName]) { + themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0]; + if (!this._themeMapping[themeName][themeVariant]) { + themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined; + } } - // return default theme + return { themeName, themeVariant }; + } + + getDefaultTheme(): string | undefined { + switch (this.preferredColorScheme) { + case ColorSchemePreference.Dark: + return this._platform.config["defaultTheme"]?.dark; + case ColorSchemePreference.Light: + return this._platform.config["defaultTheme"]?.light; + } + } + + private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined { + for (const [themeName, themeData] of Object.entries(this._themeMapping)) { + if ("id" in themeData && themeData.id === themeId) { + return { themeName, cssLocation: themeData.cssLocation }; + } + else if ("light" in themeData && themeData.light?.id === themeId) { + return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" }; + } + else if ("dark" in themeData && themeData.dark?.id === themeId) { + return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" }; + } + } + } + + get preferredColorScheme(): ColorSchemePreference { if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - return this._platform.config["defaultTheme"].dark; - } else if (window.matchMedia("(prefers-color-scheme: light)").matches) { - return this._platform.config["defaultTheme"].light; + return ColorSchemePreference.Dark; } - return undefined; + else if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return ColorSchemePreference.Light; + } + throw new Error("Cannot find preferred colorscheme!"); } } diff --git a/src/platform/web/ui/css/themes/element/manifest.json b/src/platform/web/ui/css/themes/element/manifest.json index ec1852cb..e183317c 100644 --- a/src/platform/web/ui/css/themes/element/manifest.json +++ b/src/platform/web/ui/css/themes/element/manifest.json @@ -1,45 +1,39 @@ { "version": 1, - "name": "element", + "name": "Element", "values": { - "font-faces": [ - { - "font-family": "Inter", - "src": [{"asset": "/fonts/Inter.ttf", "format": "ttf"}] - } - ], "variants": { - "light": { - "base": true, - "default": true, - "name": "Light", - "variables": { - "background-color-primary": "#fff", + "light": { + "base": true, + "default": true, + "name": "Light", + "variables": { + "background-color-primary": "#fff", "background-color-secondary": "#f6f6f6", - "text-color": "#2E2F32", + "text-color": "#2E2F32", "accent-color": "#03b381", "error-color": "#FF4B55", "fixed-white": "#fff", "room-badge": "#61708b", "link-color": "#238cf5" - } - }, - "dark": { - "dark": true, - "default": true, - "name": "Dark", - "variables": { - "background-color-primary": "#21262b", + } + }, + "dark": { + "dark": true, + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", "background-color-secondary": "#2D3239", - "text-color": "#fff", + "text-color": "#fff", "accent-color": "#03B381", "error-color": "#FF4B55", "fixed-white": "#fff", "room-badge": "#61708b", "link-color": "#238cf5" - } - } - } + } + } + } } } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index dd7bbc03..cf09ddb5 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -140,11 +140,52 @@ export class SettingsView extends TemplateView { } _themeOptions(t, vm) { - const activeTheme = vm.activeTheme; + const { themeName: activeThemeName, themeVariant: activeThemeVariant } = vm.activeTheme; const optionTags = []; - for (const name of vm.themes) { - optionTags.push(t.option({value: name, selected: name === activeTheme}, name)); + // 1. render the dropdown containing the themes + for (const name of Object.keys(vm.themeMapping)) { + optionTags.push( t.option({ value: name, selected: name === activeThemeName} , name)); } - return t.select({onChange: (e) => vm.setTheme(e.target.value)}, optionTags); + const select = t.select({ + onChange: (e) => { + const themeName = e.target.value; + if(!("id" in vm.themeMapping[themeName])) { + const colorScheme = darkRadioButton.checked ? "dark" : lightRadioButton.checked ? "light" : "default"; + // execute the radio-button callback so that the theme actually changes! + // otherwise the theme would only change when another radio-button is selected. + radioButtonCallback(colorScheme); + return; + } + vm.changeThemeOption(themeName); + } + }, optionTags); + // 2. render the radio-buttons used to choose variant + const radioButtonCallback = (colorScheme) => { + const selectedThemeName = select.options[select.selectedIndex].value; + vm.changeThemeOption(selectedThemeName, colorScheme); + }; + const isDarkSelected = activeThemeVariant === "dark"; + const isLightSelected = activeThemeVariant === "light"; + const darkRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "dark", id: "dark", checked: isDarkSelected }); + const defaultRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "default", id: "default", checked: !(isDarkSelected || isLightSelected) }); + const lightRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "light", id: "light", checked: isLightSelected }); + const radioButtons = t.form({ + className: { + hidden: () => { + const themeName = select.options[select.selectedIndex].value; + return "id" in vm.themeMapping[themeName]; + } + }, + onChange: (e) => radioButtonCallback(e.target.value) + }, + [ + defaultRadioButton, + t.label({for: "default"}, "Match system theme"), + darkRadioButton, + t.label({for: "dark"}, "dark"), + lightRadioButton, + t.label({for: "light"}, "light"), + ]); + return t.div({ className: "theme-chooser" }, [select, radioButtons]); } }