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:
ekzyis 2023-07-04 21:36:07 +02:00 committed by GitHub
parent 7cb05f6d96
commit 388e00dd04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2442 additions and 2073 deletions

View File

@ -21,6 +21,11 @@ LNAUTH_URL=<YOUR PUBLIC TUNNEL TO LOCALHOST, e.g. NGROK>
# slashtags # slashtags
SLASHTAGS_SECRET= SLASHTAGS_SECRET=
# VAPID for Web Push
VAPID_MAILTO=
NEXT_PUBLIC_VAPID_PUBKEY=
VAPID_PRIVKEY=
####################################################### #######################################################
# LND / OPTIONAL # # LND / OPTIONAL #
# if you want to work with payments you'll need these # # if you want to work with payments you'll need these #

9
.gitignore vendored
View File

@ -42,10 +42,7 @@ envbak
!.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml !.elasticbeanstalk/*.global.yml
# auto-generated files by next-pwa / workbox # service worker
public/sw.js public/sw.js
public/sw.js.map sw/precache-manifest.json
public/workbox-*.js
public/workbox-*.js.map
public/worker-*.js
public/fallback-*.js

View File

@ -12,6 +12,7 @@ import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
async function comments (me, models, id, sort) { async function comments (me, models, id, sort) {
let orderBy let orderBy
@ -893,7 +894,21 @@ export default {
}, },
createComment: async (parent, data, { me, models }) => { createComment: async (parent, data, { me, models }) => {
await ssValidate(commentSchema, data) 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 }) => { updateComment: async (parent, { id, ...data }, { me, models }) => {
await ssValidate(commentSchema, data) 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 [{ 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 { return {
vote, vote,
sats sats
@ -1182,6 +1206,13 @@ export const createMentions = async (item, models) => {
update: data, update: data,
create: data create: data
}) })
sendUserNotification(user.id, {
title: 'you were mentioned',
body: item.text,
data: { url: `/items/${item.id}` },
tag: 'MENTION'
})
}) })
} }
} catch (e) { } catch (e) {

View File

@ -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 { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem, filterClause } from './item' import { getItem, filterClause } from './item'
import { getInvoice } from './wallet' import { getInvoice } from './wallet'
import { pushSubscriptionSchema, ssValidate } from '../../lib/validate'
import { replyToSubscription } from '../webPush'
export default { export default {
Query: { 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: { Notification: {
__resolveType: async (n, args, { models }) => n.type __resolveType: async (n, args, { models }) => n.type
}, },

View File

@ -5,6 +5,11 @@ export default gql`
notifications(cursor: String, inc: String): Notifications 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 { type Votification {
earnedSats: Int! earnedSats: Int!
item: Item! item: Item!
@ -69,4 +74,12 @@ export default gql`
cursor: String cursor: String
notifications: [Notification!]! notifications: [Notification!]!
} }
type PushSubscription {
id: ID!
userId: ID!
endpoint: String!
p256dh: String!
auth: String!
}
` `

78
api/webPush/index.js Normal file
View File

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

View File

@ -22,6 +22,9 @@ export function DiscussionForm ({
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const schema = discussionSchema(client) const schema = discussionSchema(client)
// if Web Share Target API was used
const shareTitle = router.query.title
// const me = useMe() // const me = useMe()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
@ -51,7 +54,7 @@ export function DiscussionForm ({
return ( return (
<Form <Form
initial={{ initial={{
title: item?.title || '', title: item?.title || shareTitle || '',
text: item?.text || '', text: item?.text || '',
...AdvPostInitial({ forward: item?.fwdUser?.name }), ...AdvPostInitial({ forward: item?.fwdUser?.name }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })

View File

@ -19,7 +19,6 @@ import CowboyHat from './cowboy-hat'
import { Form, Select } from './form' import { Form, Select } from './form'
import SearchIcon from '../svgs/search-line.svg' import SearchIcon from '../svgs/search-line.svg'
import BackArrow from '../svgs/arrow-left-line.svg' import BackArrow from '../svgs/arrow-left-line.svg'
import { useNotification } from './notifications'
import { SUBS } from '../lib/constants' import { SUBS } from '../lib/constants'
function WalletSummary ({ me }) { function WalletSummary ({ me }) {
@ -51,7 +50,6 @@ export default function Header ({ sub }) {
const [prefix, setPrefix] = useState('') const [prefix, setPrefix] = useState('')
const [path, setPath] = useState('') const [path, setPath] = useState('')
const me = useMe() const me = useMe()
const notification = useNotification()
useEffect(() => { useEffect(() => {
// there's always at least 2 on the split, e.g. '/' yields ['',''] // there's always at least 2 on the split, e.g. '/' yields ['','']
@ -73,17 +71,7 @@ export default function Header ({ sub }) {
} }
`, { `, {
pollInterval: 30000, pollInterval: 30000,
fetchPolicy: 'cache-and-network', 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)
}
}) })
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime()) // const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
// useEffect(() => { // useEffect(() => {

View File

@ -19,6 +19,9 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const schema = linkSchema(client) 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` const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
query PageTitleAndUnshorted($url: String!) { query PageTitleAndUnshorted($url: String!) {
@ -95,8 +98,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
return ( return (
<Form <Form
initial={{ initial={{
title: item?.title || '', title: item?.title || shareTitle || '',
url: item?.url || '', url: item?.url || shareUrl || '',
...AdvPostInitial({ forward: item?.fwdUser?.name }), ...AdvPostInitial({ forward: item?.fwdUser?.name }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useContext, useEffect, createContext } from 'react' import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import Item from './item' import Item from './item'
@ -18,6 +18,8 @@ import BaldIcon from '../svgs/bald.svg'
import { RootProvider } from './root' import { RootProvider } from './root'
import { Alert } from 'react-bootstrap' import { Alert } from 'react-bootstrap'
import styles from './notifications.module.css' import styles from './notifications.module.css'
import { useServiceWorker } from './serviceworker'
import { Checkbox, Form } from './form'
function Notification ({ n }) { function Notification ({ n }) {
switch (n.__typename) { switch (n.__typename) {
@ -254,13 +256,16 @@ function Reply ({ n }) {
function NotificationAlert () { function NotificationAlert () {
const [showAlert, setShowAlert] = useState(false) const [showAlert, setShowAlert] = useState(false)
const pushNotify = useNotification() const [hasSubscription, setHasSubscription] = useState(false)
const [error, setError] = useState(null)
const sw = useServiceWorker()
useEffect(() => { useEffect(() => {
// basically, we only want to show the alert if the user hasn't interacted with const isSupported = sw.support.serviceWorker && sw.support.pushManager && sw.support.notification
// either opt-in of the double opt-in const isDefaultPermission = sw.permission.notification === 'default'
setShowAlert(pushNotify.isDefault && !localStorage.getItem('hideNotifyPrompt')) setShowAlert(isSupported && isDefaultPermission && !localStorage.getItem('hideNotifyPrompt'))
}, [pushNotify]) sw.registration?.pushManager.getSubscription().then(subscription => setHasSubscription(!!subscription))
}, [sw])
const close = () => { const close = () => {
localStorage.setItem('hideNotifyPrompt', 'yep') localStorage.setItem('hideNotifyPrompt', 'yep')
@ -268,22 +273,37 @@ function NotificationAlert () {
} }
return ( return (
showAlert error
? ( ? (
<Alert variant='success' dismissible onClose={close}> <Alert variant='danger' dismissible onClose={() => setError(null)}>
<span>{error.toString()}</span>
</Alert>
)
: showAlert
? (
<Alert variant='info' dismissible onClose={close}>
<span className='align-middle'>Enable push notifications?</span> <span className='align-middle'>Enable push notifications?</span>
<button <button
className={`${styles.alertBtn} mx-1`} className={`${styles.alertBtn} mx-1`}
onClick={() => { onClick={async () => {
pushNotify.requestPermission() await sw.requestNotificationPermission()
close() .then(close)
.catch(setError)
}} }}
>Yes >Yes
</button> </button>
<button className={`${styles.alertBtn}`} onClick={close}>No</button> <button className={`${styles.alertBtn}`} onClick={close}>No</button>
</Alert> </Alert>
) )
: null : (
<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> </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)
}

111
components/serviceworker.js Normal file
View File

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

View File

@ -230,3 +230,9 @@ export const inviteSchema = Yup.object({
gift: intValidator.positive('must be greater than 0').required('required'), gift: intValidator.positive('must be greater than 0').required('required'),
limit: intValidator.positive('must be positive') 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()
})

View File

@ -1,6 +1,6 @@
const { withPlausibleProxy } = require('next-plausible') const { withPlausibleProxy } = require('next-plausible')
const withPWA = require('next-pwa') const { InjectManifest } = require('workbox-webpack-plugin')
const defaultRuntimeCaching = require('next-pwa/cache') const { generatePrecacheManifest } = require('./sw/build')
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
const corsHeaders = [ const corsHeaders = [
@ -19,26 +19,7 @@ const commitHash = isProd
? Object.keys(require('/opt/elasticbeanstalk/deployment/app_version_manifest.json').RuntimeSources['stacker.news'])[0].match(/^app-(.+)-/)[1] // eslint-disable-line ? 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) : require('child_process').execSync('git rev-parse HEAD').toString().slice(0, 4)
module.exports = withPWA({ module.exports = withPlausibleProxy()({
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
}
}
},
...defaultRuntimeCaching.filter((c) => c.options.cacheName !== 'next-data')
]
})(
withPlausibleProxy()({
env: { env: {
NEXT_PUBLIC_COMMIT_HASH: commitHash NEXT_PUBLIC_COMMIT_HASH: commitHash
}, },
@ -141,6 +122,21 @@ module.exports = withPWA({
permanent: true 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
}
})

3589
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,6 @@
"next": "^12.3.2", "next": "^12.3.2",
"next-auth": "^3.29.10", "next-auth": "^3.29.10",
"next-plausible": "^3.6.4", "next-plausible": "^3.6.4",
"next-pwa": "^5.6.0",
"next-seo": "^4.29.0", "next-seo": "^4.29.0",
"nextjs-progressbar": "0.0.16", "nextjs-progressbar": "0.0.16",
"node-s3-url-encode": "^0.0.4", "node-s3-url-encode": "^0.0.4",
@ -79,7 +78,14 @@
"url-unshort": "^6.1.0", "url-unshort": "^6.1.0",
"use-dark-mode": "^2.3.1", "use-dark-mode": "^2.3.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web-push": "^3.6.2",
"webln": "^0.2.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" "yup": "^0.32.11"
}, },
"engines": { "engines": {

View File

@ -14,7 +14,7 @@ import Moon from '../svgs/moon-fill.svg'
import Layout from '../components/layout' import Layout from '../components/layout'
import { ShowModalProvider } from '../components/modal' import { ShowModalProvider } from '../components/modal'
import ErrorBoundary from '../components/error-boundary' import ErrorBoundary from '../components/error-boundary'
import { NotificationProvider } from '../components/notifications' import { ServiceWorkerProvider } from '../components/serviceworker'
function CSRWrapper ({ Component, apollo, ...props }) { function CSRWrapper ({ Component, apollo, ...props }) {
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' }) 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}> <Provider session={session}>
<ApolloProvider client={client}> <ApolloProvider client={client}>
<MeProvider me={me}> <MeProvider me={me}>
<NotificationProvider> <ServiceWorkerProvider>
<PriceProvider price={price}> <PriceProvider price={price}>
<LightningProvider> <LightningProvider>
<ShowModalProvider> <ShowModalProvider>
@ -99,7 +99,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
</ShowModalProvider> </ShowModalProvider>
</LightningProvider> </LightningProvider>
</PriceProvider> </PriceProvider>
</NotificationProvider> </ServiceWorkerProvider>
</MeProvider> </MeProvider>
</ApolloProvider> </ApolloProvider>
</Provider> </Provider>

24
pages/share.js Normal file
View File

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

View File

@ -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;

View File

@ -99,6 +99,7 @@ model User {
Bookmarks Bookmark[] Bookmarks Bookmark[]
Subscriptions Subscription[] Subscriptions Subscription[]
ThreadSubscriptions ThreadSubscription[] ThreadSubscriptions ThreadSubscription[]
PushSubscriptions PushSubscription[]
@@index([createdAt]) @@index([createdAt])
@@index([inviteId]) @@index([inviteId])
@ -579,3 +580,15 @@ model ThreadSubscription {
@@id([userId, itemId]) @@id([userId, itemId])
@@index([createdAt]) @@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

View File

@ -12,6 +12,11 @@
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
}, },
{
"src": "/android-chrome-96x96.png",
"type": "image/png",
"sizes": "96x96"
},
{ {
"src": "/android-chrome-24x24.png", "src": "/android-chrome-24x24.png",
"type": "image/png", "type": "image/png",
@ -26,5 +31,14 @@
{ {
"origin": "https://stacker.news" "origin": "https://stacker.news"
} }
] ],
"share_target": {
"action": "/share",
"method": "GET",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
} }

65
sw/build.js Normal file
View File

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

View File

@ -1,2 +1,96 @@
// Uncomment to disable workbox logging during development import { precacheAndRoute } from 'workbox-precaching'
// self.__WB_DISABLE_DEV_LOGS = true 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
)