Merge pull request #724 from vector-im/theme-chooser
Implement theme chooser in settings
This commit is contained in:
commit
36ddd61318
10 changed files with 210 additions and 26 deletions
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
3
scripts/cleanup.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
# Remove icons created in .tmp
|
||||
rm -rf .tmp
|
|
@ -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";
|
||||
|
|
|
@ -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,9 +165,11 @@ 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() {
|
||||
await this.logger.run("Platform init", async (log) => {
|
||||
if (!this._config) {
|
||||
if (!this._configURL) {
|
||||
throw new Error("Neither config nor configURL was provided!");
|
||||
|
@ -178,6 +181,10 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
_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();
|
||||
}
|
||||
|
|
75
src/platform/web/ThemeLoader.ts
Normal file
75
src/platform/web/ThemeLoader.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -97,6 +97,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Reference in a new issue