diff --git a/.gitignore b/.gitignore index 92e9362d8..e2f59fe19 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /.vs/ yarn-error.log* /junit.xml +*.timestamp-* /static/ /static-test/ diff --git a/app/soapbox/main.tsx b/app/soapbox/main.tsx index f5cf9f0f5..96f82e54c 100644 --- a/app/soapbox/main.tsx +++ b/app/soapbox/main.tsx @@ -1,6 +1,5 @@ import './polyfills'; -import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime'; import React from 'react'; import { createRoot } from 'react-dom/client'; @@ -40,10 +39,4 @@ ready(() => { const root = createRoot(container); root.render(); - - if (BuildConfig.NODE_ENV === 'production') { - // avoid offline in dev mode because it's harder to debug - // https://github.com/NekR/offline-plugin/pull/201#issuecomment-285133572 - OfflinePluginRuntime.install(); - } }); \ No newline at end of file diff --git a/app/soapbox/service-worker/entry.ts b/app/soapbox/service-worker/entry.ts index 3dbfee2ce..b760579c7 100644 --- a/app/soapbox/service-worker/entry.ts +++ b/app/soapbox/service-worker/entry.ts @@ -1 +1,272 @@ -import './web-push-notifications'; +/// +import IntlMessageFormat from 'intl-messageformat'; +import 'intl-pluralrules'; +import unescape from 'lodash/unescape'; + +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; +/** 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 + 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 { + 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 + const group: ClonedNotification = { + title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }), + body: notifications.map(notification => notification.title).join('\n'), + tag: GROUP_TAG, + data: { + url: (new URL('/notifications', self.location.href)).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]); + const count = (group.data.count || 0) + 1; + + group.title = formatMessage('notifications.group', options.data.preferred_locale, { count }); + group.body = `${options.title}\n${group.body}`; + group.data = { ...group.data, count }; + + return self.registration.showNotification(group.title, group); + } + + 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; + + 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(String(res.status)); + } + }).then(res => res.json()); +}; + +/** Create a mutable object that loosely matches the Notification. */ +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 as any)[k]; + } + + 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) => { + if (!event.data) { + console.error('An empty web push event was received.', { event }); + return; + } + + 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: 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.acct}/posts/${notification.status.id}` : `/@${notification.account.acct}` }, + }; + + 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) { + 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, + timestamp: Number(new Date()), + data: { access_token, preferred_locale, url: '/notifications' }, + }); + }), + ); +}; + +/** Native action to open a status on the device. */ +const actionExpand = (preferred_locale: string) => ({ + action: 'expand', + icon: `/${require('../../assets/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('../../assets/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('../../assets/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'); + + return focusedClient || visibleClient || clients[0]; +}; + +/** Update a notification with CW to display the full status. */ +const expandNotification = (notification: 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); +}; + +/** Update the native notification, but delete the action (because it was performed). */ +const removeActionFromNotification = (notification: Notification, action: string) => { + const newNotification = cloneNotification(notification); + + newNotification.actions = newNotification.actions?.filter(item => item.action !== action); + + 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) { + return self.clients.openWindow(url); + } else { + const client = findBestClient(clientList); + return client.navigate(url).then(client => client?.focus()); + } + }); + +/** Callback when a native notification is clicked/touched on the device. */ +const handleNotificationClick = (event: NotificationEvent) => { + 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); +}; + +// ServiceWorker event listeners +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/soapbox/service-worker/web-push-notifications.ts b/app/soapbox/service-worker/web-push-notifications.ts deleted file mode 100644 index b760579c7..000000000 --- a/app/soapbox/service-worker/web-push-notifications.ts +++ /dev/null @@ -1,272 +0,0 @@ -/// -import IntlMessageFormat from 'intl-messageformat'; -import 'intl-pluralrules'; -import unescape from 'lodash/unescape'; - -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; -/** 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 - 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 { - 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 - const group: ClonedNotification = { - title: formatMessage('notifications.group', options.data.preferred_locale, { count: notifications.length + 1 }), - body: notifications.map(notification => notification.title).join('\n'), - tag: GROUP_TAG, - data: { - url: (new URL('/notifications', self.location.href)).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]); - const count = (group.data.count || 0) + 1; - - group.title = formatMessage('notifications.group', options.data.preferred_locale, { count }); - group.body = `${options.title}\n${group.body}`; - group.data = { ...group.data, count }; - - return self.registration.showNotification(group.title, group); - } - - 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; - - 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(String(res.status)); - } - }).then(res => res.json()); -}; - -/** Create a mutable object that loosely matches the Notification. */ -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 as any)[k]; - } - - 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) => { - if (!event.data) { - console.error('An empty web push event was received.', { event }); - return; - } - - 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: 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.acct}/posts/${notification.status.id}` : `/@${notification.account.acct}` }, - }; - - 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) { - 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, - timestamp: Number(new Date()), - data: { access_token, preferred_locale, url: '/notifications' }, - }); - }), - ); -}; - -/** Native action to open a status on the device. */ -const actionExpand = (preferred_locale: string) => ({ - action: 'expand', - icon: `/${require('../../assets/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('../../assets/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('../../assets/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'); - - return focusedClient || visibleClient || clients[0]; -}; - -/** Update a notification with CW to display the full status. */ -const expandNotification = (notification: 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); -}; - -/** Update the native notification, but delete the action (because it was performed). */ -const removeActionFromNotification = (notification: Notification, action: string) => { - const newNotification = cloneNotification(notification); - - newNotification.actions = newNotification.actions?.filter(item => item.action !== action); - - 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) { - return self.clients.openWindow(url); - } else { - const client = findBestClient(clientList); - return client.navigate(url).then(client => client?.focus()); - } - }); - -/** Callback when a native notification is clicked/touched on the device. */ -const handleNotificationClick = (event: NotificationEvent) => { - 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); -}; - -// ServiceWorker event listeners -self.addEventListener('push', handlePush); -self.addEventListener('notificationclick', handleNotificationClick); diff --git a/package.json b/package.json index 626373a71..b76450f88 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@fontsource/roboto-mono": "^4.5.8", "@gamestdio/websocket": "^0.3.2", "@jest/globals": "^29.0.0", - "@lcdp/offline-plugin": "^5.1.0", "@metamask/providers": "^10.0.0", "@popperjs/core": "^2.11.5", "@reach/combobox": "^0.18.0", diff --git a/vite.config.ts b/vite.config.ts index 174822c6c..05c351171 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,14 @@ export default defineConfig({ // Relative to the root outDir: '../static', assetsDir: 'packs', + assetsInlineLimit: 0, + rollupOptions: { + output: { + assetFileNames: 'packs/assets/[name]-[hash].[ext]', + chunkFileNames: 'packs/js/[name]-[hash].js', + entryFileNames: 'packs/[name]-[hash].js', + }, + }, }, server: { port: 3036, diff --git a/yarn.lock b/yarn.lock index 362f4c42e..a446522bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1861,17 +1861,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@lcdp/offline-plugin@^5.1.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@lcdp/offline-plugin/-/offline-plugin-5.1.0.tgz#826f3e10d618711bd002afd674edb36dc1d9a792" - integrity sha512-GXdAsFyv+OoVLBKvog+oiOZUdgWYjWjH7O8Btfy1VLQsnnC4CRQ+fv/pYKazFgofX/wOO3MhcszoA5ARvNNlLw== - dependencies: - deep-extend "^0.5.1" - ejs "^2.3.4" - loader-utils "0.2.x" - minimatch "^3.0.3" - slash "^1.0.0" - "@mdn/browser-compat-data@^3.3.14": version "3.3.14" resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-3.3.14.tgz#b72a37c654e598f9ae6f8335faaee182bebc6b28" @@ -4247,11 +4236,6 @@ dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= -deep-extend@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f" - integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w== - deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -4438,11 +4422,6 @@ ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer "^5.0.1" -ejs@^2.3.4: - version "2.7.4" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" - integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== - ejs@^3.1.6: version "3.1.9" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361" @@ -6795,7 +6774,7 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.2.0.tgz#d7022380d66d14c5fb1d496b89864ebcfd478384" integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw== -loader-utils@0.2.x, loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^2.0.3: +loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== @@ -7140,7 +7119,7 @@ mini-svg-data-uri@^1.2.3: resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz#43177b2e93766ba338931a3e2a84a3dfd3a222b8" integrity sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA== -minimatch@^3.0.3, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -8905,11 +8884,6 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -slash@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" - integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"