Indicate Unread Notifications With The Badging API
The modern web, tested and explained in plain English
Modern Web Weekly #75
I updated my push notifications demo, which now shows a badge on the app icon with the number of unread push notifications using the Badging API.
This API is supported in Chrome, Edge, and Safari and is typically used in conjunction with the “push” event handler in a service worker. It consists of two methods on the navigator object, setAppBadge and clearAppBadge:
// sets a badge with the number 3
navigator.setAppBadge(3)
// clears the badge
navigator.clearAppBadge()
// also clears the badge
navigator.setAppBadge(0)
// sets a badge with a dot
navigator.setAppBadge()Badges are typically shown when notifications are unread, and in the demo of What PWA Can Do Today, I chose to show badges when the PWA is not running:
When the PWA is opened and then moved to the background, the badge will be cleared on iOS. On Android, the badge can only be cleared when the notifications are clicked or cleared.
In the “push” event handler, I show the notification and then check if the service worker has active clients, which means apps that are not in the background or not running. If this is not the case, the badge is either set or, when it’s already shown, the number of unread notifications is incremented:
e.waitUntil(
self.registration.showNotification(title, options)
.then(hasActiveClients)
.then((activeClients) => {
if(!activeClients) {
updateBadges();
}
})Here’s the implementation of hasActiveClients:
const hasActiveClients = async () => {
const clients = await self.clients.matchAll({
includeUncontrolled: true,
});
return clients.some(({visibilityState}) =>
visibilityState === ‘visible’);
};The number of unread notifications is stored in IndexedDB and is updated in the updateBadges function:
const updateBadges = async (inc = 1) => {
// open the IndexedDB store
const store = await openStore('badgeStore', ‘readwrite’);
// get the number of unread notifications or 0 if not stored
const result = await store.get(’num’) || {num: ‘num’, count: 0};
// get the current number of unread notifications and increment it
// by the "inc" argument (1 if not given)
const numBadges = result.count;
const count = Math.max(0, numBadges + inc);
// store the updated number
await store.put({num: ‘num’, count});
// set the badge number
navigator.setAppBadge(count);
}For brevity, I omitted the implementation of openStore, but you can find it here.
Using the native close mechanism with CloseWatcher
Many UI components have some sort of “modal” or “popup” behavior, like <dialog>, a drawer or a context menu. Usually, these components are easy to close with the device’s native closing mechanism. On desktop, this is usually the ESC key, on Android, this is usually the Back button or swiping to the right from Android 10 on, on iOS with VoiceOver enabled, it’s the “z” key, and game consoles also provide specific buttons as their closing mechanism.
If you’ve ever tried to provide a uniform way for all these devices to close a UI component, you know this is tedious and complex to implement. You would first have to assert the type of device you’re dealing with and then select the appropriate action that would close the element. When new devices emerge, you’d have to add support for these as well, which makes this into a moving technical target that’s hard to hit.
Enter CloseWatcher.
This object abstracts the device-specific closing mechanisms away and provides a uniform API for web apps to respond to these closing actions. Whenever such an action is invoked (ESC key, Back button, etc.), CloseWatcher delivers a cancel event followed by a close event that web apps can listen for to close the UI component.
You can simply create a CloseWatcher and then listen for the cancel and close events:
const watcher = new CloseWatcher();
watcher.addEventListener('cancel', e => {
// respond to the cancel event
});
watcher.addEventListener('close', (e) => {
// respond to the close event
});The cancel event gives a web app an opportunity to prevent the closing of the UI component based on some condition, for example, when the user has unsaved changes. By calling e.preventDefault(), the close event will not fire:
watcher.addEventListener('cancel', e => {
if(hasUnsavedChanges) {
// prevent the close event from firing
e.preventDefault();
}
});
watcher.addEventListener('close', (e) => {
component.close();
});
It’s important to understand that the CloseWatcher instance is a one-off component; after the close event is fired, the watcher is destroyed. It also provides two methods to fire the events:
close(): fires thecloseeventrequestClose(): fires thecancelevent and then thecloseevent, unlesse.preventDefault()was called in thecancelevent
For this reason, a new CloseWatcher needs to be created every time its close event was fired.
In the following example, we create a sidebar that is opened with a button. Inside its click handler, we instantiate a new CloseWatcher and listen for the close event to close the sidebar.
We also provide a button to close the sidebar. Inside the click handler for the close button, we call requestClose() so closing the sidebar always happens through the event handler for the close event:
const sidebar = document.querySelector('.sidebar');
const openButton = document.querySelector('.open-button');
const closeButton = document.querySelector('.close-button');
openButton.addEventListener('click', () => {
// open the sidebar
sidebar.classList.add('open');
// create a new watcher
const watcher = new CloseWatcher();
// closes the sidebar when the close action is invoked
watcher.addEventListener('close', (e) => {
// close the sidebar
sidebar.classList.remove('open');
});
// calls requestClose() so the cancel and close events are fired
// this makes sure closing the sidebar only happens in one place: the event handler for
// the close event
closeButton.onclick = () => watcher.requestClose();
});We can now also add a handler for the cancel event to warn the user that there are unsaved changes and prevent the sidebar from closing. We can also add a save button that saves the changes and closes the sidebar. Inside its click handler, we call close() instead of requestClose() because we don’t need to fire the cancel event:
const sidebar = document.querySelector('.sidebar');
const openButton = document.querySelector('.open-button');
const closeButton = document.querySelector('.close-button');
const saveButton = document.querySelector('.save-button');
openButton.addEventListener('click', () => {
// open the sidebar
sidebar.classList.add('open');
// create a new watcher
const watcher = new CloseWatcher();
watcher.addEventListener('cancel', e => {
if(hasUnsavedChanges) {
// prevents the close event from firing when there are unsaved changes
e.preventDefault();
}
});
// closes the sidebar when the close action is invoked
watcher.addEventListener('close', (e) => {
// close the sidebar
sidebar.classList.remove('open');
});
// calls requestClose() so the cancel and close events are fired
// this makes sure closing the sidebar only happens in one place: the event handler for
// the close event
closeButton.onclick = () => watcher.requestClose();
// calls close() so only the close event is fired, cancel event is not needed
// this makes sure closing the sidebar only happens in one place: the event handler for
// the close event
saveButton.onclick = () => {
saveChanges();
watcher.close();
}
});Another important thing to know is that the cancel event can only be canceled once. This is to prevent abuse by trapping the user in not being able to close the sidebar. This means you can only alert the user once when there are unsaved changes. If you want to alert the user as long as there are unsaved changes, you’ll need to use another mechanism.
In one way, this is a bit unfortunate, but the restriction is understandable to prevent abuse.
Check out the CloseWatcher demo.
Do you need help with your web app?
Book a 1-on-1 call with me and I will do my absolute best to answer all your questions and solve your problems.
€100 for one hour, money-back guarantee if you’re not satisfied.
