Skip to content

Commit

Permalink
SAK-49287 Notifications. Improved push permissions workflow
Browse files Browse the repository at this point in the history
  • Loading branch information
adrianfish committed Mar 14, 2024
1 parent 78b7910 commit 5d77766
Show file tree
Hide file tree
Showing 20 changed files with 500 additions and 248 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
271 changes: 150 additions & 121 deletions library/src/webapp/js/sakai-message-broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
});
}
}
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
<module>samigo</module>
<module>search</module>
<module>sections</module>
<module>serviceworker</module>
<module>shortenedurl</module>
<module>signup</module>
<module>simple-rss-portlet</module>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserNotification> notifications = userMessagingService.getNotifications();

Map<String, Object> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,12 @@ For opening help sidebar
aria-controls="sakai-notificationsPanel"
aria-label="$rloader.sit_notifications"
title="$rloader.sit_notifications">
<i class="bi-bell"></i>
<span class="portal-notifications-indicator p-1 bg-danger rounded-circle" style="display: none">
<i class="portal-notifications-icon bi-bell"></i>
<span class="portal-notifications-no-permissions-indicator p-1 bg-danger rounded-circle d-none">
X
<span class="visually-hidden">Notifications have not yet been permitted</span>
</span>
<span class="portal-notifications-indicator p-1 bg-danger rounded-circle d-none">
<span class="visually-hidden">There are new notifications available</span>
</span>
</button>
Expand Down
Loading

0 comments on commit 5d77766

Please sign in to comment.