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
This commit is contained in:
ekzyis 2025-07-10 18:54:23 +02:00 committed by GitHub
parent bfced699ea
commit b1a0abe32c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 266 additions and 345 deletions

View File

@ -9,6 +9,7 @@ const ServiceWorkerContext = createContext()
// message types for communication between app and service worker // message types for communication between app and service worker
export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION' export const DELETE_SUBSCRIPTION = 'DELETE_SUBSCRIPTION'
export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION' export const STORE_SUBSCRIPTION = 'STORE_SUBSCRIPTION'
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'
export const ServiceWorkerProvider = ({ children }) => { export const ServiceWorkerProvider = ({ children }) => {
const [registration, setRegistration] = useState(null) 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 () { export function useServiceWorker () {
return useContext(ServiceWorkerContext) return useContext(ServiceWorkerContext)
} }

View File

@ -1,8 +1,8 @@
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import { clearNotifications } from '@/lib/badge'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { clearNotifications } from '@/components/serviceworker'
export const HasNewNotesContext = React.createContext(false) export const HasNewNotesContext = React.createContext(false)

View File

@ -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)
}
}

View File

@ -9,17 +9,11 @@ import { Prisma } from '@prisma/client'
const webPushEnabled = process.env.NODE_ENV === 'production' || const webPushEnabled = process.env.NODE_ENV === 'production' ||
(process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY) (process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
if (webPushEnabled) { function log (...args) {
webPush.setVapidDetails( console.log('[webPush]', ...args)
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')
} }
const createPayload = (notification) => { function createPayload (notification) {
// https://web.dev/push-notifications-display-a-notification/#visual-options // https://web.dev/push-notifications-display-a-notification/#visual-options
let { title, body, ...options } = notification let { title, body, ...options } = notification
if (body) body = removeMd(body) if (body) body = removeMd(body)
@ -34,26 +28,11 @@ const createPayload = (notification) => {
}) })
} }
const createUserFilter = (tag) => { function userFilterFragment (setting) {
// filter users by notification settings return setting ? { user: { [setting]: true } } : undefined
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
} }
const createItemUrl = async ({ id }) => { async function createItemUrl (id) {
const [rootItem] = await models.$queryRawUnsafe( const [rootItem] = await models.$queryRawUnsafe(
'SELECT subpath(path, -LEAST(nlevel(path), $1::INTEGER), 1)::text AS id FROM "Item" WHERE id = $2::INTEGER', 'SELECT subpath(path, -LEAST(nlevel(path), $1::INTEGER), 1)::text AS id FROM "Item" WHERE id = $2::INTEGER',
COMMENT_DEPTH_LIMIT + 1, Number(id) COMMENT_DEPTH_LIMIT + 1, Number(id)
@ -61,28 +40,50 @@ const createItemUrl = async ({ id }) => {
return `/items/${rootItem.id}` + (rootItem.id !== id ? `?commentId=${id}` : '') return `/items/${rootItem.id}` + (rootItem.id !== id ? `?commentId=${id}` : '')
} }
const sendNotification = (subscription, payload) => { async function sendNotification (subscription, payload) {
if (!webPushEnabled) { if (!webPushEnabled) {
console.warn('webPush not configured. skipping notification') log('webPush not configured, skipping notification')
return return
} }
const { id, endpoint, p256dh, auth } = subscription 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) => { .catch(async (err) => {
if (err.statusCode === 400) { switch (err.statusCode) {
console.log('[webPush] invalid request: ', err) case 400:
} else if ([401, 403].includes(err.statusCode)) { log('invalid request:', err)
console.log('[webPush] auth error: ', err) break
} else if (err.statusCode === 404 || err.statusCode === 410) { case 401:
console.log('[webPush] subscription has expired or is no longer valid: ', err) 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 } }) const deletedSubscripton = await models.pushSubscription.delete({ where: { id } })
console.log(`[webPush] deleted subscription ${id} of user ${deletedSubscripton.userId} due to push error`) log(`deleted subscription ${id} of user ${deletedSubscripton.userId} due to push error`)
} else if (err.statusCode === 413) { break
console.log('[webPush] payload too large: ', err) }
} else if (err.statusCode === 429) { case 413:
console.log('[webPush] too many requests: ', err) log('payload too large:', err)
} else { break
console.log('[webPush] error: ', err) 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') throw new Error('user id is required')
} }
notification.data ??= {} notification.data ??= {}
if (notification.item) { if (notification.itemId) {
notification.data.url ??= await createItemUrl(notification.item) notification.data.url ??= await createItemUrl(notification.itemId)
notification.data.itemId ??= notification.item.id delete notification.itemId
delete notification.item
} }
const userFilter = createUserFilter(notification.tag)
// XXX we only want to use the tag to filter follow-up replies by user settings const filterFragment = userFilterFragment(notification.setting)
// but still merge them with normal replies
if (notification.tag === 'THREAD') notification.tag = 'REPLY'
const payload = createPayload(notification) const payload = createPayload(notification)
const subscriptions = await models.pushSubscription.findMany({ const subscriptions = await models.pushSubscription.findMany({
where: { userId, ...userFilter } where: { userId, ...filterFragment }
}) })
await Promise.allSettled( await Promise.allSettled(
subscriptions.map(subscription => sendNotification(subscription, payload)) subscriptions.map(subscription => sendNotification(subscription, payload))
) )
} catch (err) { } 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' }) const payload = createPayload({ title: 'Stacker News notifications are now active' })
await sendNotification(subscription, payload) await sendNotification(subscription, payload)
} catch (err) { } 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 { try {
const isPost = !!item.title const isPost = !!item.title
@ -158,21 +155,17 @@ export const notifyUserSubscribers = async ({ models, item }) => {
AND "SubSubscription"."subName" = ${item.subName} AND "SubSubscription"."subName" = ${item.subName}
)` )`
: Prisma.empty}` : Prisma.empty}`
const subType = isPost ? 'POST' : 'COMMENT'
const tag = `FOLLOW-${item.userId}-${subType}`
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, { await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`, title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
body: isPost ? item.title : item.text, body: isPost ? item.title : item.text,
item, itemId: item.id
data: { followeeName, subType },
tag
}))) })))
} catch (err) { } catch (err) {
console.error(err) log('error sending user notification:', err)
} }
} }
export const notifyTerritorySubscribers = async ({ models, item }) => { export async function notifyTerritorySubscribers ({ models, item }) {
try { try {
const isPost = !!item.title const isPost = !!item.title
const { subName } = item const { subName } = item
@ -188,7 +181,6 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
const author = await models.user.findUnique({ where: { id: item.userId } }) const author = await models.user.findUnique({ where: { id: item.userId } })
const tag = `TERRITORY_POST-${subName}`
await Promise.allSettled( await Promise.allSettled(
territorySubsExcludingMuted territorySubsExcludingMuted
// don't send push notification to author itself // don't send push notification to author itself
@ -197,16 +189,14 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
sendUserNotification(userId, { sendUserNotification(userId, {
title: `@${author.name} created a post in ~${subName}`, title: `@${author.name} created a post in ~${subName}`,
body: item.title, body: item.title,
item, itemId: item.id
data: { subName },
tag
}))) })))
} catch (err) { } catch (err) {
console.error(err) log('error sending territory notification:', err)
} }
} }
export const notifyThreadSubscribers = async ({ models, item }) => { export async function notifyThreadSubscribers ({ models, item }) {
try { try {
const author = await models.user.findUnique({ where: { id: item.userId } }) 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 // so we should also merge them together (= same tag+data) to avoid confusion
title: `@${author.name} replied to a post`, title: `@${author.name} replied to a post`,
body: item.text, body: item.text,
item, itemId: item.id
data: { followeeName: author.name, subType: 'COMMENT' },
tag: `FOLLOW-${author.id}-COMMENT`
}) })
)) ))
} catch (err) { } catch (err) {
console.error(err) log('error sending thread notification:', err)
} }
} }
export const notifyItemParents = async ({ models, item }) => { export async function notifyItemParents ({ models, item }) {
try { try {
const user = await models.user.findUnique({ where: { id: item.userId } }) const user = await models.user.findUnique({ where: { id: item.userId } })
const parents = await models.$queryRaw` const parents = await models.$queryRaw`
@ -265,17 +253,17 @@ export const notifyItemParents = async ({ models, item }) => {
return sendUserNotification(userId, { return sendUserNotification(userId, {
title: `@${user.name} replied to you`, title: `@${user.name} replied to you`,
body: item.text, body: item.text,
item, itemId: item.id,
tag: isDirect ? 'REPLY' : 'THREAD' setting: isDirect ? undefined : 'noteAllDescendants'
}) })
}) })
) )
} catch (err) { } 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 { try {
const forwards = await models.itemForward.findMany({ where: { itemId: item.id } }) const forwards = await models.itemForward.findMany({ where: { itemId: item.id } })
const userPromises = forwards.map(fwd => models.user.findUnique({ where: { id: fwd.userId } })) 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) 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(', ') forwardedUsers = mappedForwards.map(fwd => `@${fwd.user.name}`).join(', ')
} }
let notificationTitle let title
if (item.title) { if (item.title) {
if (forwards.length > 0) { if (forwards.length > 0) {
notificationTitle = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}` title = `your post forwarded ${numWithUnits(forwardedSats)} to ${forwardedUsers}`
} else { } else {
notificationTitle = `your post stacked ${numWithUnits(msatsToSats(item.msats))}` title = `your post stacked ${numWithUnits(msatsToSats(item.msats))}`
} }
} else { } else {
if (forwards.length > 0) { if (forwards.length > 0) {
// I don't think this case is possible // 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 { } else {
notificationTitle = `your reply stacked ${numWithUnits(msatsToSats(item.msats))}` title = `your reply stacked ${numWithUnits(msatsToSats(item.msats))}`
} }
} }
await sendUserNotification(item.userId, { await sendUserNotification(item.userId, {
title: notificationTitle, title,
body: item.title ? item.title : item.text, body: item.title ? item.title : item.text,
item, itemId: item.id,
setting: 'noteItemSats',
tag: `TIP-${item.id}` tag: `TIP-${item.id}`
}) })
@ -315,16 +304,17 @@ export const notifyZapped = async ({ models, item }) => {
await Promise.allSettled(mappedForwards.map(forward => sendUserNotification(forward.user.id, { await Promise.allSettled(mappedForwards.map(forward => sendUserNotification(forward.user.id, {
title: `you were forwarded ${numWithUnits(Math.round(msatsToSats(item.msats) * forward.pct / 100))}`, title: `you were forwarded ${numWithUnits(Math.round(msatsToSats(item.msats) * forward.pct / 100))}`,
body: item.title ?? item.text, body: item.title ?? item.text,
item, itemId: item.id,
setting: 'noteForwardedSats',
tag: `FORWARDEDTIP-${item.id}` tag: `FORWARDEDTIP-${item.id}`
}))) })))
} }
} catch (err) { } 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 { try {
const muted = await isMuted({ models, muterId: userId, mutedId: item.userId }) const muted = await isMuted({ models, muterId: userId, mutedId: item.userId })
if (muted) return if (muted) return
@ -332,15 +322,15 @@ export const notifyMention = async ({ models, userId, item }) => {
await sendUserNotification(userId, { await sendUserNotification(userId, {
title: `@${item.user.name} mentioned you`, title: `@${item.user.name} mentioned you`,
body: item.text, body: item.text,
item, itemId: item.id,
tag: 'MENTION' setting: 'noteMentions'
}) })
} catch (err) { } 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 { try {
const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId }) const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId })
if (!muted) { if (!muted) {
@ -352,39 +342,36 @@ export const notifyItemMention = async ({ models, referrerItem, refereeItem }) =
await sendUserNotification(refereeItem.userId, { await sendUserNotification(refereeItem.userId, {
title: `@${referrer.name} mentioned one of your items`, title: `@${referrer.name} mentioned one of your items`,
body, body,
item: referrerItem, itemId: referrerItem.id,
tag: 'ITEM_MENTION' setting: 'noteItemMentions'
}) })
} }
} catch (err) { } catch (err) {
console.error(err) log('error sending item mention notification:', err)
} }
} }
export const notifyReferral = async (userId) => { export async function notifyReferral (userId) {
try { try {
await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }) await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' })
} catch (err) { } catch (err) {
console.error(err) log('error sending referral notification:', err)
} }
} }
export const notifyInvite = async (userId) => { export async function notifyInvite (userId) {
try { try {
await sendUserNotification(userId, { title: 'your invite has been redeemed', tag: 'INVITE' }) await sendUserNotification(userId, { title: 'your invite has been redeemed', tag: 'INVITE' })
} catch (err) { } 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 { try {
await sendUserNotification(to.id, { await sendUserNotification(to.id, { title: `~${sub.name} was transferred to you` })
title: `~${sub.name} was transferred to you`,
tag: `TERRITORY_TRANSFER-${sub.name}`
})
} catch (err) { } 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 fmt = msats => numWithUnits(msatsToSats(msats, { abbreviate: false }))
const title = `you stacked ${fmt(earnings.msats)} in rewards` const title = `you stacked ${fmt(earnings.msats)} in rewards`
const tag = 'EARN'
let body = '' let body = ''
if (earnings.POST) body += `#${earnings.POST.bestRank} among posts with ${fmt(earnings.POST.msats)} in total\n` 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` 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` if (earnings.TIP_COMMENT) body += `#${earnings.TIP_COMMENT.bestRank} in comment zapping with ${fmt(earnings.TIP_COMMENT.msats)} in total`
try { try {
await sendUserNotification(userId, { title, tag, body }) await sendUserNotification(userId, { title, body, setting: 'noteEarning' })
} catch (err) { } catch (err) {
console.error(err) log('error sending earn notification:', err)
} }
} }
@ -411,11 +397,10 @@ export async function notifyDeposit (userId, invoice) {
await sendUserNotification(userId, { await sendUserNotification(userId, {
title: `${numWithUnits(msatsToSats(invoice.msatsReceived), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`, title: `${numWithUnits(msatsToSats(invoice.msatsReceived), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`,
body: invoice.comment || undefined, body: invoice.comment || undefined,
tag: 'DEPOSIT', setting: 'noteDeposits'
data: { sats: msatsToSats(invoice.msatsReceived) }
}) })
} catch (err) { } catch (err) {
console.error(err) log('error sending deposit notification:', err)
} }
} }
@ -423,11 +408,10 @@ export async function notifyWithdrawal (wdrwl) {
try { try {
await sendUserNotification(wdrwl.userId, { await sendUserNotification(wdrwl.userId, {
title: `${numWithUnits(msatsToSats(wdrwl.msatsPaid), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`, title: `${numWithUnits(msatsToSats(wdrwl.msatsPaid), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account`,
tag: 'WITHDRAWAL', setting: 'noteWithdrawals'
data: { sats: msatsToSats(wdrwl.msatsPaid) }
}) })
} catch (err) { } catch (err) {
console.error(err) log('error sending withdrawal notification:', err)
} }
} }
@ -439,10 +423,10 @@ export async function notifyNewStreak (userId, streak) {
await sendUserNotification(userId, { await sendUserNotification(userId, {
title: `you found a ${streak.type.toLowerCase().replace('_', ' ')}`, title: `you found a ${streak.type.toLowerCase().replace('_', ' ')}`,
body: blurb, body: blurb,
tag: `STREAK-FOUND-${streak.type}` setting: 'noteCowboyHat'
}) })
} catch (err) { } 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, { await sendUserNotification(userId, {
title: `you lost your ${streak.type.toLowerCase().replace('_', ' ')}`, title: `you lost your ${streak.type.toLowerCase().replace('_', ' ')}`,
body: blurb, body: blurb,
tag: `STREAK-LOST-${streak.type}` setting: 'noteCowboyHat'
}) })
} catch (err) { } 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, { await sendUserNotification(userId, {
title: 'you requested this reminder', title: 'you requested this reminder',
body: item.title ?? item.text, body: item.title ?? item.text,
tag: `REMIND-ITEM-${item.id}`, itemId: item.id
item
}) })
} }

