From 5d777668348d3e0c4749b2d4f91b5c57f0182088 Mon Sep 17 00:00:00 2001 From: Adrian Fish Date: Mon, 18 Sep 2023 15:24:17 +0100 Subject: [PATCH] SAK-49287 Notifications. Improved push permissions workflow https://sakaiproject.atlassian.net/browse/SAK-49287 --- .../src/sass/modules/tool/portal/_portal.scss | 14 +- library/src/webapp/js/sakai-message-broker.js | 271 ++++++++++-------- pom.xml | 1 + .../entityprovider/PortalEntityProvider.java | 14 +- .../webapp/vm/morpheus/includeAccountNav.vm | 8 +- .../webapp/vm/morpheus/includeBodyScripts.vm | 11 + .../webapp/vm/morpheus/includeMobileFooter.vm | 6 +- .../webapp/vm/morpheus/includeStandardHead.vm | 8 +- serviceworker/pom.xml | 45 +++ serviceworker/src/main/webapp/WEB-INF/web.xml | 8 + serviceworker/src/main/webapp/favicon.ico | Bin 0 -> 15086 bytes .../src/main/webapp}/sakai-service-worker.js | 34 ++- .../webapi/controllers/EventsController.java | 2 +- .../bundle/sakai-notifications.properties | 9 +- .../tool/src/main/frontend/package-lock.json | 51 +++- .../test/sakai-date-picker.test.js | 4 +- .../packages/sakai-notifications/package.json | 3 +- .../src/SakaiNotifications.js | 188 ++++++------ .../packages/sakai-notifications/test/data.js | 28 +- .../test/sakai-notifications.test.js | 43 ++- 20 files changed, 500 insertions(+), 248 deletions(-) create mode 100644 serviceworker/pom.xml create mode 100644 serviceworker/src/main/webapp/WEB-INF/web.xml create mode 100644 serviceworker/src/main/webapp/favicon.ico rename {webapi/src/main/resources/static => serviceworker/src/main/webapp}/sakai-service-worker.js (51%) diff --git a/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss b/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss index 90e191e00a9e..e69f9bf16b85 100644 --- a/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss +++ b/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss @@ -241,11 +241,21 @@ .portal-notifications-button { position: relative; } -.portal-notifications-indicator { +.portal-notifications-indicator, .portal-notifications-no-permissions-indicator { position: absolute; - top: 10; + top: 10px; right: 20%; } + +.portal-notifications-no-permissions-indicator { + width: 50%; + height: 50%; + right: 10%; + font-size: 75%; + font-weight: bold; + color: white; +} + // make offcanvas header match Sakai's header: .offcanvas-header { flex-shrink: 0; diff --git a/library/src/webapp/js/sakai-message-broker.js b/library/src/webapp/js/sakai-message-broker.js index 8b5b5b493e9d..21f5dbe0d343 100644 --- a/library/src/webapp/js/sakai-message-broker.js +++ b/library/src/webapp/js/sakai-message-broker.js @@ -3,180 +3,209 @@ portal.notifications = portal.notifications || {}; portal.notifications.pushCallbacks = new Map(); -if (portal?.user?.id) { +/** + * Create a promise which will setup the service worker and message registration + * functions before fulfilling. Consumers can wait on this promise and then register + * the push event they want to listen for. For an example of this, checkout + * sakai-notifications.js in webcomponents + */ +portal.notifications.setup = new Promise((resolve, reject) => { - const lastSubscribedUser = localStorage.getItem("last-sakai-user"); - const differentUser = lastSubscribedUser && lastSubscribedUser !== portal.user.id; + navigator.serviceWorker.register("/sakai-service-worker.js").then(reg => { - localStorage.setItem("last-sakai-user", portal.user.id); + const worker = reg.active; - if (portal.notifications.pushEnabled && (Notification.permission === "default" || differentUser)) { + if (worker) { - // Permission has neither been granted or denied yet. + // The serivce worker is already active, setup the listener and register function. - console.debug("No permission set or user changed"); + portal.notifications.setupServiceWorkerListener(); + console.debug("Worker registered and setup"); + resolve(); + } else { + console.debug("No active worker. Waiting for update ..."); - navigator.serviceWorker.register("/api/sakai-service-worker.js").then(registration => { + // Not active. We'll listen for an update then hook things up. - if (!registration.pushManager) { - // This must be Safari, or maybe IE3 or something :) - console.warn("No pushManager on this registration"); - return; - } + reg.addEventListener("updatefound", () => { - if (differentUser) { + console.debug("Worker updated. Waiting for state change ..."); - registration.pushManager.getSubscription() - .then(subscription => subscription && subscription.unsubscribe()) - .then(() => portal.notifications.subscribeIfPermitted(registration)); - } + const installingWorker = reg.installing; - window.addEventListener("DOMContentLoaded", () => { + installingWorker.addEventListener("statechange", e => { - console.debug("DOM loaded. Setting up permission triggers ..."); + console.debug("Worker state changed"); - // We're using the bullhorn buttons to trigger the permission request from the user. You - // can only instigate a permissions request from a user action. - document.querySelectorAll(".portal-notifications-button").forEach(b => { + if (e.target.state === "activated") { - b.addEventListener("click", e => { + console.debug("Worker activated. Setting up ..."); - portal.notifications.subscribeIfPermitted(registration); - }, { once: true }); + // The service worker has been updated, setup the listener and register function. + + portal.notifications.setupServiceWorkerListener(); + console.debug("Worker registered and setup"); + resolve(); + } }); }); - }); - } + } + }); +}); - portal.notifications.subscribeIfPermitted = registration => { +portal.notifications.registerPushCallback = (toolOrAll, cb) => { - console.debug("Requesting notifications permission ..."); + console.debug(`Registering push callback for ${toolOrAll}`); - Notification.requestPermission().then(permission => { + const callbacks = portal.notifications.pushCallbacks.get(toolOrAll) || []; + callbacks.push(cb); + portal.notifications.pushCallbacks.set(toolOrAll, callbacks); +}; - if (permission === "granted") { +portal.notifications.subscribeIfPermitted = reg => { - console.debug("Permission granted. Subscribing ..."); + console.debug("subscribeIfPermitted"); - // We have permission, Grab the public app server key. - fetch("/api/keys/sakaipush").then(r => r.text()).then(key => { + document.body.querySelectorAll(".portal-notifications-no-permissions-indicator") + .forEach(el => el.classList.add("d-none")); + document.body.querySelectorAll(".portal-notifications-indicator") + .forEach(el => el.classList.remove("d-none")); + document.body.querySelector("sakai-notifications")?.refresh(); - console.debug("Got the key. Subscribing for push ..."); + return new Promise(resolve => { - // Subscribe with the public key - registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).then(sub => { + if (Notification?.permission === "granted") { + console.debug("Permission already granted. Subscribing ..."); + portal.notifications.subscribe(reg, resolve); + } else if (Notification?.permission === "default") { - console.debug("Subscribed. Sending details to Sakai ..."); + console.debug("Push permission not set yet"); - const params = { - endpoint: sub.endpoint, - auth: sub.toJSON().keys.auth, - userKey: sub.toJSON().keys.p256dh, - browserFingerprint: getBrowserFingerprint(), - }; + Notification.requestPermission().then(permission => { - const url = "/api/users/me/prefs/pushEndpoint"; - fetch(url, { - credentials: "include", - method: "POST", - body: new URLSearchParams(params), - }) - .then(r => { + if (Notification.permission === "granted") { - if (!r.ok) { - throw new Error(`Network error while posting push endpoint: ${url}`); - } + console.debug("Permission granted. Subscribing ..."); + portal.notifications.subscribe(reg, resolve); + } + }) + .catch (error => console.error(error)); + } else { + resolve(); + } + }); +}; - console.debug("Subscription details sent successfully"); - }) - .catch (error => console.error(error)); - }); - }); - } - }); - }; +navigator.serviceWorker.register("/sakai-service-worker.js").then(reg => { + // We set this up for other parts of the code to call, without needing to register + // the service worker first. We capture the registration in the closure. + portal.notifications.callSubscribeIfPermitted = () => portal.notifications.subscribeIfPermitted(reg); +}); - portal.notifications.setupServiceWorkerListener = () => { +portal.notifications.serviceWorkerMessageListener = e => { - console.debug("setupServiceWorkerListener"); + // When the worker's EventSource receives an event it will message us (the client). This + // code looks up the matching callback and calls it. - // When the worker's EventSource receives an event it will message us (the client). This - // code looks up the matching callback and calls it. - navigator.serviceWorker.addEventListener('message', e => { + const allCallbacks = portal.notifications.pushCallbacks.get("all"); + allCallbacks && allCallbacks.forEach(cb => cb(e.data)); + const toolCallbacks = portal.notifications.pushCallbacks.get(e.data.tool); + toolCallbacks && toolCallbacks.forEach(cb => cb(e.data)); +}; - const allCallbacks = portal.notifications.pushCallbacks.get("all"); - allCallbacks && allCallbacks.forEach(cb => cb(e.data)); - const toolCallbacks = portal.notifications.pushCallbacks.get(e.data.tool); - toolCallbacks && toolCallbacks.forEach(cb => cb(e.data)); - }); - }; +portal.notifications.setupServiceWorkerListener = () => { + + console.debug("setupServiceWorkerListener"); + + navigator.serviceWorker.addEventListener('message', portal.notifications.serviceWorkerMessageListener); +}; + +portal.notifications.subscribe = (reg, resolve) => { + + const pushKeyUrl = "/api/keys/sakaipush"; + console.debug(`Fetching the push key from ${pushKeyUrl} ...`); + fetch(pushKeyUrl).then(r => r.text()).then(key => { - portal.notifications.registerPushCallback = (toolOrAll, cb) => { + // Subscribe with the public key + reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }) + .then(sub => { - console.debug(`Registering push callback for ${toolOrAll}`); + console.debug("Subscribed. Sending details to Sakai ..."); - const callbacks = portal.notifications.pushCallbacks.get(toolOrAll) || []; - callbacks.push(cb); - portal.notifications.pushCallbacks.set(toolOrAll, callbacks); - }; + const params = { + endpoint: sub.endpoint, + auth: sub.toJSON().keys.auth, + userKey: sub.toJSON().keys.p256dh, + browserFingerprint: getBrowserFingerprint(), + }; - /** - * Create a promise which will setup the service worker and message registration - * functions before fulfilling. Consumers can wait on this promise and then register - * the push event they want to listen for. For an example of this, checkout - * sui-notifications.js in webcomponents - */ - portal.notifications.setup = new Promise((resolve, reject) => { + const url = "/api/users/me/pushEndpoint"; + fetch(url, { + credentials: "include", + method: "POST", + body: new URLSearchParams(params), + }) + .then(r => { - console.debug("Registering worker ..."); + if (!r.ok) { + throw new Error(`Network error while posting push endpoint: ${url}`); + } - navigator.serviceWorker.register("/api/sakai-service-worker.js") - .then(registration => { + console.debug("Subscription details sent successfully"); + }) + .catch (error => console.error(error)) + .finally(() => resolve()); + }); + }); +}; - const worker = registration.active; +portal.notifications.checkUserChangedThenSet = userId => { - if (worker) { + const lastSubscribedUser = localStorage.getItem("last-sakai-user"); + const differentUser = !lastSubscribedUser || lastSubscribedUser !== portal.user.id; - // The serivce worker is already active, setup the listener and register function. + localStorage.setItem("last-sakai-user", userId); - portal.notifications.setupServiceWorkerListener(); - console.debug("Worker registered and setup"); - resolve(); - } else { - console.debug("No active worker. Waiting for update ..."); + return differentUser; +}; - // Not active. We'll listen for an update then hook things up. +if (portal?.user?.id) { - registration.addEventListener("updatefound", () => { + portal.notifications.setup.then(() => console.debug("Notifications setup complete")); - console.debug("Worker updated. Waiting for state change ..."); + const differentUser = portal.notifications.checkUserChangedThenSet(portal.user.id); - const installingWorker = registration.installing; + if (Notification?.permission !== "granted") { + document.addEventListener("DOMContentLoaded", event => { + document.querySelectorAll(".portal-notifications-no-permissions-indicator").forEach(b => b.classList.remove("d-none")); + }); + } - installingWorker.addEventListener("statechange", e => { + if (portal.notifications.pushEnabled && (Notification?.permission === "default" || differentUser)) { - console.debug("Worker state changed"); + // Permission has neither been granted or denied yet, or the user has changed. - if (e.target.state === "activated") { + console.debug("No permission set or user changed"); - console.debug("Worker activated. Setting up ..."); + navigator.serviceWorker.register("/sakai-service-worker.js").then(registration => { - // The service worker has been updated, setup the listener and register function. + if (!registration.pushManager) { + // This must be Safari < 16, or maybe IE3 or something :) + console.warn("No pushManager on this registration"); + return; + } - portal.notifications.setupServiceWorkerListener(); - console.debug("Worker registered and setup"); - resolve(); - } - }); - }); - } - }) - .catch (error => { + if (differentUser) { - console.error(`Failed to register service worker ${error}`); - reject(); - }); - }); + console.debug("The user has changed. Unsubscribing the previous user ..."); - portal.notifications.setup.then(() => console.debug("Notifications setup complete")); + // The user has changed. If there is a subscription, unsubscribe it. + registration.pushManager.getSubscription() + .then(subscription => subscription && subscription.unsubscribe()); + } + }); + } } diff --git a/pom.xml b/pom.xml index d23234484fed..64bbdf632d44 100644 --- a/pom.xml +++ b/pom.xml @@ -107,6 +107,7 @@ samigo search sections + serviceworker shortenedurl signup simple-rss-portlet diff --git a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java index c96ded73a87f..d9df92986099 100644 --- a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java +++ b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java @@ -146,19 +146,9 @@ public PortalNotifications handleNotify(EntityView view) { @EntityCustomAction(action = "notifications", viewKey = EntityView.VIEW_LIST) public ActionReturn getNotifications(EntityView view) { - String currentUserId = getCheckedCurrentUser(); - - ResourceLoader rl = new ResourceLoader("bullhorns"); - List notifications = userMessagingService.getNotifications(); - - Map data = new HashMap<>(); - data.put("i18n", rl); - - if (notifications.size() > 0) { - data.put("notifications", notifications); - } + getCheckedCurrentUser(); - return new ActionReturn(data); + return new ActionReturn(userMessagingService.getNotifications()); } @EntityCustomAction(action = "clearNotification", viewKey = EntityView.VIEW_LIST) diff --git a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm index 2f3f6caacf04..73e6bffa38f3 100644 --- a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm +++ b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm @@ -85,8 +85,12 @@ For opening help sidebar aria-controls="sakai-notificationsPanel" aria-label="$rloader.sit_notifications" title="$rloader.sit_notifications"> - -