feat: browser push notifications (#14888)

* feat: web push notifications feature

* Revert yarn.lock changes

* added new env variables requirement in .env.example

* moved useNotifications hook in packages/lib/hooks

* fix: bug

* use i18n

* chore: move to new file

* chore: add yarn.lock

* Update .env.example

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

* chore: add instruction for brave browser

* fix: tooltip

* chore: use enum

* chore

* small update

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
This commit is contained in:
Pradip Chaudhary
2024-09-02 14:39:51 +00:00
committed by GitHub
co-authored by Amit Sharma Peer Richelsen Udit Takkar Udit Takkar Peer Richelsen Carina Wollendorfer unknown Anik Dhabal Babu
parent 5d8ed130de
commit 779eb19e7a
17 changed files with 465 additions and 11 deletions
+5
View File
@@ -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=
@@ -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<HTMLDivElement>();
const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications();
const actions = (
<div>
{buttonToShow && (
<Button
color="primary"
onClick={buttonToShow === ButtonState.ALLOW ? enableNotifications : disableNotifications}
loading={isLoading}
disabled={buttonToShow === ButtonState.DENIED}
tooltipSide="bottom"
tooltip={buttonToShow === ButtonState.DENIED ? t("you_have_denied_notifications") : undefined}>
{t(
buttonToShow === ButtonState.DISABLE
? "disable_browser_notifications"
: "allow_browser_notifications"
)}
</Button>
)}
</div>
);
return (
<Shell
@@ -152,7 +174,8 @@ export default function Bookings() {
heading={t("bookings")}
subtitle={t("bookings_description")}
title="Bookings"
description="Create events to share for people to book on your calendar.">
description="Create events to share for people to book on your calendar."
actions={actions}>
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
+10 -3
View File
@@ -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 <Component {...pageProps} />;
useEffect(() => {
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
}
}, []);
const content = Component.PageWrapper ? <Component.PageWrapper {...props} /> : <Component {...pageProps} />;
return content;
}
declare global {
+16
View File
@@ -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"));
});
@@ -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",
@@ -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);
}
};
+3 -1
View File
@@ -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"
}
}
+164
View File
@@ -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>(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;
};
@@ -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;
+13 -3
View File
@@ -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())
@@ -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);
}),
});
@@ -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<TrpcSessionUser>;
};
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",
};
};
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZAddNotificationsSubscriptionInputSchema = z.object({
subscription: z.string(),
});
export type TAddNotificationsSubscriptionInputSchema = z.infer<
typeof ZAddNotificationsSubscriptionInputSchema
>;
@@ -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<TrpcSessionUser>;
};
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",
};
};
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZRemoveNotificationsSubscriptionInputSchema = z.object({
subscription: z.string(),
});
export type TRemoveNotificationsSubscriptionInputSchema = z.infer<
typeof ZRemoveNotificationsSubscriptionInputSchema
>;
+4 -1
View File
@@ -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"
]
}
+44 -1
View File
@@ -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"