From b1a0abe32c8c530d13d8a3bfbdbbab569df6d94a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 10 Jul 2025 18:54:23 +0200 Subject: [PATCH] Service Worker rewrite (#2274) * Convert all top-level arrow functions to regular functions * Refactor webPush.sendNotification call * Refactor webPush logging * Rename var to title * Rewrite service worker This rewrite simplifies the service worker by removing * merging of push notifications via tag property * badge count These features weren't properly working on iOS. We concluded that we don't really need them. For example, this means replies will no longer get merged to "you have X new replies" but show up as individual notifications. Only zaps still use the tag property so devices that support it can still replace any previous "your post stacked X sats" notification for the same item. * Don't use async/await in service worker * Support app badge count * Fix extremely slow notificationclick * Fix serialization and save in pushsubscriptionchange event --- components/serviceworker.js | 5 + components/use-has-new-notes.js | 2 +- lib/badge.js | 36 ------ lib/webPush.js | 223 +++++++++++++++----------------- pages/_app.js | 9 ++ pages/notifications.js | 2 +- sw/eventListener.js | 182 -------------------------- sw/index.js | 152 +++++++++++++++++++++- 8 files changed, 266 insertions(+), 345 deletions(-) delete mode 100644 lib/badge.js delete mode 100644 sw/eventListener.js diff --git a/components/serviceworker.js b/components/serviceworker.js index 218ca297..bdc64e04 100644 --- a/components/serviceworker.js +++ b/components/serviceworker.js @@ -9,6 +9,7 @@ const ServiceWorkerContext = createContext() // message types for communication between app and service worker export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' +export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS' export const ServiceWorkerProvider = ({ children }) => { const [registration, setRegistration] = useState(null) @@ -140,6 +141,10 @@ export const ServiceWorkerProvider = ({ children }) => { ) } +export function clearNotifications () { + return navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS }) +} + export function useServiceWorker () { return useContext(ServiceWorkerContext) } diff --git a/components/use-has-new-notes.js b/components/use-has-new-notes.js index dcc843f3..d24aa7ed 100644 --- a/components/use-has-new-notes.js +++ b/components/use-has-new-notes.js @@ -1,8 +1,8 @@ import { HAS_NOTIFICATIONS } from '@/fragments/notifications' -import { clearNotifications } from '@/lib/badge' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' import { useQuery } from '@apollo/client' import React, { useContext } from 'react' +import { clearNotifications } from '@/components/serviceworker' export const HasNewNotesContext = React.createContext(false) diff --git a/lib/badge.js b/lib/badge.js deleted file mode 100644 index 1b444c2d..00000000 --- a/lib/badge.js +++ /dev/null @@ -1,36 +0,0 @@ -export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS' - -export const clearNotifications = () => navigator.serviceWorker?.controller?.postMessage({ action: CLEAR_NOTIFICATIONS }) - -const badgingApiSupported = (sw = window) => 'setAppBadge' in sw.navigator - -// we don't need this, we can use the badging API -/* const permissionGranted = async (sw = window) => { - const 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' || sw.Notification?.permission === 'granted' -} */ - -// Apple requirement: onPush doesn't accept async functions -export const setAppBadge = (sw = window, count) => { - if (!badgingApiSupported(sw)) return - try { - return sw.navigator.setAppBadge(count) // Return a Promise to be handled - } catch (err) { - console.error('Failed to set app badge', err) - } -} - -export const clearAppBadge = (sw = window) => { - if (!badgingApiSupported(sw)) return - try { - return sw.navigator.clearAppBadge() // Return a Promise to be handled - } catch (err) { - console.error('Failed to clear app badge', err) - } -} diff --git a/lib/webPush.js b/lib/webPush.js index c1e33d33..34c6cb89 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -9,17 +9,11 @@ import { Prisma } from '@prisma/client' const webPushEnabled = process.env.NODE_ENV === 'production' || (process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY) -if (webPushEnabled) { - webPush.setVapidDetails( - process.env.VAPID_MAILTO, - process.env.NEXT_PUBLIC_VAPID_PUBKEY, - process.env.VAPID_PRIVKEY - ) -} else { - console.warn('VAPID_* env vars not set, skipping webPush setup') +function log (...args) { + console.log('[webPush]', ...args) } -const createPayload = (notification) => { +function createPayload (notification) { // https://web.dev/push-notifications-display-a-notification/#visual-options let { title, body, ...options } = notification if (body) body = removeMd(body) @@ -34,26 +28,11 @@ const createPayload = (notification) => { }) } -const createUserFilter = (tag) => { - // filter users by notification settings - const tagMap = { - THREAD: 'noteAllDescendants', - MENTION: 'noteMentions', - ITEM_MENTION: 'noteItemMentions', - TIP: 'noteItemSats', - FORWARDEDTIP: 'noteForwardedSats', - REFERRAL: 'noteInvites', - INVITE: 'noteInvites', - EARN: 'noteEarning', - DEPOSIT: 'noteDeposits', - WITHDRAWAL: 'noteWithdrawals', - STREAK: 'noteCowboyHat' - } - const key = tagMap[tag.split('-')[0]] - return key ? { user: { [key]: true } } : undefined +function userFilterFragment (setting) { + return setting ? { user: { [setting]: true } } : undefined } -const createItemUrl = async ({ id }) => { +async function createItemUrl (id) { const [rootItem] = await models.$queryRawUnsafe( 'SELECT subpath(path, -LEAST(nlevel(path), $1::INTEGER), 1)::text AS id FROM "Item" WHERE id = $2::INTEGER', COMMENT_DEPTH_LIMIT + 1, Number(id) @@ -61,28 +40,50 @@ const createItemUrl = async ({ id }) => { return `/items/${rootItem.id}` + (rootItem.id !== id ? `?commentId=${id}` : '') } -const sendNotification = (subscription, payload) => { +async function sendNotification (subscription, payload) { if (!webPushEnabled) { - console.warn('webPush not configured. skipping notification') + log('webPush not configured, skipping notification') return } + const { id, endpoint, p256dh, auth } = subscription - return webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, payload) + return await webPush.sendNotification( + { + endpoint, + keys: { p256dh, auth } + }, + payload, + { + vapidDetails: { + subject: process.env.VAPID_MAILTO, + publicKey: process.env.NEXT_PUBLIC_VAPID_PUBKEY, + privateKey: process.env.VAPID_PRIVKEY + } + }) .catch(async (err) => { - if (err.statusCode === 400) { - console.log('[webPush] invalid request: ', err) - } else if ([401, 403].includes(err.statusCode)) { - console.log('[webPush] auth error: ', err) - } else if (err.statusCode === 404 || err.statusCode === 410) { - console.log('[webPush] subscription has expired or is no longer valid: ', err) - const deletedSubscripton = await models.pushSubscription.delete({ where: { id } }) - console.log(`[webPush] deleted subscription ${id} of user ${deletedSubscripton.userId} due to push error`) - } else if (err.statusCode === 413) { - console.log('[webPush] payload too large: ', err) - } else if (err.statusCode === 429) { - console.log('[webPush] too many requests: ', err) - } else { - console.log('[webPush] error: ', err) + switch (err.statusCode) { + case 400: + log('invalid request:', err) + break + case 401: + case 403: + log('auth error:', err) + break + case 404: + case 410: { + log('subscription expired or no longer valid:', err) + const deletedSubscripton = await models.pushSubscription.delete({ where: { id } }) + log(`deleted subscription ${id} of user ${deletedSubscripton.userId} due to push error`) + break + } + case 413: + log('payload too large:', err) + break + case 429: + log('too many requests:', err) + break + default: + log('error:', err) } }) } @@ -93,26 +94,22 @@ async function sendUserNotification (userId, notification) { throw new Error('user id is required') } notification.data ??= {} - if (notification.item) { - notification.data.url ??= await createItemUrl(notification.item) - notification.data.itemId ??= notification.item.id - delete notification.item + if (notification.itemId) { + notification.data.url ??= await createItemUrl(notification.itemId) + delete notification.itemId } - const userFilter = createUserFilter(notification.tag) - // XXX we only want to use the tag to filter follow-up replies by user settings - // but still merge them with normal replies - if (notification.tag === 'THREAD') notification.tag = 'REPLY' + const filterFragment = userFilterFragment(notification.setting) const payload = createPayload(notification) const subscriptions = await models.pushSubscription.findMany({ - where: { userId, ...userFilter } + where: { userId, ...filterFragment } }) await Promise.allSettled( subscriptions.map(subscription => sendNotification(subscription, payload)) ) } catch (err) { - console.log('[webPush] error sending user notification: ', err) + log('error sending user notification:', err) } } @@ -121,11 +118,11 @@ export async function sendPushSubscriptionReply (subscription) { const payload = createPayload({ title: 'Stacker News notifications are now active' }) await sendNotification(subscription, payload) } catch (err) { - console.log('[webPush] error sending subscription reply: ', err) + log('error sending subscription reply:', err) } } -export const notifyUserSubscribers = async ({ models, item }) => { +export async function notifyUserSubscribers ({ models, item }) { try { const isPost = !!item.title @@ -158,21 +155,17 @@ export const notifyUserSubscribers = async ({ models, item }) => { AND "SubSubscription"."subName" = ${item.subName} )` : Prisma.empty}` - const subType = isPost ? 'POST' : 'COMMENT' - const tag = `FOLLOW-${item.userId}-${subType}` await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, { title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`, body: isPost ? item.title : item.text, - item, - data: { followeeName, subType }, - tag + itemId: item.id }))) } catch (err) { - console.error(err) + log('error sending user notification:', err) } } -export const notifyTerritorySubscribers = async ({ models, item }) => { +export async function notifyTerritorySubscribers ({ models, item }) { try { const isPost = !!item.title const { subName } = item @@ -188,7 +181,6 @@ export const notifyTerritorySubscribers = async ({ models, item }) => { const author = await models.user.findUnique({ where: { id: item.userId } }) - const tag = `TERRITORY_POST-${subName}` await Promise.allSettled( territorySubsExcludingMuted // don't send push notification to author itself @@ -197,16 +189,14 @@ export const notifyTerritorySubscribers = async ({ models, item }) => { sendUserNotification(userId, { title: `@${author.name} created a post in ~${subName}`, body: item.title, - item, - data: { subName }, - tag + itemId: item.id }))) } catch (err) { - console.error(err) + log('error sending territory notification:', err) } } -export const notifyThreadSubscribers = async ({ models, item }) => { +export async function notifyThreadSubscribers ({ models, item }) { try { const author = await models.user.findUnique({ where: { id: item.userId } }) @@ -234,17 +224,15 @@ export const notifyThreadSubscribers = async ({ models, item }) => { // so we should also merge them together (= same tag+data) to avoid confusion title: `@${author.name} replied to a post`, body: item.text, - item, - data: { followeeName: author.name, subType: 'COMMENT' }, - tag: `FOLLOW-${author.id}-COMMENT` + itemId: item.id }) )) } catch (err) { - console.error(err) + log('error sending thread notification:', err) } } -export const notifyItemParents = async ({ models, item }) => { +export async function notifyItemParents ({ models, item }) { try { const user = await models.user.findUnique({ where: { id: item.userId } }) const parents = await models.$queryRaw` @@ -265,17 +253,17 @@ export const notifyItemParents = async ({ models, item }) => { return sendUserNotification(userId, { title: `@${user.name} replied to you`, body: item.text, - item, - tag: isDirect ? 'REPLY' : 'THREAD' + itemId: item.id, + setting: isDirect ? undefined : 'noteAllDescendants' }) }) ) } catch (err) { - console.error(err) + log('error sending item parents notification:', err) } } -export const notifyZapped = async ({ models, item }) => { +export async function notifyZapped ({ models, item }) { try { const forwards = await models.itemForward.findMany({ where: { itemId: item.id } }) const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } })) @@ -287,26 +275,27 @@ export const notifyZapped = async ({ models, item }) => { forwardedSats = Math.floor(msatsToSats(item.msats) * mappedForwards.map(fwd => fwd.pct).reduce((sum, cur) => sum + cur) / 100) forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ') } - let notificationTitle + let title if (item.title) { if (forwards.length > 0) { - notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` + title = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` } else { - notificationTitle = `your post stacked ${numWithUnits(msatsToSats(item.msats))}` + title = `your post stacked ${numWithUnits(msatsToSats(item.msats))}` } } else { if (forwards.length > 0) { // I don't think this case is possible - notificationTitle = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` + title = `your reply forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` } else { - notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(item.msats))}` + title = `your reply stacked ${numWithUnits(msatsToSats(item.msats))}` } } await sendUserNotification(item.userId, { - title: notificationTitle, + title, body: item.title ? item.title : item.text, - item, + itemId: item.id, + setting: 'noteItemSats', tag: `TIP-${item.id}` }) @@ -315,16 +304,17 @@ export const notifyZapped = async ({ models, item }) => { await Promise.allSettled(mappedForwards.map(forward => sendUserNotification(forward.user.id, { title: `you were forwarded ${numWithUnits(Math.round(msatsToSats(item.msats) * forward.pct / 100))}`, body: item.title ?? item.text, - item, + itemId: item.id, + setting: 'noteForwardedSats', tag: `FORWARDEDTIP-${item.id}` }))) } } catch (err) { - console.error(err) + log('error sending zapped notification:', err) } } -export const notifyMention = async ({ models, userId, item }) => { +export async function notifyMention ({ models, userId, item }) { try { const muted = await isMuted({ models, muterId: userId, mutedId: item.userId }) if (muted) return @@ -332,15 +322,15 @@ export const notifyMention = async ({ models, userId, item }) => { await sendUserNotification(userId, { title: `@${item.user.name} mentioned you`, body: item.text, - item, - tag: 'MENTION' + itemId: item.id, + setting: 'noteMentions' }) } catch (err) { - console.error(err) + log('error sending mention notification:', err) } } -export const notifyItemMention = async ({ models, referrerItem, refereeItem }) => { +export async function notifyItemMention ({ models, referrerItem, refereeItem }) { try { const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId }) if (!muted) { @@ -352,39 +342,36 @@ export const notifyItemMention = async ({ models, referrerItem, refereeItem }) = await sendUserNotification(refereeItem.userId, { title: `@${referrer.name} mentioned one of your items`, body, - item: referrerItem, - tag: 'ITEM_MENTION' + itemId: referrerItem.id, + setting: 'noteItemMentions' }) } } catch (err) { - console.error(err) + log('error sending item mention notification:', err) } } -export const notifyReferral = async (userId) => { +export async function notifyReferral (userId) { try { await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }) } catch (err) { - console.error(err) + log('error sending referral notification:', err) } } -export const notifyInvite = async (userId) => { +export async function notifyInvite (userId) { try { await sendUserNotification(userId, { title: 'your invite has been redeemed', tag: 'INVITE' }) } catch (err) { - console.error(err) + log('error sending invite notification:', err) } } -export const notifyTerritoryTransfer = async ({ models, sub, to }) => { +export async function notifyTerritoryTransfer ({ models, sub, to }) { try { - await sendUserNotification(to.id, { - title: `~${sub.name} was transferred to you`, - tag: `TERRITORY_TRANSFER-${sub.name}` - }) + await sendUserNotification(to.id, { title: `~${sub.name} was transferred to you` }) } catch (err) { - console.error(err) + log('error sending territory transfer notification:', err) } } @@ -392,7 +379,6 @@ export async function notifyEarner (userId, earnings) { const fmt = msats => numWithUnits(msatsToSats(msats, { abbreviate: false })) const title = `you stacked ${fmt(earnings.msats)} in rewards` - const tag = 'EARN' let body = '' if (earnings.POST) body += `#${earnings.POST.bestRank} among posts with ${fmt(earnings.POST.msats)} in total\n` if (earnings.COMMENT) body += `#${earnings.COMMENT.bestRank} among comments with ${fmt(earnings.COMMENT.msats)} in total\n` @@ -400,9 +386,9 @@ export async function notifyEarner (userId, earnings) { if (earnings.TIP_COMMENT) body += `#${earnings.TIP_COMMENT.bestRank} in comment zapping with ${fmt(earnings.TIP_COMMENT.msats)} in total` try { - await sendUserNotification(userId, { title, tag, body }) + await sendUserNotification(userId, { title, body, setting: 'noteEarning' }) } catch (err) { - console.error(err) + log('error sending earn notification:', err) } } @@ -411,11 +397,10 @@ export async function notifyDeposit (userId, invoice) { await sendUserNotification(userId, { title: `${numWithUnits(msatsToSats(invoice.msatsReceived), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`, body: invoice.comment || undefined, - tag: 'DEPOSIT', - data: { sats: msatsToSats(invoice.msatsReceived) } + setting: 'noteDeposits' }) } catch (err) { - console.error(err) + log('error sending deposit notification:', err) } } @@ -423,11 +408,10 @@ export async function notifyWithdrawal (wdrwl) { try { await sendUserNotification(wdrwl.userId, { title: `${numWithUnits(msatsToSats(wdrwl.msatsPaid), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`, - tag: 'WITHDRAWAL', - data: { sats: msatsToSats(wdrwl.msatsPaid) } + setting: 'noteWithdrawals' }) } catch (err) { - console.error(err) + log('error sending withdrawal notification:', err) } } @@ -439,10 +423,10 @@ export async function notifyNewStreak (userId, streak) { await sendUserNotification(userId, { title: `you found a ${streak.type.toLowerCase().replace('_', ' ')}`, body: blurb, - tag: `STREAK-FOUND-${streak.type}` + setting: 'noteCowboyHat' }) } catch (err) { - console.error(err) + log('error sending streak found notification:', err) } } @@ -454,10 +438,10 @@ export async function notifyStreakLost (userId, streak) { await sendUserNotification(userId, { title: `you lost your ${streak.type.toLowerCase().replace('_', ' ')}`, body: blurb, - tag: `STREAK-LOST-${streak.type}` + setting: 'noteCowboyHat' }) } catch (err) { - console.error(err) + log('error sending streak lost notification:', err) } } @@ -465,7 +449,6 @@ export async function notifyReminder ({ userId, item }) { await sendUserNotification(userId, { title: 'you requested this reminder', body: item.title ?? item.text, - tag: `REMIND-ITEM-${item.id}`, - item + itemId: item.id }) } diff --git a/pages/_app.js b/pages/_app.js index f9ede40c..9c8ae2f1 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -60,6 +60,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { router.events.on('routeChangeComplete', nprogressDone) router.events.on('routeChangeError', nprogressDone) + const handleServiceWorkerMessage = (event) => { + if (event.data?.type === 'navigate') { + router.push(event.data.url) + } + } + + navigator.serviceWorker?.addEventListener('message', handleServiceWorkerMessage) + if (!props?.apollo) return // HACK: 'cause there's no way to tell Next to skip SSR // So every page load, we modify the route in browser history @@ -82,6 +90,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { router.events.off('routeChangeStart', nprogressStart) router.events.off('routeChangeComplete', nprogressDone) router.events.off('routeChangeError', nprogressDone) + navigator.serviceWorker?.removeEventListener('message', handleServiceWorkerMessage) } }, [router.asPath, props?.apollo, shouldShowProgressBar]) diff --git a/pages/notifications.js b/pages/notifications.js index cc25930c..f57c1beb 100644 --- a/pages/notifications.js +++ b/pages/notifications.js @@ -4,7 +4,7 @@ import Layout from '@/components/layout' import Notifications, { NotificationAlert } from '@/components/notifications' import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '@/fragments/notifications' import { useApolloClient } from '@apollo/client' -import { clearNotifications } from '@/lib/badge' +import { clearNotifications } from '@/components/serviceworker' export const getServerSideProps = getGetServerSideProps({ query: NOTIFICATIONS, authRequired: true }) diff --git a/sw/eventListener.js b/sw/eventListener.js deleted file mode 100644 index 67fc275b..00000000 --- a/sw/eventListener.js +++ /dev/null @@ -1,182 +0,0 @@ -import ServiceWorkerStorage from 'serviceworker-storage' -import { numWithUnits } from '@/lib/format' -import { CLEAR_NOTIFICATIONS, clearAppBadge, setAppBadge } from '@/lib/badge' -import { DELETE_SUBSCRIPTION, STORE_SUBSCRIPTION } from '@/components/serviceworker' - -// we store existing push subscriptions for the onpushsubscriptionchange event -const storage = new ServiceWorkerStorage('sw:storage', 1) - -// current push notification count for badge purposes -let activeCount = 0 - -export function onPush (sw) { - return (event) => { - let payload = event.data?.json() - if (!payload) return // ignore push events without payload, like isTrusted events - const { tag } = payload.options - - // iOS requirement: group all promises - const promises = [] - - // On immediate notifications we update the counter - if (immediatelyShowNotification(tag)) { - promises.push(setAppBadge(sw, ++activeCount)) - } else { - // Check if there are already notifications with the same tag and merge them - promises.push(sw.registration.getNotifications({ tag }).then((notifications) => { - if (notifications.length) { - payload = mergeNotification(event, sw, payload, notifications, tag) - } - })) - } - - // iOS requirement: wait for all promises to resolve before showing the notification - event.waitUntil(Promise.all(promises).then(() => { - return sw.registration.showNotification(payload.title, payload.options) - })) - } -} - -// if there is no tag or the tag is one of the following -// we show the notification immediately -const immediatelyShowNotification = (tag) => - !tag || ['TIP', 'FORWARDEDTIP', 'EARN', 'STREAK', 'TERRITORY_TRANSFER'].includes(tag.split('-')[0]) - -// merge notifications with the same tag -const mergeNotification = (event, 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. - 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', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST'] - // tags that need to know the sum of sats of notifications with same tag for merging - const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL'] - // this should reflect the amount of notifications that were already merged before - const initialAmount = currentNotifications.length || 1 - const initialSats = currentNotifications[0]?.data?.sats || 0 - - // currentNotifications.reduce causes iOS to sum n notifications + initialAmount which is already n notifications - const mergedPayload = { - ...incomingData, - url: '/notifications', // when merged we should always go to the notifications page - amount: initialAmount + 1, - sats: initialSats + incomingData.sats - } - - // calculate title from merged payload - const { amount, followeeName, subName, 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 === 'ITEM_MENTION') { - title = `your items 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 (compareTag === 'TERRITORY_POST') { - title = `you have ${amount} new posts in ~${subName}` - } - } else if (SUM_SATS_TAGS.includes(compareTag)) { - if (compareTag === 'DEPOSIT') { - title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account` - } else if (compareTag === 'WITHDRAWAL') { - title = `${numWithUnits(sats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account` - } - } - - const options = { icon: payload.options?.icon, tag, data: { ...mergedPayload } } - return { title, options } // send the new, merged, payload -} - -// iOS-specific bug, notificationclick event only works when the app is closed -export function onNotificationClick (sw) { - return (event) => { - const promises = [] - const url = event.notification.data?.url - if (url) { - promises.push(sw.clients.openWindow(url)) - } - activeCount = Math.max(0, activeCount - 1) - if (activeCount === 0) { - promises.push(clearAppBadge(sw)) - } else { - promises.push(setAppBadge(sw, activeCount)) - } - event.waitUntil(Promise.all(promises)) - event.notification.close() - } -} - -export function onPushSubscriptionChange (sw) { - // https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f - return async (event) => { - 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 - oldSubscription ??= await storage.getItem('subscription') - newSubscription ??= await sw.registration.pushManager.getSubscription() - if (!newSubscription || oldSubscription?.endpoint === newSubscription.endpoint) { - // no subscription exists at the moment or subscription did not change - 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 - }) - await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription))) - } -} - -export function onMessage (sw) { - return async (event) => { - if (event.data.action === STORE_SUBSCRIPTION) { - return event.waitUntil(storage.setItem('subscription', { ...event.data.subscription, swVersion: 2 })) - } - if (event.data.action === DELETE_SUBSCRIPTION) { - return event.waitUntil(storage.removeItem('subscription')) - } - if (event.data.action === CLEAR_NOTIFICATIONS) { - const promises = [] - promises.push(sw.registration.getNotifications().then((notifications) => { - notifications.forEach(notification => notification.close()) - })) - promises.push(clearAppBadge(sw)) - activeCount = 0 - event.waitUntil(Promise.all(promises)) - } - } -} diff --git a/sw/index.js b/sw/index.js index 705aba74..2f236479 100644 --- a/sw/index.js +++ b/sw/index.js @@ -6,7 +6,11 @@ import { NetworkOnly } from 'workbox-strategies' import { enable } from 'workbox-navigation-preload' import manifest from './precache-manifest.json' -import { onPush, onNotificationClick, onPushSubscriptionChange, onMessage } from './eventListener' +import ServiceWorkerStorage from 'serviceworker-storage' +import { CLEAR_NOTIFICATIONS, DELETE_SUBSCRIPTION, STORE_SUBSCRIPTION } from '@/components/serviceworker' + +// we store existing push subscriptions for the onpushsubscriptionchange event +const storage = new ServiceWorkerStorage('sw:storage', 1) // comment out to enable workbox console logs self.__WB_DISABLE_DEV_LOGS = true @@ -68,7 +72,145 @@ setDefaultHandler(new NetworkOnly({ // See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185 offlineFallback({ pageFallback: '/offline' }) -self.addEventListener('push', onPush(self)) -self.addEventListener('notificationclick', onNotificationClick(self)) -self.addEventListener('message', onMessage(self)) -self.addEventListener('pushsubscriptionchange', onPushSubscriptionChange(self), false) +self.addEventListener('push', function (event) { + let payload + + try { + payload = event.data?.json() + if (!payload) { + throw new Error('no payload in push event') + } + } catch (err) { + // we show a default nofication on any error because we *must* show a notification + // else the browser will show one for us or worse, remove our push subscription + return event.waitUntil( + self.registration.showNotification( + // TODO: funny message as easter egg? + // example: "dude i'm bugging, that's wild" from https://www.youtube.com/watch?v=QsQLIaKK2s0&t=176s but in wild west theme? + 'something went wrong', + { icon: '/icons/icon_x96.png' } + ) + ) + } + + event.waitUntil( + self.registration.showNotification(payload.title, payload.options) + .then(() => self.registration.getNotifications()) + .then(notifications => self.navigator.setAppBadge?.(notifications.length)) + ) +}) + +self.addEventListener('notificationclick', function (event) { + event.notification.close() + + const promises = [] + const url = event.notification.data?.url + if (url) { + // First try to find and focus an existing client before opening a new window + promises.push( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then(clients => { + if (clients.length > 0) { + const client = clients[0] + return client.focus() + .then(() => { + return client.postMessage({ + type: 'navigate', + url + }) + }) + } else { + return self.clients.openWindow(url) + } + }) + ) + } + + promises.push( + self.registration.getNotifications() + .then(notifications => self.navigator.setAppBadge?.(notifications.length)) + ) + + event.waitUntil(Promise.all(promises)) +}) + +// not supported by iOS +// see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclose_event +self.addEventListener('notificationclose', function (event) { + event.waitUntil( + self.registration.getNotifications() + .then(notifications => self.navigator.setAppBadge?.(notifications.length)) + ) +}) + +self.addEventListener('pushsubscriptionchange', function (event) { + // https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f + // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event + const { oldSubscription, newSubscription } = event + + return event.waitUntil( + Promise.all([ + oldSubscription ?? storage.getItem('subscription'), + newSubscription ?? self.registration.pushManager.getSubscription() + ]) + .then(([oldSubscription, newSubscription]) => { + if (!newSubscription || oldSubscription?.endpoint === newSubscription.endpoint) { + // no subscription exists at the moment or subscription did not change + return + } + + // convert keys from ArrayBuffer to string + newSubscription = JSON.parse(JSON.stringify(newSubscription)) + + // save new subscription on server + return Promise.all([ + newSubscription, + fetch('/api/graphql', { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ + query: ` + mutation savePushSubscription( + $endpoint: String!, + $p256dh: String!, + $auth: String!, + $oldEndpoint: String! + ) { + savePushSubscription( + endpoint: $endpoint, + p256dh: $p256dh, + auth: $auth, + oldEndpoint: $oldEndpoint + ) { + id + } + }`, + variables: { + endpoint: newSubscription.endpoint, + p256dh: newSubscription.keys.p256dh, + auth: newSubscription.keys.auth, + oldEndpoint: oldSubscription?.endpoint + } + }) + }) + ]) + }).then(([newSubscription]) => storage.setItem('subscription', newSubscription)) + ) +}) + +self.addEventListener('message', function (event) { + switch (event.data.action) { + case STORE_SUBSCRIPTION: return event.waitUntil(storage.setItem('subscription', { ...event.data.subscription, swVersion: 2 })) + case DELETE_SUBSCRIPTION: return event.waitUntil(storage.removeItem('subscription')) + case CLEAR_NOTIFICATIONS: + return event.waitUntil( + Promise.all([ + self.registration.getNotifications() + .then(notifications => notifications.forEach(notification => notification.close())), + self.navigator.clearAppBadge?.() + ]) + ) + } +})