diff --git a/.env.example b/.env.example index a114365bdf105e..3fc9850f2d432d 100644 --- a/.env.example +++ b/.env.example @@ -369,6 +369,11 @@ RETELL_AI_KEY= # Used to disallow emails as being added as guests on bookings BLACKLISTED_GUEST_EMAILS= +# Used to allow browser push notifications +# You can use: 'npx web-push generate-vapid-keys' to generate these keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + # Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs) NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL= NEXT_PUBLIC_WEBSITE_TERMS_URL= diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index 952592f69b4052..5ecef7c6de77ca 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -12,6 +12,7 @@ import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQ import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery"; import Shell from "@calcom/features/shell/Shell"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -144,6 +145,27 @@ export default function Bookings() { )[0] || []; const [animationParentRef] = useAutoAnimate(); + const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications(); + + const actions = ( +
+ {buttonToShow && ( + + )} +
+ ); return ( + description="Create events to share for people to book on your calendar." + actions={actions}>
diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 511bd9eb954f71..179a01531afd07 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -1,6 +1,6 @@ import type { IncomingMessage } from "http"; import type { AppContextType } from "next/dist/shared/lib/utils"; -import React from "react"; +import React, { useEffect } from "react"; import { trpc } from "@calcom/trpc/react"; @@ -11,8 +11,15 @@ import "../styles/globals.css"; function MyApp(props: AppProps) { const { Component, pageProps } = props; - if (Component.PageWrapper !== undefined) return Component.PageWrapper(props); - return ; + useEffect(() => { + if (typeof window !== "undefined" && "serviceWorker" in navigator) { + navigator.serviceWorker.register("/service-worker.js"); + } + }, []); + + const content = Component.PageWrapper ? : ; + + return content; } declare global { diff --git a/apps/web/public/service-worker.js b/apps/web/public/service-worker.js new file mode 100644 index 00000000000000..266ac7ea3b46c0 --- /dev/null +++ b/apps/web/public/service-worker.js @@ -0,0 +1,16 @@ +self.addEventListener("push", (event) => { + let notificationData = event.data.json(); + + const title = notificationData.title || "You have new notification from Cal.com"; + const image ="/cal-com-icon.svg"; + const options = { + ...notificationData.options, + icon: image, + }; + self.registration.showNotification(title, options); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(self.clients.openWindow(event.notification.data.targetURL || "https://app.cal.com")); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 32c10192005fe0..51accb3d745fcb 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -461,6 +461,14 @@ "dynamic_booking": "Dynamic group links", "allow_seo_indexing": "Allow search engines to access your public content", "seo_indexing": "Allow SEO Indexing", + "allow_browser_notifications": "Allow Browser Notifications", + "you_have_denied_notifications": "You have denied notifications. Reset permission in browser settings to enable.", + "disable_browser_notifications": "Disable Browser Notifications", + "browser_notifications_turned_on": "Browser Notifications turned on", + "browser_notifications_turned_off": "Browser Notifications turned off", + "browser_notifications_denied": "Browser Notifications denied", + "please_allow_notifications": "Please allow notifications from the prompt", + "browser_notifications_not_supported": "Your browser does not support Push Notifications. If you are Brave user then enable `Use Google services for push messaging` Option on brave://settings/?search=push+messaging", "email": "Email", "email_placeholder": "jdoe@example.com", "full_name": "Full name", diff --git a/packages/features/notifications/sendNotification.ts b/packages/features/notifications/sendNotification.ts new file mode 100644 index 00000000000000..6e436cba0592cf --- /dev/null +++ b/packages/features/notifications/sendNotification.ts @@ -0,0 +1,40 @@ +import webpush from "web-push"; + +const vapidKeys = { + publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "", + privateKey: process.env.VAPID_PRIVATE_KEY || "", +}; + +// The mail to email address should be the one at which push service providers can reach you. It can also be a URL. +webpush.setVapidDetails("https://cal.com", vapidKeys.publicKey, vapidKeys.privateKey); + +type Subscription = { + endpoint: string; + keys: { + auth: string; + p256dh: string; + }; +}; + +export const sendNotification = async ({ + subscription, + title, + body, + icon, +}: { + subscription: Subscription; + title: string; + body: string; + icon?: string; +}) => { + try { + const payload = JSON.stringify({ + title, + body, + icon, + }); + await webpush.sendNotification(subscription, payload); + } catch (error) { + console.error("Error sending notification", error); + } +}; diff --git a/packages/features/package.json b/packages/features/package.json index 4f27f21a0fb167..31de0ab73dad26 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -18,9 +18,11 @@ "react-select": "^5.7.0", "react-sticky-box": "^2.0.4", "react-virtual": "^2.10.4", + "web-push": "^3.6.7", "zustand": "^4.3.2" }, "devDependencies": { - "@testing-library/react-hooks": "^8.0.1" + "@testing-library/react-hooks": "^8.0.1", + "@types/web-push": "^3.6.3" } } diff --git a/packages/lib/hooks/useNotifications.tsx b/packages/lib/hooks/useNotifications.tsx new file mode 100644 index 00000000000000..e8d5b3e44abe72 --- /dev/null +++ b/packages/lib/hooks/useNotifications.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { showToast } from "@calcom/ui"; + +export enum ButtonState { + NONE = "none", + ALLOW = "allow", + DISABLE = "disable", + DENIED = "denied", +} + +export const useNotifications = () => { + const [buttonToShow, setButtonToShow] = useState(ButtonState.NONE); + const [isLoading, setIsLoading] = useState(false); + const { t } = useLocale(); + + const { mutate: addSubscription } = trpc.viewer.addNotificationsSubscription.useMutation({ + onSuccess: () => { + setButtonToShow(ButtonState.DISABLE); + showToast(t("browser_notifications_turned_on"), "success"); + }, + onError: (error) => { + showToast(`Error: ${error.message}`, "error"); + }, + onSettled: () => { + setIsLoading(false); + }, + }); + const { mutate: removeSubscription } = trpc.viewer.removeNotificationsSubscription.useMutation({ + onSuccess: () => { + setButtonToShow(ButtonState.ALLOW); + showToast(t("browser_notifications_turned_off"), "success"); + }, + onError: (error) => { + showToast(`Error: ${error.message}`, "error"); + }, + onSettled: () => { + setIsLoading(false); + }, + }); + + useEffect(() => { + const decideButtonToShow = async () => { + if (!("Notification" in window)) { + console.log("Notifications not supported"); + } + + const registration = await navigator.serviceWorker?.getRegistration(); + if (!registration) return; + const subscription = await registration.pushManager.getSubscription(); + + const permission = Notification.permission; + + if (permission === ButtonState.DENIED) { + setButtonToShow(ButtonState.DENIED); + return; + } + + if (permission === "default") { + setButtonToShow(ButtonState.ALLOW); + return; + } + + if (!subscription) { + setButtonToShow(ButtonState.ALLOW); + return; + } + + setButtonToShow(ButtonState.DISABLE); + }; + + decideButtonToShow(); + }, []); + + const enableNotifications = async () => { + setIsLoading(true); + const permissionResponse = await Notification.requestPermission(); + + if (permissionResponse === ButtonState.DENIED) { + setButtonToShow(ButtonState.DENIED); + setIsLoading(false); + showToast(t("browser_notifications_denied"), "warning"); + return; + } + + if (permissionResponse === "default") { + setIsLoading(false); + showToast(t("please_allow_notifications"), "warning"); + return; + } + + const registration = await navigator.serviceWorker?.getRegistration(); + if (!registration) { + // This will not happen ideally as the button will not be shown if the service worker is not registered + return; + } + + let subscription: PushSubscription; + try { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlB64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ""), + }); + } catch (error) { + // This happens in Brave browser as it does not have a push service + console.error(error); + setIsLoading(false); + setButtonToShow(ButtonState.NONE); + showToast(t("browser_notifications_not_supported"), "error"); + return; + } + + addSubscription( + { subscription: JSON.stringify(subscription) }, + { + onError: async () => { + await subscription.unsubscribe(); + }, + } + ); + }; + + const disableNotifications = async () => { + setIsLoading(true); + const registration = await navigator.serviceWorker?.getRegistration(); + if (!registration) { + // This will not happen ideally as the button will not be shown if the service worker is not registered + return; + } + const subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + // This will not happen ideally as the button will not be shown if the subscription is not present + return; + } + removeSubscription( + { subscription: JSON.stringify(subscription) }, + { + onSuccess: async () => { + await subscription.unsubscribe(); + }, + } + ); + }; + + return { + buttonToShow, + isLoading, + enableNotifications, + disableNotifications, + }; +}; + +const urlB64ToUint8Array = (base64String: string) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; diff --git a/packages/prisma/migrations/20240506065443_added_notifications_subscriptions_table/migration.sql b/packages/prisma/migrations/20240506065443_added_notifications_subscriptions_table/migration.sql new file mode 100644 index 00000000000000..233e81a1d1caba --- /dev/null +++ b/packages/prisma/migrations/20240506065443_added_notifications_subscriptions_table/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "NotificationsSubscriptions" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "subscription" TEXT NOT NULL, + + CONSTRAINT "NotificationsSubscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "NotificationsSubscriptions_userId_subscription_idx" ON "NotificationsSubscriptions"("userId", "subscription"); + +-- AddForeignKey +ALTER TABLE "NotificationsSubscriptions" ADD CONSTRAINT "NotificationsSubscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d1127071b82643..67624957f3ae4f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -331,9 +331,10 @@ model User { secondaryEmails SecondaryEmail[] isPlatformManaged Boolean @default(false) - OutOfOfficeReasons OutOfOfficeReason[] - smsLockState SMSLockState @default(UNLOCKED) - smsLockReviewedByAdmin Boolean @default(false) + OutOfOfficeReasons OutOfOfficeReason[] + smsLockState SMSLockState @default(UNLOCKED) + smsLockReviewedByAdmin Boolean @default(false) + NotificationsSubscriptions NotificationsSubscriptions[] @@unique([email]) @@unique([email, username]) @@ -346,6 +347,15 @@ model User { @@map(name: "users") } +model NotificationsSubscriptions { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + subscription String + + @@index([userId, subscription]) +} + // It holds Organization Profiles as well as User Profiles for users that have been added to an organization model Profile { id Int @id @default(autoincrement()) diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index 83d0bec39dc87f..0a42498542e981 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -1,5 +1,6 @@ import authedProcedure from "../../procedures/authedProcedure"; import { importHandler, router } from "../../trpc"; +import { ZAddNotificationsSubscriptionInputSchema } from "./addNotificationsSubscription.schema"; import { ZAddSecondaryEmailInputSchema } from "./addSecondaryEmail.schema"; import { ZAppByIdInputSchema } from "./appById.schema"; import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; @@ -16,6 +17,7 @@ import { ZNoShowInputSchema } from "./markNoShow.schema"; import { ZOutOfOfficeInputSchema, ZOutOfOfficeDelete } from "./outOfOffice.schema"; import { me } from "./procedures/me"; import { teamsAndUserProfilesQuery } from "./procedures/teamsAndUserProfilesQuery"; +import { ZRemoveNotificationsSubscriptionInputSchema } from "./removeNotificationsSubscription.schema"; import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema"; import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema"; @@ -59,6 +61,8 @@ type AppsRouterHandlerCache = { addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler; getTravelSchedules?: typeof import("./getTravelSchedules.handler").getTravelSchedulesHandler; outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList; + addNotificationsSubscription?: typeof import("./addNotificationsSubscription.handler").addNotificationsSubscriptionHandler; + removeNotificationsSubscription?: typeof import("./removeNotificationsSubscription.handler").removeNotificationsSubscriptionHandler; markNoShow?: typeof import("./markNoShow.handler").markNoShow; }; @@ -503,6 +507,38 @@ export const loggedInViewerRouter = router({ return UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList(); }), + addNotificationsSubscription: authedProcedure + .input(ZAddNotificationsSubscriptionInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.addNotificationsSubscription) { + UNSTABLE_HANDLER_CACHE.addNotificationsSubscription = ( + await import("./addNotificationsSubscription.handler") + ).addNotificationsSubscriptionHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.addNotificationsSubscription) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.addNotificationsSubscription({ ctx, input }); + }), + removeNotificationsSubscription: authedProcedure + .input(ZRemoveNotificationsSubscriptionInputSchema) + .mutation(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.removeNotificationsSubscription) { + UNSTABLE_HANDLER_CACHE.removeNotificationsSubscription = ( + await import("./removeNotificationsSubscription.handler") + ).removeNotificationsSubscriptionHandler; + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.removeNotificationsSubscription) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.removeNotificationsSubscription({ ctx, input }); + }), markNoShow: authedProcedure.input(ZNoShowInputSchema).mutation(async (opts) => { if (!UNSTABLE_HANDLER_CACHE.markNoShow) { UNSTABLE_HANDLER_CACHE.markNoShow = (await import("./markNoShow.handler")).markNoShow; @@ -512,7 +548,6 @@ export const loggedInViewerRouter = router({ if (!UNSTABLE_HANDLER_CACHE.markNoShow) { throw new Error("Failed to load handler"); } - return UNSTABLE_HANDLER_CACHE.markNoShow(opts); }), }); diff --git a/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts new file mode 100644 index 00000000000000..19529ab2ddbefd --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts @@ -0,0 +1,30 @@ +import prisma from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TAddNotificationsSubscriptionInputSchema } from "./addNotificationsSubscription.schema"; + +type AddSecondaryEmailOptions = { + ctx: { + user: NonNullable; + }; + input: TAddNotificationsSubscriptionInputSchema; +}; + +export const addNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { + const { user } = ctx; + const { subscription } = input; + + const existingSubscription = await prisma.notificationsSubscriptions.findFirst({ + where: { userId: user.id, subscription }, + }); + + if (!existingSubscription) { + await prisma.notificationsSubscriptions.create({ + data: { userId: user.id, subscription }, + }); + } + + return { + message: "Subscription added successfully", + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.schema.ts b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.schema.ts new file mode 100644 index 00000000000000..25e1e9efe8ec69 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZAddNotificationsSubscriptionInputSchema = z.object({ + subscription: z.string(), +}); + +export type TAddNotificationsSubscriptionInputSchema = z.infer< + typeof ZAddNotificationsSubscriptionInputSchema +>; diff --git a/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts new file mode 100644 index 00000000000000..55e576939c5509 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts @@ -0,0 +1,36 @@ +import prisma from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TRemoveNotificationsSubscriptionInputSchema } from "./removeNotificationsSubscription.schema"; + +type AddSecondaryEmailOptions = { + ctx: { + user: NonNullable; + }; + input: TRemoveNotificationsSubscriptionInputSchema; +}; + +export const removeNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { + const { user } = ctx; + const { subscription } = input; + + // We just use findFirst because there will only be single unique subscription for a user + const subscriptionToDelete = await prisma.notificationsSubscriptions.findFirst({ + where: { + userId: user.id, + subscription, + }, + }); + + if (subscriptionToDelete) { + await prisma.notificationsSubscriptions.delete({ + where: { + id: subscriptionToDelete.id, + }, + }); + } + + return { + message: "Subscription removed successfully", + }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.schema.ts b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.schema.ts new file mode 100644 index 00000000000000..be7cd36de8ddd0 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ZRemoveNotificationsSubscriptionInputSchema = z.object({ + subscription: z.string(), +}); + +export type TRemoveNotificationsSubscriptionInputSchema = z.infer< + typeof ZRemoveNotificationsSubscriptionInputSchema +>; diff --git a/turbo.json b/turbo.json index 9e7e75d544aab2..c297d69d31a7d9 100644 --- a/turbo.json +++ b/turbo.json @@ -42,6 +42,7 @@ "STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_ORG_MONTHLY_PRICE_ID", "NEXT_PUBLIC_API_V2_URL", + "NEXT_PUBLIC_VAPID_PUBLIC_KEY", "BUILD_STANDALONE" ] }, @@ -425,6 +426,8 @@ "NEXT_PUBLIC_HEAD_SCRIPTS", "NEXT_PUBLIC_BODY_SCRIPTS", "NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED", - "NEXT_PUBLIC_API_V2_ROOT_URL" + "NEXT_PUBLIC_API_V2_ROOT_URL", + "NEXT_PUBLIC_VAPID_PUBLIC_KEY", + "VAPID_PRIVATE_KEY" ] } diff --git a/yarn.lock b/yarn.lock index 3e5cd780b2581d..43e8efcbfc8504 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4123,11 +4123,13 @@ __metadata: "@lexical/react": ^0.9.0 "@tanstack/react-table": ^8.9.3 "@testing-library/react-hooks": ^8.0.1 + "@types/web-push": ^3.6.3 framer-motion: ^10.12.8 lexical: ^0.9.0 react-select: ^5.7.0 react-sticky-box: ^2.0.4 react-virtual: ^2.10.4 + web-push: ^3.6.7 zustand: ^4.3.2 languageName: unknown linkType: soft @@ -15937,6 +15939,15 @@ __metadata: languageName: node linkType: hard +"@types/web-push@npm:^3.6.3": + version: 3.6.3 + resolution: "@types/web-push@npm:3.6.3" + dependencies: + "@types/node": "*" + checksum: 6fc6ac93dbb87d99ae1d08f065d1c80620ac69d966796d358363849903aaf6dcbf222cf3914a05b92db542289618484ef8cf5d6b50d1737d56527e9f2c65f578 + languageName: node + linkType: hard + "@types/webidl-conversions@npm:*": version: 6.1.1 resolution: "@types/webidl-conversions@npm:6.1.1" @@ -17761,7 +17772,7 @@ __metadata: languageName: node linkType: hard -"asn1.js@npm:^5.2.0": +"asn1.js@npm:^5.2.0, asn1.js@npm:^5.3.0": version: 5.4.1 resolution: "asn1.js@npm:5.4.1" dependencies: @@ -26567,6 +26578,13 @@ __metadata: languageName: node linkType: hard +"http_ece@npm:1.2.0": + version: 1.2.0 + resolution: "http_ece@npm:1.2.0" + checksum: 8b716baa83eb985104abe60fd7acdc5198e3d626e9608bdfa1de62851b6b420d89735b483d51045688e14c8fa2031a8f182ac1067394db61c307c7e541983b38 + languageName: node + linkType: hard + "https-browserify@npm:^1.0.0": version: 1.0.0 resolution: "https-browserify@npm:1.0.0" @@ -26624,6 +26642,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.0": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" + dependencies: + agent-base: ^7.0.2 + debug: 4 + checksum: 2e1a28960f13b041a50702ee74f240add8e75146a5c37fc98f1960f0496710f6918b3a9fe1e5aba41e50f58e6df48d107edd9c405c5f0d73ac260dabf2210857 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.3": version: 7.0.4 resolution: "https-proxy-agent@npm:7.0.4" @@ -42561,6 +42589,21 @@ __metadata: languageName: node linkType: hard +"web-push@npm:^3.6.7": + version: 3.6.7 + resolution: "web-push@npm:3.6.7" + 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 + bin: + web-push: src/cli.js + checksum: 306862864675a28702f378c779dfc87f5f2d717fadbcb622c8b40231e0ec074a5592251cb937b96a7e85dcfeafd475d12f0ec9044be210134ad386872d8e56ec + languageName: node + linkType: hard + "web-streams-polyfill@npm:4.0.0-beta.1": version: 4.0.0-beta.1 resolution: "web-streams-polyfill@npm:4.0.0-beta.1"