From 5c49cc0b84109b9cebaecb016f0f87a20e851ec2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 26 May 2022 14:51:59 -0400 Subject: [PATCH 1/5] Convert ServiceWorker to TypeScript --- .../service_worker/{entry.js => entry.ts} | 0 ...fications.js => web_push_notifications.ts} | 129 ++++++++++++------ tsconfig.json | 1 + webpack/production.js | 2 +- 4 files changed, 88 insertions(+), 44 deletions(-) rename app/soapbox/service_worker/{entry.js => entry.ts} (100%) rename app/soapbox/service_worker/{web_push_notifications.js => web_push_notifications.ts} (58%) diff --git a/app/soapbox/service_worker/entry.js b/app/soapbox/service_worker/entry.ts similarity index 100% rename from app/soapbox/service_worker/entry.js rename to app/soapbox/service_worker/entry.ts diff --git a/app/soapbox/service_worker/web_push_notifications.js b/app/soapbox/service_worker/web_push_notifications.ts similarity index 58% rename from app/soapbox/service_worker/web_push_notifications.js rename to app/soapbox/service_worker/web_push_notifications.ts index 5dbd749f4..dee650600 100644 --- a/app/soapbox/service_worker/web_push_notifications.js +++ b/app/soapbox/service_worker/web_push_notifications.ts @@ -4,18 +4,60 @@ import { unescape } from 'lodash'; import locales from './web_push_locales'; +import type { + Account as AccountEntity, + Notification as NotificationEntity, + Status as StatusEntity, +} from 'soapbox/types/entities'; + const MAX_NOTIFICATIONS = 5; const GROUP_TAG = 'tag'; -const notify = options => +// https://www.devextent.com/create-service-worker-typescript/ +declare const self: ServiceWorkerGlobalScope; + +interface NotificationData { + access_token?: string, + preferred_locale: string, + hiddenBody?: string, + hiddenImage?: string, + id?: string, + url: string, + count?: number, +} + +interface ExtendedNotificationOptions extends NotificationOptions { + title: string, + data: NotificationData, +} + +interface ClonedNotification { + body?: string, + image?: string, + actions?: NotificationAction[], + data: NotificationData, + title: string, + tag?: string, +} + +interface APIStatus extends Omit { + media_attachments: { preview_url: string }[], +} + +interface APINotification extends Omit { + account: AccountEntity, + status?: APIStatus, +} + +const notify = (options: ExtendedNotificationOptions): Promise => self.registration.getNotifications().then(notifications => { 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 }), - 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, data: { - url: (new URL('/notifications', self.location)).href, + url: (new URL('/notifications', self.location.href)).href, count: notifications.length + 1, preferred_locale: options.data.preferred_locale, }, @@ -26,10 +68,11 @@ const notify = options => 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]); + 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.data = { ...group.data, count: group.data.count + 1 }; + group.data = { ...group.data, count }; return self.registration.showNotification(group.title, group); } @@ -37,8 +80,8 @@ const notify = options => return self.registration.showNotification(options.title, options); }); -const fetchFromApi = (path, method, accessToken) => { - const url = (new URL(path, self.location)).href; +const fetchFromApi = (path: string, method: string, accessToken: string): Promise => { + const url = (new URL(path, self.location.href)).href; return fetch(url, { headers: { @@ -52,50 +95,50 @@ const fetchFromApi = (path, method, accessToken) => { if (res.ok) { return res; } else { - throw new Error(res.status); + throw new Error(String(res.status)); } }).then(res => res.json()); }; -const cloneNotification = notification => { - const clone = {}; - let k; +const cloneNotification = (notification: Notification): ClonedNotification => { + const clone: any = {}; + let k: string; // Object.assign() does not work with notifications 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 = {}) => - (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 => +const htmlToPlainText = (html: string): string => unescape(html.replace(//g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, '')); -const handlePush = (event) => { - 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 event.waitUntil( 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 }); - 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.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) { + options.data.hiddenBody = htmlToPlainText(notification.status?.content); + options.data.hiddenImage = notification.status?.media_attachments[0]?.preview_url; - if (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) { + if (notification.status?.spoiler_text) { options.body = notification.status.spoiler_text; } @@ -112,39 +155,39 @@ const handlePush = (event) => { body, icon, tag: notification_id, - timestamp: new Date(), + timestamp: Number(new Date()), data: { access_token, preferred_locale, url: '/notifications' }, }); }), ); }; -const actionExpand = preferred_locale => ({ +const actionExpand = (preferred_locale: string) => ({ action: 'expand', icon: `/${require('../../images/web-push/web-push-icon_expand.png')}`, title: formatMessage('status.show_more', preferred_locale), }); -const actionReblog = preferred_locale => ({ +const actionReblog = (preferred_locale: string) => ({ action: 'reblog', icon: `/${require('../../images/web-push/web-push-icon_reblog.png')}`, title: formatMessage('status.reblog', preferred_locale), }); -const actionFavourite = preferred_locale => ({ +const actionFavourite = (preferred_locale: string) => ({ action: 'favourite', icon: `/${require('../../images/web-push/web-push-icon_favourite.png')}`, title: formatMessage('status.favourite', preferred_locale), }); -const findBestClient = clients => { +const findBestClient = (clients: readonly WindowClient[]): WindowClient => { const focusedClient = clients.find(client => client.focused); const visibleClient = clients.find(client => client.visibilityState === 'visible'); return focusedClient || visibleClient || clients[0]; }; -const expandNotification = notification => { +const expandNotification = (notification: Notification) => { const newNotification = cloneNotification(notification); newNotification.body = newNotification.data.hiddenBody; @@ -154,25 +197,25 @@ const expandNotification = notification => { return self.registration.showNotification(newNotification.title, newNotification); }; -const removeActionFromNotification = (notification, action) => { +const removeActionFromNotification = (notification: Notification, action: string) => { 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); }; -const openUrl = url => +const openUrl = (url: string) => self.clients.matchAll({ type: 'window' }).then(clientList => { if (clientList.length === 0) { return self.clients.openWindow(url); } else { const client = findBestClient(clientList); - return client.navigate(url).then(client => client.focus()); + return client.navigate(url).then(client => client?.focus()); } }); -const handleNotificationClick = (event) => { +const handleNotificationClick = (event: NotificationEvent) => { const reactToNotificationClick = new Promise((resolve, reject) => { if (event.action) { if (event.action === 'expand') { diff --git a/tsconfig.json b/tsconfig.json index 5e0e8d07c..8989bd57e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "sourceMap": true, "strict": true, "module": "es2022", + "lib": ["es2019", "es6", "dom", "webworker"], "target": "es5", "jsx": "react", "allowJs": true, diff --git a/webpack/production.js b/webpack/production.js index ce47d647b..18666a109 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -84,7 +84,7 @@ module.exports = merge(sharedConfig, { ], ServiceWorker: { cacheName: 'soapbox', - entry: join(__dirname, '../app/soapbox/service_worker/entry.js'), + entry: join(__dirname, '../app/soapbox/service_worker/entry.ts'), minify: true, }, cacheMaps: [{ From d111c4c2d2ee60f687aceba43e5bba4fe9e18790 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 26 May 2022 15:16:03 -0400 Subject: [PATCH 2/5] ServiceWorker: add jsdoc comments --- .../service_worker/web_push_notifications.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/soapbox/service_worker/web_push_notifications.ts b/app/soapbox/service_worker/web_push_notifications.ts index dee650600..f60c21f54 100644 --- a/app/soapbox/service_worker/web_push_notifications.ts +++ b/app/soapbox/service_worker/web_push_notifications.ts @@ -10,12 +10,15 @@ import type { Status as StatusEntity, } from 'soapbox/types/entities'; +/** Limit before we start grouping device notifications into a single notification. */ const MAX_NOTIFICATIONS = 5; +/** Tag for the grouped notification. */ const GROUP_TAG = 'tag'; // https://www.devextent.com/create-service-worker-typescript/ declare const self: ServiceWorkerGlobalScope; +/** Soapbox notification data from push event. */ interface NotificationData { access_token?: string, preferred_locale: string, @@ -26,11 +29,13 @@ interface NotificationData { count?: number, } +/** ServiceWorker Notification options with extra fields. */ interface ExtendedNotificationOptions extends NotificationOptions { title: string, data: NotificationData, } +/** Partial clone of ServiceWorker Notification with mutability. */ interface ClonedNotification { body?: string, image?: string, @@ -40,15 +45,20 @@ interface ClonedNotification { tag?: string, } +/** Status entitiy from the API (kind of). */ +// HACK interface APIStatus extends Omit { media_attachments: { preview_url: string }[], } +/** Notification entity from the API (kind of). */ +// HACK interface APINotification extends Omit { account: AccountEntity, status?: APIStatus, } +/** Show the actual push notification on the device. */ const notify = (options: ExtendedNotificationOptions): Promise => self.registration.getNotifications().then(notifications => { if (notifications.length >= MAX_NOTIFICATIONS) { // Reached the maximum number of notifications, proceed with grouping @@ -80,6 +90,7 @@ const notify = (options: ExtendedNotificationOptions): Promise => return self.registration.showNotification(options.title, options); }); +/** Perform an API request to the backend. */ const fetchFromApi = (path: string, method: string, accessToken: string): Promise => { const url = (new URL(path, self.location.href)).href; @@ -100,6 +111,7 @@ const fetchFromApi = (path: string, method: string, accessToken: string): Promis }).then(res => res.json()); }; +/** Create a mutable object that loosely matches the Notification. */ const cloneNotification = (notification: Notification): ClonedNotification => { const clone: any = {}; let k: string; @@ -112,12 +124,15 @@ const cloneNotification = (notification: Notification): ClonedNotification => { return clone as ClonedNotification; }; +/** Get translated message for the user's locale. */ const formatMessage = (messageId: string, locale: string, values = {}): string => (new IntlMessageFormat(locales[locale][messageId], locale)).format(values) as string; +/** Strip HTML for display in a native notification. */ const htmlToPlainText = (html: string): string => unescape(html.replace(//g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, '')); +/** ServiceWorker `push` event callback. */ const handlePush = (event: PushEvent) => { const { access_token, notification_id, preferred_locale, title, body, icon } = event.data?.json(); @@ -162,24 +177,28 @@ const handlePush = (event: PushEvent) => { ); }; +/** Native action to open a status on the device. */ const actionExpand = (preferred_locale: string) => ({ action: 'expand', icon: `/${require('../../images/web-push/web-push-icon_expand.png')}`, title: formatMessage('status.show_more', preferred_locale), }); +/** Native action to repost status. */ const actionReblog = (preferred_locale: string) => ({ action: 'reblog', icon: `/${require('../../images/web-push/web-push-icon_reblog.png')}`, title: formatMessage('status.reblog', preferred_locale), }); +/** Native action to like status. */ const actionFavourite = (preferred_locale: string) => ({ action: 'favourite', icon: `/${require('../../images/web-push/web-push-icon_favourite.png')}`, title: formatMessage('status.favourite', preferred_locale), }); +/** Get the active tab if possible, or any open tab. */ const findBestClient = (clients: readonly WindowClient[]): WindowClient => { const focusedClient = clients.find(client => client.focused); const visibleClient = clients.find(client => client.visibilityState === 'visible'); @@ -197,6 +216,7 @@ const expandNotification = (notification: Notification) => { return self.registration.showNotification(newNotification.title, newNotification); }; +/** Update the native notification, but delete the action (because it was performed). */ const removeActionFromNotification = (notification: Notification, action: string) => { const newNotification = cloneNotification(notification); @@ -205,6 +225,7 @@ const removeActionFromNotification = (notification: Notification, action: string return self.registration.showNotification(newNotification.title, newNotification); }; +/** Open a URL on the device. */ const openUrl = (url: string) => self.clients.matchAll({ type: 'window' }).then(clientList => { if (clientList.length === 0) { @@ -215,6 +236,7 @@ const openUrl = (url: string) => } }); +/** Callback when a native notification is clicked/touched on the device. */ const handleNotificationClick = (event: NotificationEvent) => { const reactToNotificationClick = new Promise((resolve, reject) => { if (event.action) { @@ -238,5 +260,6 @@ const handleNotificationClick = (event: NotificationEvent) => { event.waitUntil(reactToNotificationClick); }; +// ServiceWorker event listeners self.addEventListener('push', handlePush); self.addEventListener('notificationclick', handleNotificationClick); From b8cfb567d1bb48388c342cc303fb5003adfe1027 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 26 May 2022 15:16:54 -0400 Subject: [PATCH 3/5] ServiceWorker: alphabetize type definitions --- app/soapbox/service_worker/web_push_notifications.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/soapbox/service_worker/web_push_notifications.ts b/app/soapbox/service_worker/web_push_notifications.ts index f60c21f54..099bf8f45 100644 --- a/app/soapbox/service_worker/web_push_notifications.ts +++ b/app/soapbox/service_worker/web_push_notifications.ts @@ -21,28 +21,28 @@ declare const self: ServiceWorkerGlobalScope; /** Soapbox notification data from push event. */ interface NotificationData { access_token?: string, - preferred_locale: string, + count?: number, hiddenBody?: string, hiddenImage?: string, id?: string, + preferred_locale: string, url: string, - count?: number, } /** ServiceWorker Notification options with extra fields. */ interface ExtendedNotificationOptions extends NotificationOptions { - title: string, data: NotificationData, + title: string, } /** Partial clone of ServiceWorker Notification with mutability. */ interface ClonedNotification { - body?: string, - image?: string, actions?: NotificationAction[], + body?: string, data: NotificationData, - title: string, + image?: string, tag?: string, + title: string, } /** Status entitiy from the API (kind of). */ From 5c549a46e5ef45e5c15043f3cd5947c8ea4092a1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 26 May 2022 17:21:38 -0400 Subject: [PATCH 4/5] ServiceWorker: add missing jsdoc comment to expandNotification --- app/soapbox/service_worker/web_push_notifications.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/soapbox/service_worker/web_push_notifications.ts b/app/soapbox/service_worker/web_push_notifications.ts index 099bf8f45..9939f88a2 100644 --- a/app/soapbox/service_worker/web_push_notifications.ts +++ b/app/soapbox/service_worker/web_push_notifications.ts @@ -206,6 +206,7 @@ const findBestClient = (clients: readonly WindowClient[]): WindowClient => { return focusedClient || visibleClient || clients[0]; }; +/** Update a notification with CW to display the full status. */ const expandNotification = (notification: Notification) => { const newNotification = cloneNotification(notification); From 4e7256698945997f878d6119398c7a6399de7a98 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 26 May 2022 17:29:44 -0400 Subject: [PATCH 5/5] Jest: fix ServiceWorker filename in collectCoverageFrom --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index c2c762be2..8796e5595 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,7 @@ module.exports = { 'app/soapbox/**/*.tsx', '!app/soapbox/features/emoji/emoji_compressed.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-helpers.ts', ],