View File

@ -60,6 +60,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.on('routeChangeComplete', nprogressDone) router.events.on('routeChangeComplete', nprogressDone)
router.events.on('routeChangeError', 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 if (!props?.apollo) return
// HACK: 'cause there's no way to tell Next to skip SSR // HACK: 'cause there's no way to tell Next to skip SSR
// So every page load, we modify the route in browser history // 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('routeChangeStart', nprogressStart)
router.events.off('routeChangeComplete', nprogressDone) router.events.off('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone) router.events.off('routeChangeError', nprogressDone)
navigator.serviceWorker?.removeEventListener('message', handleServiceWorkerMessage)
} }
}, [router.asPath, props?.apollo, shouldShowProgressBar]) }, [router.asPath, props?.apollo, shouldShowProgressBar])

View File

@ -4,7 +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' import { clearNotifications } from '@/components/serviceworker'
export const getServerSideProps = getGetServerSideProps({ query: NOTIFICATIONS, authRequired: true }) export const getServerSideProps = getGetServerSideProps({ query: NOTIFICATIONS, authRequired: true })

View File

@ -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))
}
}
}

View File

@ -6,7 +6,11 @@ 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 { 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 // comment out to enable workbox console logs
self.__WB_DISABLE_DEV_LOGS = true 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 // See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185
offlineFallback({ pageFallback: '/offline' }) offlineFallback({ pageFallback: '/offline' })
self.addEventListener('push', onPush(self)) self.addEventListener('push', function (event) {
self.addEventListener('notificationclick', onNotificationClick(self)) let payload
self.addEventListener('message', onMessage(self))
self.addEventListener('pushsubscriptionchange', onPushSubscriptionChange(self), false) 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?.()
])
)
}
})