diff --git a/api/webPush/index.js b/api/webPush/index.js index 2bb32171..a380a03f 100644 --- a/api/webPush/index.js +++ b/api/webPush/index.js @@ -87,6 +87,7 @@ export async function sendUserNotification (userId, notification) { notification.data ??= {} if (notification.item) { notification.data.url ??= await createItemUrl(notification.item) + notification.data.itemId ??= notification.item.id delete notification.item } const userFilter = createUserFilter(notification.tag) diff --git a/sw/eventListener.js b/sw/eventListener.js new file mode 100644 index 00000000..9c972e38 --- /dev/null +++ b/sw/eventListener.js @@ -0,0 +1,159 @@ +import ServiceWorkerStorage from 'serviceworker-storage' +import { numWithUnits } from '../lib/format' + +// we store existing push subscriptions to keep them in sync with server +const storage = new ServiceWorkerStorage('sw:storage', 1) + +// for communication between app and service worker +// see https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel +let messageChannelPort + +// keep track of item ids where we received a MENTION notification already to not show one again +const itemMentions = [] + +export function onPush (sw) { + return async (event) => { + const payload = event.data?.json() + if (!payload) return + const { tag } = payload.options + event.waitUntil((async () => { + if (skipNotification(payload)) return + if (immediatelyShowNotification(payload)) { + return sw.registration.showNotification(payload.title, payload.options) + } + + // fetch existing notifications with same tag + const notifications = await sw.registration.getNotifications({ tag }) + + // since we used a tag filter, there should only be zero or one notification + if (notifications.length > 1) { + const message = `[sw:push] more than one notification with tag ${tag} found` + messageChannelPort?.postMessage({ level: 'error', message }) + console.error(message) + return null + } + + // save item id of MENTION notification so we can skip following ones + if (tag === 'MENTION' && payload.options.data?.itemId) itemMentions.push(payload.options.data.itemId) + + if (notifications.length === 0) { + // incoming notification is first notification with this tag + return sw.registration.showNotification(payload.title, payload.options) + } + + const currentNotification = notifications[0] + return mergeAndShowNotification(sw, payload, currentNotification) + })()) + } +} + +const skipNotification = ({ options: { tag, data } }) => { + return tag === 'MENTION' && itemMentions.includes(data.itemId) +} + +// if there is no tag or it's a TIP or EARN notification +// we don't need to merge notifications and thus the notification should be immediately shown using `showNotification` +const immediatelyShowNotification = ({ options: { tag } }) => !tag || ['TIP', 'EARN'].includes(tag.split('-')[0]) + +const mergeAndShowNotification = (sw, payload, currentNotification) => { + const { data: incomingData } = payload.options + const { tag, data: currentData } = currentNotification + + // how many notification with this tag are there already? + // (start from 2 and +1 to include incoming notification) + const amount = currentNotification.data?.amount ? currentNotification.data.amount + 1 : 2 + + let title = '' + const newData = {} + if (tag === 'REPLY') { + title = `You have ${amount} new replies` + } else if (tag === 'MENTION') { + title = `You were mentioned ${amount} times` + } else if (tag === 'REFERRAL') { + title = `${amount} stackers joined via your referral links` + } else if (tag === 'INVITE') { + title = `your invite has been redeemed by ${amount} stackers` + } else if (tag === 'DEPOSIT') { + const currentSats = currentData.sats + const incomingSats = incomingData.sats + const newSats = currentSats + incomingSats + title = `${numWithUnits(newSats, { abbreviate: false })} were deposited in your account` + newData.sats = newSats + } + + // close current notification before showing new one to "merge" notifications + currentNotification.close() + const newNotificationOptions = { icon: currentNotification.icon, tag, data: { url: '/notifications', amount, ...newData } } + return sw.registration.showNotification(title, newNotificationOptions) +} + +export function onNotificationClick (sw) { + return (event) => { + const url = event.notification.data?.url + if (url) { + event.waitUntil(sw.clients.openWindow(url)) + } + event.notification.close() + } +} + +export function onPushSubscriptionChange (sw) { + // https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f + return async (oldSubscription, newSubscription) => { + // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event + // fallbacks since browser may not set oldSubscription and newSubscription + messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] invoked' }) + oldSubscription ??= await storage.getItem('subscription') + newSubscription ??= await sw.registration.pushManager.getSubscription() + if (!newSubscription) { + // no subscription exists at the moment + messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] no existing subscription found' }) + return + } + if (oldSubscription?.endpoint === newSubscription.endpoint) { + // subscription did not change. no need to sync with server + messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] old subscription matches existing subscription' }) + return + } + // convert keys from ArrayBuffer to string + newSubscription = JSON.parse(JSON.stringify(newSubscription)) + const variables = { + endpoint: newSubscription.endpoint, + p256dh: newSubscription.keys.p256dh, + auth: newSubscription.keys.auth, + oldEndpoint: oldSubscription?.endpoint + } + const query = ` + mutation savePushSubscription($endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String!) { + savePushSubscription(endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint) { + id + } + }` + const body = JSON.stringify({ query, variables }) + await fetch('/api/graphql', { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body + }) + messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] synced push subscription with server', context: { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint } }) + await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription))) + } +} + +export function onMessage (sw) { + return (event) => { + if (event.data.action === 'MESSAGE_PORT') { + messageChannelPort = event.ports[0] + } + messageChannelPort?.postMessage({ message: '[sw:message] received message', context: { action: event.data.action } }) + if (event.data.action === 'STORE_SUBSCRIPTION') { + messageChannelPort?.postMessage({ message: '[sw:message] storing subscription in IndexedDB', context: { endpoint: event.data.subscription.endpoint } }) + return event.waitUntil(storage.setItem('subscription', event.data.subscription)) + } + if (event.data.action === 'SYNC_SUBSCRIPTION') { + return event.waitUntil(onPushSubscriptionChange(sw)(event.oldSubscription, event.newSubscription)) + } + } +} diff --git a/sw/index.js b/sw/index.js index 4616365c..c5e8db52 100644 --- a/sw/index.js +++ b/sw/index.js @@ -4,16 +4,13 @@ import { offlineFallback } from 'workbox-recipes' import { setDefaultHandler } from 'workbox-routing' import { NetworkOnly } from 'workbox-strategies' import { enable } from 'workbox-navigation-preload' + import manifest from './precache-manifest.json' -import ServiceWorkerStorage from 'serviceworker-storage' -import { numWithUnits } from '../lib/format' +import { onPush, onNotificationClick, onPushSubscriptionChange, onMessage } from './eventListener' // comment out to enable workbox console logs self.__WB_DISABLE_DEV_LOGS = true -const storage = new ServiceWorkerStorage('sw:storage', 1) -let messageChannelPort - // preloading improves startup performance // https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/ enable() @@ -21,12 +18,12 @@ enable() // ignore precache manifest generated by InjectManifest // they statically check for the presence of this variable console.log(self.__WB_MANIFEST) - +// precache the manifest we generated ourselves precacheAndRoute(manifest) -self.addEventListener('install', () => { - self.skipWaiting() -}) +// immediately replace existing service workers with this one +// (no wait until this one becomes active) +self.addEventListener('install', () => self.skipWaiting()) // Using network-only as the default strategy ensures that we fallback // to the browser as if the service worker wouldn't exist. @@ -71,118 +68,7 @@ setDefaultHandler(new NetworkOnly({ // See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185 offlineFallback({ pageFallback: '/offline' }) -self.addEventListener('push', async function (event) { - const payload = event.data?.json() - if (!payload) return - const { tag } = payload.options - event.waitUntil((async () => { - // TIP and EARN notifications simply replace the previous notifications - if (!tag || ['TIP', 'FORWARDEDTIP', 'EARN'].includes(tag.split('-')[0])) { - return self.registration.showNotification(payload.title, payload.options) - } - - const notifications = await self.registration.getNotifications({ tag }) - // since we used a tag filter, there should only be zero or one notification - if (notifications.length > 1) { - const message = `[sw:push] more than one notification with tag ${tag} found` - messageChannelPort?.postMessage({ level: 'error', message }) - console.error(message) - return null - } - if (notifications.length === 0) { - return self.registration.showNotification(payload.title, payload.options) - } - const currentNotification = notifications[0] - const amount = currentNotification.data?.amount ? currentNotification.data.amount + 1 : 2 - let newTitle = '' - const data = {} - if (tag === 'REPLY') { - newTitle = `You have ${amount} new replies` - } else if (tag === 'MENTION') { - newTitle = `You were mentioned ${amount} times` - } else if (tag === 'REFERRAL') { - newTitle = `${amount} stackers joined via your referral links` - } else if (tag === 'INVITE') { - newTitle = `your invite has been redeemed by ${amount} stackers` - } else if (tag === 'DEPOSIT') { - const currentSats = currentNotification.data.sats - const incomingSats = payload.options.data.sats - const newSats = currentSats + incomingSats - data.sats = newSats - newTitle = `${numWithUnits(newSats, { abbreviate: false })} were deposited in your account` - } - currentNotification.close() - const { icon } = currentNotification - return self.registration.showNotification(newTitle, { icon, tag, data: { url: '/notifications', amount, ...data } }) - })()) -}) - -self.addEventListener('notificationclick', (event) => { - const url = event.notification.data?.url - if (url) { - event.waitUntil(self.clients.openWindow(url)) - } - event.notification.close() -}) - -// https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f -self.addEventListener('message', (event) => { - if (event.data.action === 'MESSAGE_PORT') { - messageChannelPort = event.ports[0] - } - messageChannelPort?.postMessage({ message: '[sw:message] received message', context: { action: event.data.action } }) - if (event.data.action === 'STORE_SUBSCRIPTION') { - messageChannelPort?.postMessage({ message: '[sw:message] storing subscription in IndexedDB', context: { endpoint: event.data.subscription.endpoint } }) - return event.waitUntil(storage.setItem('subscription', event.data.subscription)) - } - if (event.data.action === 'SYNC_SUBSCRIPTION') { - return event.waitUntil(handlePushSubscriptionChange()) - } -}) - -async function handlePushSubscriptionChange (oldSubscription, newSubscription) { - // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event - // fallbacks since browser may not set oldSubscription and newSubscription - messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] invoked' }) - oldSubscription ??= await storage.getItem('subscription') - newSubscription ??= await self.registration.pushManager.getSubscription() - if (!newSubscription) { - // no subscription exists at the moment - messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] no existing subscription found' }) - return - } - if (oldSubscription?.endpoint === newSubscription.endpoint) { - // subscription did not change. no need to sync with server - messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] old subscription matches existing subscription' }) - return - } - // convert keys from ArrayBuffer to string - newSubscription = JSON.parse(JSON.stringify(newSubscription)) - const variables = { - endpoint: newSubscription.endpoint, - p256dh: newSubscription.keys.p256dh, - auth: newSubscription.keys.auth, - oldEndpoint: oldSubscription?.endpoint - } - const query = ` - mutation savePushSubscription($endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String!) { - savePushSubscription(endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint) { - id - } - }` - const body = JSON.stringify({ query, variables }) - await fetch('/api/graphql', { - method: 'POST', - headers: { - 'Content-type': 'application/json' - }, - body - }) - messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] synced push subscription with server', context: { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint } }) - await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription))) -} - -self.addEventListener('pushsubscriptionchange', (event) => { - messageChannelPort?.postMessage({ message: '[sw:pushsubscriptionchange] received event' }) - event.waitUntil(handlePushSubscriptionChange(event.oldSubscription, event.newSubscription)) -}, false) +self.addEventListener('push', onPush(self)) +self.addEventListener('notificationclick', onNotificationClick(self)) +self.addEventListener('message', onMessage(self)) +self.addEventListener('pushsubscriptionchange', onPushSubscriptionChange(self), false)