From 2fb255d2ecce87a549a73b9f4aed9c151fc88bff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:40:24 +0100 Subject: [PATCH 01/28] dot down some notes wrt to push --- doc/impl-thoughts/PUSH.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 doc/impl-thoughts/PUSH.md diff --git a/doc/impl-thoughts/PUSH.md b/doc/impl-thoughts/PUSH.md new file mode 100644 index 00000000..204e76fd --- /dev/null +++ b/doc/impl-thoughts/PUSH.md @@ -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. From 1b0f175b023ce305db6bb5fef5002e883c39a058 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:41:01 +0100 Subject: [PATCH 02/28] put web-specific parts of notifications (push and in-app) in platform --- src/platform/web/dom/NotificationService.js | 101 +++++++++++++++++++ src/platform/web/dom/ServiceWorkerHandler.js | 7 ++ 2 files changed, 108 insertions(+) create mode 100644 src/platform/web/dom/NotificationService.js diff --git a/src/platform/web/dom/NotificationService.js b/src/platform/web/dom/NotificationService.js new file mode 100644 index 00000000..20273d70 --- /dev/null +++ b/src/platform/web/dom/NotificationService.js @@ -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 (this.registration?.pushManager) { + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } + } + + async isPushEnabled() { + const registration = await this._serviceWorkerHandler?.getRegistration(); + if (this.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}); + } +} diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index f5bae8d0..447b5dc8 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -182,4 +182,11 @@ export class ServiceWorkerHandler { async preventConcurrentSessionAccess(sessionId) { return this._sendAndWaitForReply("closeSession", {sessionId}); } + + async getRegistration() { + if (this._registrationPromise) { + await this._registrationPromise; + } + return this._registration; + } } From d4fc08c06bac3ca69ba881a51868e8de5c5f2076 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:42:46 +0100 Subject: [PATCH 03/28] put pusher bits in separate class to enable and disable on the HS --- src/matrix/net/HomeServerApi.js | 4 ++++ src/matrix/push/Pusher.js | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/matrix/push/Pusher.js diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 8e7a110b..a6acaf74 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -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() { diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js new file mode 100644 index 00000000..a8ac1b21 --- /dev/null +++ b/src/matrix/push/Pusher.js @@ -0,0 +1,35 @@ +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) { + 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; + } +} From f764323c80301250171326c8b4155408a5e15eb3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:43:40 +0100 Subject: [PATCH 04/28] fixup: notif service --- src/platform/web/Platform.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 7682dfd3..747855de 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -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); } } From 630e61a6743728431bc6ee2d4b47acf391b23618 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:44:16 +0100 Subject: [PATCH 05/28] support enabling/disabling push notifs on a session --- src/matrix/Session.js | 56 ++++++++++++++++++++++++++++++++++ src/matrix/SessionContainer.js | 1 + 2 files changed, 57 insertions(+) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c01574ea..ad52aec6 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -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() { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index efbf70e3..85a6826d 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -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, From 7b9904e423a3dd801f0bd6ae125d8c85103f4664 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:45:01 +0100 Subject: [PATCH 06/28] add UI in settings for push notifs status/enable/disable --- .../session/settings/SettingsViewModel.js | 27 ++++++++ .../web/ui/session/settings/SettingsView.js | 65 ++++++++++++++----- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 50aabd2c..ec171eea 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -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"); + } + } } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 02e57f5e..4d4b2aba 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -34,27 +34,60 @@ export class SettingsView extends TemplateView { ]); }; + 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") + ); + settingNodes.push( + t.h3("Session Backup"), + 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"}, [ - 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"), - t.h3("Session Backup"), - t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), - t.h3("Preferences"), - row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), - 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"), "."]), - ]) + t.div({className: "SettingsBody"}, settingNodes) ]); } From 8fcf7f8c7f01708c425eec77c1dba79814a293e1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:45:46 +0100 Subject: [PATCH 07/28] show notification when receiving push message --- src/platform/web/service-worker.template.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index ebb31cdb..f46150f6 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -185,6 +185,27 @@ self.addEventListener('message', (event) => { } }); +self.addEventListener('push', event => { + const n = event.data.json(); + console.log("got a push message", n); + let sender = n.sender_display_name || n.sender; + if (sender && n.event_id) { + let label; + if (n.room_name) { + label = `${sender} wrote you in ${n.room_name}`; + } else { + label = `${sender} wrote you`; + } + let body = n.content?.body; + self.registration.showNotification(label, { + body, + data: { + sessionId: n.session_id, + roomId: n.room_id, + } + }); + } +}); async function closeSession(sessionId, requestingClientId) { const clients = await self.clients.matchAll(); From 725098f262c61f9b0f80f88e047641d48508547e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:46:11 +0100 Subject: [PATCH 08/28] open client when clicking notification --- src/platform/web/service-worker.template.js | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index f46150f6..0e28ac7a 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -185,6 +185,33 @@ self.addEventListener('message', (event) => { } }); +async function openClientFromNotif(event) { + const clientList = await self.clients.matchAll({type: "window"}); + const {sessionId, roomId} = event.notification.data; + const sessionHash = `#/session/${sessionId}`; + const roomHash = `${sessionHash}/room/${roomId}`; + const roomURL = `/${roomHash}`; + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + const url = new URL(client.url, baseURL); + if (url.hash.startsWith(sessionHash)) { + client.navigate(roomURL); + if ('focus' in client) { + await client.focus(); + } + return; + } + } + if (self.clients.openWindow) { + await self.clients.openWindow(roomURL); + } +} + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil(openClientFromNotif(event)); +}); + self.addEventListener('push', event => { const n = event.data.json(); console.log("got a push message", n); From bddf6ba6ae9f9e88c043b924e128a2f64f8a174c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:52:50 +0100 Subject: [PATCH 09/28] add example config for locally testing push notifs/service worker --- index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/index.html b/index.html index 55e7f0b2..e5746572 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,13 @@ main(new Platform(document.body, { worker: "src/worker.js", downloadSandbox: "assets/download-sandbox.html", + // ln -s src/platform/web/service-worker.template.js sw.js + // serviceWorker: "sw.js", + // push: { + // appId: "io.element.hydrogen.web", + // gatewayUrl: "...", + // applicationServerKey: "...", + // }, olm: { wasm: "lib/olm/olm.wasm", legacyBundle: "lib/olm/olm_legacy.js", From dbddba3691f678b740c6aed37dcdcf1f15d569f5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:19:07 +0100 Subject: [PATCH 10/28] fix c/p errors when moving code over to notif service --- src/platform/web/dom/NotificationService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/dom/NotificationService.js b/src/platform/web/dom/NotificationService.js index 20273d70..151ccaf0 100644 --- a/src/platform/web/dom/NotificationService.js +++ b/src/platform/web/dom/NotificationService.js @@ -45,7 +45,7 @@ export class NotificationService { async disablePush() { const registration = await this._serviceWorkerHandler?.getRegistration(); - if (this.registration?.pushManager) { + if (registration?.pushManager) { const subscription = await registration.pushManager.getSubscription(); if (subscription) { await subscription.unsubscribe(); @@ -55,7 +55,7 @@ export class NotificationService { async isPushEnabled() { const registration = await this._serviceWorkerHandler?.getRegistration(); - if (this.registration?.pushManager) { + if (registration?.pushManager) { const subscription = await registration.pushManager.getSubscription(); return !!subscription; } From 3313d0623a0050c4a8a4207cc28d6e646ed39b07 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:19:25 +0100 Subject: [PATCH 11/28] thinko with push checks --- src/matrix/Session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index ad52aec6..735967bf 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -515,7 +515,7 @@ export class Session { } async arePushNotificationsEnabled() { - if (await this._platform.notificationService.isPushEnabled()) { + if (!await this._platform.notificationService.isPushEnabled()) { return false; } const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); From f92f3b2c21a7989be1862f27ff19c9f430d1dbf6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:19:44 +0100 Subject: [PATCH 12/28] copy push config in build script --- assets/config.json | 7 +++++++ scripts/build.mjs | 13 +++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 assets/config.json diff --git a/assets/config.json b/assets/config.json new file mode 100644 index 00000000..ae46ccfd --- /dev/null +++ b/assets/config.json @@ -0,0 +1,7 @@ +{ + "push": { + "appId": "io.element.hydrogen.web", + "gatewayUrl": "https://matrix.org", + "applicationServerKey": "tbc" + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs index dbbeabcd..6622f092 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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); @@ -95,7 +96,7 @@ async function build({modernOnly}) { 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 = [ - `` + `` ]; if (!modernOnly) { mainScripts.push( ``, - `` + `` ); } doc("script#main").replaceWith(mainScripts.join("")); From d5b12fa7f98525a416b34cdf42d79b29c0879fe4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:23:01 +0100 Subject: [PATCH 13/28] log endpoint hostname --- src/matrix/push/Pusher.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js index a8ac1b21..e12db164 100644 --- a/src/matrix/push/Pusher.js +++ b/src/matrix/push/Pusher.js @@ -21,6 +21,12 @@ export class Pusher { } async enable(hsApi, log) { + try { + let endpointDomain = new URL(this._description.data.endpoint).host; + log.set("endpoint", endpointDomain); + } catch { + log.set("endpoint", null); + } await hsApi.setPusher(this._description, {log}).response(); } From 76fdbbb2fea29670fc3c85442f2417ced9cd995a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 13:36:42 +0100 Subject: [PATCH 14/28] shorten this --- src/matrix/push/Pusher.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js index e12db164..cc370f82 100644 --- a/src/matrix/push/Pusher.js +++ b/src/matrix/push/Pusher.js @@ -22,8 +22,7 @@ export class Pusher { async enable(hsApi, log) { try { - let endpointDomain = new URL(this._description.data.endpoint).host; - log.set("endpoint", endpointDomain); + log.set("endpoint", new URL(this._description.data.endpoint).host); } catch { log.set("endpoint", null); } From a8ca82ca4d2a01baf16a2a0c79ce2da4a665c91b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 15:18:07 +0100 Subject: [PATCH 15/28] support running the service worker during local development --- index.html | 4 ++-- scripts/build.mjs | 15 ++++++++++++--- src/platform/web/service-worker.template.js | 6 +++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index e5746572..fb6934bb 100644 --- a/index.html +++ b/index.html @@ -23,8 +23,8 @@ import {Platform} from "./src/platform/web/Platform.js"; main(new Platform(document.body, { worker: "src/worker.js", - downloadSandbox: "assets/download-sandbox.html", - // ln -s src/platform/web/service-worker.template.js sw.js + downloadSandbox: "assets/download-sandbox.html", + // NOTE: uncomment this if you want the service worker for local development // serviceWorker: "sw.js", // push: { // appId: "io.element.hydrogen.web", diff --git a/scripts/build.mjs b/scripts/build.mjs index 6622f092..29a4bb54 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -270,12 +270,21 @@ 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; + }; + // 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); // service worker should not have a hashed name as it is polled by the browser for updates await assets.writeUnhashed("sw.js", swSource); } diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index 0e28ac7a..7d545138 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -17,9 +17,9 @@ 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 unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`; const hashedCacheName = `hydrogen-assets`; const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`; From 178790d816f2e71411cf107bd4ce790cef5fbed9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 15:20:33 +0100 Subject: [PATCH 16/28] symlink service worker for local dev, so its scope captures whole app also rename service worker (as it is not a template anymore) --- scripts/build.mjs | 2 +- .../web/{service-worker.template.js => service-worker.js} | 0 sw.js | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename src/platform/web/{service-worker.template.js => service-worker.js} (100%) create mode 120000 sw.js diff --git a/scripts/build.mjs b/scripts/build.mjs index 29a4bb54..ef043135 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -90,7 +90,7 @@ 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(); diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.js similarity index 100% rename from src/platform/web/service-worker.template.js rename to src/platform/web/service-worker.js diff --git a/sw.js b/sw.js new file mode 120000 index 00000000..edcb3642 --- /dev/null +++ b/sw.js @@ -0,0 +1 @@ +src/platform/web/service-worker.js \ No newline at end of file From c9642cc98c67fe99c871f9f9f37aa92e46d06ba5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 15:22:37 +0100 Subject: [PATCH 17/28] add notes how to enable push for local dev --- index.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index fb6934bb..d1a9db0d 100644 --- a/index.html +++ b/index.html @@ -26,11 +26,9 @@ downloadSandbox: "assets/download-sandbox.html", // NOTE: uncomment this if you want the service worker for local development // serviceWorker: "sw.js", - // push: { - // appId: "io.element.hydrogen.web", - // gatewayUrl: "...", - // applicationServerKey: "...", - // }, + // 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", From 73c433ec3dc2f07ca6c797e773c1f7dc123e04c7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 16:39:46 +0100 Subject: [PATCH 18/28] add public key for matrix.org sygnal instance --- assets/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/config.json b/assets/config.json index ae46ccfd..a98c5ba9 100644 --- a/assets/config.json +++ b/assets/config.json @@ -2,6 +2,6 @@ "push": { "appId": "io.element.hydrogen.web", "gatewayUrl": "https://matrix.org", - "applicationServerKey": "tbc" + "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" } } From 2de61c5928216dac87ef1b9cab8ff94fa08daec8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Mar 2021 15:19:10 +0100 Subject: [PATCH 19/28] ask the new version to the new and not old service worker --- src/platform/web/dom/ServiceWorkerHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index 447b5dc8..cb105f0a 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -94,7 +94,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 From f91abe4301ca23ca6057bb6268dd0b77402a6828 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Mar 2021 15:23:01 +0100 Subject: [PATCH 20/28] improve notifications shown - use event.waitUntil to prevent default notification - replace notifications for same room - replace notifications when receiving unread=0 with "Read messages" to prevent default notification - don't rely on client.url to figure out if a room is open as FF does not update this field on hash changes. --- src/platform/web/dom/ServiceWorkerHandler.js | 9 ++- src/platform/web/service-worker.js | 77 ++++++++++++++++++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index cb105f0a..d2dacc6b 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -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}); diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 7d545138..daef3640 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -185,6 +185,9 @@ self.addEventListener('message', (event) => { } }); +const NOTIF_TAG_MESSAGES_READ = "messages_read"; +const NOTIF_TAG_NEW_MESSAGE = "new_message"; + async function openClientFromNotif(event) { const clientList = await self.clients.matchAll({type: "window"}); const {sessionId, roomId} = event.notification.data; @@ -212,11 +215,32 @@ self.addEventListener('notificationclick', event => { event.waitUntil(openClientFromNotif(event)); }); -self.addEventListener('push', event => { - const n = event.data.json(); +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 clientList = await self.clients.matchAll({type: "window"}); + 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; + } + for (const client of clientList) { + // if the app is open and focused, don't show a notif when looking at the room already + if (client.visibilityState === "visible" && client.focused) { + const isRoomOpen = await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId}); + if (isRoomOpen) { + console.log("client is focused, room is open, don't show notif"); + return; + } + } + } let label; if (n.room_name) { label = `${sender} wrote you in ${n.room_name}`; @@ -224,14 +248,44 @@ self.addEventListener('push', event => { label = `${sender} wrote you`; } let body = n.content?.body; - self.registration.showNotification(label, { + // close any previous notifications for this room + const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); + const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); + for (const notif of notifsForRoom) { + console.log("close previous notification for room"); + notif.close(); + } + console.log("showing new message notification"); + await self.registration.showNotification(label, { body, - data: { - sessionId: n.session_id, - roomId: n.room_id, - } + data: {sessionId, roomId}, + tag: NOTIF_TAG_NEW_MESSAGE }); + } else if (n.unread === 0) { + // hide the notifs + console.log("unread=0, close all notifs"); + const notifs = Array.from(await self.registration.getNotifications()); + for (const notif of notifs) { + if (notif.tag !== NOTIF_TAG_MESSAGES_READ) { + notif.close(); + } + } + const hasVisibleClient = !!await findClient(client => client.visibilityState === "visible"); + // ensure we always show a notification when no client is visible, see https://goo.gl/yqv4Q4 + if (!hasVisibleClient) { + const readNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_MESSAGES_READ})); + if (readNotifs.length === 0) { + await self.registration.showNotification("New messages that have since been read", { + tag: NOTIF_TAG_MESSAGES_READ, + data: {sessionId} + }); + } + } } +} + +self.addEventListener('push', event => { + event.waitUntil(handlePushNotification(event.data.json())); }); async function closeSession(sessionId, requestingClientId) { @@ -264,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; + } + } +} From 0b3f2a7fa05c473720344e8dba3dd27a25cdb12c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Mar 2021 15:25:59 +0100 Subject: [PATCH 21/28] improve notification click handling - also here don't use client.url to figure out if a session is open as that doesn't work in FF - use tag to make sure we're dealing with the right type of notif - use findClient function --- src/platform/web/service-worker.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index daef3640..1aba7a96 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -189,23 +189,24 @@ const NOTIF_TAG_MESSAGES_READ = "messages_read"; const NOTIF_TAG_NEW_MESSAGE = "new_message"; async function openClientFromNotif(event) { - const clientList = await self.clients.matchAll({type: "window"}); + if (event.notification.tag !== NOTIF_TAG_NEW_MESSAGE) { + return; + } const {sessionId, roomId} = event.notification.data; const sessionHash = `#/session/${sessionId}`; const roomHash = `${sessionHash}/room/${roomId}`; const roomURL = `/${roomHash}`; - for (let i = 0; i < clientList.length; i++) { - const client = clientList[i]; - const url = new URL(client.url, baseURL); - if (url.hash.startsWith(sessionHash)) { - client.navigate(roomURL); - if ('focus' in client) { - await client.focus(); - } - return; + 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) { + await clientWithSession.focus(); } - } - if (self.clients.openWindow) { + } else if (self.client.openWindow) { + console.log("notificationclick: no client found with session open, opening new window"); await self.clients.openWindow(roomURL); } } From 165532be302430c7908d0015badf061e34d94a5a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 00:12:57 +0100 Subject: [PATCH 22/28] add badge icon to notifs --- scripts/build.mjs | 9 +++++++++ src/platform/web/service-worker.js | 2 ++ 2 files changed, 11 insertions(+) diff --git a/scripts/build.mjs b/scripts/build.mjs index ef043135..4d29c9db 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -278,6 +278,13 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { } 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}"`); @@ -285,6 +292,8 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { 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); } diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 1aba7a96..d7d0c34f 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -20,6 +20,7 @@ const GLOBAL_HASH = "%%GLOBAL_HASH%%"; 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`; @@ -261,6 +262,7 @@ async function handlePushNotification(n) { body, data: {sessionId, roomId}, tag: NOTIF_TAG_NEW_MESSAGE + badge: NOTIFICATION_BADGE_ICON }); } else if (n.unread === 0) { // hide the notifs From bc763e2a1984f79b2a0183f0c253abda8c973376 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:01:25 +0100 Subject: [PATCH 23/28] fix typo --- src/platform/web/service-worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index d7d0c34f..b01ad5ff 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -206,7 +206,7 @@ async function openClientFromNotif(event) { if ('focus' in clientWithSession) { await clientWithSession.focus(); } - } else if (self.client.openWindow) { + } else if (self.clients.openWindow) { console.log("notificationclick: no client found with session open, opening new window"); await self.clients.openWindow(roomURL); } From a70a38f481dd3b41b458e8517b0e9fc3c9625554 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:03:44 +0100 Subject: [PATCH 24/28] focus can throw on Android, wrap it in a try/catch --- src/platform/web/service-worker.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index b01ad5ff..3a68d125 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -204,7 +204,9 @@ async function openClientFromNotif(event) { console.log("notificationclick: client has session open, showing room there"); clientWithSession.navigate(roomURL); if ('focus' in clientWithSession) { - await clientWithSession.focus(); + 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"); From f98369c4d6e37f43a18a7160ea3907b47ecfd023 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:07:42 +0100 Subject: [PATCH 25/28] remove obsolete code from refactoring before --- src/platform/web/service-worker.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 3a68d125..ebfddf71 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -224,7 +224,6 @@ async function handlePushNotification(n) { const sessionId = n.session_id; let sender = n.sender_display_name || n.sender; if (sender && n.event_id) { - const clientList = await self.clients.matchAll({type: "window"}); const roomId = n.room_id; const hasFocusedClientOnRoom = !!await findClient(async client => { if (client.visibilityState === "visible" && client.focused) { @@ -235,16 +234,6 @@ async function handlePushNotification(n) { console.log("client is focused, room is open, don't show notif"); return; } - for (const client of clientList) { - // if the app is open and focused, don't show a notif when looking at the room already - if (client.visibilityState === "visible" && client.focused) { - const isRoomOpen = await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId}); - if (isRoomOpen) { - console.log("client is focused, room is open, don't show notif"); - return; - } - } - } let label; if (n.room_name) { label = `${sender} wrote you in ${n.room_name}`; From e54a70768462a84b5f21df68bd9d7f40862e3d3c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:08:38 +0100 Subject: [PATCH 26/28] rework notifications - we don't close them when receiving a push message without event_id as we always need to have a notification open after a push message and replacing them with a generic one like we did is worse than just leaving it open - after the second notification for a room, we just show "New messages" and you don't get binged again for new messages after that. - You will still have a notification for every room, and on Android you will just see the one for the last room as it only shows one notification at a time. --- src/platform/web/service-worker.js | 66 +++++++++++++++--------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index ebfddf71..081c9053 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -186,7 +186,6 @@ self.addEventListener('message', (event) => { } }); -const NOTIF_TAG_MESSAGES_READ = "messages_read"; const NOTIF_TAG_NEW_MESSAGE = "new_message"; async function openClientFromNotif(event) { @@ -234,48 +233,51 @@ async function handlePushNotification(n) { console.log("client is focused, room is open, don't show notif"); return; } - let label; - if (n.room_name) { - label = `${sender} wrote you in ${n.room_name}`; - } else { - label = `${sender} wrote you`; - } - let body = n.content?.body; - // close any previous notifications for this room const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); - for (const notif of notifsForRoom) { - console.log("close previous notification for room"); - notif.close(); + 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"; } - console.log("showing new message notification"); + // close any previous notifications for this room await self.registration.showNotification(label, { body, - data: {sessionId, roomId}, - tag: NOTIF_TAG_NEW_MESSAGE + data: {sessionId, roomId, multi}, + tag: NOTIF_TAG_NEW_MESSAGE, badge: NOTIFICATION_BADGE_ICON }); - } else if (n.unread === 0) { - // hide the notifs - console.log("unread=0, close all notifs"); - const notifs = Array.from(await self.registration.getNotifications()); - for (const notif of notifs) { - if (notif.tag !== NOTIF_TAG_MESSAGES_READ) { + if (notifsToClose) { + for (const notif of notifsToClose) { + console.log("close previous notification"); notif.close(); } } - const hasVisibleClient = !!await findClient(client => client.visibilityState === "visible"); - // ensure we always show a notification when no client is visible, see https://goo.gl/yqv4Q4 - if (!hasVisibleClient) { - const readNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_MESSAGES_READ})); - if (readNotifs.length === 0) { - await self.registration.showNotification("New messages that have since been read", { - tag: NOTIF_TAG_MESSAGES_READ, - data: {sessionId} - }); - } - } } + // 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 => { From 889ca0550664ecb356b7415cac9efaf1e74f549d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:11:05 +0100 Subject: [PATCH 27/28] log when we get a click from a notif without a tag like when the browser decides to show "site got updated in the background" notif in response to a unread=0 push message. --- src/platform/web/service-worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 081c9053..cef18f6c 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -190,6 +190,7 @@ 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; From b3680af3425ef405790458711e02e27ed023f4e2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:23:43 +0100 Subject: [PATCH 28/28] move comment back to right place, and explain we we do it after --- src/platform/web/service-worker.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index cef18f6c..c859afc4 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -261,13 +261,16 @@ async function handlePushNotification(n) { } body = n.content?.body || "New message"; } - // close any previous notifications for this room 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");