bigbuffet-rw/app/soapbox/service-worker/web-push-notifications.ts

270 lines
10 KiB
TypeScript
Raw Normal View History

2020-03-27 13:59:38 -07:00
import IntlMessageFormat from 'intl-messageformat';
import 'intl-pluralrules';
import unescape from 'lodash/unescape';
import locales from './web-push-locales';
2020-03-27 13:59:38 -07:00
2022-05-26 11:51:59 -07:00
import type {
Account as AccountEntity,
Notification as NotificationEntity,
Status as StatusEntity,
} from 'soapbox/types/entities';
2022-05-26 12:16:03 -07:00
/** Limit before we start grouping device notifications into a single notification. */
2020-03-27 13:59:38 -07:00
const MAX_NOTIFICATIONS = 5;
2022-05-26 12:16:03 -07:00
/** Tag for the grouped notification. */
2020-03-27 13:59:38 -07:00
const GROUP_TAG = 'tag';
2022-05-26 11:51:59 -07:00
// https://www.devextent.com/create-service-worker-typescript/
declare const self: ServiceWorkerGlobalScope;
2022-05-26 12:16:03 -07:00
/** Soapbox notification data from push event. */
2022-05-26 11:51:59 -07:00
interface NotificationData {
access_token?: string,
count?: number,
2022-05-26 11:51:59 -07:00
hiddenBody?: string,
hiddenImage?: string,
id?: string,
preferred_locale: string,
2022-05-26 11:51:59 -07:00
url: string,
}
2022-05-26 12:16:03 -07:00
/** ServiceWorker Notification options with extra fields. */
2022-05-26 11:51:59 -07:00
interface ExtendedNotificationOptions extends NotificationOptions {
data: NotificationData,
title: string,
2022-05-26 11:51:59 -07:00
}
2022-05-26 12:16:03 -07:00
/** Partial clone of ServiceWorker Notification with mutability. */
2022-05-26 11:51:59 -07:00
interface ClonedNotification {
actions?: NotificationAction[],
body?: string,
2022-05-26 11:51:59 -07:00
data: NotificationData,
image?: string,
2022-05-26 11:51:59 -07:00
tag?: string,
title: string,
2022-05-26 11:51:59 -07:00
}
2022-05-26 12:16:03 -07:00
/** Status entitiy from the API (kind of). */
// HACK
2022-05-26 11:51:59 -07:00
interface APIStatus extends Omit<StatusEntity, 'media_attachments'> {
media_attachments: { preview_url: string }[],
}
2022-05-26 12:16:03 -07:00
/** Notification entity from the API (kind of). */
// HACK
2022-05-26 11:51:59 -07:00
interface APINotification extends Omit<NotificationEntity, 'account' | 'status'> {
account: AccountEntity,
status?: APIStatus,
}
2022-05-26 12:16:03 -07:00
/** Show the actual push notification on the device. */
2022-05-26 11:51:59 -07:00
const notify = (options: ExtendedNotificationOptions): Promise<void> =>
2020-03-27 13:59:38 -07:00
self.registration.getNotifications().then(notifications => {
if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping
2022-05-26 11:51:59 -07:00
const group: ClonedNotification = {
2020-03-27 13:59:38 -07:00
title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }),
2022-05-26 11:51:59 -07:00
body: notifications.map(notification => notification.title).join('\n'),
2020-03-27 13:59:38 -07:00
tag: GROUP_TAG,
data: {
2022-05-26 11:51:59 -07:00
url: (new URL('/notifications', self.location.href)).href,
2020-03-27 13:59:38 -07:00
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]);
2022-05-26 11:51:59 -07:00
const count = (group.data.count || 0) + 1;
2020-03-27 13:59:38 -07:00
2022-05-26 11:51:59 -07:00
group.title = formatMessage('notifications.group', options.data.preferred_locale, { count });
2020-03-27 13:59:38 -07:00
group.body = `${options.title}\n${group.body}`;
2022-05-26 11:51:59 -07:00
group.data = { ...group.data, count };
2020-03-27 13:59:38 -07:00
return self.registration.showNotification(group.title, group);
}
return self.registration.showNotification(options.title, options);
});
2022-05-26 12:16:03 -07:00
/** Perform an API request to the backend. */
2022-05-26 11:51:59 -07:00
const fetchFromApi = (path: string, method: string, accessToken: string): Promise<APINotification> => {
const url = (new URL(path, self.location.href)).href;
2020-03-27 13:59:38 -07:00
return fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
method: method,
credentials: 'include',
}).then(res => {
if (res.ok) {
return res;
} else {
2022-05-26 11:51:59 -07:00
throw new Error(String(res.status));
2020-03-27 13:59:38 -07:00
}
}).then(res => res.json());
};
2022-05-26 12:16:03 -07:00
/** Create a mutable object that loosely matches the Notification. */
2022-05-26 11:51:59 -07:00
const cloneNotification = (notification: Notification): ClonedNotification => {
const clone: any = {};
let k: string;
2020-03-27 13:59:38 -07:00
// Object.assign() does not work with notifications
for (k in notification) {
2022-05-26 11:51:59 -07:00
clone[k] = (notification as any)[k];
2020-03-27 13:59:38 -07:00
}
2022-05-26 11:51:59 -07:00
return clone as ClonedNotification;
2020-03-27 13:59:38 -07:00
};
2022-05-26 12:16:03 -07:00
/** Get translated message for the user's locale. */
2022-05-26 11:51:59 -07:00
const formatMessage = (messageId: string, locale: string, values = {}): string =>
(new IntlMessageFormat(locales[locale][messageId], locale)).format(values) as string;
2020-03-27 13:59:38 -07:00
2022-05-26 12:16:03 -07:00
/** Strip HTML for display in a native notification. */
2022-05-26 11:51:59 -07:00
const htmlToPlainText = (html: string): string =>
unescape(html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, ''));
2020-03-27 13:59:38 -07:00
2022-05-26 12:16:03 -07:00
/** ServiceWorker `push` event callback. */
2022-05-26 11:51:59 -07:00
const handlePush = (event: PushEvent) => {
2023-01-05 09:57:44 -08:00
// If event has no data, stop here.
if (!event.data) return;
const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json();
2020-03-27 13:59:38 -07:00
// Placeholder until more information can be loaded
event.waitUntil(
fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => {
2022-05-26 11:51:59 -07:00
const options: ExtendedNotificationOptions = {
title: formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }),
2022-05-26 11:51:59 -07:00
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.acct}/posts/${notification.status.id}` : `/@${notification.account.acct}` },
2022-05-26 11:51:59 -07:00
};
2020-03-27 13:59:38 -07:00
2022-05-26 11:51:59 -07:00
if (notification.status?.spoiler_text || notification.status?.sensitive) {
options.data.hiddenBody = htmlToPlainText(notification.status?.content);
options.data.hiddenImage = notification.status?.media_attachments[0]?.preview_url;
2020-03-27 13:59:38 -07:00
2022-05-26 11:51:59 -07:00
if (notification.status?.spoiler_text) {
2020-03-27 13:59:38 -07:00
options.body = notification.status.spoiler_text;
}
options.image = undefined;
options.actions = [actionExpand(preferred_locale)];
} else if (notification.type === 'mention') {
options.actions = [actionReblog(preferred_locale), actionFavourite(preferred_locale)];
}
return notify(options);
}).catch(() => {
return notify({
title,
body,
icon,
tag: notification_id,
2022-05-26 11:51:59 -07:00
timestamp: Number(new Date()),
2020-03-27 13:59:38 -07:00
data: { access_token, preferred_locale, url: '/notifications' },
});
}),
2020-03-27 13:59:38 -07:00
);
};
2022-05-26 12:16:03 -07:00
/** Native action to open a status on the device. */
2022-05-26 11:51:59 -07:00
const actionExpand = (preferred_locale: string) => ({
2020-03-27 13:59:38 -07:00
action: 'expand',
icon: `/${require('../../assets/images/web-push/web-push-icon_expand.png')}`,
2020-03-27 13:59:38 -07:00
title: formatMessage('status.show_more', preferred_locale),
});
2022-05-26 12:16:03 -07:00
/** Native action to repost status. */
2022-05-26 11:51:59 -07:00
const actionReblog = (preferred_locale: string) => ({
2020-03-27 13:59:38 -07:00
action: 'reblog',
icon: `/${require('../../assets/images/web-push/web-push-icon_reblog.png')}`,
2020-03-27 13:59:38 -07:00
title: formatMessage('status.reblog', preferred_locale),
});
2022-05-26 12:16:03 -07:00
/** Native action to like status. */
2022-05-26 11:51:59 -07:00
const actionFavourite = (preferred_locale: string) => ({
2020-03-27 13:59:38 -07:00
action: 'favourite',
icon: `/${require('../../assets/images/web-push/web-push-icon_favourite.png')}`,
2020-03-27 13:59:38 -07:00
title: formatMessage('status.favourite', preferred_locale),
});
2022-05-26 12:16:03 -07:00
/** Get the active tab if possible, or any open tab. */
2022-05-26 11:51:59 -07:00
const findBestClient = (clients: readonly WindowClient[]): WindowClient => {
2020-03-27 13:59:38 -07:00
const focusedClient = clients.find(client => client.focused);
const visibleClient = clients.find(client => client.visibilityState === 'visible');
return focusedClient || visibleClient || clients[0];
};
/** Update a notification with CW to display the full status. */
2022-05-26 11:51:59 -07:00
const expandNotification = (notification: Notification) => {
2020-03-27 13:59:38 -07:00
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);
};
2022-05-26 12:16:03 -07:00
/** Update the native notification, but delete the action (because it was performed). */
2022-05-26 11:51:59 -07:00
const removeActionFromNotification = (notification: Notification, action: string) => {
2020-03-27 13:59:38 -07:00
const newNotification = cloneNotification(notification);
2022-05-26 11:51:59 -07:00
newNotification.actions = newNotification.actions?.filter(item => item.action !== action);
2020-03-27 13:59:38 -07:00
return self.registration.showNotification(newNotification.title, newNotification);
};
2022-05-26 12:16:03 -07:00
/** Open a URL on the device. */
2022-05-26 11:51:59 -07:00
const openUrl = (url: string) =>
2020-03-27 13:59:38 -07:00
self.clients.matchAll({ type: 'window' }).then(clientList => {
if (clientList.length === 0) {
return self.clients.openWindow(url);
} else {
const client = findBestClient(clientList);
2022-05-26 11:51:59 -07:00
return client.navigate(url).then(client => client?.focus());
2020-03-27 13:59:38 -07:00
}
});
2022-05-26 12:16:03 -07:00
/** Callback when a native notification is clicked/touched on the device. */
2022-05-26 11:51:59 -07:00
const handleNotificationClick = (event: NotificationEvent) => {
2020-03-27 13:59:38 -07:00
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);
};
2022-05-26 12:16:03 -07:00
// ServiceWorker event listeners
2020-03-27 13:59:38 -07:00
self.addEventListener('push', handlePush);
self.addEventListener('notificationclick', handleNotificationClick);