Service worker rework, Web Target Share API & Web Push API (#324)
* npm uninstall next-pwa next-pwa was last updated in August 2022. There is also an issue which mentions that next-pwa is abandoned (?): https://github.com/shadowwalker/next-pwa/issues/482 But the main reason for me uninstalling it is that it adds a lot of preconfigured stuff which is not necessary for us. It even lead to a bug since pages were cached without our knowledge. So I will go with a different PWA approach. This different approach should do the following: - make it more transparent what the service worker is doing - gives us more control to configure the service worker and thus making it easier * Use workbox-webpack-plugin Every other plugin (`next-offline`, `next-workbox-webpack-plugin`, `next-with-workbox`, ...) added unnecessary configuration which felt contrary to how PWAs should be built. (PWAs should progressivly enhance the website in small steps, see https://web.dev/learn/pwa/getting-started/#focus-on-a-feature) These default configurations even lead to worse UX since they made invalid assumptions about stacker.news: We _do not_ want to cache our start url and we _do not_ want to cache anything unless explicitly told to. Almost every page on SN should be fresh for the best UX. To achieve this, by default, the service worker falls back to the network (as if the service worker wasn't there). Therefore, this should be the simplest configuration with a valid precache and cache busting support. In the future, we can try to use prefetching to improve performance of navigation requests. * Add support for Web Share Target API See https://developer.chrome.com/articles/web-share-target/ * Use Web Push API for push notifications I followed this (very good!) guide: https://web.dev/notifications/ * Refactor code related to Web Push * Send push notification to users on events * Merge notifications * Send notification to author of every parent recursively * Remove unused userId param in savePushSubscription As it should be, the user id is retrieved from the authenticated user in the backend. * Resubscribe user if push subscription changed * Update old subscription if oldEndpoint was given * Allow users to unsubscribe * Use LTREE operator instead of recursive query * Always show checkbox for push notifications * Justify checkbox to end * Update title of first push notification * Fix warning from uncontrolled to controlled * Add comment about Notification.requestPermission * Fix timestamp * Catch error on push subscription toggle * Wrap function bodies in try/catch * Use Promise.allSettled * Filter subscriptions by user notification settings * Fix user notification filter * Use skipWaiting --------- Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
parent
7cb05f6d96
commit
388e00dd04
|
@ -21,6 +21,11 @@ LNAUTH_URL=<YOUR PUBLIC TUNNEL TO LOCALHOST, e.g. NGROK>
|
|||
# slashtags
|
||||
SLASHTAGS_SECRET=
|
||||
|
||||
# VAPID for Web Push
|
||||
VAPID_MAILTO=
|
||||
NEXT_PUBLIC_VAPID_PUBKEY=
|
||||
VAPID_PRIVKEY=
|
||||
|
||||
#######################################################
|
||||
# LND / OPTIONAL #
|
||||
# if you want to work with payments you'll need these #
|
||||
|
|
|
@ -42,10 +42,7 @@ envbak
|
|||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
|
||||
# auto-generated files by next-pwa / workbox
|
||||
# service worker
|
||||
public/sw.js
|
||||
public/sw.js.map
|
||||
public/workbox-*.js
|
||||
public/workbox-*.js.map
|
||||
public/worker-*.js
|
||||
public/fallback-*.js
|
||||
sw/precache-manifest.json
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import { msatsToSats } from '../../lib/format'
|
|||
import { parse } from 'tldts'
|
||||
import uu from 'url-unshort'
|
||||
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
||||
import { sendUserNotification } from '../webPush'
|
||||
|
||||
async function comments (me, models, id, sort) {
|
||||
let orderBy
|
||||
|
@ -893,7 +894,21 @@ export default {
|
|||
},
|
||||
createComment: async (parent, data, { me, models }) => {
|
||||
await ssValidate(commentSchema, data)
|
||||
return await createItem(parent, data, { me, models })
|
||||
const item = await createItem(parent, data, { me, models })
|
||||
|
||||
const parents = await models.$queryRaw(
|
||||
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2',
|
||||
Number(item.parentId), Number(me.id))
|
||||
Promise.allSettled(
|
||||
parents.map(({ userId }) => sendUserNotification(userId, {
|
||||
title: 'you have a new reply',
|
||||
body: data.text,
|
||||
data: { url: `/items/${item.id}` },
|
||||
tag: 'REPLY'
|
||||
}))
|
||||
)
|
||||
|
||||
return item
|
||||
},
|
||||
updateComment: async (parent, { id, ...data }, { me, models }) => {
|
||||
await ssValidate(commentSchema, data)
|
||||
|
@ -929,6 +944,15 @@ export default {
|
|||
|
||||
const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`)
|
||||
|
||||
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${Math.floor(Number(updatedItem.msats) / 1000)} sats${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
|
||||
sendUserNotification(updatedItem.userId, {
|
||||
title,
|
||||
body: updatedItem.title ? updatedItem.title : updatedItem.text,
|
||||
data: { url: `/items/${updatedItem.id}` },
|
||||
tag: `TIP-${updatedItem.id}`
|
||||
})
|
||||
|
||||
return {
|
||||
vote,
|
||||
sats
|
||||
|
@ -1182,6 +1206,13 @@ export const createMentions = async (item, models) => {
|
|||
update: data,
|
||||
create: data
|
||||
})
|
||||
|
||||
sendUserNotification(user.id, {
|
||||
title: 'you were mentioned',
|
||||
body: item.text,
|
||||
data: { url: `/items/${item.id}` },
|
||||
tag: 'MENTION'
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { getItem, filterClause } from './item'
|
||||
import { getInvoice } from './wallet'
|
||||
import { pushSubscriptionSchema, ssValidate } from '../../lib/validate'
|
||||
import { replyToSubscription } from '../webPush'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
|
@ -223,6 +225,44 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
Mutation: {
|
||||
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
|
||||
|
||||
let dbPushSubscription
|
||||
if (oldEndpoint) {
|
||||
dbPushSubscription = await models.pushSubscription.update({
|
||||
data: { userId: me.id, endpoint, p256dh, auth }, where: { endpoint: oldEndpoint }
|
||||
})
|
||||
} else {
|
||||
dbPushSubscription = await models.pushSubscription.create({
|
||||
data: { userId: me.id, endpoint, p256dh, auth }
|
||||
})
|
||||
}
|
||||
|
||||
await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
|
||||
|
||||
return dbPushSubscription
|
||||
},
|
||||
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
|
||||
if (!subscription) {
|
||||
throw new UserInputError('endpoint not found', {
|
||||
argumentName: 'endpoint'
|
||||
})
|
||||
}
|
||||
await models.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
return subscription
|
||||
}
|
||||
},
|
||||
Notification: {
|
||||
__resolveType: async (n, args, { models }) => n.type
|
||||
},
|
||||
|
|
|
@ -5,6 +5,11 @@ export default gql`
|
|||
notifications(cursor: String, inc: String): Notifications
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
savePushSubscription(endpoint: String!, p256dh: String!, auth: String!, oldEndpoint: String): PushSubscription
|
||||
deletePushSubscription(endpoint: String!): PushSubscription
|
||||
}
|
||||
|
||||
type Votification {
|
||||
earnedSats: Int!
|
||||
item: Item!
|
||||
|
@ -69,4 +74,12 @@ export default gql`
|
|||
cursor: String
|
||||
notifications: [Notification!]!
|
||||
}
|
||||
|
||||
type PushSubscription {
|
||||
id: ID!
|
||||
userId: ID!
|
||||
endpoint: String!
|
||||
p256dh: String!
|
||||
auth: String!
|
||||
}
|
||||
`
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import webPush from 'web-push'
|
||||
import models from '../models'
|
||||
|
||||
webPush.setVapidDetails(
|
||||
process.env.VAPID_MAILTO,
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBKEY,
|
||||
process.env.VAPID_PRIVKEY
|
||||
)
|
||||
|
||||
const createPayload = (notification) => {
|
||||
// https://web.dev/push-notifications-display-a-notification/#visual-options
|
||||
const { title, ...options } = notification
|
||||
return JSON.stringify({
|
||||
title,
|
||||
options: {
|
||||
timestamp: Date.now(),
|
||||
icon: '/android-chrome-96x96.png',
|
||||
...options
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createUserFilter = (tag) => {
|
||||
// filter users by notification settings
|
||||
const tagMap = {
|
||||
REPLY: 'noteAllDescendants',
|
||||
MENTION: 'noteMentions',
|
||||
TIP: 'noteItemSats'
|
||||
}
|
||||
const key = tagMap[tag.split('-')[0]]
|
||||
return key ? { user: { [key]: true } } : undefined
|
||||
}
|
||||
|
||||
const sendNotification = (subscription, payload) => {
|
||||
const { id, endpoint, p256dh, auth } = subscription
|
||||
return webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, payload)
|
||||
.catch((err) => {
|
||||
if (err.statusCode === 400) {
|
||||
console.log('[webPush] invalid request: ', err)
|
||||
} else if (err.statusCode === 403) {
|
||||
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)
|
||||
return models.pushSubscription.delete({ where: { id } })
|
||||
} 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendUserNotification (userId, notification) {
|
||||
try {
|
||||
const userFilter = createUserFilter(notification.tag)
|
||||
const payload = createPayload(notification)
|
||||
const subscriptions = await models.pushSubscription.findMany({
|
||||
where: { userId, ...userFilter }
|
||||
})
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(subscription => sendNotification(subscription, payload))
|
||||
)
|
||||
} catch (err) {
|
||||
console.log('[webPush] error sending user notification: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function replyToSubscription (subscriptionId, notification) {
|
||||
try {
|
||||
const payload = createPayload(notification)
|
||||
const subscription = await models.pushSubscription.findUnique({ where: { id: subscriptionId } })
|
||||
await sendNotification(subscription, payload)
|
||||
} catch (err) {
|
||||
console.log('[webPush] error sending subscription reply: ', err)
|
||||
}
|
||||
}
|
|
@ -22,6 +22,9 @@ export function DiscussionForm ({
|
|||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const schema = discussionSchema(client)
|
||||
// if Web Share Target API was used
|
||||
const shareTitle = router.query.title
|
||||
|
||||
// const me = useMe()
|
||||
const [upsertDiscussion] = useMutation(
|
||||
gql`
|
||||
|
@ -51,7 +54,7 @@ export function DiscussionForm ({
|
|||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: item?.title || '',
|
||||
title: item?.title || shareTitle || '',
|
||||
text: item?.text || '',
|
||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
|
|
|
@ -19,7 +19,6 @@ import CowboyHat from './cowboy-hat'
|
|||
import { Form, Select } from './form'
|
||||
import SearchIcon from '../svgs/search-line.svg'
|
||||
import BackArrow from '../svgs/arrow-left-line.svg'
|
||||
import { useNotification } from './notifications'
|
||||
import { SUBS } from '../lib/constants'
|
||||
|
||||
function WalletSummary ({ me }) {
|
||||
|
@ -51,7 +50,6 @@ export default function Header ({ sub }) {
|
|||
const [prefix, setPrefix] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const me = useMe()
|
||||
const notification = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
||||
|
@ -73,17 +71,7 @@ export default function Header ({ sub }) {
|
|||
}
|
||||
`, {
|
||||
pollInterval: 30000,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
// Trigger onComplete after every poll
|
||||
// See https://github.com/apollographql/apollo-client/issues/5531#issuecomment-568235629
|
||||
notifyOnNetworkStatusChange: true,
|
||||
onCompleted: (data) => {
|
||||
const notified = JSON.parse(localStorage.getItem('notified')) || false
|
||||
if (!notified && data.hasNewNotes) {
|
||||
notification.show('you have Stacker News notifications')
|
||||
}
|
||||
localStorage.setItem('notified', data.hasNewNotes)
|
||||
}
|
||||
fetchPolicy: 'cache-and-network'
|
||||
})
|
||||
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
||||
// useEffect(() => {
|
||||
|
|
|
@ -19,6 +19,9 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const schema = linkSchema(client)
|
||||
// if Web Share Target API was used
|
||||
const shareUrl = router.query.url
|
||||
const shareTitle = router.query.title
|
||||
|
||||
const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
|
||||
query PageTitleAndUnshorted($url: String!) {
|
||||
|
@ -95,8 +98,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
return (
|
||||
<Form
|
||||
initial={{
|
||||
title: item?.title || '',
|
||||
url: item?.url || '',
|
||||
title: item?.title || shareTitle || '',
|
||||
url: item?.url || shareUrl || '',
|
||||
...AdvPostInitial({ forward: item?.fwdUser?.name }),
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useContext, useEffect, createContext } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import Item from './item'
|
||||
|
@ -18,6 +18,8 @@ import BaldIcon from '../svgs/bald.svg'
|
|||
import { RootProvider } from './root'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import styles from './notifications.module.css'
|
||||
import { useServiceWorker } from './serviceworker'
|
||||
import { Checkbox, Form } from './form'
|
||||
|
||||
function Notification ({ n }) {
|
||||
switch (n.__typename) {
|
||||
|
@ -254,13 +256,16 @@ function Reply ({ n }) {
|
|||
|
||||
function NotificationAlert () {
|
||||
const [showAlert, setShowAlert] = useState(false)
|
||||
const pushNotify = useNotification()
|
||||
const [hasSubscription, setHasSubscription] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const sw = useServiceWorker()
|
||||
|
||||
useEffect(() => {
|
||||
// basically, we only want to show the alert if the user hasn't interacted with
|
||||
// either opt-in of the double opt-in
|
||||
setShowAlert(pushNotify.isDefault && !localStorage.getItem('hideNotifyPrompt'))
|
||||
}, [pushNotify])
|
||||
const isSupported = sw.support.serviceWorker && sw.support.pushManager && sw.support.notification
|
||||
const isDefaultPermission = sw.permission.notification === 'default'
|
||||
setShowAlert(isSupported && isDefaultPermission && !localStorage.getItem('hideNotifyPrompt'))
|
||||
sw.registration?.pushManager.getSubscription().then(subscription => setHasSubscription(!!subscription))
|
||||
}, [sw])
|
||||
|
||||
const close = () => {
|
||||
localStorage.setItem('hideNotifyPrompt', 'yep')
|
||||
|
@ -268,22 +273,37 @@ function NotificationAlert () {
|
|||
}
|
||||
|
||||
return (
|
||||
showAlert
|
||||
error
|
||||
? (
|
||||
<Alert variant='success' dismissible onClose={close}>
|
||||
<span className='align-middle'>Enable push notifications?</span>
|
||||
<button
|
||||
className={`${styles.alertBtn} mx-1`}
|
||||
onClick={() => {
|
||||
pushNotify.requestPermission()
|
||||
close()
|
||||
}}
|
||||
>Yes
|
||||
</button>
|
||||
<button className={`${styles.alertBtn}`} onClick={close}>No</button>
|
||||
<Alert variant='danger' dismissible onClose={() => setError(null)}>
|
||||
<span>{error.toString()}</span>
|
||||
</Alert>
|
||||
)
|
||||
: null
|
||||
: showAlert
|
||||
? (
|
||||
<Alert variant='info' dismissible onClose={close}>
|
||||
<span className='align-middle'>Enable push notifications?</span>
|
||||
<button
|
||||
className={`${styles.alertBtn} mx-1`}
|
||||
onClick={async () => {
|
||||
await sw.requestNotificationPermission()
|
||||
.then(close)
|
||||
.catch(setError)
|
||||
}}
|
||||
>Yes
|
||||
</button>
|
||||
<button className={`${styles.alertBtn}`} onClick={close}>No</button>
|
||||
</Alert>
|
||||
)
|
||||
: (
|
||||
<Form className='d-flex justify-content-end' initial={{ pushNotify: hasSubscription }}>
|
||||
<Checkbox
|
||||
name='pushNotify' label='push notifications' inline checked={hasSubscription} handleChange={async () => {
|
||||
await sw.togglePushSubscription().catch(setError)
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -329,55 +349,3 @@ function CommentsFlatSkeleton () {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NotificationContext = createContext({})
|
||||
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
const [isSupported] = useState(isBrowser ? 'Notification' in window : false)
|
||||
const [permission, setPermission_] = useState(
|
||||
isSupported
|
||||
? window.Notification.permission === 'granted'
|
||||
// if permission was granted, we need to check if user has withdrawn permission using the settings
|
||||
// since requestPermission only works once
|
||||
? localStorage.getItem('notify-permission') ?? window.Notification.permission
|
||||
: window.Notification.permission
|
||||
: 'unsupported')
|
||||
const isDefault = permission === 'default'
|
||||
const isGranted = permission === 'granted'
|
||||
const isDenied = permission === 'denied'
|
||||
const isWithdrawn = permission === 'withdrawn'
|
||||
|
||||
const show_ = (title, options) => {
|
||||
const icon = '/android-chrome-24x24.png'
|
||||
return new window.Notification(title, { icon, ...options })
|
||||
}
|
||||
|
||||
const show = useCallback((...args) => {
|
||||
if (!isGranted) return
|
||||
show_(...args)
|
||||
}, [isGranted])
|
||||
|
||||
const setPermission = useCallback((perm) => {
|
||||
localStorage.setItem('notify-permission', perm)
|
||||
setPermission_(perm)
|
||||
}, [])
|
||||
|
||||
const requestPermission = useCallback((cb) => {
|
||||
window.Notification.requestPermission().then(result => {
|
||||
setPermission(window.Notification.permission)
|
||||
if (result === 'granted') show_('Stacker News notifications enabled')
|
||||
cb?.(result)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const withdrawPermission = useCallback(() => isGranted ? setPermission('withdrawn') : null, [isGranted])
|
||||
|
||||
const ctx = { isBrowser, isSupported, isDefault, isGranted, isDenied, isWithdrawn, requestPermission, withdrawPermission, show }
|
||||
|
||||
return <NotificationContext.Provider value={ctx}>{children}</NotificationContext.Provider>
|
||||
}
|
||||
|
||||
export function useNotification () {
|
||||
return useContext(NotificationContext)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { Workbox } from 'workbox-window'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
|
||||
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
|
||||
|
||||
const ServiceWorkerContext = createContext()
|
||||
|
||||
export const ServiceWorkerProvider = ({ children }) => {
|
||||
const [registration, setRegistration] = useState(null)
|
||||
const [support, setSupport] = useState({ serviceWorker: undefined, pushManager: undefined })
|
||||
const [permission, setPermission] = useState({ notification: undefined })
|
||||
const [savePushSubscription] = useMutation(
|
||||
gql`
|
||||
mutation savePushSubscription(
|
||||
$endpoint: String!
|
||||
$p256dh: String!
|
||||
$auth: String!
|
||||
) {
|
||||
savePushSubscription(
|
||||
endpoint: $endpoint
|
||||
p256dh: $p256dh
|
||||
auth: $auth
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
const [deletePushSubscription] = useMutation(
|
||||
gql`
|
||||
mutation deletePushSubscription($endpoint: String!) {
|
||||
deletePushSubscription(endpoint: $endpoint) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
// I am not entirely sure if this is needed since at least in Brave,
|
||||
// using `registration.pushManager.subscribe` also prompts the user.
|
||||
// However, I am keeping this here since that's how it's done in most guides.
|
||||
// Could be that this is required for the `registration.showNotification` call
|
||||
// to work or that some browsers will break without this.
|
||||
const requestNotificationPermission = useCallback(() => {
|
||||
// https://web.dev/push-notifications-subscribing-a-user/#requesting-permission
|
||||
return new Promise(function (resolve, reject) {
|
||||
const permission = Notification.requestPermission(function (result) {
|
||||
resolve(result)
|
||||
})
|
||||
if (permission) {
|
||||
permission.then(resolve, reject)
|
||||
}
|
||||
}).then(function (permission) {
|
||||
setPermission({ notification: permission })
|
||||
if (permission === 'granted') return subscribeToPushNotifications()
|
||||
})
|
||||
})
|
||||
|
||||
const subscribeToPushNotifications = async () => {
|
||||
const subscribeOptions = { userVisibleOnly: true, applicationServerKey }
|
||||
// Brave users must enable a flag in brave://settings/privacy first
|
||||
// see https://stackoverflow.com/a/69624651
|
||||
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
|
||||
// convert keys from ArrayBuffer to string
|
||||
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
|
||||
const variables = {
|
||||
endpoint: pushSubscription.endpoint,
|
||||
p256dh: pushSubscription.keys.p256dh,
|
||||
auth: pushSubscription.keys.auth
|
||||
}
|
||||
await savePushSubscription({ variables })
|
||||
}
|
||||
|
||||
const unsubscribeFromPushNotifications = async (subscription) => {
|
||||
await subscription.unsubscribe()
|
||||
const { endpoint } = subscription
|
||||
await deletePushSubscription({ variables: { endpoint } })
|
||||
}
|
||||
|
||||
const togglePushSubscription = useCallback(async () => {
|
||||
const pushSubscription = await registration.pushManager.getSubscription()
|
||||
if (pushSubscription) return unsubscribeFromPushNotifications(pushSubscription)
|
||||
return subscribeToPushNotifications()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setSupport({
|
||||
serviceWorker: 'serviceWorker' in navigator,
|
||||
notification: 'Notification' in window,
|
||||
pushManager: 'PushManager' in window
|
||||
})
|
||||
setPermission({ notification: 'Notification' in window ? Notification.permission : 'denied' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!support.serviceWorker) return
|
||||
const wb = new Workbox('/sw.js', { scope: '/' })
|
||||
wb.register().then(registration => {
|
||||
setRegistration(registration)
|
||||
})
|
||||
}, [support.serviceWorker])
|
||||
|
||||
return (
|
||||
<ServiceWorkerContext.Provider value={{ registration, support, permission, requestNotificationPermission, togglePushSubscription }}>
|
||||
{children}
|
||||
</ServiceWorkerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useServiceWorker () {
|
||||
return useContext(ServiceWorkerContext)
|
||||
}
|
|
@ -230,3 +230,9 @@ export const inviteSchema = Yup.object({
|
|||
gift: intValidator.positive('must be greater than 0').required('required'),
|
||||
limit: intValidator.positive('must be positive')
|
||||
})
|
||||
|
||||
export const pushSubscriptionSchema = Yup.object({
|
||||
endpoint: Yup.string().url().required('required').trim(),
|
||||
p256dh: Yup.string().required('required').trim(),
|
||||
auth: Yup.string().required('required').trim()
|
||||
})
|
||||
|
|
246
next.config.js
246
next.config.js
|
@ -1,6 +1,6 @@
|
|||
const { withPlausibleProxy } = require('next-plausible')
|
||||
const withPWA = require('next-pwa')
|
||||
const defaultRuntimeCaching = require('next-pwa/cache')
|
||||
const { InjectManifest } = require('workbox-webpack-plugin')
|
||||
const { generatePrecacheManifest } = require('./sw/build')
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
const corsHeaders = [
|
||||
|
@ -19,128 +19,124 @@ const commitHash = isProd
|
|||
? Object.keys(require('/opt/elasticbeanstalk/deployment/app_version_manifest.json').RuntimeSources['stacker.news'])[0].match(/^app-(.+)-/)[1] // eslint-disable-line
|
||||
: require('child_process').execSync('git rev-parse HEAD').toString().slice(0, 4)
|
||||
|
||||
module.exports = withPWA({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
customWorkerDir: 'sw',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'next-data',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60 // 24 hours
|
||||
}
|
||||
module.exports = withPlausibleProxy()({
|
||||
env: {
|
||||
NEXT_PUBLIC_COMMIT_HASH: commitHash
|
||||
},
|
||||
compress: false,
|
||||
experimental: {
|
||||
scrollRestoration: true
|
||||
},
|
||||
generateBuildId: isProd ? async () => commitHash : undefined,
|
||||
// Use the CDN in production and localhost for development.
|
||||
assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
|
||||
async headers () {
|
||||
return [
|
||||
{
|
||||
source: '/_next/:asset*',
|
||||
headers: corsHeaders
|
||||
},
|
||||
{
|
||||
source: '/Lightningvolt-xoqm.ttf',
|
||||
headers: [
|
||||
...corsHeaders,
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/.well-known/:slug*',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/api/lnauth',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/api/lnurlp/:slug*',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
}
|
||||
},
|
||||
...defaultRuntimeCaching.filter((c) => c.options.cacheName !== 'next-data')
|
||||
]
|
||||
})(
|
||||
withPlausibleProxy()({
|
||||
env: {
|
||||
NEXT_PUBLIC_COMMIT_HASH: commitHash
|
||||
},
|
||||
compress: false,
|
||||
experimental: {
|
||||
scrollRestoration: true
|
||||
},
|
||||
generateBuildId: isProd ? async () => commitHash : undefined,
|
||||
// Use the CDN in production and localhost for development.
|
||||
assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
|
||||
async headers () {
|
||||
return [
|
||||
{
|
||||
source: '/_next/:asset*',
|
||||
headers: corsHeaders
|
||||
},
|
||||
{
|
||||
source: '/Lightningvolt-xoqm.ttf',
|
||||
headers: [
|
||||
...corsHeaders,
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/.well-known/:slug*',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/api/lnauth',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/api/lnurlp/:slug*',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
async rewrites () {
|
||||
return [
|
||||
{
|
||||
source: '/faq',
|
||||
destination: '/items/349'
|
||||
},
|
||||
{
|
||||
source: '/story',
|
||||
destination: '/items/1620'
|
||||
},
|
||||
{
|
||||
source: '/privacy',
|
||||
destination: '/items/76894'
|
||||
},
|
||||
{
|
||||
source: '/changes',
|
||||
destination: '/items/78763'
|
||||
},
|
||||
{
|
||||
source: '/guide',
|
||||
destination: '/items/81862'
|
||||
},
|
||||
{
|
||||
source: '/daily',
|
||||
destination: '/api/daily'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/lnurlp/:username',
|
||||
destination: '/api/lnurlp/:username'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/nostr.json',
|
||||
destination: '/api/nostr/nip05'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/web-app-origin-association',
|
||||
destination: '/api/web-app-origin-association'
|
||||
},
|
||||
{
|
||||
source: '/~:sub',
|
||||
destination: '/~/:sub'
|
||||
},
|
||||
{
|
||||
source: '/~:sub/:slug*',
|
||||
destination: '/~/:sub/:slug*'
|
||||
}
|
||||
]
|
||||
},
|
||||
async redirects () {
|
||||
return [
|
||||
{
|
||||
source: '/statistics',
|
||||
destination: '/satistics?inc=invoice,withdrawal',
|
||||
permanent: true
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
async rewrites () {
|
||||
return [
|
||||
{
|
||||
source: '/faq',
|
||||
destination: '/items/349'
|
||||
},
|
||||
{
|
||||
source: '/story',
|
||||
destination: '/items/1620'
|
||||
},
|
||||
{
|
||||
source: '/privacy',
|
||||
destination: '/items/76894'
|
||||
},
|
||||
{
|
||||
source: '/changes',
|
||||
destination: '/items/78763'
|
||||
},
|
||||
{
|
||||
source: '/guide',
|
||||
destination: '/items/81862'
|
||||
},
|
||||
{
|
||||
source: '/daily',
|
||||
destination: '/api/daily'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/lnurlp/:username',
|
||||
destination: '/api/lnurlp/:username'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/nostr.json',
|
||||
destination: '/api/nostr/nip05'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/web-app-origin-association',
|
||||
destination: '/api/web-app-origin-association'
|
||||
},
|
||||
{
|
||||
source: '/~:sub',
|
||||
destination: '/~/:sub'
|
||||
},
|
||||
{
|
||||
source: '/~:sub/:slug*',
|
||||
destination: '/~/:sub/:slug*'
|
||||
}
|
||||
]
|
||||
},
|
||||
async redirects () {
|
||||
return [
|
||||
{
|
||||
source: '/statistics',
|
||||
destination: '/satistics?inc=invoice,withdrawal',
|
||||
permanent: true
|
||||
}
|
||||
]
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
generatePrecacheManifest()
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
// ignore the precached manifest which includes the webpack assets
|
||||
// since they are not useful to us
|
||||
exclude: [/.*/],
|
||||
// by default, webpack saves service worker at .next/server/
|
||||
swDest: '../../public/sw.js',
|
||||
swSrc: './sw/index.js'
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
return config
|
||||
}
|
||||
})
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -43,7 +43,6 @@
|
|||
"next": "^12.3.2",
|
||||
"next-auth": "^3.29.10",
|
||||
"next-plausible": "^3.6.4",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-seo": "^4.29.0",
|
||||
"nextjs-progressbar": "0.0.16",
|
||||
"node-s3-url-encode": "^0.0.4",
|
||||
|
@ -79,7 +78,14 @@
|
|||
"url-unshort": "^6.1.0",
|
||||
"use-dark-mode": "^2.3.1",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.2",
|
||||
"webln": "^0.2.2",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
"workbox-recipes": "^7.0.0",
|
||||
"workbox-routing": "^7.0.0",
|
||||
"workbox-strategies": "^7.0.0",
|
||||
"workbox-webpack-plugin": "^7.0.0",
|
||||
"workbox-window": "^7.0.0",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
@ -14,7 +14,7 @@ import Moon from '../svgs/moon-fill.svg'
|
|||
import Layout from '../components/layout'
|
||||
import { ShowModalProvider } from '../components/modal'
|
||||
import ErrorBoundary from '../components/error-boundary'
|
||||
import { NotificationProvider } from '../components/notifications'
|
||||
import { ServiceWorkerProvider } from '../components/serviceworker'
|
||||
|
||||
function CSRWrapper ({ Component, apollo, ...props }) {
|
||||
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
|
||||
|
@ -89,7 +89,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||
<Provider session={session}>
|
||||
<ApolloProvider client={client}>
|
||||
<MeProvider me={me}>
|
||||
<NotificationProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ShowModalProvider>
|
||||
|
@ -99,7 +99,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||
</ShowModalProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</NotificationProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</MeProvider>
|
||||
</ApolloProvider>
|
||||
</Provider>
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
export function getServerSideProps ({ query }) {
|
||||
// used to redirect to appropriate post type if Web Share Target API was used
|
||||
const title = query.title
|
||||
const text = query.text
|
||||
let url = query.url
|
||||
// apps may share links as text
|
||||
if (text && /^https?:\/\//.test(text)) url = text
|
||||
|
||||
let destination = '/post'
|
||||
if (url && title) {
|
||||
destination += `?type=link&url=${url}&title=${title}`
|
||||
} else if (title) {
|
||||
destination += `?type=discussion&title=${title}`
|
||||
if (text) destination += `&text=${text}`
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default () => null
|
|
@ -0,0 +1,17 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription.userId_index" ON "PushSubscription"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -99,6 +99,7 @@ model User {
|
|||
Bookmarks Bookmark[]
|
||||
Subscriptions Subscription[]
|
||||
ThreadSubscriptions ThreadSubscription[]
|
||||
PushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([inviteId])
|
||||
|
@ -579,3 +580,15 @@ model ThreadSubscription {
|
|||
@@id([userId, itemId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
endpoint String
|
||||
p256dh String
|
||||
auth String
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
|
@ -12,6 +12,11 @@
|
|||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-96x96.png",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-24x24.png",
|
||||
"type": "image/png",
|
||||
|
@ -26,5 +31,14 @@
|
|||
{
|
||||
"origin": "https://stacker.news"
|
||||
}
|
||||
]
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const getRevision = filePath => crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex')
|
||||
|
||||
function formatBytes (bytes, decimals = 2) {
|
||||
if (bytes === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const formattedSize = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))
|
||||
|
||||
return `${formattedSize} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function generatePrecacheManifest () {
|
||||
const manifest = []
|
||||
let size = 0
|
||||
|
||||
const addToManifest = (filePath, url, s) => {
|
||||
const revision = getRevision(filePath)
|
||||
manifest.push({ url, revision })
|
||||
size += s
|
||||
}
|
||||
|
||||
const staticDir = path.join(__dirname, '../public')
|
||||
const staticFiles = fs.readdirSync(staticDir)
|
||||
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|webmanifest)$/, /^darkmode\.js$/].some(m => m.test(f))
|
||||
staticFiles.filter(staticMatch).forEach(file => {
|
||||
const filePath = path.join(staticDir, file)
|
||||
const stats = fs.statSync(filePath)
|
||||
if (stats.isFile()) {
|
||||
addToManifest(filePath, '/' + file, stats.size)
|
||||
}
|
||||
})
|
||||
|
||||
const pagesDir = path.join(__dirname, '../pages')
|
||||
const precacheURLs = ['/offline']
|
||||
const pagesFiles = fs.readdirSync(pagesDir)
|
||||
const fileToUrl = f => '/' + f.replace(/\.js$/, '')
|
||||
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
|
||||
pagesFiles.filter(pageMatch).forEach(file => {
|
||||
const filePath = path.join(pagesDir, file)
|
||||
const stats = fs.statSync(filePath)
|
||||
if (stats.isFile()) {
|
||||
// This is not ideal since dependencies of the pages may have changed
|
||||
// but we would still generate the same revision ...
|
||||
// The ideal solution would be to create a revision from the file generated by webpack
|
||||
// in .next/server/pages but the file may not exist yet when we run this script
|
||||
addToManifest(filePath, fileToUrl(file), stats.size)
|
||||
}
|
||||
})
|
||||
|
||||
const output = 'sw/precache-manifest.json'
|
||||
fs.writeFileSync(output, JSON.stringify(manifest, null, 2))
|
||||
|
||||
console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
|
||||
}
|
||||
|
||||
module.exports = { generatePrecacheManifest }
|
98
sw/index.js
98
sw/index.js
|
@ -1,2 +1,96 @@
|
|||
// Uncomment to disable workbox logging during development
|
||||
// self.__WB_DISABLE_DEV_LOGS = true
|
||||
import { precacheAndRoute } from 'workbox-precaching'
|
||||
import { offlineFallback } from 'workbox-recipes'
|
||||
import { setDefaultHandler } from 'workbox-routing'
|
||||
import { NetworkOnly } from 'workbox-strategies'
|
||||
import manifest from './precache-manifest.json'
|
||||
|
||||
// ignore precache manifest generated by InjectManifest
|
||||
// self.__WB_MANIFEST
|
||||
|
||||
precacheAndRoute(manifest)
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Using network-only as the default strategy ensures that we fallback
|
||||
// to the browser as if the service worker wouldn't exist.
|
||||
// The browser may use own caching (HTTP cache).
|
||||
// Also, the offline fallback only works if request matched a route
|
||||
setDefaultHandler(new NetworkOnly())
|
||||
|
||||
// This won't work in dev because pages are never cached.
|
||||
// See https://github.com/vercel/next.js/blob/337fb6a9aadb61c916f0121c899e463819cd3f33/server/render.js#L181-L185
|
||||
offlineFallback({ pageFallback: '/offline' })
|
||||
|
||||
self.addEventListener('push', async function (event) {
|
||||
const payload = event.data?.json()
|
||||
if (!payload) return
|
||||
const { tag } = payload.options
|
||||
event.waitUntil((async () => {
|
||||
if (!['REPLY', 'MENTION'].includes(tag)) {
|
||||
return self.registration.showNotification(payload.title, payload.options)
|
||||
}
|
||||
|
||||
const notifications = await self.registration.getNotifications({ tag })
|
||||
// since we used a tag filter, there should only be zero or one notification
|
||||
if (notifications.length > 1) {
|
||||
console.error(`more than one notification with tag ${tag} found`)
|
||||
return null
|
||||
}
|
||||
if (notifications.length === 0) {
|
||||
return self.registration.showNotification(payload.title, payload.options)
|
||||
}
|
||||
const currentNotification = notifications[0]
|
||||
const amount = currentNotification.data?.amount ? currentNotification.data.amount + 1 : 2
|
||||
let title = ''
|
||||
if (tag === 'REPLY') {
|
||||
title = `You have ${amount} new replies`
|
||||
} else if (tag === 'MENTION') {
|
||||
title = `You were mentioned ${amount} times`
|
||||
}
|
||||
currentNotification.close()
|
||||
const { icon } = currentNotification
|
||||
return self.registration.showNotification(title, { icon, tag, data: { url: '/notifications', amount } })
|
||||
})())
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
const url = event.notification.data?.url
|
||||
if (url) {
|
||||
event.waitUntil(self.clients.openWindow(url))
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
|
||||
const query = `
|
||||
mutation savePushSubscription($endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String!) {
|
||||
savePushSubscription(endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint) {
|
||||
id
|
||||
}
|
||||
}`
|
||||
const subscription = self.registration.pushManager
|
||||
.subscribe(event.oldSubscription.options)
|
||||
.then((subscription) => {
|
||||
// convert keys from ArrayBuffer to string
|
||||
subscription = JSON.parse(JSON.stringify(subscription))
|
||||
const variables = {
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
oldEndpoint: event.oldSubscription.endpoint
|
||||
}
|
||||
const body = JSON.stringify({ query, variables })
|
||||
return fetch('/api/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json'
|
||||
},
|
||||
body
|
||||
})
|
||||
})
|
||||
event.waitUntil(subscription)
|
||||
},
|
||||
false
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue