Skip to content

Commit

Permalink
feat: browser push notifications (#14888)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
9 people authored Sep 2, 2024
1 parent 5d8ed13 commit 779eb19
Show file tree
Hide file tree
Showing 17 changed files with 465 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
25 changes: 24 additions & 1 deletion apps/web/modules/bookings/views/bookings-listing-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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} />
Expand Down
13 changes: 10 additions & 3 deletions apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions apps/web/public/service-worker.js
Original file line number Diff line number Diff line change
@@ -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"));
});
8 changes: 8 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions packages/features/notifications/sendNotification.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
4 changes: 3 additions & 1 deletion packages/features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 164 additions & 0 deletions packages/lib/hooks/useNotifications.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 13 additions & 3 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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())
Expand Down
Loading

0 comments on commit 779eb19

Please sign in to comment.