From e9f653788b1221c83fb6e0c7dc18aba7ad76999d Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 3 Jun 2026 15:17:21 +0200 Subject: [PATCH] [Glitch] Remove PWA plugin Port 0172d819f7882db3dd4f1350c01e5d728924bc86 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/main.tsx | 43 +- .../glitch/service_worker/caching.test.ts | 390 ++++++++++++++++++ .../flavours/glitch/service_worker/caching.ts | 143 +++++++ .../flavours/glitch/service_worker/sw.ts | 22 + .../service_worker/web_push_notifications.js | 208 ++++++++++ 5 files changed, 785 insertions(+), 21 deletions(-) create mode 100644 app/javascript/flavours/glitch/service_worker/caching.test.ts create mode 100644 app/javascript/flavours/glitch/service_worker/caching.ts create mode 100644 app/javascript/flavours/glitch/service_worker/sw.ts create mode 100644 app/javascript/flavours/glitch/service_worker/web_push_notifications.js diff --git a/app/javascript/flavours/glitch/main.tsx b/app/javascript/flavours/glitch/main.tsx index 395e80d0ba..b4ce80eade 100644 --- a/app/javascript/flavours/glitch/main.tsx +++ b/app/javascript/flavours/glitch/main.tsx @@ -10,7 +10,7 @@ import { me, reduceMotion } from 'flavours/glitch/initial_state'; import ready from 'flavours/glitch/ready'; import { store } from 'flavours/glitch/store'; -import { isProduction, isDevelopment } from './utils/environment'; +import { isDevelopment, isProduction } from './utils/environment'; function main() { perf.start('main()'); @@ -41,29 +41,30 @@ function main() { ); store.dispatch(setupBrowserNotifications()); - if (isProduction() && me && 'serviceWorker' in navigator) { - const { Workbox } = await import('workbox-window'); - const wb = new Workbox( - isDevelopment() ? '/packs-dev/dev-sw.js?dev-sw' : '/sw.js', - { type: 'module', scope: '/' }, - ); - let registration; - - try { - registration = await wb.register(); - } catch (err) { - console.error(err); + if ( + me && + 'serviceWorker' in navigator && + (isDevelopment() || isProduction()) // Disallow testing environment + ) { + let swPath = '/sw.js'; + if (isDevelopment()) { + const { default: swDevUrl } = + await import('@/flavours/glitch/service_worker/sw?url'); + swPath = swDevUrl; } - if ( - registration && - 'Notification' in window && - Notification.permission === 'granted' - ) { - const registerPushNotifications = - await import('flavours/glitch/actions/push_notifications'); + await navigator.serviceWorker.register(swPath, { + scope: '/', + type: 'module', + }); - store.dispatch(registerPushNotifications.register()); + if (isProduction()) { + if ('Notification' in window && Notification.permission === 'granted') { + const registerPushNotifications = + await import('flavours/glitch/actions/push_notifications'); + + store.dispatch(registerPushNotifications.register()); + } } } diff --git a/app/javascript/flavours/glitch/service_worker/caching.test.ts b/app/javascript/flavours/glitch/service_worker/caching.test.ts new file mode 100644 index 0000000000..3d67604c35 --- /dev/null +++ b/app/javascript/flavours/glitch/service_worker/caching.test.ts @@ -0,0 +1,390 @@ +import { DAY } from '../utils/time'; + +import { expireCachedItems, handleFetch } from './caching'; + +const now = 1_700_000_000_000; + +describe('expireCachedItems', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + test('deletes expired entries and requests without a cached response', async () => { + const cache = new MockCache(); + const missingRequest = new Request('https://example.com/missing'); + + cache.set('/fresh', now - DAY); + cache.set('/expired', now - DAY * 31); + cache.store.set(missingRequest.url, { request: missingRequest }); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(cache), + }); + + await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 5 }); + + expect(Array.from(cache.store.keys())).toEqual([ + 'https://example.com/fresh', + ]); + }); + + test('trims the oldest valid entries when the cache exceeds max size', async () => { + const cache = new MockCache(); + + cache.set('/oldest', now - DAY * 3); + cache.set('/older', now - DAY * 2); + cache.set('/newest', now - DAY); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(cache), + }); + + await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 2 }); + + expect(Array.from(cache.store.keys())).toEqual([ + 'https://example.com/older', + 'https://example.com/newest', + ]); + }); + + test('keeps entries without a timestamp header over timestamped entries', async () => { + const cache = new MockCache(); + const untimestampedRequest = new Request('https://example.com/no-header'); + + cache.store.set(untimestampedRequest.url, { + request: untimestampedRequest, + response: createResponse(), + }); + cache.set('/older', now - DAY * 2); + cache.set('/newer', now - DAY); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(cache), + }); + + await expireCachedItems({ name: 'images', ttl: DAY * 30, max: 2 }); + + expect(Array.from(cache.store.keys())).toEqual([ + 'https://example.com/no-header', + 'https://example.com/newer', + ]); + }); +}); + +describe('handleFetch', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + test('serves cached images without hitting the network while the TTL is valid', async () => { + const imageCache = new MockCache(); + const request = createRequest('/test.png', 'image'); + const cachedResponse = createResponse(now - DAY); + + imageCache.store.set(request.url, { request, response: cachedResponse }); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(imageCache), + }); + + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent(request); + + handleFetch(event); + + await expect(respondWith()).resolves.toBe(cachedResponse); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('fetches stale cached images from the network and stores a refreshed response', async () => { + const imageCache = new MockCache(); + const request = createRequest('/stale.png', 'image'); + const networkResponse = new Response('fresh', { + headers: { 'content-type': 'image/png' }, + status: 200, + statusText: 'OK', + }); + const putSpy = vi.spyOn(imageCache, 'put'); + + imageCache.store.set(request.url, { + request, + response: createResponse(now - DAY * 8), + }); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(imageCache), + }); + + const fetch = vi.fn().mockResolvedValue(networkResponse); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent(request); + + handleFetch(event); + + await expect(respondWith()).resolves.toBe(networkResponse); + expect(fetch).toHaveBeenCalledWith(request); + expect(putSpy).toHaveBeenCalledWith(request, expect.any(Response)); + expect( + imageCache.store.get(request.url)?.response?.headers.get('x-timestamp'), + ).toBe(now.toString()); + }); + + test('does not cache opaque image responses with status zero', async () => { + const imageCache = new MockCache(); + const request = createRequest('/opaque.png', 'image'); + const opaqueResponse = Response.error(); + const putSpy = vi.spyOn(imageCache, 'put'); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(imageCache), + }); + + const fetch = vi.fn().mockResolvedValue(opaqueResponse); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent(request); + + handleFetch(event); + + await expect(respondWith()).resolves.toBe(opaqueResponse); + expect(fetch).toHaveBeenCalledWith(request); + expect(putSpy).not.toHaveBeenCalled(); + expect(imageCache.store.size).toBe(0); + }); + + test.each([ + ['/intl/en.js', '', 'mastodon-locales'], + ['/fonts/mastodon.woff2', 'font', 'mastodon-fonts'], + ])( + 'routes %s requests through %s', + async (pathname, destination, cacheName) => { + const cache = new MockCache(); + const request = createRequest(pathname, destination); + const networkResponse = new Response('asset', { status: 200 }); + const open = vi.fn().mockImplementation((name: string) => { + expect(name).toBe(cacheName); + return Promise.resolve(cache); + }); + + vi.stubGlobal('caches', { open }); + + const fetch = vi.fn().mockResolvedValue(networkResponse); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent(request); + + handleFetch(event); + + await expect(respondWith()).resolves.toBe(networkResponse); + expect(fetch).toHaveBeenCalledWith(request); + expect(open).toHaveBeenCalledWith(cacheName); + }, + ); + + test('clears the root cache after a successful logout request', async () => { + const webCache = new MockCache(); + const deleteSpy = vi.spyOn(webCache, 'delete'); + + vi.stubGlobal('caches', { + open: vi.fn().mockImplementation((name: string) => { + expect(name).toBe('mastodon-web'); + return Promise.resolve(webCache); + }), + }); + + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent( + createRequest('/auth/sign_out'), + ); + + handleFetch(event); + + await expect(respondWith()).resolves.toBeInstanceOf(Response); + expect(deleteSpy).toHaveBeenCalledWith('/'); + }); + + test('does not clear the root cache after a failed logout request', async () => { + const webCache = new MockCache(); + const deleteSpy = vi.spyOn(webCache, 'delete'); + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(webCache), + }); + + const fetch = vi + .fn() + .mockResolvedValue( + new Response(null, { status: 500, statusText: 'Error' }), + ); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent( + createRequest('/auth/sign_out'), + ); + + handleFetch(event); + + await expect(respondWith()).resolves.toBeInstanceOf(Response); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + test('clears the root cache for opaqueredirect logout responses', async () => { + const webCache = new MockCache(); + const deleteSpy = vi.spyOn(webCache, 'delete'); + const opaqueRedirectResponse = { + ok: false, + type: 'opaqueredirect', + } as Response; + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(webCache), + }); + + const fetch = vi.fn().mockResolvedValue(opaqueRedirectResponse); + vi.stubGlobal('fetch', fetch); + + const { event, respondWith } = createFetchEvent( + createRequest('/auth/sign_out'), + ); + + handleFetch(event); + + await expect(respondWith()).resolves.toBe(opaqueRedirectResponse); + expect(deleteSpy).toHaveBeenCalledWith('/'); + }); + + test('ignores requests that are not handled by the service worker cache', () => { + const { event, respondWith, respondWithMock } = createFetchEvent( + createRequest('/api/v1/timelines/home'), + ); + + handleFetch(event); + + expect(respondWithMock).not.toHaveBeenCalled(); + expect(respondWith()).toBeUndefined(); + }); +}); + +interface CacheEntry { + request: Request; + response?: Response; +} + +class MockCache implements Cache { + public readonly store = new Map(); + + private normalizeRequest(request: RequestInfo | URL) { + if (request instanceof Request) { + return request.url; + } + + return request.toString(); + } + + add(): Promise { + return Promise.reject(new Error('Not implemented')); + } + + addAll(): Promise { + return Promise.reject(new Error('Not implemented')); + } + + delete(request: RequestInfo | URL): Promise { + return Promise.resolve(this.store.delete(this.normalizeRequest(request))); + } + + keys(): Promise { + return Promise.resolve( + Array.from(this.store.values(), ({ request }) => request), + ); + } + + match(request: RequestInfo | URL): Promise { + return Promise.resolve( + this.store.get(this.normalizeRequest(request))?.response, + ); + } + + matchAll(): Promise { + return Promise.reject(new Error('Not implemented')); + } + + put(request: RequestInfo | URL, response: Response): Promise { + const normalizedRequest = + request instanceof Request ? request : new Request(request); + + this.store.set(this.normalizeRequest(normalizedRequest), { + request: normalizedRequest, + response, + }); + + return Promise.resolve(); + } + + set(pathname: string, timestamp?: number) { + const request = new Request(`https://example.com${pathname}`); + + this.store.set(request.url, { + request, + response: createResponse(timestamp), + }); + + return request; + } +} + +function createResponse(timestamp?: number) { + const headers = new Headers(); + + if (timestamp !== undefined) { + headers.set('x-timestamp', timestamp.toString()); + } + + return new Response('body', { headers }); +} + +function createRequest(pathname: string, destination = '') { + const request = new Request(`https://example.com${pathname}`); + + Object.defineProperty(request, 'destination', { + value: destination, + configurable: true, + }); + + return request; +} + +function createFetchEvent(request: Request) { + let responsePromise: Promise | undefined; + const respondWith = vi.fn((response: Response | Promise) => { + responsePromise = Promise.resolve(response); + }); + + return { + event: { + request, + respondWith, + } as unknown as FetchEvent, + respondWith: () => responsePromise, + respondWithMock: respondWith, + }; +} diff --git a/app/javascript/flavours/glitch/service_worker/caching.ts b/app/javascript/flavours/glitch/service_worker/caching.ts new file mode 100644 index 0000000000..96c2898dd7 --- /dev/null +++ b/app/javascript/flavours/glitch/service_worker/caching.ts @@ -0,0 +1,143 @@ +/// +/// + +import { DAY } from '../utils/time'; + +const CACHE_NAME_PREFIX = 'mastodon-'; +const CACHE_HEADER_TTL = 'x-timestamp'; + +export async function cacheRoot() { + const cache = await openWebCache(); + const response = await fetch('/', { + credentials: 'include', + redirect: 'manual', + }); + await cache.put('/', response); +} + +export function handleFetch(event: FetchEvent) { + const url = new URL(event.request.url); + + if (url.pathname === '/auth/sign_out') { + event.respondWith(handleLogout(event)); + } else if (/intl\/.*\.js$/.test(url.pathname)) { + event.respondWith(cacheFirst({ event, name: 'locales' })); + } else if (event.request.destination === 'font') { + event.respondWith(cacheFirst({ event, name: 'fonts' })); + } else if (event.request.destination === 'image') { + event.respondWith(cacheFirst({ event, name: 'images', ttl: DAY * 7 })); + } +} + +async function cacheFirst({ + event, + name, + ttl = DAY * 30, + max = 5, +}: { + event: FetchEvent; + name: string; + ttl?: number; + max?: number; +}) { + const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`); + const request = event.request; + const cachedResponse = await cache.match(request); + + // Start expiring cache items while the process continues. + void expireCachedItems({ name, ttl, max }); + + if (cachedResponse) { + // If we have a cached response, check the TTL header. + const ttlHeader = Number.parseInt( + cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0', + ); + + if (!ttlHeader || ttlHeader + ttl > Date.now()) { + return cachedResponse; + } + } + + const networkResponse = await fetch(request); + + // For opaque responses, the status will be zero so we can't clone them. + if (networkResponse.status !== 0) { + // Clone request with a custom header to store timestamp. + const cloneHeaders = new Headers(networkResponse.headers); + cloneHeaders.set(CACHE_HEADER_TTL, Date.now().toString()); + + const cloneResponse = new Response(networkResponse.clone().body, { + headers: cloneHeaders, + status: networkResponse.status, + statusText: networkResponse.statusText, + }); + + await cache.put(request, cloneResponse); + } + + return networkResponse; +} + +export async function expireCachedItems({ + name, + ttl = DAY * 30, + max = 5, +}: { + name: string; + ttl?: number; + max?: number; +}) { + const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`); + + const keys = await cache.keys(); + const now = Date.now(); + const validKeys: { key: Request; timestamp: number }[] = []; + + for (const key of keys) { + const cachedResponse = await cache.match(key); + + if (!cachedResponse) { + await cache.delete(key); + continue; + } + + const timestamp = Number.parseInt( + cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0', + ); + + if (!timestamp || timestamp + ttl > now) { + validKeys.push({ key, timestamp: timestamp || Number.POSITIVE_INFINITY }); + continue; + } + + await cache.delete(key); + } + + if (validKeys.length <= max) { + return; + } + + const sortedValidKeys = validKeys.toSorted( + ({ timestamp: a }, { timestamp: b }) => a - b, + ); + await Promise.all( + sortedValidKeys + .slice(0, sortedValidKeys.length - max) + .map(({ key }) => cache.delete(key)), + ); +} + +function openWebCache() { + return caches.open(`${CACHE_NAME_PREFIX}web`); +} + +async function handleLogout(event: FetchEvent) { + const response = await fetch(event.request); + + if (response.ok || response.type === 'opaqueredirect') { + const cache = await openWebCache(); + await cache.delete('/'); + } + + return response; +} diff --git a/app/javascript/flavours/glitch/service_worker/sw.ts b/app/javascript/flavours/glitch/service_worker/sw.ts new file mode 100644 index 0000000000..d86bb6d585 --- /dev/null +++ b/app/javascript/flavours/glitch/service_worker/sw.ts @@ -0,0 +1,22 @@ +/// +/// + +import { cacheRoot, handleFetch } from './caching'; +import { handleNotificationClick, handlePush } from './web_push_notifications'; + +declare const self: ServiceWorkerGlobalScope; + +// Cause a new version of a registered Service Worker to replace an existing one +// that is already installed, and replace the currently active worker on open pages. +self.addEventListener('install', (event) => { + event.waitUntil(cacheRoot()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', handleFetch); + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/javascript/flavours/glitch/service_worker/web_push_notifications.js b/app/javascript/flavours/glitch/service_worker/web_push_notifications.js new file mode 100644 index 0000000000..88db63280e --- /dev/null +++ b/app/javascript/flavours/glitch/service_worker/web_push_notifications.js @@ -0,0 +1,208 @@ +import { IntlMessageFormat } from 'intl-messageformat'; + +import { unescape } from 'lodash'; +// see config/vite/plugins/sw-locales +// it needs to be updated when new locale keys are used in this file +import locales from "virtual:mastodon-sw-locales"; + +const MAX_NOTIFICATIONS = 5; +const GROUP_TAG = 'tag'; + +const notify = options => + self.registration.getNotifications().then(notifications => { + if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping + const group = { + title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }), + body: notifications.sort((n1, n2) => n1.timestamp < n2.timestamp).map(notification => notification.title).join('\n'), + badge: '/badge.png', + icon: '/android-chrome-192x192.png', + tag: GROUP_TAG, + data: { + url: (new URL('/notifications', self.location)).href, + count: notifications.length + 1, + preferred_locale: options.data.preferred_locale, + }, + }; + + notifications.forEach(notification => notification.close()); + + return self.registration.showNotification(group.title, group); + } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) { // Already grouped, proceed with appending the notification to the group + const group = cloneNotification(notifications[0]); + + group.title = formatMessage('notifications.group', options.data.preferred_locale, { count: group.data.count + 1 }); + group.body = `${options.title}\n${group.body}`; + group.data = { ...group.data, count: group.data.count + 1 }; + + return self.registration.showNotification(group.title, group); + } + + return self.registration.showNotification(options.title, options); + }); + +const fetchFromApi = (path, method, accessToken) => { + const url = (new URL(path, self.location)).href; + + return fetch(url, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + + method: method, + credentials: 'include', + }).then(res => { + if (res.ok) { + return res; + } else { + throw new Error(res.status); + } + }).then(res => res.json()); +}; + +const cloneNotification = notification => { + const clone = {}; + let k; + + // Object.assign() does not work with notifications + for(k in notification) { + clone[k] = notification[k]; + } + + return clone; +}; + +const formatMessage = (messageId, locale, values = {}) => + (new IntlMessageFormat(locales[locale][messageId], locale)).format(values); + +const htmlToPlainText = html => + unescape(html.replace(//g, '\n').replace(/<\/p>

/g, '\n\n').replace(/<[^>]*>/g, '')); + +export const handlePush = (event) => { + const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json(); + + // Placeholder until more information can be loaded + event.waitUntil( + fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => { + const options = {}; + + options.title = formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + options.body = notification.status && htmlToPlainText(notification.status.content); + options.icon = notification.account.avatar_static; + options.timestamp = notification.created_at && new Date(notification.created_at); + options.tag = notification.id; + options.badge = '/badge.png'; + options.image = notification.status && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url || undefined; + options.data = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id }; + + if (notification.status) { + options.data.url = `/@${notification.status.account.acct}/${notification.status.id}`; + } else { + options.data.url = `/@${notification.account.acct}`; + } + + if (notification.status && notification.status.spoiler_text || notification.status.sensitive) { + options.data.hiddenBody = htmlToPlainText(notification.status.content); + options.data.hiddenImage = notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url; + + if (notification.status.spoiler_text) { + options.body = notification.status.spoiler_text; + } + + options.image = undefined; + options.actions = [actionExpand(preferred_locale)]; + } else if (['mention', 'status'].includes(notification.type)) { + options.actions = [actionReblog(preferred_locale), actionFavourite(preferred_locale)]; + } + + return notify(options); + }).catch(() => { + return notify({ + title, + body, + icon, + tag: notification_id, + timestamp: new Date(), + badge: '/badge.png', + data: { access_token, preferred_locale, url: '/notifications' }, + }); + }), + ); +}; + +const actionExpand = preferred_locale => ({ + action: 'expand', + icon: '/web-push-icon_expand.png', + title: formatMessage('status.show_more', preferred_locale), +}); + +const actionReblog = preferred_locale => ({ + action: 'reblog', + icon: '/web-push-icon_reblog.png', + title: formatMessage('status.reblog', preferred_locale), +}); + +const actionFavourite = preferred_locale => ({ + action: 'favourite', + icon: '/web-push-icon_favourite.png', + title: formatMessage('status.favourite', preferred_locale), +}); + +const findBestClient = clients => { + const focusedClient = clients.find(client => client.focused); + const visibleClient = clients.find(client => client.visibilityState === 'visible'); + + return focusedClient || visibleClient || clients[0]; +}; + +const expandNotification = notification => { + const newNotification = cloneNotification(notification); + + newNotification.body = newNotification.data.hiddenBody; + newNotification.image = newNotification.data.hiddenImage; + newNotification.actions = [actionReblog(notification.data.preferred_locale), actionFavourite(notification.data.preferred_locale)]; + + return self.registration.showNotification(newNotification.title, newNotification); +}; + +const removeActionFromNotification = (notification, action) => { + const newNotification = cloneNotification(notification); + + newNotification.actions = newNotification.actions.filter(item => item.action !== action); + + return self.registration.showNotification(newNotification.title, newNotification); +}; + +const openUrl = url => + self.clients.matchAll({ type: 'window' }).then(clientList => { + if (clientList.length !== 0 && 'navigate' in clientList[0]) { // Chrome 42-48 does not support navigate + const client = findBestClient(clientList); + + return client.navigate(url).then(client => client.focus()); + } + + return self.clients.openWindow(url); + }); + +export const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + if (event.action === 'expand') { + resolve(expandNotification(event.notification)); + } else if (event.action === 'reblog') { + const { data } = event.notification; + resolve(fetchFromApi(`/api/v1/statuses/${data.id}/reblog`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'reblog'))); + } else if (event.action === 'favourite') { + const { data } = event.notification; + resolve(fetchFromApi(`/api/v1/statuses/${data.id}/favourite`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'favourite'))); + } else { + reject(`Unknown action: ${event.action}`); + } + } else { + event.notification.close(); + resolve(openUrl(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +};