Fix duplicate push notifications on item edit if mentioned (#563)
* Refactor service worker event listeners into own file * Refactor service worker onPush listener * Only show one MENTION push notification per item * Update index.js to have newline --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
parent
c1c1240eab
commit
dd36155b04
@ -87,6 +87,7 @@ export async function sendUserNotification (userId, notification) {
|
|||||||
notification.data ??= {}
|
notification.data ??= {}
|
||||||
if (notification.item) {
|
if (notification.item) {
|
||||||
notification.data.url ??= await createItemUrl(notification.item)
|
notification.data.url ??= await createItemUrl(notification.item)
|
||||||
|
notification.data.itemId ??= notification.item.id
|
||||||
delete notification.item
|
delete notification.item
|
||||||
}
|
}
|
||||||
const userFilter = createUserFilter(notification.tag)
|
const userFilter = createUserFilter(notification.tag)
|
||||||
|
159
sw/eventListener.js
Normal file
159
sw/eventListener.js
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
134
sw/index.js
134
sw/index.js
@ -4,16 +4,13 @@ import { offlineFallback } from 'workbox-recipes'
|
|||||||
import { setDefaultHandler } from 'workbox-routing'
|
import { setDefaultHandler } from 'workbox-routing'
|
||||||
import { NetworkOnly } from 'workbox-strategies'
|
import { NetworkOnly } from 'workbox-strategies'
|
||||||
import { enable } from 'workbox-navigation-preload'
|
import { enable } from 'workbox-navigation-preload'
|
||||||
|
|
||||||
import manifest from './precache-manifest.json'
|
import manifest from './precache-manifest.json'
|
||||||
import ServiceWorkerStorage from 'serviceworker-storage'
|
import { onPush, onNotificationClick, onPushSubscriptionChange, onMessage } from './eventListener'
|
||||||
import { numWithUnits } from '../lib/format'
|
|
||||||
|
|
||||||
// comment out to enable workbox console logs
|
// comment out to enable workbox console logs
|
||||||
self.__WB_DISABLE_DEV_LOGS = true
|
self.__WB_DISABLE_DEV_LOGS = true
|
||||||
|
|
||||||
const storage = new ServiceWorkerStorage('sw:storage', 1)
|
|
||||||
let messageChannelPort
|
|
||||||
|
|
||||||
// preloading improves startup performance
|
// preloading improves startup performance
|
||||||
// https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/
|
// https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/
|
||||||
enable()
|
enable()
|
||||||
@ -21,12 +18,12 @@ enable()
|
|||||||
// ignore precache manifest generated by InjectManifest
|
// ignore precache manifest generated by InjectManifest
|
||||||
// they statically check for the presence of this variable
|
// they statically check for the presence of this variable
|
||||||
console.log(self.__WB_MANIFEST)
|
console.log(self.__WB_MANIFEST)
|
||||||
|
// precache the manifest we generated ourselves
|
||||||
precacheAndRoute(manifest)
|
precacheAndRoute(manifest)
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
// immediately replace existing service workers with this one
|
||||||
self.skipWaiting()
|
// (no wait until this one becomes active)
|
||||||
})
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
// Using network-only as the default strategy ensures that we fallback
|
// Using network-only as the default strategy ensures that we fallback
|
||||||
// to the browser as if the service worker wouldn't exist.
|
// 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
|
// See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185
|
||||||
offlineFallback({ pageFallback: '/offline' })
|
offlineFallback({ pageFallback: '/offline' })
|
||||||
|
|
||||||
self.addEventListener('push', async function (event) {
|
self.addEventListener('push', onPush(self))
|
||||||
const payload = event.data?.json()
|
self.addEventListener('notificationclick', onNotificationClick(self))
|
||||||
if (!payload) return
|
self.addEventListener('message', onMessage(self))
|
||||||
const { tag } = payload.options
|
self.addEventListener('pushsubscriptionchange', onPushSubscriptionChange(self), false)
|
||||||
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)
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user