Notification badges (#595)

* First pass of implementing Badging API for notifications

* Only show app badge when driven from push notifications

* Display number of unread push notifications instead of just an empty badge

Clear badge via postMessage when notifications page is loaded

* de-dupe some code, update badge counter on each notification click

* remove ids, track open note count instead

* restore optional chaining

* ensure note count doesn't go below 0, and fix event.waitUntil error when clearing badge

* incorporate PR feedback
This commit is contained in:
SatsAllDay 2023-11-08 19:17:01 -05:00 committed by GitHub
parent 3a56782572
commit 522c821c89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 67 additions and 1 deletions

View File

@ -25,6 +25,7 @@ import { HAS_NOTIFICATIONS } from '../fragments/notifications'
import AnonIcon from '../svgs/spy-fill.svg' import AnonIcon from '../svgs/spy-fill.svg'
import Hat from './hat' import Hat from './hat'
import HiddenWalletSummary from './hidden-wallet-summary' import HiddenWalletSummary from './hidden-wallet-summary'
import { clearNotifications } from '../lib/badge'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
if (!me) return null if (!me) return null
@ -56,7 +57,12 @@ function NotificationBell () {
? {} ? {}
: { : {
pollInterval: 30000, pollInterval: 30000,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network',
onCompleted: ({ hasNewNotes }) => {
if (!hasNewNotes) {
clearNotifications()
}
}
}) })
return ( return (

33
lib/badge.js Normal file
View File

@ -0,0 +1,33 @@
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
export const clearNotifications = () => navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS })
const badgingApiSupported = (sw = window) => 'setAppBadge' in sw.navigator
const permissionGranted = async (sw = window, name = 'notifications') => {
let permission
try {
permission = await sw.navigator.permissions.query({ name })
} catch (err) {
console.error('Failed to check permissions', err)
}
return permission?.state === 'granted'
}
export const setAppBadge = async (sw = window, count) => {
if (!badgingApiSupported(sw) || !(await permissionGranted(sw))) return
try {
await sw.navigator.setAppBadge(count)
} catch (err) {
console.error('Failed to set app badge', err)
}
}
export const clearAppBadge = async (sw = window) => {
if (!badgingApiSupported(sw) || !(await permissionGranted(sw))) return
try {
await sw.navigator.clearAppBadge()
} catch (err) {
console.error('Failed to clear app badge', err)
}
}

View File

@ -4,6 +4,7 @@ import Layout from '../components/layout'
import Notifications, { NotificationAlert } from '../components/notifications' import Notifications, { NotificationAlert } from '../components/notifications'
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications' import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
import { useApolloClient } from '@apollo/client' import { useApolloClient } from '@apollo/client'
import { clearNotifications } from '../lib/badge'
export const getServerSideProps = getGetServerSideProps({ query: NOTIFICATIONS, authRequired: true }) export const getServerSideProps = getGetServerSideProps({ query: NOTIFICATIONS, authRequired: true })
@ -17,6 +18,7 @@ export default function NotificationPage ({ ssrData }) {
hasNewNotes: false hasNewNotes: false
} }
}) })
clearNotifications()
}, [ssrData]) }, [ssrData])
return ( return (

View File

@ -1,5 +1,6 @@
import ServiceWorkerStorage from 'serviceworker-storage' import ServiceWorkerStorage from 'serviceworker-storage'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import { CLEAR_NOTIFICATIONS, clearAppBadge, setAppBadge } from '../lib/badge'
// we store existing push subscriptions to keep them in sync with server // we store existing push subscriptions to keep them in sync with server
const storage = new ServiceWorkerStorage('sw:storage', 1) const storage = new ServiceWorkerStorage('sw:storage', 1)
@ -12,6 +13,9 @@ let actionChannelPort
// keep track of item ids where we received a MENTION notification already to not show one again // keep track of item ids where we received a MENTION notification already to not show one again
const itemMentions = [] const itemMentions = []
// current push notification count for badge purposes
let activeCount = 0
export function onPush (sw) { export function onPush (sw) {
return async (event) => { return async (event) => {
const payload = event.data?.json() const payload = event.data?.json()
@ -20,6 +24,7 @@ export function onPush (sw) {
event.waitUntil((async () => { event.waitUntil((async () => {
if (skipNotification(payload)) return if (skipNotification(payload)) return
if (immediatelyShowNotification(payload)) { if (immediatelyShowNotification(payload)) {
setAppBadge(sw, ++activeCount)
return sw.registration.showNotification(payload.title, payload.options) return sw.registration.showNotification(payload.title, payload.options)
} }
@ -39,6 +44,7 @@ export function onPush (sw) {
if (notifications.length === 0) { if (notifications.length === 0) {
// incoming notification is first notification with this tag // incoming notification is first notification with this tag
setAppBadge(sw, ++activeCount)
return sw.registration.showNotification(payload.title, payload.options) return sw.registration.showNotification(payload.title, payload.options)
} }
@ -98,6 +104,12 @@ export function onNotificationClick (sw) {
if (url) { if (url) {
event.waitUntil(sw.clients.openWindow(url)) event.waitUntil(sw.clients.openWindow(url))
} }
activeCount = Math.max(0, activeCount - 1)
if (activeCount === 0) {
clearAppBadge(sw)
} else {
setAppBadge(sw, activeCount)
}
event.notification.close() event.notification.close()
} }
} }
@ -176,5 +188,18 @@ export function onMessage (sw) {
if (event.data.action === 'DELETE_SUBSCRIPTION') { if (event.data.action === 'DELETE_SUBSCRIPTION') {
return event.waitUntil(storage.removeItem('subscription')) return event.waitUntil(storage.removeItem('subscription'))
} }
if (event.data.action === CLEAR_NOTIFICATIONS) {
return event.waitUntil((async () => {
let notifications = []
try {
notifications = await sw.registration.getNotifications()
} catch (err) {
console.error('failed to get notifications')
}
notifications.forEach(notification => notification.close())
activeCount = 0
return await clearAppBadge(sw)
})())
}
} }
} }