Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs

This commit is contained in:
Bruno Windels 2022-05-30 14:15:19 +02:00 committed by GitHub
commit 1b2a6b5d0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 260 additions and 30 deletions

View file

@ -10,13 +10,34 @@ Hydrogen's goals are:
- It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities. - It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities.
- Loading (unused) parts of the application after initial page load should be supported - Loading (unused) parts of the application after initial page load should be supported
For embedded usage, see the [SDK instructions](doc/SDK.md).
If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org). If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org).
# How to use # How to use
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can run it locally `yarn install` (only the first time) and `yarn start` in the terminal, and point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md). Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can also deploy Hydrogen on your own web server:
Hydrogen uses symbolic links in the codebase, so if you are on Windows, have a look at [making git & symlinks work](https://github.com/git-for-windows/git/wiki/Symbolic-Links) there. 1. Download the [latest release package](https://github.com/vector-im/hydrogen-web/releases).
1. Extract the package to the public directory of your web server.
1. If this is your first deploy:
1. copy `config.sample.json` to `config.json` and if needed, make any modifications (unless you've set up your own [sygnal](https://github.com/matrix-org/sygnal) instance, you don't need to change anything in the `push` section).
1. Disable caching entirely on the server for:
- `index.html`
- `sw.js`
- `config.json`
- All theme manifests referenced in the `themeManifests` of `config.json`, these files are typically called `theme-{name}.json`.
These resources will still be cached client-side by the service worker. Because of this; you'll still need to refresh the app twice before config.json changes are applied.
## Set up a dev environment
You can run Hydrogen locally by the following commands in the terminal:
- `yarn install` (only the first time)
- `yarn start` in the terminal
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
# FAQ # FAQ

View file

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.28", "version": "0.2.29",
"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"
@ -13,7 +13,7 @@
"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",
"test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.js", "test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.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",
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch" "watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
}, },

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 === "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, undefined, 2));
}
}
}
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

@ -2,6 +2,9 @@ VERSION=$(jq -r ".version" package.json)
PACKAGE=hydrogen-web-$VERSION.tar.gz PACKAGE=hydrogen-web-$VERSION.tar.gz
yarn build yarn build
pushd target pushd target
# move config file so we don't override it
# when deploying a new version
mv config.json config.sample.json
tar -czvf ../$PACKAGE ./ tar -czvf ../$PACKAGE ./
popd popd
echo $PACKAGE echo $PACKAGE

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,36 @@ 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) { try {
if (!this._configURL) { await this.logger.run("Platform init", async (log) => {
throw new Error("Neither config nor configURL was provided!"); if (!this._config) {
} if (!this._configURL) {
const {body}= await this.request(this._configURL, {method: "GET", format: "json", cache: true}).response(); throw new Error("Neither config nor configURL was provided!");
this._config = body; }
const {status, body}= await this.request(this._configURL, {method: "GET", format: "json", cache: true}).response();
if (status === 404) {
throw new Error(`Could not find ${this._configURL}. Did you copy over config.sample.json?`);
} else if (status >= 400) {
throw new Error(`Got status ${status} while trying to fetch ${this._configURL}`);
}
this._config = body;
}
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);
});
} catch (err) {
this._container.innerText = err.message;
throw err;
} }
this.notificationService = new NotificationService(
this._serviceWorkerHandler,
this._config.push
);
} }
_createLogger(isDevelopment) { _createLogger(isDevelopment) {
@ -307,6 +324,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: {
@ -156,8 +160,14 @@ async function updateCache(request, response) {
cache.put(request, response.clone()); cache.put(request, response.clone());
} else if (request.url.startsWith(baseURL)) { } else if (request.url.startsWith(baseURL)) {
let assetName = request.url.substr(baseURL.length); let assetName = request.url.substr(baseURL.length);
let cacheName;
if (HASHED_CACHED_ON_REQUEST_ASSETS.includes(assetName)) { if (HASHED_CACHED_ON_REQUEST_ASSETS.includes(assetName)) {
const cache = await caches.open(hashedCacheName); cacheName = hashedCacheName;
} else if (UNHASHED_PRECACHED_ASSETS.includes(assetName)) {
cacheName = unhashedCacheName;
}
if (cacheName) {
const cache = await caches.open(cacheName);
await cache.put(request, response.clone()); await cache.put(request, response.clone());
} }
} }

View file

@ -97,6 +97,9 @@ 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,54 @@ 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) => {
if (asset.name.includes("config.json")) {
return "[name][extname]";
}
else if (asset.name.match(/theme-.+\.json/)) {
return "assets/[name][extname]";
}
else {
return "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;
}