Merge pull request #724 from vector-im/theme-chooser

Implement theme chooser in settings
This commit is contained in:
Bruno Windels 2022-05-18 20:22:38 +02:00 committed by GitHub
commit 36ddd61318
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 210 additions and 26 deletions

View file

@ -12,7 +12,7 @@
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/", "test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js", "test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
"start": "vite --port 3000", "start": "vite --port 3000",
"build": "vite build", "build": "vite build && ./scripts/cleanup.sh",
"build:sdk": "./scripts/sdk/build.sh" "build:sdk": "./scripts/sdk/build.sh"
}, },
"repository": { "repository": {

View file

@ -31,6 +31,18 @@ function appendVariablesToCSS(variables, cssSource) {
return cssSource + getRootSectionWithVariables(variables); return cssSource + getRootSectionWithVariables(variables);
} }
function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
for (const [fileName, info] of Object.entries(bundle)) {
if (fileName === "assets/config.json") {
const source = new TextDecoder().decode(info.source);
const config = JSON.parse(source);
config["themeManifests"] = manifestLocations;
config["defaultTheme"] = defaultThemes;
info.source = new TextEncoder().encode(JSON.stringify(config));
}
}
}
function parseBundle(bundle) { function parseBundle(bundle) {
const chunkMap = new Map(); const chunkMap = new Map();
const assetMap = new Map(); const assetMap = new Map();
@ -72,7 +84,7 @@ function parseBundle(bundle) {
} }
module.exports = function buildThemes(options) { module.exports = function buildThemes(options) {
let manifest, variants, defaultDark, defaultLight; let manifest, variants, defaultDark, defaultLight, defaultThemes = {};
let isDevelopment = false; let isDevelopment = false;
const virtualModuleId = '@theme/' const virtualModuleId = '@theme/'
const resolvedVirtualModuleId = '\0' + virtualModuleId; const resolvedVirtualModuleId = '\0' + virtualModuleId;
@ -99,9 +111,11 @@ module.exports = function buildThemes(options) {
// This is the default theme, stash the file name for later // This is the default theme, stash the file name for later
if (details.dark) { if (details.dark) {
defaultDark = fileName; defaultDark = fileName;
defaultThemes["dark"] = `${name}-${variant}`;
} }
else { else {
defaultLight = fileName; defaultLight = fileName;
defaultThemes["light"] = `${name}-${variant}`;
} }
} }
// emit the css as built theme bundle // emit the css as built theme bundle
@ -215,6 +229,7 @@ module.exports = function buildThemes(options) {
type: "text/css", type: "text/css",
media: "(prefers-color-scheme: dark)", media: "(prefers-color-scheme: dark)",
href: `./${darkThemeLocation}`, href: `./${darkThemeLocation}`,
class: "theme",
} }
}, },
{ {
@ -224,6 +239,7 @@ module.exports = function buildThemes(options) {
type: "text/css", type: "text/css",
media: "(prefers-color-scheme: light)", media: "(prefers-color-scheme: light)",
href: `./${lightThemeLocation}`, href: `./${lightThemeLocation}`,
class: "theme",
} }
}, },
]; ];
@ -231,24 +247,36 @@ module.exports = function buildThemes(options) {
generateBundle(_, bundle) { generateBundle(_, bundle) {
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle); const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
const manifestLocations = [];
for (const [location, chunkArray] of chunkMap) { for (const [location, chunkArray] of chunkMap) {
const manifest = require(`${location}/manifest.json`); const manifest = require(`${location}/manifest.json`);
const compiledVariables = options.compiledVariables.get(location); const compiledVariables = options.compiledVariables.get(location);
const derivedVariables = compiledVariables["derived-variables"]; const derivedVariables = compiledVariables["derived-variables"];
const icon = compiledVariables["icon"]; 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;
}
manifest.source = { manifest.source = {
"built-asset": chunkArray.map(chunk => assetMap.get(chunk.fileName).fileName), "built-assets": builtAssets,
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName, "runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
"derived-variables": derivedVariables, "derived-variables": derivedVariables,
"icon": icon "icon": icon
}; };
const name = `theme-${manifest.name}.json`; const name = `theme-${manifest.name}.json`;
manifestLocations.push(`assets/${name}`);
this.emitFile({ this.emitFile({
type: "asset", type: "asset",
name, name,
source: JSON.stringify(manifest), source: JSON.stringify(manifest),
}); });
} }
addThemesToConfig(bundle, manifestLocations, defaultThemes);
}, },
} }
} }

View file

