Implement subscriptions for mobile notifications

This commit is contained in:
MartinBraquet
2025-10-22 02:52:41 +02:00
parent f00acf6af1
commit 6c864c35cd
13 changed files with 303 additions and 71 deletions

View File

@@ -49,18 +49,19 @@
"gcp-metadata": "6.1.0",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"openapi-types": "12.1.3",
"pg-promise": "11.4.1",
"posthog-node": "4.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "4.1.2",
"string-similarity": "4.0.4",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"tsconfig-paths": "4.2.0",
"twitter-api-v2": "1.15.0",
"web-push": "3.6.7",
"ws": "8.17.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"openapi-types": "12.1.3",
"zod": "3.22.3"
},
"devDependencies": {
@@ -68,6 +69,7 @@
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@types/swagger-ui-express": "4.1.8",
"@types/web-push": "3.6.4",
"@types/ws": "8.5.10"
}
}

View File

@@ -1,6 +1,6 @@
import {API, type APIPath} from 'common/api/schema'
import {APIError, pathWithPrefix} from 'common/api/utils'
import cors, {CorsOptions} from 'cors'
import cors from 'cors'
import * as crypto from 'crypto'
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
import {hrtime} from 'node:process'
@@ -59,6 +59,7 @@ import {getMessagesCount} from "api/get-messages-count";
import {createVote} from "api/create-vote";
import {vote} from "api/vote";
import {contact} from "api/contact";
import {saveSubscription} from "api/save-subscription";
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
@@ -183,6 +184,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'set-channel-seen-time': setChannelLastSeenTime,
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
'save-subscription': saveSubscription,
}
Object.entries(handlers).forEach(([path, handler]) => {

View File

@@ -1,18 +1,19 @@
import { Json } from 'common/supabase/schema'
import { SupabaseDirectClient } from 'shared/supabase/init'
import { ChatVisibility } from 'common/chat-message'
import { User } from 'common/user'
import { first } from 'lodash'
import { log } from 'shared/monitoring/log'
import { getPrivateUser, getUser } from 'shared/utils'
import { type JSONContent } from '@tiptap/core'
import { APIError } from 'common/api/utils'
import { broadcast } from 'shared/websockets/server'
import { track } from 'shared/analytics'
import { sendNewMessageEmail } from 'email/functions/helpers'
import {Json} from 'common/supabase/schema'
import {SupabaseDirectClient} from 'shared/supabase/init'
import {ChatVisibility} from 'common/chat-message'
import {User} from 'common/user'
import {first} from 'lodash'
import {log} from 'shared/monitoring/log'
import {getPrivateUser, getUser} from 'shared/utils'
import {type JSONContent} from '@tiptap/core'
import {APIError} from 'common/api/utils'
import {broadcast} from 'shared/websockets/server'
import {track} from 'shared/analytics'
import {sendNewMessageEmail} from 'email/functions/helpers'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import webPush from 'web-push';
dayjs.extend(utc)
dayjs.extend(timezone)
@@ -22,7 +23,7 @@ export const leaveChatContent = (userName: string) => ({
content: [
{
type: 'paragraph',
content: [{ text: `${userName} left the chat`, type: 'text' }],
content: [{text: `${userName} left the chat`, type: 'text'}],
},
],
})
@@ -32,7 +33,7 @@ export const joinChatContent = (userName: string) => {
content: [
{
type: 'paragraph',
content: [{ text: `${userName} joined the chat!`, type: 'text' }],
content: [{text: `${userName} joined the chat!`, type: 'text'}],
},
],
}
@@ -47,11 +48,14 @@ export const insertPrivateMessage = async (
) => {
const lastMessage = await pg.one(
`insert into private_user_messages (content, channel_id, user_id, visibility)
values ($1, $2, $3, $4) returning created_time`,
values ($1, $2, $3, $4)
returning created_time`,
[content, channelId, userId, visibility]
)
await pg.none(
`update private_user_message_channels set last_updated_time = $1 where id = $2`,
`update private_user_message_channels
set last_updated_time = $1
where id = $2`,
[lastMessage.created_time, channelId]
)
}
@@ -65,16 +69,17 @@ export const addUsersToPrivateMessageChannel = async (
userIds.map((id) =>
pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values
($1, $2, 'member', 'proposed')
on conflict do nothing
`,
values ($1, $2, 'member', 'proposed')
on conflict do nothing
`,
[channelId, id]
)
)
)
await pg.none(
`update private_user_message_channels set last_updated_time = now() where id = $1`,
`update private_user_message_channels
set last_updated_time = now()
where id = $1`,
[channelId]
)
}
@@ -90,9 +95,9 @@ export const createPrivateUserMessageMain = async (
// Normally, users can only submit messages to channels that they are members of
const authorized = await pg.oneOrNone(
`select 1
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
[channelId, creator.id]
)
if (!authorized)
@@ -108,10 +113,12 @@ export const createPrivateUserMessageMain = async (
}
const otherUserIds = await pg.map<string>(
`select user_id from private_user_message_channel_members
where channel_id = $1 and user_id != $2
and status != 'left'
`,
`select user_id
from private_user_message_channel_members
where channel_id = $1
and user_id != $2
and status != 'left'
`,
[channelId, creator.id],
(r) => r.user_id
)
@@ -133,10 +140,12 @@ const notifyOtherUserInChannelIfInactive = async (
pg: SupabaseDirectClient
) => {
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
`select user_id from private_user_message_channel_members
where channel_id = $1 and user_id != $2
and status != 'left'
`,
`select user_id
from private_user_message_channel_members
where channel_id = $1
and user_id != $2
and status != 'left'
`,
[channelId, creator.id]
)
// We're only sending notifs for 1:1 channels
@@ -150,11 +159,12 @@ const notifyOtherUserInChannelIfInactive = async (
.startOf('day')
.toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*) from private_user_messages
where channel_id = $1
and user_id = $2
and created_time > $3
`,
`select count(*)
from private_user_messages
where channel_id = $1
and user_id = $2
and created_time > $3
`,
[channelId, creator.id, startOfDay]
)
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
@@ -166,16 +176,63 @@ const notifyOtherUserInChannelIfInactive = async (
console.debug('otherUser:', otherUser)
if (!otherUser) return
await createNewMessageNotification(creator, otherUser, channelId)
await createNewMessageNotification(creator, otherUser, channelId, pg)
}
const createNewMessageNotification = async (
fromUser: User,
toUser: User,
channelId: number
channelId: number,
pg: SupabaseDirectClient
) => {
const privateUser = await getPrivateUser(toUser.id)
console.debug('privateUser:', privateUser)
if (!privateUser) return
webPush.setVapidDetails(
'mailto:you@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
// Retrieve subscription from your database
const subscriptions = await getSubscriptionsFromDB(toUser.id, pg);
for (const subscription of subscriptions) {
try {
console.log('Sending notification to:', subscription.endpoint);
await webPush.sendNotification(subscription, JSON.stringify({
title: `Message from ${fromUser.name}`,
body: 'You have a new message!',
url: `/messages/${channelId}`,
}));
} catch (err) {
console.error('Failed to send notification', err);
// optionally remove invalid subscription from DB
}
}
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}
export async function getSubscriptionsFromDB(
userId: string,
pg: SupabaseDirectClient
) {
try {
const subscriptions = await pg.manyOrNone(`
select endpoint, keys from push_subscriptions
where user_id = $1
`, [userId]
);
return subscriptions.map(sub => ({
endpoint: sub.endpoint,
keys: sub.keys,
}));
} catch (err) {
console.error('Error fetching subscriptions', err);
return [];
}
}

View File

@@ -0,0 +1,41 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
const {subscription} = body
if (!subscription?.endpoint || !subscription?.keys) {
throw new APIError(400, `Invalid subscription object`)
}
const userId = auth?.uid
try {
const pg = createSupabaseDirectClient()
// Check if a subscription already exists
const exists = await pg.oneOrNone(
'select id from push_subscriptions where endpoint = $1',
[subscription.endpoint]
);
if (exists) {
// Already exists, optionally update keys and userId
await pg.none(
'update push_subscriptions set keys = $1, user_id = $2 where id = $3',
[subscription.keys, userId, exists.id]
);
} else {
await pg.none(
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
on conflict(endpoint) do update set keys = excluded.keys
`,
[subscription.endpoint, subscription.keys, userId]
);
}
return {success: true};
} catch (err) {
console.error('Error saving subscription', err);
throw new APIError(500, `Failed to save subscription`)
}
}

