Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ const paths = {
},
};

if (import.meta.env.PROD) {
paths["serviceWorker"] = "sw.js";
}

export default paths;
Binary file added frontend/assets/icon-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions frontend/assets/icon-maskable.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions frontend/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions frontend/assets/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "Chatrix",
"short_name": "Chatrix",
"display": "standalone",
"description": "Embedded Matrix client for WordPress",
"start_url": "../index.html",
"icons": [
{"src": "icon.png", "sizes": "384x384", "type": "image/png"},
{"src": "icon-maskable.png", "sizes": "384x384", "type": "image/png", "purpose": "maskable"}
],
"theme_color": "#007cba"
}
32 changes: 29 additions & 3 deletions frontend/build/vite.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import injectWebManifest from "hydrogen-web/scripts/build-plugins/manifest";
import themeBuilder from "hydrogen-web/scripts/build-plugins/rollup-plugin-build-themes";
import { createPlaceholderValues, injectServiceWorker } from "hydrogen-web/scripts/build-plugins/service-worker";
import compileVariables from "hydrogen-web/scripts/postcss/css-compile-variables";
import urlProcessor from "hydrogen-web/scripts/postcss/css-url-processor";
import urlVariables from "hydrogen-web/scripts/postcss/css-url-to-variables";
Expand All @@ -10,20 +12,21 @@ import { derive } from "./color";

const compiledVariables = new Map();

