Skip to content

Commit

Permalink
Service worker rework, Web Target Share API & Web Push API (#324)
Browse files Browse the repository at this point in the history
* npm uninstall next-pwa

next-pwa was last updated in August 2022.
There is also an issue which mentions that next-pwa is abandoned (?): shadowwalker/next-pwa#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 <[email protected]>
  • Loading branch information
ekzyis and ekzyis authored Jul 4, 2023
1 parent 7cb05f6 commit 388e00d
Show file tree
Hide file tree
Showing 24 changed files with 2,483 additions and 2,114 deletions.
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down
9 changes: 3 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

33 changes: 32 additions & 1 deletion api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
42 changes: 41 additions & 1 deletion api/resolvers/notifications.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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
},
Expand Down
13 changes: 13 additions & 0 deletions api/typeDefs/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -69,4 +74,12 @@ export default gql`
cursor: String
notifications: [Notification!]!
}
type PushSubscription {
id: ID!
userId: ID!
endpoint: String!
p256dh: String!
auth: String!
}
`
78 changes: 78 additions & 0 deletions api/webPush/index.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion components/discussion-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 })
Expand Down
14 changes: 1 addition & 13 deletions components/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 ['','']
Expand All @@ -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(() => {
Expand Down
7 changes: 5 additions & 2 deletions components/link-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down Expand Up @@ -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 })
}}
Expand Down
Loading

0 comments on commit 388e00d

Please sign in to comment.