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: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",
"build": "vite build",
"build": "vite build && ./scripts/cleanup.sh",
"build:sdk": "./scripts/sdk/build.sh"
},
"repository": {

View file

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

View file

@ -8,7 +8,7 @@ function contentHash(str) {
return hasher.digest();
}
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
const swName = path.basename(swFile);
let root;
let version;
@ -31,6 +31,7 @@ function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
logger = config.logger;
},
generateBundle: async function(options, bundle) {
const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle);
const unhashedFilenames = [swName].concat(otherUnhashedFiles);
const unhashedFileContentMap = unhashedFilenames.reduce((map, 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.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined;
}
get _session() {
@ -76,6 +77,9 @@ export class SettingsViewModel extends ViewModel {
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
if (!import.meta.env.DEV) {
this._activeTheme = await this.platform.themeLoader.getActiveTheme();
}
this.emitChange("");
}
@ -127,6 +131,18 @@ export class SettingsViewModel extends ViewModel {
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) {
if (typeof n === "number") {
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 {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar";
import {ThemeLoader} from "./ThemeLoader";
function addScript(src) {
return new Promise(function (resolve, reject) {
@ -164,20 +165,26 @@ export class Platform {
this._disposables = new Disposables();
this._olmPromise = undefined;
this._workerPromise = undefined;
this._themeLoader = import.meta.env.DEV? null: new ThemeLoader(this);
}
async init() {
if (!this._config) {
if (!this._configURL) {
throw new Error("Neither config nor configURL was provided!");
await this.logger.run("Platform init", async (log) => {
if (!this._config) {
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._config = body;
}
this.notificationService = new NotificationService(
this._serviceWorkerHandler,
this._config.push
);
this.notificationService = new NotificationService(
this._serviceWorkerHandler,
this._config.push
);
const manifests = this.config["themeManifests"];
await this._themeLoader?.init(manifests);
this._themeLoader?.setTheme(await this._themeLoader.getActiveTheme(), log);
});
}
_createLogger(isDevelopment) {
@ -307,6 +314,23 @@ export class Platform {
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() {
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) {
try {
if (request.url.includes("config.json")) {
return handleConfigRequest(request);
if (request.url.includes("config.json") || /theme-.+\.json/.test(request.url)) {
return handleStaleWhileRevalidateRequest(request);
}
const url = new URL(request.url);
// 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);
const networkResponsePromise = fetchAndUpdateConfig(request);
const networkResponsePromise = fetchAndUpdateCache(request);
if (response) {
return response;
} else {
@ -133,7 +137,7 @@ async function handleConfigRequest(request) {
}
}
async function fetchAndUpdateConfig(request) {
async function fetchAndUpdateCache(request) {
const response = await fetch(request, {
signal: pendingFetchAbortController.signal,
headers: {

View file

@ -93,10 +93,13 @@ export class SettingsView extends TemplateView {
]);
})
);
settingNodes.push(
t.h3("Preferences"),
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(
t.h3("Application"),
@ -135,4 +138,13 @@ export class SettingsView extends TemplateView {
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,
rollupOptions: {
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: [
themeBuilder({
themeConfig: {
themes: {"element": "./src/platform/web/ui/css/themes/element"},
themes: {
element: "./src/platform/web/ui/css/themes/element",
},
default: "element",
},
compiledVariables
compiledVariables,
}),
// important this comes before service worker
// otherwise the manifest and the icons it refers to won't be cached
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
"index": {DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH},
"sw": definePlaceholders
index: {
DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH,
},
sw: 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;
}