sforkowany z mirror/soapbox
Merge branch 'sw-typescript' into 'develop'
Convert ServiceWorker to TypeScript See merge request soapbox-pub/soapbox-fe!1456ci-review-rules
commit
45bb991993
|
@ -4,18 +4,70 @@ import { unescape } from 'lodash';
|
||||||
|
|
||||||
import locales from './web_push_locales';
|
import locales from './web_push_locales';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Account as AccountEntity,
|
||||||
|
Notification as NotificationEntity,
|
||||||
|
Status as StatusEntity,
|
||||||
|
} from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
/** Limit before we start grouping device notifications into a single notification. */
|
||||||
const MAX_NOTIFICATIONS = 5;
|
const MAX_NOTIFICATIONS = 5;
|
||||||
|
/** Tag for the grouped notification. */
|
||||||
const GROUP_TAG = 'tag';
|
const GROUP_TAG = 'tag';
|
||||||
|
|
||||||
const notify = options =>
|
// https://www.devextent.com/create-service-worker-typescript/
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
/** Soapbox notification data from push event. */
|
||||||
|
interface NotificationData {
|
||||||
|
access_token?: string,
|
||||||
|
count?: number,
|
||||||
|
hiddenBody?: string,
|
||||||
|
hiddenImage?: string,
|
||||||
|
id?: string,
|
||||||
|
preferred_locale: string,
|
||||||
|
url: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ServiceWorker Notification options with extra fields. */
|
||||||
|
interface ExtendedNotificationOptions extends NotificationOptions {
|
||||||
|
data: NotificationData,
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partial clone of ServiceWorker Notification with mutability. */
|
||||||
|
interface ClonedNotification {
|
||||||
|
actions?: NotificationAction[],
|
||||||
|
body?: string,
|
||||||
|
data: NotificationData,
|
||||||
|
image?: string,
|
||||||
|
tag?: string,
|
||||||
|
title: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status entitiy from the API (kind of). */
|
||||||
|
// HACK
|
||||||
|
interface APIStatus extends Omit<StatusEntity, 'media_attachments'> {
|
||||||
|
media_attachments: { preview_url: string }[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notification entity from the API (kind of). */
|
||||||
|
// HACK
|
||||||
|
interface APINotification extends Omit<NotificationEntity, 'account' | 'status'> {
|
||||||
|
account: AccountEntity,
|
||||||
|
status?: APIStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the actual push notification on the device. */
|
||||||
|
const notify = (options: ExtendedNotificationOptions): Promise<void> =>
|
||||||
self.registration.getNotifications().then(notifications => {
|
self.registration.getNotifications().then(notifications => {
|
||||||
if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping
|
if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping
|
||||||
const group = {
|
const group: ClonedNotification = {
|
||||||
title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }),
|
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'),
|
body: notifications.map(notification => notification.title).join('\n'),
|
||||||
tag: GROUP_TAG,
|
tag: GROUP_TAG,
|
||||||
data: {
|
data: {
|
||||||
url: (new URL('/notifications', self.location)).href,
|
url: (new URL('/notifications', self.location.href)).href,
|
||||||
count: notifications.length + 1,
|
count: notifications.length + 1,
|
||||||
preferred_locale: options.data.preferred_locale,
|
preferred_locale: options.data.preferred_locale,
|
||||||
},
|
},
|
||||||
|
@ -26,10 +78,11 @@ const notify = options =>
|
||||||
return self.registration.showNotification(group.title, group);
|
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
|
} 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]);
|
const group = cloneNotification(notifications[0]);
|
||||||
|
const count = (group.data.count || 0) + 1;
|
||||||
|
|
||||||
group.title = formatMessage('notifications.group', options.data.preferred_locale, { count: group.data.count + 1 });
|
group.title = formatMessage('notifications.group', options.data.preferred_locale, { count });
|
||||||
group.body = `${options.title}\n${group.body}`;
|
group.body = `${options.title}\n${group.body}`;
|
||||||
group.data = { ...group.data, count: group.data.count + 1 };
|
group.data = { ...group.data, count };
|
||||||
|
|
||||||
return self.registration.showNotification(group.title, group);
|
return self.registration.showNotification(group.title, group);
|
||||||
}
|
}
|
||||||
|
@ -37,8 +90,9 @@ const notify = options =>
|
||||||
return self.registration.showNotification(options.title, options);
|
return self.registration.showNotification(options.title, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchFromApi = (path, method, accessToken) => {
|
/** Perform an API request to the backend. */
|
||||||
const url = (new URL(path, self.location)).href;
|
const fetchFromApi = (path: string, method: string, accessToken: string): Promise<APINotification> => {
|
||||||
|
const url = (new URL(path, self.location.href)).href;
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -52,50 +106,54 @@ const fetchFromApi = (path, method, accessToken) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return res;
|
return res;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res.status);
|
throw new Error(String(res.status));
|
||||||
}
|
}
|
||||||
}).then(res => res.json());
|
}).then(res => res.json());
|
||||||
};
|
};
|
||||||
|
|
||||||
const cloneNotification = notification => {
|
/** Create a mutable object that loosely matches the Notification. */
|
||||||
const clone = {};
|
const cloneNotification = (notification: Notification): ClonedNotification => {
|
||||||
let k;
|
const clone: any = {};
|
||||||
|
let k: string;
|
||||||
|
|
||||||
// Object.assign() does not work with notifications
|
// Object.assign() does not work with notifications
|
||||||
for (k in notification) {
|
for (k in notification) {
|
||||||
clone[k] = notification[k];
|
clone[k] = (notification as any)[k];
|
||||||
}
|
}
|
||||||
|
|
||||||
return clone;
|
return clone as ClonedNotification;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMessage = (messageId, locale, values = {}) =>
|
/** Get translated message for the user's locale. */
|
||||||
(new IntlMessageFormat(locales[locale][messageId], locale)).format(values);
|
const formatMessage = (messageId: string, locale: string, values = {}): string =>
|
||||||
|
(new IntlMessageFormat(locales[locale][messageId], locale)).format(values) as string;
|
||||||
|
|
||||||
const htmlToPlainText = html =>
|
/** Strip HTML for display in a native notification. */
|
||||||
|
const htmlToPlainText = (html: string): string =>
|
||||||
unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, ''));
|
unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, ''));
|
||||||
|
|
||||||
const handlePush = (event) => {
|
/** ServiceWorker `push` event callback. */
|
||||||
const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json();
|
const handlePush = (event: PushEvent) => {
|
||||||
|
const { access_token, notification_id, preferred_locale, title, body, icon } = event.data?.json();
|
||||||
|
|
||||||
// Placeholder until more information can be loaded
|
// Placeholder until more information can be loaded
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
|
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
|
||||||
const options = {};
|
const options: ExtendedNotificationOptions = {
|
||||||
|
title: formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }),
|
||||||
|
body: notification.status && htmlToPlainText(notification.status.content),
|
||||||
|
icon: notification.account.avatar_static,
|
||||||
|
timestamp: notification.created_at && Number(new Date(notification.created_at)),
|
||||||
|
tag: notification.id,
|
||||||
|
image: notification.status?.media_attachments[0]?.preview_url,
|
||||||
|
data: { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/@${notification.account.username}/posts/${notification.status.id}` : `/@${notification.account.username}` },
|
||||||
|
};
|
||||||
|
|
||||||
options.title = formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
|
if (notification.status?.spoiler_text || notification.status?.sensitive) {
|
||||||
options.body = notification.status && htmlToPlainText(notification.status.content);
|
options.data.hiddenBody = htmlToPlainText(notification.status?.content);
|
||||||
options.icon = notification.account.avatar_static;
|
options.data.hiddenImage = notification.status?.media_attachments[0]?.preview_url;
|
||||||
options.timestamp = notification.created_at && new Date(notification.created_at);
|
|
||||||
options.tag = notification.id;
|
|
||||||
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, url: notification.status ? `/@${notification.account.username}/posts/${notification.status.id}` : `/@${notification.account.username}` };
|
|
||||||
|
|
||||||
if (notification.status?.spoiler_text || notification.status.sensitive) {
|
if (notification.status?.spoiler_text) {
|
||||||
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.body = notification.status.spoiler_text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,39 +170,44 @@ const handlePush = (event) => {
|
||||||
body,
|
body,
|
||||||
icon,
|
icon,
|
||||||
tag: notification_id,
|
tag: notification_id,
|
||||||
timestamp: new Date(),
|
timestamp: Number(new Date()),
|
||||||
data: { access_token, preferred_locale, url: '/notifications' },
|
data: { access_token, preferred_locale, url: '/notifications' },
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionExpand = preferred_locale => ({
|
/** Native action to open a status on the device. */
|
||||||
|
const actionExpand = (preferred_locale: string) => ({
|
||||||
action: 'expand',
|
action: 'expand',
|
||||||
icon: `/${require('../../images/web-push/web-push-icon_expand.png')}`,
|
icon: `/${require('../../images/web-push/web-push-icon_expand.png')}`,
|
||||||
title: formatMessage('status.show_more', preferred_locale),
|
title: formatMessage('status.show_more', preferred_locale),
|
||||||
});
|
});
|
||||||
|
|
||||||
const actionReblog = preferred_locale => ({
|
/** Native action to repost status. */
|
||||||
|
const actionReblog = (preferred_locale: string) => ({
|
||||||
action: 'reblog',
|
action: 'reblog',
|
||||||
icon: `/${require('../../images/web-push/web-push-icon_reblog.png')}`,
|
icon: `/${require('../../images/web-push/web-push-icon_reblog.png')}`,
|
||||||
title: formatMessage('status.reblog', preferred_locale),
|
title: formatMessage('status.reblog', preferred_locale),
|
||||||
});
|
});
|
||||||
|
|
||||||
const actionFavourite = preferred_locale => ({
|
/** Native action to like status. */
|
||||||
|
const actionFavourite = (preferred_locale: string) => ({
|
||||||
action: 'favourite',
|
action: 'favourite',
|
||||||
icon: `/${require('../../images/web-push/web-push-icon_favourite.png')}`,
|
icon: `/${require('../../images/web-push/web-push-icon_favourite.png')}`,
|
||||||
title: formatMessage('status.favourite', preferred_locale),
|
title: formatMessage('status.favourite', preferred_locale),
|
||||||
});
|
});
|
||||||
|
|
||||||
const findBestClient = clients => {
|
/** Get the active tab if possible, or any open tab. */
|
||||||
|
const findBestClient = (clients: readonly WindowClient[]): WindowClient => {
|
||||||
const focusedClient = clients.find(client => client.focused);
|
const focusedClient = clients.find(client => client.focused);
|
||||||
const visibleClient = clients.find(client => client.visibilityState === 'visible');
|
const visibleClient = clients.find(client => client.visibilityState === 'visible');
|
||||||
|
|
||||||
return focusedClient || visibleClient || clients[0];
|
return focusedClient || visibleClient || clients[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const expandNotification = notification => {
|
/** Update a notification with CW to display the full status. */
|
||||||
|
const expandNotification = (notification: Notification) => {
|
||||||
const newNotification = cloneNotification(notification);
|
const newNotification = cloneNotification(notification);
|
||||||
|
|
||||||
newNotification.body = newNotification.data.hiddenBody;
|
newNotification.body = newNotification.data.hiddenBody;
|
||||||
|
@ -154,25 +217,28 @@ const expandNotification = notification => {
|
||||||
return self.registration.showNotification(newNotification.title, newNotification);
|
return self.registration.showNotification(newNotification.title, newNotification);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeActionFromNotification = (notification, action) => {
|
/** Update the native notification, but delete the action (because it was performed). */
|
||||||
|
const removeActionFromNotification = (notification: Notification, action: string) => {
|
||||||
const newNotification = cloneNotification(notification);
|
const newNotification = cloneNotification(notification);
|
||||||
|
|
||||||
newNotification.actions = newNotification.actions.filter(item => item.action !== action);
|
newNotification.actions = newNotification.actions?.filter(item => item.action !== action);
|
||||||
|
|
||||||
return self.registration.showNotification(newNotification.title, newNotification);
|
return self.registration.showNotification(newNotification.title, newNotification);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUrl = url =>
|
/** Open a URL on the device. */
|
||||||
|
const openUrl = (url: string) =>
|
||||||
self.clients.matchAll({ type: 'window' }).then(clientList => {
|
self.clients.matchAll({ type: 'window' }).then(clientList => {
|
||||||
if (clientList.length === 0) {
|
if (clientList.length === 0) {
|
||||||
return self.clients.openWindow(url);
|
return self.clients.openWindow(url);
|
||||||
} else {
|
} else {
|
||||||
const client = findBestClient(clientList);
|
const client = findBestClient(clientList);
|
||||||
return client.navigate(url).then(client => client.focus());
|
return client.navigate(url).then(client => client?.focus());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleNotificationClick = (event) => {
|
/** Callback when a native notification is clicked/touched on the device. */
|
||||||
|
const handleNotificationClick = (event: NotificationEvent) => {
|
||||||
const reactToNotificationClick = new Promise((resolve, reject) => {
|
const reactToNotificationClick = new Promise((resolve, reject) => {
|
||||||
if (event.action) {
|
if (event.action) {
|
||||||
if (event.action === 'expand') {
|
if (event.action === 'expand') {
|
||||||
|
@ -195,5 +261,6 @@ const handleNotificationClick = (event) => {
|
||||||
event.waitUntil(reactToNotificationClick);
|
event.waitUntil(reactToNotificationClick);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ServiceWorker event listeners
|
||||||
self.addEventListener('push', handlePush);
|
self.addEventListener('push', handlePush);
|
||||||
self.addEventListener('notificationclick', handleNotificationClick);
|
self.addEventListener('notificationclick', handleNotificationClick);
|
|
@ -22,7 +22,7 @@ module.exports = {
|
||||||
'app/soapbox/**/*.tsx',
|
'app/soapbox/**/*.tsx',
|
||||||
'!app/soapbox/features/emoji/emoji_compressed.js',
|
'!app/soapbox/features/emoji/emoji_compressed.js',
|
||||||
'!app/soapbox/locales/locale-data/*.js',
|
'!app/soapbox/locales/locale-data/*.js',
|
||||||
'!app/soapbox/service_worker/entry.js',
|
'!app/soapbox/service_worker/entry.ts',
|
||||||
'!app/soapbox/jest/test-setup.ts',
|
'!app/soapbox/jest/test-setup.ts',
|
||||||
'!app/soapbox/jest/test-helpers.ts',
|
'!app/soapbox/jest/test-helpers.ts',
|
||||||
],
|
],
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "es2022",
|
"module": "es2022",
|
||||||
|
"lib": ["es2019", "es6", "dom", "webworker"],
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
|
@ -84,7 +84,7 @@ module.exports = merge(sharedConfig, {
|
||||||
],
|
],
|
||||||
ServiceWorker: {
|
ServiceWorker: {
|
||||||
cacheName: 'soapbox',
|
cacheName: 'soapbox',
|
||||||
entry: join(__dirname, '../app/soapbox/service_worker/entry.js'),
|
entry: join(__dirname, '../app/soapbox/service_worker/entry.ts'),
|
||||||
minify: true,
|
minify: true,
|
||||||
},
|
},
|
||||||
cacheMaps: [{
|
cacheMaps: [{
|
||||||
|
|
Ładowanie…
Reference in New Issue