View File

@@ -0,0 +1,10 @@
create table push_subscriptions (
id serial primary key,
user_id TEXT references users(id), -- optional if per-user
endpoint text not null unique,
keys jsonb not null,
created_at timestamptz default now()
);
-- Row Level Security
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;

View File

@@ -572,6 +572,15 @@ export const API = (_apiTypeCheck = {
props: z.object({}),
returns: {} as { count: number },
},
'save-subscription': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
subscription: z.record(z.any())
}),
},
} as const)
export type APIPath = keyof typeof API

View File

@@ -2,7 +2,6 @@ import {
getText,
getSchema,
getTextSerializersFromSchema,
Node,
JSONContent,
} from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
@@ -77,3 +76,10 @@ export function richTextToString(text?: JSONContent) {
export function parseJsonContentToText(content: JSONContent | string) {
return typeof content === 'string' ? content : richTextToString(content)
}
export function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return new Uint8Array([...rawData].map(c => c.charCodeAt(0)));
}

View File

@@ -0,0 +1,52 @@
import {useEffect} from "react";
import {urlBase64ToUint8Array} from "common/util/parse";
import {api} from "web/lib/api";
import {useUser} from "web/hooks/use-user";
export default function PushSubscriber() {
const user = useUser(); // authenticated user
useEffect(() => {
console.log('PushSubscriber', user, 'serviceWorker' in navigator)
if (!user) return; // only subscribe logged-in users
if (!('serviceWorker' in navigator)) return;
const registerPush = async () => {
navigator.serviceWorker
.register('/service-worker.js')
.then(async (registration) => {
console.log('Service worker registered:', registration);
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return
};
// Check if already subscribed
const existing = await registration.pushManager.getSubscription();
if (existing) {
console.log('Already subscribed:', existing);
return
} // already subscribed
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('BF80q7LrDa4a5ksS2BZrX6PPvL__y0jCNvNqyUzvk8Y4ofTdrS0kRnKfGpClCQAHWmcPHIUmWq8jgQ4ROquSpJQ'),
});
// Send subscription to server
const {data} = await api('save-subscription', {subscription});
console.log('Subscription saved:', data);
})
.catch((err) => {
console.error('SW registration failed:', err)
return
});
};
registerPush();
}, [user?.id]);
return null; // component doesn't render anything
}

