feat: pwa with push notifications (#337)

This commit is contained in:
Joaquín Sánchez 2022-12-18 00:29:16 +01:00 committed by GitHub
parent a18e5e2332
commit f0c91a3974
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2903 additions and 14 deletions

65
service-worker/sw.ts Normal file
View file

@ -0,0 +1,65 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { onNotificationClick, onPush } from './web-push-notifications'
declare const self: ServiceWorkerGlobalScope
/*
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
*/
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING')
self.skipWaiting()
})
const entries = self.__WB_MANIFEST
if (import.meta.env.DEV)
entries.push({ url: '/', revision: Math.random().toString() })
precacheAndRoute(entries)
// clean old assets
cleanupOutdatedCaches()
// allow only fallback in dev: we don't want to cache anything
let allowlist: undefined | RegExp[]
if (import.meta.env.DEV)
allowlist = [/^\/$/]
// deny api and server page calls
let denylist: undefined | RegExp[]
if (import.meta.env.PROD)
denylist = [/^\/api\//, /^\/login\//, /^\/oauth\//, /^\/signin\//]
// only cache pages and external assets on local build + start or in production
if (import.meta.env.PROD) {
// external assets: rn avatars from mas.to
// requires <img crossorigin="anonymous".../> and http header: Allow-Control-Allow-Origin: *
/*
registerRoute(
({ sameOrigin, request }) => !sameOrigin && request.destination === 'image',
new NetworkFirst({
cacheName: 'elk-external-media',
plugins: [
// add opaque responses?
new CacheableResponsePlugin({ statuses: [/!* 0, *!/200] }),
// 15 days max
new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 15 }),
],
}),
)
*/
}
// to allow work offline
registerRoute(new NavigationRoute(
createHandlerBoundToURL('/'),
{ allowlist, denylist },
))
self.addEventListener('push', onPush)
self.addEventListener('notificationclick', onNotificationClick)

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"lib": ["ESNext", "WebWorker"],
"types": ["vite/client", "service-worker"]
},
"include": ["./"],
"extends": "../tsconfig.json"
}

9
service-worker/types.ts Normal file
View file

@ -0,0 +1,9 @@
export interface PushPayload {
access_token: string
notification_id: string
notification_type: 'follow' | 'favourite' | 'reblog' | 'mention' | 'poll'
preferred_locale: string
title: string
body: string
icon: string
}

View file

@ -0,0 +1,85 @@
/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import type { PushPayload } from '~/service-worker/types'
declare const self: ServiceWorkerGlobalScope
export const onPush = (event: PushEvent) => {
const promise = isClientFocused().then((isFocused) => {
if (isFocused)
return Promise.resolve()
const options: PushPayload = event.data!.json()
const {
access_token,
body,
icon,
notification_id,
notification_type,
preferred_locale,
} = options
let url = 'home'
if (notification_type) {
switch (notification_type) {
case 'follow':
url = 'notifications'
break
case 'mention':
url = 'notifications/mention'
break
}
}
const notificationOptions: NotificationOptions = {
badge: '/pwa-192x192.png',
body,
data: {
access_token,
preferred_locale,
url: `/${url}`,
},
dir: 'auto',
icon,
lang: preferred_locale,
tag: notification_id,
timestamp: new Date().getUTCDate(),
}
return self.registration.showNotification(options.title, notificationOptions)
})
event.waitUntil(promise)
}
export const onNotificationClick = (event: NotificationEvent) => {
const reactToNotificationClick = new Promise((resolve) => {
event.notification.close()
resolve(openUrl(event.notification.data.url))
})
event.waitUntil(reactToNotificationClick)
}
function findBestClient(clients: WindowClient[]) {
const focusedClient = clients.find(client => client.focused)
const visibleClient = clients.find(client => client.visibilityState === 'visible')
return focusedClient || visibleClient || clients[0]
}
async function openUrl(url: string) {
const clients = await self.clients.matchAll({ type: 'window' })
// Chrome 42-48 does not support navigate
if (clients.length !== 0 && 'navigate' in clients[0]) {
const client = findBestClient(clients as WindowClient[])
await client.navigate(url).then(client => client?.focus())
}
await self.clients.openWindow(url)
}
function isClientFocused() {
return self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(windowClients => Promise.resolve(windowClients.some(windowClient => windowClient.focused)))
}