Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: browser push notifications #14888

Merged
merged 29 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7e6ee5c
feat: web push notifications feature
thepradipvc May 6, 2024
d051e3d
Revert yarn.lock changes
thepradipvc Jul 31, 2024
f995147
Merge branch 'main' into push-notifications
thepradipvc Jul 31, 2024
6306030
added new env variables requirement in .env.example
thepradipvc Aug 1, 2024
ccb096c
moved useNotifications hook in packages/lib/hooks
thepradipvc Aug 2, 2024
e6745ac
Merge branch 'main' into push-notifications
PeerRich Aug 5, 2024
0d2f182
Merge branch 'main' into push-notifications
PeerRich Aug 6, 2024
cdb019a
Merge branch 'main' into push-notifications
PeerRich Aug 21, 2024
376171f
Merge branch 'main' into push-notifications
Udit-takkar Aug 22, 2024
3515600
fix: bug
Udit-takkar Aug 22, 2024
7c28a52
use i18n
Udit-takkar Aug 22, 2024
516ceb3
chore: move to new file
Udit-takkar Aug 22, 2024
77c2dac
chore: add yarn.lock
Udit-takkar Aug 22, 2024
9065229
Merge branch 'main' into push-notifications
PeerRich Aug 26, 2024
49b6f0a
Update .env.example
PeerRich Aug 26, 2024
b2a047a
chore: add instruction for brave browser
Udit-takkar Aug 26, 2024
fd66721
fix: tooltip
Udit-takkar Aug 26, 2024
1e07b2a
Merge branch 'main' into push-notifications
CarinaWolli Aug 26, 2024
9b102c1
Merge branch 'main' into push-notifications
Amit91848 Aug 26, 2024
6ff00f0
Merge branch 'main' into push-notifications
Udit-takkar Aug 27, 2024
77dcfe9
chore: use enum
Udit-takkar Aug 28, 2024
18a5476
chore
Udit-takkar Aug 28, 2024
8d24843
small update
anikdhabal Aug 28, 2024
7e3b1c4
Merge branch 'main' into push-notifications
CarinaWolli Aug 28, 2024
096069e
Merge branch 'main' into push-notifications
anikdhabal Aug 30, 2024
f7ac3c3
Merge branch 'main' into push-notifications
anikdhabal Aug 30, 2024
109f842
chore: flaky test
Udit-takkar Sep 2, 2024
6f3b87b
Merge branch 'main' into push-notifications
Udit-takkar Sep 2, 2024
6519e0a
fix: tests
Udit-takkar Sep 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
PeerRich marked this conversation as resolved.
Show resolved Hide resolved
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
10 changes: 9 additions & 1 deletion 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,6 +11,14 @@ import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps } = props;

useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
} else {
console.log("Service workers not supported");
}
}, []);

if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
return <Component {...pageProps} />;
}
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",
Udit-takkar marked this conversation as resolved.
Show resolved Hide resolved
"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);
Comment on lines +107 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can enable push notifications on brave by enabling this and relaunching browser

Screenshot 2024-08-22 at 1 35 33 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, so I was wondering if there is some browser API that we can call on the user's confirmation to enable this for them on the brave browser. What do you say?

Copy link
Contributor Author

@thepradipvc thepradipvc Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw there is no such option, but should we guide user by showing screenshot? and probably redirect them to this setting?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

brave://settings/?search=push+messaging

You can add this link and it would redirect to settings page with option highlighted. No need for screenshot.

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
Loading