@ -8,7 +8,7 @@ function contentHash(str) {
return hasher.digest(); return hasher.digest();
} }
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) { function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
const swName = path.basename(swFile); const swName = path.basename(swFile);
let root; let root;
let version; let version;
@ -31,6 +31,7 @@ function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
logger = config.logger; logger = config.logger;
}, },
generateBundle: async function(options, bundle) { generateBundle: async function(options, bundle) {
const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle);
const unhashedFilenames = [swName].concat(otherUnhashedFiles); const unhashedFilenames = [swName].concat(otherUnhashedFiles);
const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => { const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => {
const chunkOrAsset = bundle[fileName]; const chunkOrAsset = bundle[fileName];

3
scripts/cleanup.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
# Remove icons created in .tmp
rm -rf .tmp

View file

@ -50,6 +50,7 @@ export class SettingsViewModel extends ViewModel {
this.minSentImageSizeLimit = 400; this.minSentImageSizeLimit = 400;
this.maxSentImageSizeLimit = 4000; this.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus(); this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined;
} }
get _session() { get _session() {
@ -76,6 +77,9 @@ export class SettingsViewModel extends ViewModel {
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
this.pushNotifications.supported = await this.platform.notificationService.supportsPush(); this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled(); this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
if (!import.meta.env.DEV) {
this._activeTheme = await this.platform.themeLoader.getActiveTheme();
}
this.emitChange(""); this.emitChange("");
} }
@ -127,6 +131,18 @@ export class SettingsViewModel extends ViewModel {
return this._formatBytes(this._estimate?.usage); return this._formatBytes(this._estimate?.usage);
} }
get themes() {
return this.platform.themeLoader.themes;
}
get activeTheme() {
return this._activeTheme;
}
setTheme(name) {
this.platform.themeLoader.setTheme(name);
}
_formatBytes(n) { _formatBytes(n) {
if (typeof n === "number") { if (typeof n === "number") {
return Math.round(n / (1024 * 1024)).toFixed(1) + " MB"; return Math.round(n / (1024 * 1024)).toFixed(1) + " MB";

View file

@ -38,6 +38,7 @@ import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables"; import {Disposables} from "../../utils/Disposables";
import {parseHTML} from "./parsehtml.js"; import {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar"; import {handleAvatarError} from "./ui/avatar";
import {ThemeLoader} from "./ThemeLoader";
function addScript(src) { function addScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -164,20 +165,26 @@ export class Platform {
this._disposables = new Disposables(); this._disposables = new Disposables();
this._olmPromise = undefined; this._olmPromise = undefined;
this._workerPromise = undefined; this._workerPromise = undefined;
this._themeLoader = import.meta.env.DEV? null: new ThemeLoader(this);
} }
async init() { async init() {
if (!this._config) { await this.logger.run("Platform init", async (log) => {
if (!this._configURL) { if (!this._config) {
throw new Error("Neither config nor configURL was provided!"); if (!this._configURL) {
throw new Error("Neither config nor configURL was provided!");
}
const {body}= await this.request(this._configURL, {method: "GET", format: "json", cache: true}).response();
this._config = body;
} }
const {body}= await this.request(this._configURL, {method: "GET", format: "json", cache: true}).response(); this.notificationService = new NotificationService(
this._config = body; this._serviceWorkerHandler,
} this._config.push
this.notificationService = new NotificationService( );
this._serviceWorkerHandler, const manifests = this.config["themeManifests"];
this._config.push await this._themeLoader?.init(manifests);
); this._themeLoader?.setTheme(await this._themeLoader.getActiveTheme(), log);
});
} }
_createLogger(isDevelopment) { _createLogger(isDevelopment) {
@ -307,6 +314,23 @@ export class Platform {
return DEFINE_VERSION; return DEFINE_VERSION;
} }
get themeLoader() {
return this._themeLoader;
}
replaceStylesheet(newPath) {
const head = document.querySelector("head");
// remove default theme
document.querySelectorAll(".theme").forEach(e => e.remove());
// add new theme
const styleTag = document.createElement("link");
styleTag.href = `./${newPath}`;
styleTag.rel = "stylesheet";
styleTag.type = "text/css";
styleTag.className = "theme";
head.appendChild(styleTag);
}
dispose() { dispose() {
this._disposables.dispose(); this._disposables.dispose();
} }

View file

@ -0,0 +1,75 @@
/*
Copyright 2020 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 type {ILogItem} from "../../logging/types.js";
import type {Platform} from "./Platform.js";
export class ThemeLoader {
private _platform: Platform;
private _themeMapping: Record<string, string> = {};
constructor(platform: Platform) {
this._platform = platform;
}
async init(manifestLocations: string[]): Promise<void> {
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"]);
}
}
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}"!`);
}
this._platform.replaceStylesheet(themeLocation);
this._platform.settingsStorage.setString("theme", themeName);
});
}
get themes(): string[] {
return Object.keys(this._themeMapping);
}
async getActiveTheme(): Promise<string|undefined> {
// check if theme is set via settings
let theme = await this._platform.settingsStorage.getString("theme");
if (theme) {
return theme;
}
// return default theme
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 undefined;
}
}

View file

@ -95,8 +95,8 @@ let pendingFetchAbortController = new AbortController();
async function handleRequest(request) { async function handleRequest(request) {
try { try {
if (request.url.includes("config.json")) { if (request.url.includes("config.json") || /theme-.+\.json/.test(request.url)) {
return handleConfigRequest(request); return handleStaleWhileRevalidateRequest(request);
} }
const url = new URL(request.url); const url = new URL(request.url);
// rewrite / to /index.html so it hits the cache // rewrite / to /index.html so it hits the cache
@ -123,9 +123,13 @@ async function handleRequest(request) {
} }
} }
async function handleConfigRequest(request) { /**
* Stale-while-revalidate caching for certain files
* see https://developer.chrome.com/docs/workbox/caching-strategies-overview/#stale-while-revalidate
*/
async function handleStaleWhileRevalidateRequest(request) {
let response = await readCache(request); let response = await readCache(request);
const networkResponsePromise = fetchAndUpdateConfig(request); const networkResponsePromise = fetchAndUpdateCache(request);
if (response) { if (response) {
return response; return response;
} else { } else {
@ -133,7 +137,7 @@ async function handleConfigRequest(request) {
} }
} }
async function fetchAndUpdateConfig(request) { async function fetchAndUpdateCache(request) {
const response = await fetch(request, { const response = await fetch(request, {
signal: pendingFetchAbortController.signal, signal: pendingFetchAbortController.signal,
headers: { headers: {

View file

@ -93,10 +93,13 @@ export class SettingsView extends TemplateView {
]); ]);
}) })
); );
settingNodes.push( settingNodes.push(
t.h3("Preferences"), t.h3("Preferences"),
row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
t.if(vm => !import.meta.env.DEV && vm.activeTheme, (t, vm) => {
return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm));
}),
); );
settingNodes.push( settingNodes.push(
t.h3("Application"), t.h3("Application"),
@ -135,4 +138,13 @@ export class SettingsView extends TemplateView {
vm.i18n`no resizing`; vm.i18n`no resizing`;
})]; })];
} }
_themeOptions(t, vm) {
const activeTheme = vm.activeTheme;
const optionTags = [];
for (const name of vm.themes) {
optionTags.push(t.option({value: name, selected: name === activeTheme}, name));
}
return t.select({onChange: (e) => vm.setTheme(e.target.value)}, optionTags);
}
} }

