merge github master
This commit is contained in:
commit
3068252adf
|
@ -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 #
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Report a problem
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
*Note: this template is meant to help you report the bug so that we can fix it faster, ie not all of these sections are required*
|
||||
|
||||
**Description**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps to Reproduce**
|
||||
A clear and concise way we might be able to reproduce the bug.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
If applicable, add your browsers console logs.
|
||||
|
||||
**Environment:**
|
||||
If you only experience the issue on certain devices or browsers, provide that info.
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature
|
||||
title: ''
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
|
@ -42,10 +42,9 @@ envbak
|
|||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
|
||||
# auto-generated files by next-pwa / workbox
|
||||
public/sw.js
|
||||
public/sw.js.map
|
||||
public/workbox-*.js
|
||||
public/workbox-*.js.map
|
||||
public/worker-*.js
|
||||
public/fallback-*.js
|
||||
# service worker
|
||||
public/sw.js*
|
||||
sw/precache-manifest.json
|
||||
public/workbox-*.js*
|
||||
public/*-development.js
|
||||
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -79,7 +79,6 @@ export function InputSkeleton ({ label, hint }) {
|
|||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const [, meta, helpers] = useField(props)
|
||||
const formik = useFormikContext()
|
||||
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
||||
innerRef = innerRef || useRef(null)
|
||||
|
||||
|
|
|
@ -4,6 +4,14 @@
|
|||
|
||||
.markdownInput textarea {
|
||||
margin-top: -1px;
|
||||
font-size: 94%;
|
||||
line-height: 140%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 767px) {
|
||||
.markdownInput textarea {
|
||||
line-height: 130%;
|
||||
}
|
||||
}
|
||||
|
||||
.markdownInput .text {
|
||||
|
|
|
@ -18,7 +18,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'
|
||||
import { useFireworks } from './fireworks'
|
||||
|
||||
|
@ -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",
|
||||
|
@ -82,7 +81,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": {
|
||||
|
|
|
@ -13,8 +13,8 @@ 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 { FireworksProvider } from '../components/fireworks'
|
||||
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}>
|
||||
<FireworksProvider>
|
||||
<ShowModalProvider>
|
||||
|
@ -99,7 +99,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||
</ShowModalProvider>
|
||||
</FireworksProvider>
|
||||
</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