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:
parent
bfced699ea
commit
b1a0abe32c
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
36
lib/badge.js
36
lib/badge.js
@ -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)
|
||||
}
|
||||
}
|
223
lib/webPush.js
223
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
|
||||
})
|
||||
}
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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 })
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
152
sw/index.js
152
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?.()
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user