forked from mystiq/hydrogen-web
Merge pull request #283 from vector-im/bwindels/pushnotifs
Basic push notification support
This commit is contained in:
commit
fb6f31d958
15 changed files with 485 additions and 42 deletions
7
assets/config.json
Normal file
7
assets/config.json
Normal 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
22
doc/impl-thoughts/PUSH.md
Normal 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.
|
|
@ -24,6 +24,11 @@
|
|||
main(new Platform(document.body, {
|
||||
worker: "src/worker.js",
|
||||
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: {
|
||||
wasm: "lib/olm/olm.wasm",
|
||||
legacyBundle: "lib/olm/olm_legacy.js",
|
||||
|
|
|
@ -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']));
|
||||
}
|
||||
// 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";
|
||||
let downloadSandboxHtml = await fs.readFile(path.join(projectDir, `assets/${downloadSandbox}`));
|
||||
await assets.write(downloadSandbox, downloadSandboxHtml);
|
||||
|
@ -89,13 +90,13 @@ async function build({modernOnly}) {
|
|||
await buildManifest(assets);
|
||||
// all assets have been added, create a hash from all assets name to cache unhashed files like index.html
|
||||
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);
|
||||
|
||||
const globalHash = assets.hashForAll();
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
|
@ -135,7 +136,7 @@ async function copyThemeAssets(themes, assets) {
|
|||
return assets;
|
||||
}
|
||||
|
||||
async function buildHtml(doc, version, globalHash, modernOnly, assets) {
|
||||
async function buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets) {
|
||||
// transform html file
|
||||
// change path to main.css to css bundle
|
||||
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) => {
|
||||
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,
|
||||
downloadSandbox: assets.resolve("download-sandbox.html"),
|
||||
serviceWorker: "sw.js",
|
||||
|
@ -154,14 +155,14 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) {
|
|||
legacyBundle: assets.resolve("olm_legacy.js"),
|
||||
wasmBundle: assets.resolve("olm.js"),
|
||||
}
|
||||
});
|
||||
}));
|
||||
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) {
|
||||
mainScripts.push(
|
||||
`<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(""));
|
||||
|
@ -269,12 +270,30 @@ async function buildServiceWorker(swSource, version, globalHash, assets) {
|
|||
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
|
||||
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`);
|
||||
swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
|
||||
swSource = swSource.replace(`"%%UNHASHED_PRECACHED_ASSETS%%"`, JSON.stringify(unhashedPreCachedAssets));
|
||||
swSource = swSource.replace(`"%%HASHED_PRECACHED_ASSETS%%"`, JSON.stringify(hashedPreCachedAssets));
|
||||
swSource = swSource.replace(`"%%HASHED_CACHED_ON_REQUEST_ASSETS%%"`, JSON.stringify(hashedCachedOnRequestAssets));
|
||||
swSource = replaceArrayInSource("UNHASHED_PRECACHED_ASSETS", unhashedPreCachedAssets);
|
||||
swSource = replaceArrayInSource("HASHED_PRECACHED_ASSETS", hashedPreCachedAssets);
|
||||
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
|
||||
await assets.writeUnhashed("sw.js", swSource);
|
||||
}
|
||||
|
|
|
@ -17,6 +17,14 @@ limitations under the License.
|
|||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {SessionBackupViewModel} from "./SessionBackupViewModel.js";
|
||||
|
||||
class PushNotificationStatus {
|
||||
constructor() {
|
||||
this.supported = null;
|
||||
this.enabled = false;
|
||||
this.updating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatKey(key) {
|
||||
const partLength = 4;
|
||||
const partCount = Math.ceil(key.length / partLength);
|
||||
|
@ -40,6 +48,7 @@ export class SettingsViewModel extends ViewModel {
|
|||
this.sentImageSizeLimit = null;
|
||||
this.minSentImageSizeLimit = 400;
|
||||
this.maxSentImageSizeLimit = 4000;
|
||||
this.pushNotifications = new PushNotificationStatus();
|
||||
}
|
||||
|
||||
setSentImageSizeLimit(size) {
|
||||
|
@ -56,6 +65,8 @@ export class SettingsViewModel extends ViewModel {
|
|||
async load() {
|
||||
this._estimate = await this.platform.estimateStorageUsage();
|
||||
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("");
|
||||
}
|
||||
|
||||
|
@ -115,5 +126,21 @@ export class SettingsViewModel extends ViewModel {
|
|||
const logExport = await this.logger.export();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {Room} from "./room/Room.js";
|
||||
import {Pusher} from "./push/Pusher.js";
|
||||
import { ObservableMap } from "../observable/index.js";
|
||||
import {User} from "./User.js";
|
||||
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
|
||||
|
@ -38,6 +39,7 @@ import {SecretStorage} from "./ssss/SecretStorage.js";
|
|||
import {ObservableValue} from "../observable/ObservableValue.js";
|
||||
|
||||
const PICKLE_KEY = "DEFAULT_KEY";
|
||||
const PUSHER_KEY = "pusher";
|
||||
|
||||
export class Session {
|
||||
// sessionInfo contains deviceId, userId and homeServer
|
||||
|
@ -466,6 +468,60 @@ export class Session {
|
|||
get 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() {
|
||||
|
|
|
@ -166,6 +166,7 @@ export class SessionContainer {
|
|||
this._storage = await this._platform.storageFactory.create(sessionInfo.id);
|
||||
// no need to pass access token to session
|
||||
const filteredSessionInfo = {
|
||||
id: sessionInfo.id,
|
||||
deviceId: sessionInfo.deviceId,
|
||||
userId: sessionInfo.userId,
|
||||
homeServer: sessionInfo.homeServer,
|
||||
|
|
|
@ -254,6 +254,10 @@ export class HomeServerApi {
|
|||
uploadAttachment(blob, filename, options = null) {
|
||||
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() {
|
||||
|
|
40
src/matrix/push/Pusher.js
Normal file
40
src/matrix/push/Pusher.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@ import {ConsoleLogger} from "../../logging/ConsoleLogger.js";
|
|||
import {RootView} from "./ui/RootView.js";
|
||||
import {Clock} from "./dom/Clock.js";
|
||||
import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js";
|
||||
import {NotificationService} from "./dom/NotificationService.js";
|
||||
import {History} from "./dom/History.js";
|
||||
import {OnlineStatus} from "./dom/OnlineStatus.js";
|
||||
import {Crypto} from "./dom/Crypto.js";
|
||||
|
@ -73,18 +74,18 @@ function relPath(path, basePath) {
|
|||
return "../".repeat(dirCount) + path;
|
||||
}
|
||||
|
||||
async function loadOlmWorker(paths) {
|
||||
const workerPool = new WorkerPool(paths.worker, 4);
|
||||
async function loadOlmWorker(config) {
|
||||
const workerPool = new WorkerPool(config.worker, 4);
|
||||
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});
|
||||
const olmWorker = new OlmWorker(workerPool);
|
||||
return olmWorker;
|
||||
}
|
||||
|
||||
export class Platform {
|
||||
constructor(container, paths, cryptoExtras = null, options = null) {
|
||||
this._paths = paths;
|
||||
constructor(container, config, cryptoExtras = null, options = null) {
|
||||
this._config = config;
|
||||
this._container = container;
|
||||
this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_");
|
||||
this.clock = new Clock();
|
||||
|
@ -98,10 +99,11 @@ export class Platform {
|
|||
this.history = new History();
|
||||
this.onlineStatus = new OnlineStatus();
|
||||
this._serviceWorkerHandler = null;
|
||||
if (paths.serviceWorker && "serviceWorker" in navigator) {
|
||||
if (config.serviceWorker && "serviceWorker" in navigator) {
|
||||
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.storageFactory = new StorageFactory(this._serviceWorkerHandler);
|
||||
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
|
||||
|
@ -120,12 +122,12 @@ export class Platform {
|
|||
}
|
||||
|
||||
loadOlm() {
|
||||
return loadOlm(this._paths.olm);
|
||||
return loadOlm(this._config.olm);
|
||||
}
|
||||
|
||||
async loadOlmWorker() {
|
||||
if (!window.WebAssembly) {
|
||||
return await loadOlmWorker(this._paths);
|
||||
return await loadOlmWorker(this._config);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -150,7 +152,7 @@ export class Platform {
|
|||
if (navigator.msSaveBlob) {
|
||||
navigator.msSaveBlob(blobHandle.nativeBlob, filename);
|
||||
} else {
|
||||
downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle, filename);
|
||||
downloadInIframe(this._container, this._config.downloadSandbox, blobHandle, filename);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
101
src/platform/web/dom/NotificationService.js
Normal file
101
src/platform/web/dom/NotificationService.js
Normal 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});
|
||||
}
|
||||
}
|
|
@ -60,7 +60,14 @@ export class ServiceWorkerHandler {
|
|||
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;
|
||||
this._closeSessionIfNeeded(sessionId).finally(() => {
|
||||
event.source.postMessage({replyTo: data.id});
|
||||
|
@ -94,7 +101,7 @@ export class ServiceWorkerHandler {
|
|||
if (document.hidden) {
|
||||
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?`)) {
|
||||
// prevent any fetch requests from going to the service worker
|
||||
// from any client, so that it is not kept active
|
||||
|
@ -182,4 +189,11 @@ export class ServiceWorkerHandler {
|
|||
async preventConcurrentSessionAccess(sessionId) {
|
||||
return this._sendAndWaitForReply("closeSession", {sessionId});
|
||||
}
|
||||
|
||||
async getRegistration() {
|
||||
if (this._registrationPromise) {
|
||||
await this._registrationPromise;
|
||||
}
|
||||
return this._registration;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,10 @@ limitations under the License.
|
|||
|
||||
const VERSION = "%%VERSION%%";
|
||||
const GLOBAL_HASH = "%%GLOBAL_HASH%%";
|
||||
const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%";
|
||||
const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%";
|
||||
const HASHED_CACHED_ON_REQUEST_ASSETS = "%%HASHED_CACHED_ON_REQUEST_ASSETS%%";
|
||||
const UNHASHED_PRECACHED_ASSETS = [];
|
||||
const HASHED_PRECACHED_ASSETS = [];
|
||||
const HASHED_CACHED_ON_REQUEST_ASSETS = [];
|
||||
const NOTIFICATION_BADGE_ICON = "assets/icon.png";
|
||||
const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`;
|
||||
const hashedCacheName = `hydrogen-assets`;
|
||||
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) {
|
||||
const clients = await self.clients.matchAll();
|
||||
|
@ -216,3 +318,12 @@ function sendAndWaitForReply(client, type, payload) {
|
|||
client.postMessage({type, id, payload});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -34,27 +34,60 @@ export class SettingsView extends TemplateView {
|
|||
]);
|
||||
};
|
||||
|
||||
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"}, [
|
||||
const settingNodes = [];
|
||||
|
||||
settingNodes.push(
|
||||
t.h3("Session"),
|
||||
row(vm.i18n`User ID`, vm.userId),
|
||||
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.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"),
|
||||
row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
|
||||
);
|
||||
settingNodes.push(
|
||||
t.h3("Application"),
|
||||
row(vm.i18n`Version`, version),
|
||||
row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
|
||||
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.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
1
sw.js
Symbolic link
|
@ -0,0 +1 @@
|
|||
src/platform/web/service-worker.js
|
Loading…
Reference in a new issue