[Glitch] Remove PWA plugin
Port 0172d819f7882db3dd4f1350c01e5d728924bc86 to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
a1ab96baed
commit
e9f653788b
@ -10,7 +10,7 @@ import { me, reduceMotion } from 'flavours/glitch/initial_state';
|
|||||||
import ready from 'flavours/glitch/ready';
|
import ready from 'flavours/glitch/ready';
|
||||||
import { store } from 'flavours/glitch/store';
|
import { store } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { isProduction, isDevelopment } from './utils/environment';
|
import { isDevelopment, isProduction } from './utils/environment';
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
perf.start('main()');
|
perf.start('main()');
|
||||||
@ -41,29 +41,30 @@ function main() {
|
|||||||
);
|
);
|
||||||
store.dispatch(setupBrowserNotifications());
|
store.dispatch(setupBrowserNotifications());
|
||||||
|
|
||||||
if (isProduction() && me && 'serviceWorker' in navigator) {
|
if (
|
||||||
const { Workbox } = await import('workbox-window');
|
me &&
|
||||||
const wb = new Workbox(
|
'serviceWorker' in navigator &&
|
||||||
isDevelopment() ? '/packs-dev/dev-sw.js?dev-sw' : '/sw.js',
|
(isDevelopment() || isProduction()) // Disallow testing environment
|
||||||
{ type: 'module', scope: '/' },
|
) {
|
||||||
);
|
let swPath = '/sw.js';
|
||||||
let registration;
|
if (isDevelopment()) {
|
||||||
|
const { default: swDevUrl } =
|
||||||
try {
|
await import('@/flavours/glitch/service_worker/sw?url');
|
||||||
registration = await wb.register();
|
swPath = swDevUrl;
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
await navigator.serviceWorker.register(swPath, {
|
||||||
registration &&
|
scope: '/',
|
||||||
'Notification' in window &&
|
type: 'module',
|
||||||
Notification.permission === 'granted'
|
});
|
||||||
) {
|
|
||||||
const registerPushNotifications =
|
|
||||||
await import('flavours/glitch/actions/push_notifications');
|
|
||||||
|
|
||||||
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
390
app/javascript/flavours/glitch/service_worker/caching.test.ts
Normal file
390
app/javascript/flavours/glitch/service_worker/caching.test.ts
Normal file
@ -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<string, CacheEntry>();
|
||||||
|
|
||||||
|
private normalizeRequest(request: RequestInfo | URL) {
|
||||||
|
if (request instanceof Request) {
|
||||||
|
return request.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(): Promise<void> {
|
||||||
|
return Promise.reject(new Error('Not implemented'));
|
||||||
|
}
|
||||||
|
|
||||||
|
addAll(): Promise<void> {
|
||||||
|
return Promise.reject(new Error('Not implemented'));
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(request: RequestInfo | URL): Promise<boolean> {
|
||||||
|
return Promise.resolve(this.store.delete(this.normalizeRequest(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
keys(): Promise<readonly Request[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
Array.from(this.store.values(), ({ request }) => request),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||||
|
return Promise.resolve(
|
||||||
|
this.store.get(this.normalizeRequest(request))?.response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchAll(): Promise<readonly Response[]> {
|
||||||
|
return Promise.reject(new Error('Not implemented'));
|
||||||
|
}
|
||||||
|
|
||||||
|
put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||||
|
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<Response> | undefined;
|
||||||
|
const respondWith = vi.fn((response: Response | Promise<Response>) => {
|
||||||
|
responsePromise = Promise.resolve(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: {
|
||||||
|
request,
|
||||||
|
respondWith,
|
||||||
|
} as unknown as FetchEvent,
|
||||||
|
respondWith: () => responsePromise,
|
||||||
|
respondWithMock: respondWith,
|
||||||
|
};
|
||||||
|
}
|
||||||
143
app/javascript/flavours/glitch/service_worker/caching.ts
Normal file
143
app/javascript/flavours/glitch/service_worker/caching.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/// <reference lib="WebWorker" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
22
app/javascript/flavours/glitch/service_worker/sw.ts
Normal file
22
app/javascript/flavours/glitch/service_worker/sw.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/// <reference lib="WebWorker" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
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);
|
||||||
@ -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(/<br\s*\/?>/g, '\n').replace(/<\/p><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);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user