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
|
// 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)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
221
lib/webPush.js
221
lib/webPush.js
@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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 })
|
||||||
|
|
||||||
|
@ -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 { 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?.()
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user