Merge pull request #283 from vector-im/bwindels/pushnotifs

Basic push notification support
This commit is contained in:
Bruno Windels 2021-03-25 09:31:05 +00:00 committed by GitHub
commit fb6f31d958
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 485 additions and 42 deletions

7
assets/config.json Normal file
View file

@ -0,0 +1,7 @@
{
"push": {
"appId": "io.element.hydrogen.web",
"gatewayUrl": "https://matrix.org",
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
}
}

22
doc/impl-thoughts/PUSH.md Normal file
View file

@ -0,0 +1,22 @@
# Push Notifications
- we setup the app on the sygnal server, with an app_id (io.element.hydrogen.web), generating a key pair
- we create a web push subscription, passing the server pub key, and get `endpoint`, `p256dh` and `auth` back. We put `webpush_endpoint` and `auth` in the push data, and use `p256dh` as the push key?
- we call `POST /_matrix/client/r0/pushers/set` on the homeserver with the sygnal instance url. We pass the web push subscription as pusher data.
- the homeserver wants to send out a notification, calling sygnal on `POST /_matrix/push/v1/notify` with for each device the pusher data.
- we encrypt and send with the data in the data for each device in the notification
- this wakes up the service worker
- now we need to find which local session id this notification is for
## Testing/development
- set up local synapse
- set up local sygnal
- write pushkin
- configure "hydrogen" app in sygnal config with a webpush pushkin
- start writing service worker code in hydrogen (we'll need to enable it for local dev)
- try to get a notification through
## Questions
- do we use the `event_id_only` format?
- for e2ee rooms, are we fine with just showing "Bob sent you a message (in room if not DM)", or do we want to sync and show the actual message? perhaps former can be MVP.

View file

@ -24,6 +24,11 @@
main(new Platform(document.body, { main(new Platform(document.body, {
worker: "src/worker.js", worker: "src/worker.js",
downloadSandbox: "assets/download-sandbox.html", downloadSandbox: "assets/download-sandbox.html",
// NOTE: uncomment this if you want the service worker for local development
// serviceWorker: "sw.js",
// NOTE: provide push config if you want push notifs for local development
// see assets/config.json for what the config looks like
// push: {...},
olm: { olm: {
wasm: "lib/olm/olm.wasm", wasm: "lib/olm/olm.wasm",
legacyBundle: "lib/olm/olm_legacy.js", legacyBundle: "lib/olm/olm_legacy.js",

View file

@ -79,6 +79,7 @@ async function build({modernOnly}) {
await assets.write(`worker.js`, await buildJsLegacy("src/platform/web/worker/main.js", ['src/platform/web/worker/polyfill.js'])); await assets.write(`worker.js`, await buildJsLegacy("src/platform/web/worker/main.js", ['src/platform/web/worker/polyfill.js']));
} }
// copy over non-theme assets // copy over non-theme assets
const baseConfig = JSON.parse(await fs.readFile(path.join(projectDir, "assets/config.json"), {encoding: "utf8"}));
const downloadSandbox = "download-sandbox.html"; const downloadSandbox = "download-sandbox.html";
let downloadSandboxHtml = await fs.readFile(path.join(projectDir, `assets/${downloadSandbox}`)); let downloadSandboxHtml = await fs.readFile(path.join(projectDir, `assets/${downloadSandbox}`));
await assets.write(downloadSandbox, downloadSandboxHtml); await assets.write(downloadSandbox, downloadSandboxHtml);
@ -89,13 +90,13 @@ async function build({modernOnly}) {
await buildManifest(assets); await buildManifest(assets);
// all assets have been added, create a hash from all assets name to cache unhashed files like index.html // all assets have been added, create a hash from all assets name to cache unhashed files like index.html
assets.addToHashForAll("index.html", devHtml); assets.addToHashForAll("index.html", devHtml);
let swSource = await fs.readFile(path.join(projectDir, "src/platform/web/service-worker.template.js"), "utf8"); let swSource = await fs.readFile(path.join(projectDir, "src/platform/web/service-worker.js"), "utf8");
assets.addToHashForAll("sw.js", swSource); assets.addToHashForAll("sw.js", swSource);
const globalHash = assets.hashForAll(); const globalHash = assets.hashForAll();
await buildServiceWorker(swSource, version, globalHash, assets); await buildServiceWorker(swSource, version, globalHash, assets);
await buildHtml(doc, version, globalHash, modernOnly, assets); await buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets);
console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`); console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`);
} }
@ -135,7 +136,7 @@ async function copyThemeAssets(themes, assets) {
return assets; return assets;
} }
async function buildHtml(doc, version, globalHash, modernOnly, assets) { async function buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets) {
// transform html file // transform html file
// change path to main.css to css bundle // change path to main.css to css bundle
doc("link[rel=stylesheet]:not([title])").attr("href", assets.resolve(`hydrogen.css`)); doc("link[rel=stylesheet]:not([title])").attr("href", assets.resolve(`hydrogen.css`));
@ -145,7 +146,7 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) {
findThemes(doc, (themeName, theme) => { findThemes(doc, (themeName, theme) => {
theme.attr("href", assets.resolve(`themes/${themeName}/bundle.css`)); theme.attr("href", assets.resolve(`themes/${themeName}/bundle.css`));
}); });
const pathsJSON = JSON.stringify({ const configJSON = JSON.stringify(Object.assign({}, baseConfig, {
worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null, worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null,
downloadSandbox: assets.resolve("download-sandbox.html"), downloadSandbox: assets.resolve("download-sandbox.html"),
serviceWorker: "sw.js", serviceWorker: "sw.js",
@ -154,14 +155,14 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) {
legacyBundle: assets.resolve("olm_legacy.js"), legacyBundle: assets.resolve("olm_legacy.js"),
wasmBundle: assets.resolve("olm.js"), wasmBundle: assets.resolve("olm.js"),
} }
}); }));
const mainScripts = [ const mainScripts = [
`<script type="module">import {main, Platform} from "./${assets.resolve(`hydrogen.js`)}"; main(new Platform(document.body, ${pathsJSON}));</script>` `<script type="module">import {main, Platform} from "./${assets.resolve(`hydrogen.js`)}"; main(new Platform(document.body, ${configJSON}));</script>`
]; ];
if (!modernOnly) { if (!modernOnly) {
mainScripts.push( mainScripts.push(
`<script type="text/javascript" nomodule src="${assets.resolve(`hydrogen-legacy.js`)}"></script>`, `<script type="text/javascript" nomodule src="${assets.resolve(`hydrogen-legacy.js`)}"></script>`,
`<script type="text/javascript" nomodule>hydrogen.main(new hydrogen.Platform(document.body, ${pathsJSON}));</script>` `<script type="text/javascript" nomodule>hydrogen.main(new hydrogen.Platform(document.body, ${configJSON}));</script>`
); );
} }
doc("script#main").replaceWith(mainScripts.join("")); doc("script#main").replaceWith(mainScripts.join(""));
@ -269,12 +270,30 @@ async function buildServiceWorker(swSource, version, globalHash, assets) {
hashedCachedOnRequestAssets.push(resolved); hashedCachedOnRequestAssets.push(resolved);
} }
} }
const replaceArrayInSource = (name, value) => {
const newSource = swSource.replace(`${name} = []`, `${name} = ${JSON.stringify(value)}`);
if (newSource === swSource) {
throw new Error(`${name} was not found in the service worker source`);
}
return newSource;
};
const replaceStringInSource = (name, value) => {
const newSource = swSource.replace(new RegExp(`${name}\\s=\\s"[^"]*"`), `${name} = ${JSON.stringify(value)}`);
if (newSource === swSource) {
throw new Error(`${name} was not found in the service worker source`);
}
return newSource;
};
// write service worker // write service worker
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`); swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`);
swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`); swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
swSource = swSource.replace(`"%%UNHASHED_PRECACHED_ASSETS%%"`, JSON.stringify(unhashedPreCachedAssets)); swSource = replaceArrayInSource("UNHASHED_PRECACHED_ASSETS", unhashedPreCachedAssets);
swSource = swSource.replace(`"%%HASHED_PRECACHED_ASSETS%%"`, JSON.stringify(hashedPreCachedAssets)); swSource = replaceArrayInSource("HASHED_PRECACHED_ASSETS", hashedPreCachedAssets);
swSource = swSource.replace(`"%%HASHED_CACHED_ON_REQUEST_ASSETS%%"`, JSON.stringify(hashedCachedOnRequestAssets)); swSource = replaceArrayInSource("HASHED_CACHED_ON_REQUEST_ASSETS", hashedCachedOnRequestAssets);
swSource = replaceStringInSource("NOTIFICATION_BADGE_ICON", assets.resolve("icon.png"));
// service worker should not have a hashed name as it is polled by the browser for updates // service worker should not have a hashed name as it is polled by the browser for updates
await assets.writeUnhashed("sw.js", swSource); await assets.writeUnhashed("sw.js", swSource);
} }

View file

@ -17,6 +17,14 @@ limitations under the License.
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
import {SessionBackupViewModel} from "./SessionBackupViewModel.js"; import {SessionBackupViewModel} from "./SessionBackupViewModel.js";
class PushNotificationStatus {
constructor() {
this.supported = null;
this.enabled = false;
this.updating = false;
}
}
function formatKey(key) { function formatKey(key) {
const partLength = 4; const partLength = 4;
const partCount = Math.ceil(key.length / partLength); const partCount = Math.ceil(key.length / partLength);
@ -40,6 +48,7 @@ export class SettingsViewModel extends ViewModel {
this.sentImageSizeLimit = null; this.sentImageSizeLimit = null;
this.minSentImageSizeLimit = 400; this.minSentImageSizeLimit = 400;
this.maxSentImageSizeLimit = 4000; this.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus();
} }
setSentImageSizeLimit(size) { setSentImageSizeLimit(size) {
@ -56,6 +65,8 @@ export class SettingsViewModel extends ViewModel {
async load() { async load() {
this._estimate = await this.platform.estimateStorageUsage(); this._estimate = await this.platform.estimateStorageUsage();
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.enabled = await this._session.arePushNotificationsEnabled();
this.emitChange(""); this.emitChange("");
} }
@ -115,5 +126,21 @@ export class SettingsViewModel extends ViewModel {
const logExport = await this.logger.export(); const logExport = await this.logger.export();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
} }
async togglePushNotifications() {
this.pushNotifications.updating = true;
this.emitChange("pushNotifications.updating");
try {
if (await this._session.enablePushNotifications(!this.pushNotifications.enabled)) {
this.pushNotifications.enabled = !this.pushNotifications.enabled;
if (this.pushNotifications.enabled) {
this.platform.notificationService.showNotification(this.i18n`Push notifications are now enabled`);
}
}
} finally {
this.pushNotifications.updating = false;
this.emitChange("pushNotifications.updating");
}
}
} }

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {Room} from "./room/Room.js"; import {Room} from "./room/Room.js";
import {Pusher} from "./push/Pusher.js";
import { ObservableMap } from "../observable/index.js"; import { ObservableMap } from "../observable/index.js";
import {User} from "./User.js"; import {User} from "./User.js";
import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
@ -38,6 +39,7 @@ import {SecretStorage} from "./ssss/SecretStorage.js";
import {ObservableValue} from "../observable/ObservableValue.js"; import {ObservableValue} from "../observable/ObservableValue.js";
const PICKLE_KEY = "DEFAULT_KEY"; const PICKLE_KEY = "DEFAULT_KEY";
const PUSHER_KEY = "pusher";
export class Session { export class Session {
// sessionInfo contains deviceId, userId and homeServer // sessionInfo contains deviceId, userId and homeServer
@ -466,6 +468,60 @@ export class Session {
get user() { get user() {
return this._user; return this._user;
} }
enablePushNotifications(enable) {
if (enable) {
return this._enablePush();
} else {
return this._disablePush();
}
}
async _enablePush() {
return this._platform.logger.run("enablePush", async log => {
const defaultPayload = Pusher.createDefaultPayload(this._sessionInfo.id);
const pusher = await this._platform.notificationService.enablePush(Pusher, defaultPayload);
if (!pusher) {
log.set("no_pusher", true);
return false;
}
await pusher.enable(this._hsApi, log);
// store pusher data, so we know we enabled it across reloads,
// and we can disable it without too much hassle
const txn = await this._storage.readWriteTxn([this._storage.storeNames.session]);
txn.session.set(PUSHER_KEY, pusher.serialize());
await txn.complete();
return true;
});
}
async _disablePush() {
return this._platform.logger.run("disablePush", async log => {
await this._platform.notificationService.disablePush();
const readTxn = await this._storage.readTxn([this._storage.storeNames.session]);
const pusherData = await readTxn.session.get(PUSHER_KEY);
if (!pusherData) {
// we've disabled push in the notif service at least
return true;
}
const pusher = new Pusher(pusherData);
await pusher.disable(this._hsApi, log);
const txn = await this._storage.readWriteTxn([this._storage.storeNames.session]);
txn.session.remove(PUSHER_KEY);
await txn.complete();
return true;
});
}
async arePushNotificationsEnabled() {
if (!await this._platform.notificationService.isPushEnabled()) {
return false;
}
const readTxn = await this._storage.readTxn([this._storage.storeNames.session]);
const pusherData = await readTxn.session.get(PUSHER_KEY);
return !!pusherData;
}
} }
export function tests() { export function tests() {

View file

@ -166,6 +166,7 @@ export class SessionContainer {
this._storage = await this._platform.storageFactory.create(sessionInfo.id); this._storage = await this._platform.storageFactory.create(sessionInfo.id);
// no need to pass access token to session // no need to pass access token to session
const filteredSessionInfo = { const filteredSessionInfo = {
id: sessionInfo.id,
deviceId: sessionInfo.deviceId, deviceId: sessionInfo.deviceId,
userId: sessionInfo.userId, userId: sessionInfo.userId,
homeServer: sessionInfo.homeServer, homeServer: sessionInfo.homeServer,

View file

@ -254,6 +254,10 @@ export class HomeServerApi {
uploadAttachment(blob, filename, options = null) { uploadAttachment(blob, filename, options = null) {
return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options); return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options);
} }
setPusher(pusher, options = null) {
return this._post("/pushers/set", null, pusher, options);
}
} }
export function tests() { export function tests() {

40
src/matrix/push/Pusher.js Normal file
View file

@ -0,0 +1,40 @@
export class Pusher {
constructor(description) {
this._description = description;
}
static httpPusher(host, appId, pushkey, data) {
return new Pusher({
kind: "http",
append: true, // as pushkeys are shared between multiple users on one origin
data: Object.assign({}, data, {url: host + "/_matrix/push/v1/notify"}),
pushkey,
app_id: appId,
app_display_name: "Hydrogen",
device_display_name: "Hydrogen",
lang: "en"
});
}
static createDefaultPayload(sessionId) {
return {session_id: sessionId};
}
async enable(hsApi, log) {
try {
log.set("endpoint", new URL(this._description.data.endpoint).host);
} catch {
log.set("endpoint", null);
}
await hsApi.setPusher(this._description, {log}).response();
}
async disable(hsApi, log) {
const deleteDescription = Object.assign({}, this._description, {kind: null});
await hsApi.setPusher(deleteDescription, {log}).response();
}
serialize() {
return this._description;
}
}

View file

@ -26,6 +26,7 @@ import {ConsoleLogger} from "../../logging/ConsoleLogger.js";
import {RootView} from "./ui/RootView.js"; import {RootView} from "./ui/RootView.js";
import {Clock} from "./dom/Clock.js"; import {Clock} from "./dom/Clock.js";
import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js"; import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js";
import {NotificationService} from "./dom/NotificationService.js";
import {History} from "./dom/History.js"; import {History} from "./dom/History.js";
import {OnlineStatus} from "./dom/OnlineStatus.js"; import {OnlineStatus} from "./dom/OnlineStatus.js";
import {Crypto} from "./dom/Crypto.js"; import {Crypto} from "./dom/Crypto.js";
@ -73,18 +74,18 @@ function relPath(path, basePath) {
return "../".repeat(dirCount) + path; return "../".repeat(dirCount) + path;
} }
async function loadOlmWorker(paths) { async function loadOlmWorker(config) {
const workerPool = new WorkerPool(paths.worker, 4); const workerPool = new WorkerPool(config.worker, 4);
await workerPool.init(); await workerPool.init();
const path = relPath(paths.olm.legacyBundle, paths.worker); const path = relPath(config.olm.legacyBundle, config.worker);
await workerPool.sendAll({type: "load_olm", path}); await workerPool.sendAll({type: "load_olm", path});
const olmWorker = new OlmWorker(workerPool); const olmWorker = new OlmWorker(workerPool);
return olmWorker; return olmWorker;
} }
export class Platform { export class Platform {
constructor(container, paths, cryptoExtras = null, options = null) { constructor(container, config, cryptoExtras = null, options = null) {
this._paths = paths; this._config = config;
this._container = container; this._container = container;
this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_"); this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_");
this.clock = new Clock(); this.clock = new Clock();
@ -98,10 +99,11 @@ export class Platform {
this.history = new History(); this.history = new History();
this.onlineStatus = new OnlineStatus(); this.onlineStatus = new OnlineStatus();
this._serviceWorkerHandler = null; this._serviceWorkerHandler = null;
if (paths.serviceWorker && "serviceWorker" in navigator) { if (config.serviceWorker && "serviceWorker" in navigator) {
this._serviceWorkerHandler = new ServiceWorkerHandler(); this._serviceWorkerHandler = new ServiceWorkerHandler();
this._serviceWorkerHandler.registerAndStart(paths.serviceWorker); this._serviceWorkerHandler.registerAndStart(config.serviceWorker);
} }
this.notificationService = new NotificationService(this._serviceWorkerHandler, config.push);
this.crypto = new Crypto(cryptoExtras); this.crypto = new Crypto(cryptoExtras);
this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
@ -120,12 +122,12 @@ export class Platform {
} }
loadOlm() { loadOlm() {
return loadOlm(this._paths.olm); return loadOlm(this._config.olm);
} }
async loadOlmWorker() { async loadOlmWorker() {
if (!window.WebAssembly) { if (!window.WebAssembly) {
return await loadOlmWorker(this._paths); return await loadOlmWorker(this._config);
} }
} }
@ -150,7 +152,7 @@ export class Platform {
if (navigator.msSaveBlob) { if (navigator.msSaveBlob) {
navigator.msSaveBlob(blobHandle.nativeBlob, filename); navigator.msSaveBlob(blobHandle.nativeBlob, filename);
} else { } else {
downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle, filename); downloadInIframe(this._container, this._config.downloadSandbox, blobHandle, filename);
} }
} }

View file

@ -0,0 +1,101 @@
/*
Copyright 2021 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.
*/
export class NotificationService {
constructor(serviceWorkerHandler, pushConfig) {
this._serviceWorkerHandler = serviceWorkerHandler;
this._pushConfig = pushConfig;
}
async enablePush(pusherFactory, defaultPayload) {
const registration = await this._serviceWorkerHandler?.getRegistration();
if (registration?.pushManager) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this._pushConfig.applicationServerKey,
});
const subscriptionData = subscription.toJSON();
const pushkey = subscriptionData.keys.p256dh;
const data = {
endpoint: subscriptionData.endpoint,
auth: subscriptionData.keys.auth,
default_payload: defaultPayload
};
return pusherFactory.httpPusher(
this._pushConfig.gatewayUrl,
this._pushConfig.appId,
pushkey,
data
);
}
}
async disablePush() {
const registration = await this._serviceWorkerHandler?.getRegistration();
if (registration?.pushManager) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
}
}
async isPushEnabled() {
const registration = await this._serviceWorkerHandler?.getRegistration();
if (registration?.pushManager) {
const subscription = await registration.pushManager.getSubscription();
return !!subscription;
}
return false;
}
async supportsPush() {
if (!this._pushConfig) {
return false;
}
const registration = await this._serviceWorkerHandler?.getRegistration();
return registration && "pushManager" in registration;
}
async enableNotifications() {
if ("Notification" in window) {
return (await Notification.requestPermission()) === "granted";
}
return false;
}
async supportsNotifications() {
return "Notification" in window;
}
async areNotificationsEnabled() {
if ("Notification" in window) {
return Notification.permission === "granted";
} else {
return false;
}
}
async showNotification(title, body = undefined) {
if ("Notification" in window) {
new Notification(title, {body});
return;
}
// Chrome on Android does not support the Notification constructor
const registration = await this._serviceWorkerHandler?.getRegistration();
registration?.showNotification(title, {body});
}
}

View file

@ -60,7 +60,14 @@ export class ServiceWorkerHandler {
resolve(data.payload); resolve(data.payload);
} }
} }
if (data.type === "closeSession") { 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; const {sessionId} = data.payload;
this._closeSessionIfNeeded(sessionId).finally(() => { this._closeSessionIfNeeded(sessionId).finally(() => {
event.source.postMessage({replyTo: data.id}); event.source.postMessage({replyTo: data.id});
@ -94,7 +101,7 @@ export class ServiceWorkerHandler {
if (document.hidden) { if (document.hidden) {
return; return;
} }
const version = await this._sendAndWaitForReply("version"); const version = await this._sendAndWaitForReply("version", null, this._registration.waiting);
if (confirm(`Version ${version.version} (${version.buildHash}) is available. Reload to apply?`)) { if (confirm(`Version ${version.version} (${version.buildHash}) is available. Reload to apply?`)) {
// prevent any fetch requests from going to the service worker // prevent any fetch requests from going to the service worker
// from any client, so that it is not kept active // from any client, so that it is not kept active
@ -182,4 +189,11 @@ export class ServiceWorkerHandler {
async preventConcurrentSessionAccess(sessionId) { async preventConcurrentSessionAccess(sessionId) {
return this._sendAndWaitForReply("closeSession", {sessionId}); return this._sendAndWaitForReply("closeSession", {sessionId});
} }
async getRegistration() {
if (this._registrationPromise) {
await this._registrationPromise;
}
return this._registration;
}
} }

View file

@ -17,9 +17,10 @@ limitations under the License.
const VERSION = "%%VERSION%%"; const VERSION = "%%VERSION%%";
const GLOBAL_HASH = "%%GLOBAL_HASH%%"; const GLOBAL_HASH = "%%GLOBAL_HASH%%";
const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%"; const UNHASHED_PRECACHED_ASSETS = [];
const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%"; const HASHED_PRECACHED_ASSETS = [];
const HASHED_CACHED_ON_REQUEST_ASSETS = "%%HASHED_CACHED_ON_REQUEST_ASSETS%%"; const HASHED_CACHED_ON_REQUEST_ASSETS = [];
const NOTIFICATION_BADGE_ICON = "assets/icon.png";
const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`; const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`;
const hashedCacheName = `hydrogen-assets`; const hashedCacheName = `hydrogen-assets`;
const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`; const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`;
@ -185,6 +186,107 @@ self.addEventListener('message', (event) => {
} }
}); });
const NOTIF_TAG_NEW_MESSAGE = "new_message";
async function openClientFromNotif(event) {
if (event.notification.tag !== NOTIF_TAG_NEW_MESSAGE) {
console.log("clicked notif with tag", event.notification.tag);
return;
}
const {sessionId, roomId} = event.notification.data;
const sessionHash = `#/session/${sessionId}`;
const roomHash = `${sessionHash}/room/${roomId}`;
const roomURL = `/${roomHash}`;
const clientWithSession = await findClient(async client => {
return await sendAndWaitForReply(client, "hasSessionOpen", {sessionId});
});
if (clientWithSession) {
console.log("notificationclick: client has session open, showing room there");
clientWithSession.navigate(roomURL);
if ('focus' in clientWithSession) {
try {
await clientWithSession.focus();
} catch (err) { console.error(err); } // I've had this throw on me on Android
}
} else if (self.clients.openWindow) {
console.log("notificationclick: no client found with session open, opening new window");
await self.clients.openWindow(roomURL);
}
}
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(openClientFromNotif(event));
});
async function handlePushNotification(n) {
console.log("got a push message", n);
const sessionId = n.session_id;
let sender = n.sender_display_name || n.sender;
if (sender && n.event_id) {
const roomId = n.room_id;
const hasFocusedClientOnRoom = !!await findClient(async client => {
if (client.visibilityState === "visible" && client.focused) {
return await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId});
}
});
if (hasFocusedClientOnRoom) {
console.log("client is focused, room is open, don't show notif");
return;
}
const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE}));
const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId);
const nonMultiNotifsForRoom = newMessageNotifs.filter(n => !n.data.multi);
const roomName = n.room_name || n.room_alias;
const hasMultiNotification = notifsForRoom.some(n => n.data.multi);
let notifsToClose;
let multi = false;
let label;
let body;
if (hasMultiNotification) {
console.log("already have a multi message, don't do anything");
return;
} else if (nonMultiNotifsForRoom.length) {
notifsToClose = nonMultiNotifsForRoom;
console.log("showing multi message notification");
multi = true;
label = roomName || sender;
body = "New messages";
} else {
console.log("showing new message notification");
if (roomName && roomName !== sender) {
label = `${sender} in ${roomName}`;
} else {
label = sender;
}
body = n.content?.body || "New message";
}
await self.registration.showNotification(label, {
body,
data: {sessionId, roomId, multi},
tag: NOTIF_TAG_NEW_MESSAGE,
badge: NOTIFICATION_BADGE_ICON
});
// close any previous notifications for this room
// AFTER showing the new notification as on Android
// where we can only show 1 notification, this creates
// a smoother transition
if (notifsToClose) {
for (const notif of notifsToClose) {
console.log("close previous notification");
notif.close();
}
}
}
// we could consider hiding previous notifications here based on the unread count
// (although we can't really figure out which notifications to hide) and also hiding
// notifications makes it hard to ensure we always show a notification after a push message
// when no client is visible, see https://goo.gl/yqv4Q4
}
self.addEventListener('push', event => {
event.waitUntil(handlePushNotification(event.data.json()));
});
async function closeSession(sessionId, requestingClientId) { async function closeSession(sessionId, requestingClientId) {
const clients = await self.clients.matchAll(); const clients = await self.clients.matchAll();
@ -216,3 +318,12 @@ function sendAndWaitForReply(client, type, payload) {
client.postMessage({type, id, payload}); client.postMessage({type, id, payload});
return promise; return promise;
} }
async function findClient(predicate) {
const clientList = await self.clients.matchAll({type: "window"});
for (const client of clientList) {
if (await predicate(client)) {
return client;
}
}
}

View file

@ -34,27 +34,60 @@ export class SettingsView extends TemplateView {
]); ]);
}; };
return t.main({className: "Settings middle"}, [ const settingNodes = [];
t.div({className: "middle-header"}, [
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close settings`}), settingNodes.push(
t.h2("Settings")
]),
t.div({className: "SettingsBody"}, [
t.h3("Session"), t.h3("Session"),
row(vm.i18n`User ID`, vm.userId), row(vm.i18n`User ID`, vm.userId),
row(vm.i18n`Session ID`, vm.deviceId, "code"), row(vm.i18n`Session ID`, vm.deviceId, "code"),
row(vm.i18n`Session key`, vm.fingerprintKey, "code"), row(vm.i18n`Session key`, vm.fingerprintKey, "code")
);
settingNodes.push(
t.h3("Session Backup"), t.h3("Session Backup"),
t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel))
);
settingNodes.push(
t.h3("Notifications"),
t.map(vm => vm.pushNotifications.supported, (supported, t) => {
if (supported === null) {
return t.p(vm.i18n`Loading…`);
} else if (supported) {
const label = vm => vm.pushNotifications.enabled ?
vm.i18n`Push notifications are enabled`:
vm.i18n`Push notifications are disabled`;
const buttonLabel = vm => vm.pushNotifications.enabled ?
vm.i18n`Disable`:
vm.i18n`Enable`;
return row(label, t.button({
onClick: () => vm.togglePushNotifications(),
disabled: vm => vm.pushNotifications.updating
}, buttonLabel));
} else {
return t.p(vm.i18n`Push notifications are not supported on this browser`);
}
})
);
settingNodes.push(
t.h3("Preferences"), t.h3("Preferences"),
row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
);
settingNodes.push(
t.h3("Application"), t.h3("Application"),
row(vm.i18n`Version`, version), row(vm.i18n`Version`, version),
row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
row(vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), row(vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")),
t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ",
t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]),
]) );
return t.main({className: "Settings middle"}, [
t.div({className: "middle-header"}, [
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close settings`}),
t.h2("Settings")
]),
t.div({className: "SettingsBody"}, settingNodes)
]); ]);
} }

1
sw.js Symbolic link
View file

@ -0,0 +1 @@
src/platform/web/service-worker.js