View File

@@ -10,6 +10,7 @@ import '../styles/globals.css'
import {Major_Mono_Display} from 'next/font/google'
import clsx from 'clsx'
import {initTracking} from 'web/lib/service/analytics'
import PushSubscriber from "web/lib/service/notifications";
// See https://nextjs.org/docs/basic-features/font-optimization#google-fonts
// and if you add a font, you must add it to tailwind config as well for it to work.
@@ -50,18 +51,6 @@ function MyApp({Component, pageProps}: AppProps<PageProps>) {
useEffect(printBuildInfo, [])
useHasLoaded()
useEffect(() => {
console.log('Registering service worker...');
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/service-worker.js')
.then((reg) => console.log('✅ registered', reg))
.catch((err) => console.error('❌ failed', err));
} else {
console.warn('Service workers not supported in this browser');
}
}, []);
useEffect(() => {
initTracking()
@@ -122,6 +111,7 @@ function MyApp({Component, pageProps}: AppProps<PageProps>) {
)}
>
<AuthProvider serverUser={pageProps.auth}>
<PushSubscriber/>
<Component {...pageProps} />
</AuthProvider>
{/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -3,7 +3,7 @@
"name": "Compass",
"icons": [
{
"src": "/images/compass-192.png",
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}

View File

@@ -2,16 +2,16 @@ console.log('SW loaded');
// const CACHE_NAME = 'compass-cache-v1';
self.addEventListener('install', (event) => {
self.addEventListener('install', (_event) => {
console.log('SW installing…');
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
self.addEventListener('activate', (_event) => {
console.log('SW activated!');
});
self.addEventListener('fetch', (event) => {
self.addEventListener('fetch', (_event) => {
// const url = new URL(event.request.url);
//
// // Ignore Next.js dev HMR and static chunks
@@ -35,3 +35,23 @@ self.addEventListener('fetch', (event) => {
// })
// );
});
// Listen for push events
self.addEventListener('push', event => {
const data = event.data?.json() || {};
const title = data.title || 'Notification';
const options = {
body: data.body || 'You have a new message',
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-192x192.png',
data: data.url || '/'
};
event.waitUntil(self.registration.showNotification(title, options));
});
// Handle notification clicks
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data));
});

View File

@@ -3723,6 +3723,13 @@
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
"@types/web-push@3.6.4":
version "3.6.4"
resolved "https://registry.yarnpkg.com/@types/web-push/-/web-push-3.6.4.tgz#4c6e10d3963ba51e7b4b8fff185f43612c0d1346"
integrity sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==
dependencies:
"@types/node" "*"
"@types/ws@8.5.10", "@types/ws@^8.5.10":
version "8.5.10"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787"
@@ -4166,6 +4173,16 @@ arrify@^2.0.0:
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
asn1.js@^5.3.0:
version "5.4.1"
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
dependencies:
bn.js "^4.0.0"
inherits "^2.0.1"
minimalistic-assert "^1.0.0"
safer-buffer "^2.1.0"
assert-options@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/assert-options/-/assert-options-0.8.0.tgz#cf71882534d23d3027945bc7462e20d3d3682380"
@@ -4368,6 +4385,11 @@ blueimp-canvas-to-blob@^3.29.0:
resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz#d965f06cb1a67fdae207a2be56683f55ef531466"
integrity sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==
bn.js@^4.0.0:
version "4.12.2"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.2.tgz#3d8fed6796c24e177737f7cc5172ee04ef39ec99"
integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==
body-parser@^2.0.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa"
@@ -6793,6 +6815,11 @@ http-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
http_ece@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.2.0.tgz#84d5885f052eae8c9b075eee4d2eb5105f114479"
integrity sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==
https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
@@ -6801,6 +6828,14 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.2:
version "7.0.6"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==
dependencies:
agent-base "^7.1.2"
debug "4"
https-proxy-agent@^7.0.1:
version "7.0.5"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2"
@@ -6809,14 +6844,6 @@ https-proxy-agent@^7.0.1:
agent-base "^7.0.2"
debug "4"
https-proxy-agent@^7.0.2:
version "7.0.6"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==
dependencies:
agent-base "^7.1.2"
debug "4"
human-signals@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
@@ -6890,7 +6917,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -8530,6 +8557,11 @@ mini-svg-data-uri@^1.2.3:
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
minimalistic-assert@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimatch@9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
@@ -8572,7 +8604,7 @@ minimatch@^9.0.4:
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.6:
minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@@ -10253,7 +10285,7 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
"safer-buffer@>= 2.1.2 < 3.0.0":
"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@@ -11507,6 +11539,17 @@ warning@^4.0.2:
dependencies:
loose-envify "^1.0.0"
web-push@3.6.7:
version "3.6.7"
resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.6.7.tgz#5f5e645951153e37ef90a6ddea5c150ea0f709e1"
integrity sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==
dependencies:
asn1.js "^5.3.0"
http_ece "1.2.0"
https-proxy-agent "^7.0.0"
jws "^4.0.0"
minimist "^1.2.5"
web-streams-polyfill@^3.0.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"