/* eslint-env serviceworker */ import 'virtual:pleroma-fe/service_worker_env' import { storage } from 'src/lib/storage.js' import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js' import { prepareNotificationObject } from './services/notification_utils/notification_utils.js' import { shouldCache, cacheKey, emojiCacheKey } from './services/sw/sw.js' import { createI18n } from 'vue-i18n' // Collects all messages for service workers // Needed because service workers cannot use dynamic imports // See /build/sw_plugin.js for more information import messages from 'virtual:pleroma-fe/service_worker_messages' const i18n = createI18n({ // By default, use the browser locale, we will update it if neccessary locale: 'en', fallbackLocale: 'en', messages }) const state = { lastFocused: null, notificationIds: new Set(), allowedNotificationTypes: null } function getWindowClients () { return clients.matchAll({ includeUncontrolled: true }) .then((clientList) => clientList.filter(({ type }) => type === 'window')) } const setSettings = async () => { const vuexState = await storage.getItem('vuex-lz') const locale = vuexState.config.interfaceLanguage || 'en' i18n.locale = locale const notificationsNativeArray = Object.entries(vuexState.config.notificationNative) state.webPushAlwaysShowNotifications = vuexState.config.webPushAlwaysShowNotifications state.allowedNotificationTypes = new Set( notificationsNativeArray .filter(([, v]) => v) .map(([k]) => { switch (k) { case 'mentions': return 'mention' case 'statuses': return 'status' case 'likes': return 'like' case 'repeats': return 'repeat' case 'emojiReactions': return 'pleroma:emoji_reaction' case 'reports': return 'pleroma:report' case 'followRequest': return 'follow_request' case 'follows': return 'follow' case 'polls': return 'poll' default: return k } }) ) } const showPushNotification = async (event) => { const activeClients = await getWindowClients() await setSettings() // Only show push notifications if all tabs/windows are closed if (state.webPushAlwaysShowNotifications || activeClients.length === 0) { const data = event.data.json() const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}` const notification = await fetch(url, { headers: { Authorization: 'Bearer ' + data.access_token } }) const notificationJson = await notification.json() const parsedNotification = parseNotification(notificationJson) const res = prepareNotificationObject(parsedNotification, i18n) if (state.webPushAlwaysShowNotifications || state.allowedNotificationTypes.has(parsedNotification.type)) { return self.registration.showNotification(res.title, res) } } return Promise.resolve() } const cacheFiles = self.serviceWorkerOption.assets const isEmoji = req => { if (req.method !== 'GET') { return false } const url = new URL(req.url) return url.pathname.startsWith('/emoji/') } const isNotMedia = req => { if (req.method !== 'GET') { return false } const url = new URL(req.url) return !url.pathname.startsWith('/media/') } const isAsset = req => { const url = new URL(req.url) return cacheFiles.includes(url.pathname) } const isSuccessful = (resp) => { if (!resp.ok) { return false } if ((new URL(resp.url)).pathname === '/index.html') { // For index.html itself, there is no fallback possible. return true } const type = resp.headers.get('Content-Type') // Backend will revert to index.html if the file does not exist, so text/html for emojis and assets is a failure return type && !type.includes('text/html') } self.addEventListener('install', async (event) => { if (shouldCache) { event.waitUntil((async () => { // Do not preload i18n and emoji annotations to speed up loading const shouldPreload = (route) => { return !route.startsWith('/static/js/i18n/') && !route.startsWith('/static/js/emoji-annotations/') } const cache = await caches.open(cacheKey) await Promise.allSettled(cacheFiles.filter(shouldPreload).map(async (route) => { // https://developer.mozilla.org/en-US/docs/Web/API/Cache/add // originally we used addAll() but it will raise a problem in one edge case: // when the file for the route is not found, backend will return index.html with code 200 // but it's wrong, and it's cached, so we end up with a bad cache. // this can happen when you refresh when you are in the process of upgrading // the frontend. const resp = await fetch(route) if (isSuccessful(resp)) { await cache.put(route, resp) } })) })()) } }) self.addEventListener('activate', async (event) => { if (shouldCache) { event.waitUntil((async () => { const cache = await caches.open(cacheKey) const keys = await cache.keys() await Promise.all( keys.filter(request => { const url = new URL(request.url) const shouldKeep = cacheFiles.includes(url.pathname) return !shouldKeep }).map(k => cache.delete(k)) ) })()) } }) self.addEventListener('push', async (event) => { if (event.data) { // Supposedly, we HAVE to return a promise inside waitUntil otherwise it will // show (extra) notification that website is updated in background event.waitUntil(showPushNotification(event)) } }) self.addEventListener('message', async (event) => { await setSettings() const { type, content } = event.data if (type === 'desktopNotification') { const { title, ...rest } = content const { tag, type } = rest if (state.notificationIds.has(tag)) return state.notificationIds.add(tag) setTimeout(() => state.notificationIds.delete(tag), 10000) if (state.allowedNotificationTypes.has(type)) { self.registration.showNotification(title, rest) } } if (type === 'desktopNotificationClose') { const { id, all } = content const search = all ? null : { tag: id } const notifications = await self.registration.getNotifications(search) notifications.forEach(n => n.close()) } if (type === 'updateFocus') { state.lastFocused = event.source.id const notifications = await self.registration.getNotifications() notifications.forEach(n => n.close()) } }) self.addEventListener('notificationclick', (event) => { event.notification.close() event.waitUntil(getWindowClients().then((list) => { for (let i = 0; i < list.length; i++) { const client = list[i] client.postMessage({ type: 'notificationClicked', id: event.notification.tag }) } for (let i = 0; i < list.length; i++) { const client = list[i] if (state.lastFocused === null || client.id === state.lastFocused) { if ('focus' in client) return client.focus() } } if (clients.openWindow) return clients.openWindow('/') })) }) self.addEventListener('fetch', (event) => { // Do not mess up with remote things const isSameOrigin = (new URL(event.request.url)).origin === self.location.origin if (shouldCache && event.request.method === 'GET' && isSameOrigin && isNotMedia(event.request)) { // this is a bit spammy // console.debug('[Service worker] fetch:', event.request.url) event.respondWith((async () => { const r = await caches.match(event.request) const isEmojiReq = isEmoji(event.request) if (r && isSuccessful(r)) { console.debug('[Service worker] already cached:', event.request.url) return r } try { const response = await fetch(event.request) if (response.ok && isSuccessful(response) && (isEmojiReq || isAsset(event.request))) { console.debug(`[Service worker] caching ${isEmojiReq ? 'emoji' : 'asset'}:`, event.request.url) const cache = await caches.open(isEmojiReq ? emojiCacheKey : cacheKey) await cache.put(event.request.clone(), response.clone()) } return response } catch (e) { console.error('[Service worker] error when caching emoji:', e) throw e } })()) } })