Merge commit '52aa8a44bbd0c9a6673cbd9b7c088020be938799' into glitch-soc/merge-upstream

This commit is contained in:
Claire committed 2026-06-03 22:30:16 +02:00
commit a1ab96baed
25 files changed
+843 -1143

No files matched your search

@@ -109,8 +109,8 @@ const hotkeyMatcherMap = {
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: just('j'),
moveUp: just('k'),
moveDown: any('j', 'pagedown'),
moveUp: any('k', 'pageup'),
moveToTop: just('0'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
@@ -147,9 +147,15 @@ const hotkeyMatcherMap = {
type HotkeyName = keyof typeof hotkeyMatcherMap;
export type HandlerMap = Partial<
Record<HotkeyName, (event: KeyboardEvent) => void>
>;
type HandlerFunction =
// When a handler returns a boolean, it should indicate whether the
// hotkey was handled (i.e. it resulted in an action).
// If `false` is returned, `preventDefault` and `stopPropagation`
// will not be called on the keyboard event, restoring the key's
// native behaviour.
((event: KeyboardEvent) => boolean) | ((event: KeyboardEvent) => void);
export type HandlerMap = Partial<Record<HotkeyName, HandlerFunction>>;
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const ref = useRef<T>(null);
@@ -184,7 +190,7 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const matchCandidates: {
// A candidate will be have an undefined handler if it's matched,
// but handled in a parent component rather than this one.
handler: ((event: KeyboardEvent) => void) | undefined;
handler: HandlerFunction | undefined;
priority: number;
}[] = [];
@@ -209,9 +215,11 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const bestMatchingHandler = matchCandidates.at(0)?.handler;
if (bestMatchingHandler) {
bestMatchingHandler(event);
event.stopPropagation();
event.preventDefault();
const wasHandled = bestMatchingHandler(event);
if (wasHandled !== false) {
event.stopPropagation();
event.preventDefault();
}
}
// Add last keypress to buffer
@@ -6,6 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import type { ApiRuleJSON } from '@/mastodon/api_types/instance';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { Select } from '@/mastodon/components/form_fields';
import type { RootState } from '@/mastodon/store';
@@ -104,13 +105,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
defaultMessage='Language'
/>
</label>
<Select onChange={handleLocaleChange} id='language-select'>
<Select
onChange={handleLocaleChange}
id='language-select'
value={selectedLocale}
>
{localeOptions.map((option) => (
<option
key={option.value}
value={option.value}
selected={option.value === selectedLocale}
>
<option key={option.value} value={option.value}>
{option.text}
</option>
))}
@@ -121,15 +122,10 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
);
};
const selectRules = (state: RootState) => {
const rules = state.server.server.item?.rules;
if (!rules) {
return [];
}
return rules;
};
const selectRules = createSelector(
[(state: RootState) => state.server.server.item],
(item) => item?.rules ?? [],
);
const rulesSelector = createSelector(
[selectRules, (_state, locale: string) => locale],
@@ -142,18 +138,19 @@ const rulesSelector = createSelector(
return rule;
}
const translatedRule: ApiRuleJSON = { ...rule };
const partialLocale = locale.split('-')[0];
if (partialLocale && translations[partialLocale]) {
rule.text = translations[partialLocale].text;
rule.hint = translations[partialLocale].hint;
translatedRule.text = translations[partialLocale].text;
translatedRule.hint = translations[partialLocale].hint;
}
if (translations[locale]) {
rule.text = translations[locale].text;
rule.hint = translations[locale].hint;
translatedRule.text = translations[locale].text;
translatedRule.hint = translations[locale].hint;
}
return rule;
return translatedRule;
});
},
);
@@ -68,7 +68,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
</tr>
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.enter' defaultMessage='Enter' tagName='kbd' />, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
</tr>
<tr>
@@ -88,11 +88,11 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>k</kbd></td>
<td><kbd>k</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_up' defaultMessage='Page Up' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr>
<tr>
<td><kbd>j</kbd></td>
<td><kbd>j</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_down' defaultMessage='Page Down' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr>
<tr>
@@ -112,15 +112,15 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.backspace' defaultMessage='Backspace' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
</tr>
<tr>
@@ -128,7 +128,7 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
</tr>
<tr>
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.keys.esc' defaultMessage='Esc' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
</tr>
<tr>
@@ -508,7 +508,8 @@ class UI extends PureComponent {
if (currentItemIndex === -1) {
focusColumn(1);
} else {
focusItemSibling(currentItemIndex, -1);
const wasHandled = focusItemSibling(currentItemIndex, -1);
return wasHandled;
}
};
@@ -517,7 +518,8 @@ class UI extends PureComponent {
if (currentItemIndex === -1) {
focusColumn(1);
} else {
focusItemSibling(currentItemIndex, 1);
const wasHandled = focusItemSibling(currentItemIndex, 1);
return wasHandled;
}
};
@@ -159,13 +159,12 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
);
if (!siblingItem) {
return;
return false;
}
// If sibling element is empty, we skip it
if (siblingItem.matches(':empty')) {
focusItemSibling(index + direction, direction);
return;
return focusItemSibling(index + direction, direction);
}
// Check if the sibling is a post or a 'follow suggestions' widget
@@ -184,5 +183,8 @@ export function focusItemSibling(index: number, direction: 1 | -1) {
});
targetElement.focus();
return true;
} else {
return false;
}
}
+6
View File
@@ -819,6 +819,12 @@
"keyboard_shortcuts.heading": "Keyboard shortcuts",
"keyboard_shortcuts.home": "Open home timeline",
"keyboard_shortcuts.hotkey": "Hotkey",
"keyboard_shortcuts.keys.alt": "Alt",
"keyboard_shortcuts.keys.backspace": "Backspace",
"keyboard_shortcuts.keys.enter": "Enter",
"keyboard_shortcuts.keys.esc": "Esc",
"keyboard_shortcuts.keys.page_down": "Page Down",
"keyboard_shortcuts.keys.page_up": "Page Up",
"keyboard_shortcuts.legend": "Display this legend",
"keyboard_shortcuts.load_more": "Focus \"Load more\" button",
"keyboard_shortcuts.local": "Open local timeline",
+22 -21
View File
@@ -10,7 +10,7 @@ import { me, reduceMotion } from 'mastodon/initial_state';
import ready from 'mastodon/ready';
import { store } from 'mastodon/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('@/mastodon/service_worker/sw?url');
swPath = swDevUrl;
}
if (
registration &&
'Notification' in window &&
Notification.permission === 'granted'
) {
const registerPushNotifications =
await import('mastodon/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('mastodon/actions/push_notifications');
store.dispatch(registerPushNotifications.register());
}
}
}
@@ -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,
};
}
@@ -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;
}
@@ -1,88 +0,0 @@
import { ExpirationPlugin } from 'workbox-expiration';
import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { handleNotificationClick, handlePush } from './web_push_notifications';
const CACHE_NAME_PREFIX = 'mastodon-';
function openWebCache() {
return caches.open(`${CACHE_NAME_PREFIX}web`);
}
function fetchRoot() {
return fetch('/', { credentials: 'include', redirect: 'manual' });
}
registerRoute(
/intl\/.*\.js$/,
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}locales`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month
maxEntries: 5,
}),
],
}),
);
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: `${CACHE_NAME_PREFIX}fonts`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * 24 * 60 * 60, // 1 month
maxEntries: 5,
}),
],
}),
);
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: `m${CACHE_NAME_PREFIX}media`,
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
maxEntries: 256,
}),
],
}),
);
// 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', function(event) {
event.waitUntil(Promise.all([openWebCache(), fetchRoot()]).then(([cache, root]) => cache.put('/', root)));
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function(event) {
const url = new URL(event.request.url);
if (url.pathname === '/auth/sign_out') {
const asyncResponse = fetch(event.request);
const asyncCache = openWebCache();
event.respondWith(asyncResponse.then(response => {
if (response.ok || response.type === 'opaqueredirect') {
return Promise.all([
asyncCache.then(cache => cache.delete('/')),
indexedDB.deleteDatabase('mastodon'),
]).then(() => response);
}
return response;
}));
}
});
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);
@@ -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);
@@ -614,6 +614,7 @@ body > [data-popper-placement] {
display: block;
box-sizing: border-box;
width: 100%;
max-height: 300px;
margin: 0;
color: var(--color-text-primary);
background: transparent;