export function defaultConfig(rootDir: string, targetName: string) {
export function defaultConfig(mode: string, rootDir: string, targetName: string) {
const definePlaceholders = createPlaceholderValues(mode);
return {
base: "",
root: rootDir,
envDir: __dirname,
define: {
DEFINE_VERSION: JSON.stringify(manifest.version),
DEFINE_GLOBAL_HASH: JSON.stringify(null),
...definePlaceholders,
},
build: {
outDir: resolve(__dirname, `../../build/frontend/${targetName}`),
rollupOptions: {
input: {
app: resolve(rootDir, "index.html"),
index: resolve(rootDir, "index.html"),
},
output: {
assetFileNames: (asset) => {
Expand Down Expand Up @@ -59,6 +62,29 @@ export function defaultConfig(rootDir: string, targetName: string) {
},
compiledVariables,
}),
// Manifest must come before service worker so that the manifest and the icons it refers to are cached.
injectWebManifest(resolve(__dirname, "../assets/manifest.json")),
injectServiceWorker(
resolve(__dirname, `../platform/sw.js`),
findUnhashedFileNamesFromBundle,
{
// Placeholders to replace at end of the build by chunk name.
index: {
DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH,
},
sw: definePlaceholders,
}
),
],
};
}

function findUnhashedFileNamesFromBundle(bundle) {
const names = ["index.html"];
for (const fileName of Object.keys(bundle)) {
if (/theme-.+\.json/.test(fileName)) {
names.push(fileName);
}
}
return names;
}
15 changes: 13 additions & 2 deletions frontend/platform/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,31 @@ import { Platform as BasePlatform } from "hydrogen-web/src/platform/web/Platform
import { IConfig } from "../config/IConfig";
import { History } from "./History";
import { Navigation } from "./Navigation";
import { ServiceWorkerHandler } from "./ServiceWorkerHandler";
import { StorageFactory } from "./StorageFactory";

export class Platform extends BasePlatform {
private settingsStorage: SettingsStorage;
private sessionInfoStorage: SessionInfoStorage;
private storageFactory: StorageFactory;
private _serviceWorkerHandler: ServiceWorkerHandler;

constructor(options) {
const assetPaths = structuredClone(options.assetPaths);

// Unset serviceWorker path so that the base constructor doesn't register the service worker handler.
delete options.assetPaths.serviceWorker;
super(options);

// Register our own service worker handler.
if (assetPaths.serviceWorker && "serviceWorker" in navigator) {
this._serviceWorkerHandler = new ServiceWorkerHandler();
this._serviceWorkerHandler.registerAndStart(assetPaths.serviceWorker);
}

this.settingsStorage = new SettingsStorage("chatrix_setting_v1_");
this.sessionInfoStorage = new SessionInfoStorage("chatrix_sessions_v1");
// @ts-ignore
this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
// @ts-ignore
this.history = new History();
}

Expand Down
223 changes: 223 additions & 0 deletions frontend/platform/ServiceWorkerHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*
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.
*/

// 3 (imaginary) interfaces are implemented here:
// - OfflineAvailability (done by registering the sw)
// - UpdateService (see checkForUpdate method, and should also emit events rather than showing confirm dialog here)
// - ConcurrentAccessBlocker (see preventConcurrentSessionAccess method)
export class ServiceWorkerHandler {
constructor() {
this._waitingForReply = new Map();
this._messageIdCounter = 0;
this._navigation = null;
this._registration = null;
this._registrationPromise = null;
this._currentController = null;
this.haltRequests = false;
}

get version() {
return DEFINE_VERSION;
}

get buildHash() {
return DEFINE_GLOBAL_HASH;
}

setNavigation(navigation) {
this._navigation = navigation;
}

registerAndStart(path) {
this._registrationPromise = (async () => {
navigator.serviceWorker.addEventListener("message", this);
navigator.serviceWorker.addEventListener("controllerchange", this);
this._registration = await navigator.serviceWorker.register(path);
await navigator.serviceWorker.ready;
this._currentController = navigator.serviceWorker.controller;
this._registration.addEventListener("updatefound", this);
this._registrationPromise = null;
// do we have a new service worker waiting to activate?
if (this._registration.waiting && this._registration.active) {
await this._proposeUpdate();
}
console.log("Service Worker registered");
})();
}

_onMessage(event) {
const {data} = event;
const replyTo = data.replyTo;
if (replyTo) {
const resolve = this._waitingForReply.get(replyTo);
if (resolve) {
this._waitingForReply.delete(replyTo);
resolve(data.payload);
}
}
if (data.type === "hasSessionOpen") {
const hasOpen = this._navigation.observe("session").get() === data.payload.sessionId;
event.source.postMessage({replyTo: data.id, payload: hasOpen});
} else if (data.type === "hasRoomOpen") {
const hasSessionOpen = this._navigation.observe("session").get() === data.payload.sessionId;
const hasRoomOpen = this._navigation.observe("room").get() === data.payload.roomId;
event.source.postMessage({replyTo: data.id, payload: hasSessionOpen && hasRoomOpen});
} else if (data.type === "closeSession") {
const {sessionId} = data.payload;
this._closeSessionIfNeeded(sessionId).finally(() => {
event.source.postMessage({replyTo: data.id});
});
} else if (data.type === "haltRequests") {
// this flag is read in fetch.js
this.haltRequests = true;
event.source.postMessage({replyTo: data.id});
} else if (data.type === "openRoom") {
this._navigation.push("room", data.payload.roomId);
}
}

_closeSessionIfNeeded(sessionId) {
const currentSession = this._navigation?.path.get("session");
if (sessionId && currentSession?.value === sessionId) {
return new Promise(resolve => {
const unsubscribe = this._navigation.pathObservable.subscribe(path => {
const session = path.get("session");
if (!session || session.value !== sessionId) {
unsubscribe();
resolve();
}
});
this._navigation.push("session");
});
} else {
return Promise.resolve();
}
}

async _proposeUpdate() {
if (document.hidden) {
return;
}

if (!await this.confirm()) {
return;
}

console.log("Applying update");

// prevent any fetch requests from going to the service worker
// from any client, so that it is not kept active
// when calling skipWaiting on the new one
await this._sendAndWaitForReply("haltRequests");

// only once all requests are blocked, ask the new
// service worker to skipWaiting
await this._send("skipWaiting", null, this._registration.waiting);
}

async confirm() {
const dialog = document.getElementsByClassName('chatrix-update-confirm').item(0);

// Don't show dialog if browser doesn't support dialogs.
if (!dialog || typeof dialog.showModal !== 'function') {
return Promise.reject();
}

dialog.showModal();

return new Promise(function (resolve) {
dialog.addEventListener('close', () => {
resolve(dialog.returnValue === "update");
});
});
}

handleEvent(event) {
switch (event.type) {
case "message":
this._onMessage(event);
break;
case "updatefound":
this._registration.installing.addEventListener("statechange", this);
break;
case "statechange": {
if (event.target.state === "installed") {
this._proposeUpdate();
event.target.removeEventListener("statechange", this);
}
break;
}
case "controllerchange":
if (!this._currentController) {
// Clients.claim() in the SW can trigger a controllerchange event
// if we had no SW before. This is fine,
// and now our requests will be served from the SW.
this._currentController = navigator.serviceWorker.controller;
} else {
// active service worker changed,
// refresh, so we can get all assets
// (and not only some if we would not refresh)
// up to date from it
document.location.reload();
}
break;
}
}

async _send(type, payload, worker = undefined) {
if (this._registrationPromise) {
await this._registrationPromise;
}
if (!worker) {
worker = this._registration.active;
}
worker.postMessage({type, payload});
}

async _sendAndWaitForReply(type, payload, worker = undefined) {
if (this._registrationPromise) {
await this._registrationPromise;
}
if (!worker) {
worker = this._registration.active;
}
this._messageIdCounter += 1;
const id = this._messageIdCounter;
const promise = new Promise(resolve => {
this._waitingForReply.set(id, resolve);
});
worker.postMessage({type, id, payload});
return await promise;
}

async checkForUpdate() {
if (this._registrationPromise) {
await this._registrationPromise;
}
this._registration.update();
}

async preventConcurrentSessionAccess(sessionId) {
return this._sendAndWaitForReply("closeSession", {sessionId});
}

async getRegistration() {
if (this._registrationPromise) {
await this._registrationPromise;
}
return this._registration;
}
}
Loading