Merge branch 'master' into bwindels/calls

This commit is contained in:
Bruno Windels 2022-06-14 11:09:19 +02:00
commit ee5bd3b95f
7 changed files with 258 additions and 81 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.29", "version": "0.2.30",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": { "directories": {
"doc": "doc" "doc": "doc"

View file

@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const path = require('path'); const path = require('path').posix;
async function readCSSSource(location) { async function readCSSSource(location) {
const fs = require("fs").promises; const fs = require("fs").promises;
@ -246,6 +246,9 @@ module.exports = function buildThemes(options) {
}, },
generateBundle(_, bundle) { 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 { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
const manifestLocations = []; const manifestLocations = [];
for (const [location, chunkArray] of chunkMap) { for (const [location, chunkArray] of chunkMap) {
@ -254,10 +257,6 @@ module.exports = function buildThemes(options) {
const derivedVariables = compiledVariables["derived-variables"]; const derivedVariables = compiledVariables["derived-variables"];
const icon = compiledVariables["icon"]; const icon = compiledVariables["icon"];
const builtAssets = {}; 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) { for (const chunk of chunkArray) {
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName;

View file

@ -131,18 +131,14 @@ export class SettingsViewModel extends ViewModel {
return this._formatBytes(this._estimate?.usage); return this._formatBytes(this._estimate?.usage);
} }
get themes() { get themeMapping() {
return this.platform.themeLoader.themes; return this.platform.themeLoader.themeMapping;
} }
get activeTheme() { get activeTheme() {
return this._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";
@ -191,5 +187,15 @@ export class SettingsViewModel extends ViewModel {
this.emitChange("pushNotifications.serverError"); 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;
}
} }

View file

@ -192,9 +192,13 @@ export class Platform {
this._serviceWorkerHandler, this._serviceWorkerHandler,
this._config.push this._config.push
); );
if (this._themeLoader) {
const manifests = this.config["themeManifests"]; const manifests = this.config["themeManifests"];
await this._themeLoader?.init(manifests); await this._themeLoader?.init(manifests, log);
this._themeLoader?.setTheme(await this._themeLoader.getActiveTheme(), 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) { } catch (err) {
this._container.innerText = err.message; this._container.innerText = err.message;

View file

@ -17,59 +17,192 @@ limitations under the License.
import type {ILogItem} from "../../logging/types.js"; import type {ILogItem} from "../../logging/types.js";
import type {Platform} from "./Platform.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 { export class ThemeLoader {
private _platform: Platform; private _platform: Platform;
private _themeMapping: Record<string, string> = {}; private _themeMapping: Record<string, ThemeInformation>;
constructor(platform: Platform) { constructor(platform: Platform) {
this._platform = platform; this._platform = platform;
} }
async init(manifestLocations: string[]): Promise<void> { async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
for (const manifestLocation of manifestLocations) { await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
const { body } = await this._platform this._themeMapping = {};
.request(manifestLocation, { const results = await Promise.all(
method: "GET", manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
format: "json", );
cache: true, results.forEach(({ body }) => this._populateThemeMap(body, log));
})
.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[] { private _populateThemeMap(manifest, log: ILogItem) {
return Object.keys(this._themeMapping); 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<string, string> = 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
};
}
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 });
});
} }
async getActiveTheme(): Promise<string|undefined> { setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
// check if theme is set via settings this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
let theme = await this._platform.settingsStorage.getString("theme"); let cssLocation: string;
if (theme) { let themeDetails = this._themeMapping[themeName];
return theme; if ("id" in themeDetails) {
cssLocation = themeDetails.cssLocation;
} }
// return default theme 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");
}
});
}
/** Maps theme display name to theme information */
get themeMapping(): Record<string, ThemeInformation> {
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 { 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) { if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return this._platform.config["defaultTheme"].dark; return ColorSchemePreference.Dark;
} else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return this._platform.config["defaultTheme"].light;
} }
return undefined; else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return ColorSchemePreference.Light;
}
throw new Error("Cannot find preferred colorscheme!");
} }
} }

View file

@ -1,13 +1,7 @@
{ {
"version": 1, "version": 1,
"name": "element", "name": "Element",
"values": { "values": {
"font-faces": [
{
"font-family": "Inter",
"src": [{"asset": "/fonts/Inter.ttf", "format": "ttf"}]
}
],
"variants": { "variants": {
"light": { "light": {
"base": true, "base": true,

View file

@ -145,12 +145,53 @@ export class SettingsView extends TemplateView {
} }
_themeOptions(t, vm) { _themeOptions(t, vm) {
const activeTheme = vm.activeTheme; const { themeName: activeThemeName, themeVariant: activeThemeVariant } = vm.activeTheme;
const optionTags = []; const optionTags = [];
for (const name of vm.themes) { // 1. render the dropdown containing the themes
optionTags.push(t.option({value: name, selected: name === activeTheme}, name)); 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]);
} }
} }