stacker.news/sw/eventListener.js

242 lines
11 KiB
JavaScript

import ServiceWorkerStorage from 'serviceworker-storage'
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
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
let actionChannelPort
// current push notification count for badge purposes
let activeCount = 0
export function onPush (sw) {
return async (event) => {
const payload = event.data?.json()
if (!payload) return
const { tag } = payload.options
event.waitUntil((async () => {
if (immediatelyShowNotification(tag)) {
setAppBadge(sw, ++activeCount)
// due to missing proper tag support in Safari on iOS, we can't rely on the tag property to replace notifications.
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
// we therefore fetch all notifications with the same tag (+ manual filter),
// close them and then we display the notification.
const notifications = await sw.registration.getNotifications({ tag })
notifications.filter(({ tag: nTag }) => nTag === tag).forEach(n => n.close())
return await sw.registration.showNotification(payload.title, payload.options)
}
// fetch existing notifications with same tag
let notifications = await sw.registration.getNotifications({ tag })
// according to the spec, there should only be zero or one notification since we used a tag filter
// handle zero case here
if (notifications.length === 0) {
// incoming notification is first notification with this tag
setAppBadge(sw, ++activeCount)
return await sw.registration.showNotification(payload.title, payload.options)
}
// handle unexpected case here
if (notifications.length > 1) {
const message = `[sw:push] more than one notification with tag ${tag} found`
messageChannelPort?.postMessage({ level: 'error', message })
console.error(message)
// due to missing proper tag support in Safari on iOS,
// we only acknowledge this error in our logs and don't bail here anymore
// see https://bugs.webkit.org/show_bug.cgi?id=258922 for more information
const message2 = '[sw:push] skip bail -- merging notifications manually'
messageChannelPort?.postMessage({ level: 'info', message: message2 })
console.log(message2)
// return null
}
// we manually filter notifications by their tag since iOS doesn't properly support tag
// and we're not sure if the built-in tag filter actually filters by tag on iOS
// or if it just returns all currently displayed notifications.
notifications = notifications.filter(({ tag: nTag }) => nTag === tag)
return await mergeAndShowNotification(sw, payload, notifications, tag)
})())
}
}
// if there is no tag or it's a TIP, FORWARDEDTIP or EARN notification
// we don't need to merge notifications and thus the notification should be immediately shown using `showNotification`
const immediatelyShowNotification = (tag) => !tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK'].includes(tag.split('-')[0])
const mergeAndShowNotification = async (sw, payload, currentNotifications, tag) => {
// sanity check
const otherTagNotifications = currentNotifications.filter(({ tag: nTag }) => nTag !== tag)
if (otherTagNotifications.length > 0) {
// we can't recover from this here. bail.
const message = `[sw:push] more than one notification with tag ${tag} after filter`
messageChannelPort?.postMessage({ level: 'error', message })
console.error(message)
return
}
const { data: incomingData } = payload.options
// we can ignore everything after the first dash in the tag for our control flow
const compareTag = tag.split('-')[0]
// merge notifications into single notification payload
// ---
// tags that need to know the amount of notifications with same tag for merging
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'REFERRAL', 'INVITE', 'FOLLOW']
// tags that need to know the sum of sats of notifications with same tag for merging
const SUM_SATS_TAGS = ['DEPOSIT']
// this should reflect the amount of notifications that were already merged before
const initialAmount = currentNotifications[0].data?.amount || 1
const mergedPayload = currentNotifications.reduce((acc, { data }) => {
let newAmount, newSats
if (AMOUNT_TAGS.includes(compareTag)) {
newAmount = acc.amount + 1
}
if (SUM_SATS_TAGS.includes(compareTag)) {
newSats = acc.sats + data.sats
}
const newPayload = { ...data, amount: newAmount, sats: newSats }
return newPayload
}, { ...incomingData, amount: initialAmount })
// calculate title from merged payload
const { amount, followeeName, subType, sats } = mergedPayload
let title = ''
if (AMOUNT_TAGS.includes(compareTag)) {
if (compareTag === 'REPLY') {
title = `you have ${amount} new replies`
} else if (compareTag === 'MENTION') {
title = `you were mentioned ${amount} times`
} else if (compareTag === 'REFERRAL') {
title = `${amount} stackers joined via your referral links`
} else if (compareTag === 'INVITE') {
title = `your invite has been redeemed by ${amount} stackers`
} else if (compareTag === 'FOLLOW') {
title = `@${followeeName} ${subType === 'POST' ? `created ${amount} posts` : `replied ${amount} times`}`
}
} else if (SUM_SATS_TAGS.includes(compareTag)) {
// there is only DEPOSIT in this array
title = `${numWithUnits(sats, { abbreviate: false })} were deposited in your account`
}
// close all current notifications before showing new one to "merge" notifications
currentNotifications.forEach(n => n.close())
const options = { icon: payload.options?.icon, tag, data: { url: '/notifications', ...mergedPayload } }
return await sw.registration.showNotification(title, options)
}
export function onNotificationClick (sw) {
return (event) => {
const url = event.notification.data?.url
if (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()
}
}
export function onPushSubscriptionChange (sw) {
// https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
// `isSync` is passed if function was called because of 'SYNC_SUBSCRIPTION' event
// this makes sure we can differentiate between 'pushsubscriptionchange' events and our custom 'SYNC_SUBSCRIPTION' event
return async (event, isSync) => {
let { oldSubscription, newSubscription } = event
// 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) {
if (isSync && oldSubscription?.swVersion === 2) {
// service worker lost the push subscription somehow, we assume this is a bug -> resubscribe
// see https://github.com/stackernews/stacker.news/issues/411#issuecomment-1790675861
// NOTE: this is only run on IndexedDB subscriptions stored under service worker version 2 since this is not backwards compatible
// see discussion in https://github.com/stackernews/stacker.news/pull/597
messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] service worker lost subscription' })
actionChannelPort?.postMessage({ action: 'RESUBSCRIBE' })
return
}
// 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 === 'ACTION_PORT') {
actionChannelPort = event.ports[0]
return
}
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, swVersion: 2 }))
}
if (event.data.action === 'SYNC_SUBSCRIPTION') {
return event.waitUntil(onPushSubscriptionChange(sw)(event, true))
}
if (event.data.action === 'DELETE_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)
})())
}
}
}