View file

@ -16,27 +16,48 @@ export default defineConfig(({mode}) => {
sourcemap: true, sourcemap: true,
rollupOptions: { rollupOptions: {
output: { output: {
assetFileNames: (asset) => asset.name.includes("config.json") ? "assets/[name][extname]": "assets/[name].[hash][extname]", assetFileNames: (asset) =>
asset.name.includes("config.json") ||
asset.name.match(/theme-.+\.json/)
? "assets/[name][extname]"
: "assets/[name].[hash][extname]",
}, },
}, },
}, },
plugins: [ plugins: [
themeBuilder({ themeBuilder({
themeConfig: { themeConfig: {
themes: {"element": "./src/platform/web/ui/css/themes/element"}, themes: {
element: "./src/platform/web/ui/css/themes/element",
},
default: "element", default: "element",
}, },
compiledVariables compiledVariables,
}), }),
// important this comes before service worker // important this comes before service worker
// otherwise the manifest and the icons it refers to won't be cached // otherwise the manifest and the icons it refers to won't be cached
injectWebManifest("assets/manifest.json"), injectWebManifest("assets/manifest.json"),
injectServiceWorker("./src/platform/web/sw.js", ["index.html"], { injectServiceWorker("./src/platform/web/sw.js", findUnhashedFileNamesFromBundle, {
// placeholders to replace at end of build by chunk name // placeholders to replace at end of build by chunk name
"index": {DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH}, index: {
"sw": definePlaceholders DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH,
},
sw: definePlaceholders,
}), }),
], ],
define: definePlaceholders, define: definePlaceholders,
}); });
}); });
function findUnhashedFileNamesFromBundle(bundle) {
const names = ["index.html"];
for (const fileName of Object.keys(bundle)) {
if (fileName.includes("config.json")) {
names.push(fileName);
}
if (/theme-.+\.json/.test(fileName)) {
names.push(fileName);
}
}
return names;
}