feat: instant meeting browser notifcations (#16480)

* feat: instant meeting browser notifcations

* chore: support multiple notifications

* chore: save progress

* fix: add test notification and move file

* fix: type err

* chore: feedback improvements

* chore: undo DENY
This commit is contained in:
Udit Takkar
2024-09-11 18:02:14 +04:00
committed by GitHub
parent 5bc0e1265d
commit 860d173f0a
9 changed files with 220 additions and 37 deletions
@@ -12,7 +12,6 @@ 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";
@@ -145,27 +144,6 @@ 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
@@ -174,8 +152,7 @@ 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."
actions={actions}>
description="Create events to share for people to book on your calendar.">
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
+60 -8
View File
@@ -1,16 +1,68 @@
self.addEventListener("push", (event) => {
self.addEventListener("push", async (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,
const allClients = await clients.matchAll({
type: 'window',
includeUncontrolled: true
});
if(!allClients.length) {
console.log("No open tabs, skipping the push notification.");
return;
}
const title = notificationData.title || "You have a new notification from Cal.com";
const image = "https://cal.com/api/logo?type=icon";
const newNotificationOptions = {
requireInteraction: true,
...notificationData,
icon: image,
badge: image,
data: {
url: notificationData.data?.url || "https://app.cal.com",
},
silent: false,
vibrate: [300, 100, 400],
tag: `notification-${Date.now()}-${Math.random()}`,
};
self.registration.showNotification(title, options);
const existingNotifications = await self.registration.getNotifications();
// Display each existing notification again to make sure old ones can still be clicked
existingNotifications.forEach((notification) => {
const options = {
body: notification.body,
icon: notification.icon,
badge: notification.badge,
data: notification.data,
silent: notification.silent,
vibrate: notification.vibrate,
requireInteraction: notification.requireInteraction,
tag: notification.tag,
};
self.registration.showNotification(notification.title, options);
});
// Show the new notification
self.registration.showNotification(title, newNotificationOptions);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.targetURL || "https://app.cal.com"));
if (!event.action) {
// Normal Notification Click
event.notification.close();
const url = event.notification.data.url;
event.waitUntil(self.clients.openWindow(url));
}
switch (event.action) {
case 'connect-action':
event.notification.close();
const url = event.notification.data.url;
event.waitUntil(self.clients.openWindow(url));
break;
}
});
@@ -12,6 +12,7 @@ import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNe
import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData";
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
import { getFullName } from "@calcom/features/form-builder/utils";
import { sendNotification } from "@calcom/features/notifications/sendNotification";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -21,6 +22,8 @@ import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { subscriptionSchema } from "./schema";
const handleInstantMeetingWebhookTrigger = async (args: {
eventTypeId: number;
webhookData: Record<string, unknown>;
@@ -86,6 +89,74 @@ const handleInstantMeetingWebhookTrigger = async (args: {
}
};
const triggerBrowserNotifications = async (args: {
title: string;
connectAndJoinUrl: string;
teamId?: number | null;
}) => {
const { title, connectAndJoinUrl, teamId } = args;
if (!teamId) {
logger.warn("No teamId provided, skipping browser notification trigger");
return;
}
const subscribers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
user: {
select: {
id: true,
NotificationsSubscriptions: {
select: {
id: true,
subscription: true,
},
},
},
},
},
});
const promises = subscribers.map((sub) => {
const subscription = sub.user?.NotificationsSubscriptions?.[0]?.subscription;
if (!subscription) return Promise.resolve();
const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription));
if (!parsedSubscription.success) {
logger.error("Invalid subscription", parsedSubscription.error, JSON.stringify(sub.user));
return Promise.resolve();
}
return sendNotification({
subscription: {
endpoint: parsedSubscription.data.endpoint,
keys: {
auth: parsedSubscription.data.keys.auth,
p256dh: parsedSubscription.data.keys.p256dh,
},
},
title: title,
body: "User is waiting for you to join. Click to Connect",
url: connectAndJoinUrl,
actions: [
{
action: "connect-action",
title: "Connect and join",
type: "button",
image: "https://cal.com/api/logo?type=icon",
},
],
});
});
await Promise.allSettled(promises);
};
export type HandleInstantMeetingResponse = {
message: string;
meetingTokenId: number;
@@ -252,6 +323,12 @@ async function handler(req: NextApiRequest) {
teamId: eventType.team?.id,
});
await triggerBrowserNotifications({
title: newBooking.title,
connectAndJoinUrl: webhookData.connectAndJoinUrl,
teamId: eventType.team?.id,
});
return {
message: "Success",
meetingTokenId: instantMeetingToken.id,
@@ -0,0 +1,10 @@
import { z } from "zod";
export const subscriptionSchema = z.object({
endpoint: z.string(),
expirationTime: z.any().optional(),
keys: z.object({
auth: z.string(),
p256dh: z.string(),
}),
});
@@ -21,17 +21,28 @@ export const sendNotification = async ({
title,
body,
icon,
url,
actions,
requireInteraction,
}: {
subscription: Subscription;
title: string;
body: string;
icon?: string;
url?: string;
actions?: { action: string; title: string; type: string; image: string | null }[];
requireInteraction?: boolean;
}) => {
try {
const payload = JSON.stringify({
title,
body,
icon,
data: {
url,
},
actions,
requireInteraction,
});
await webpush.sendNotification(subscription, payload);
} catch (error) {
+21 -1
View File
@@ -53,6 +53,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client";
import getBrandColours from "@calcom/lib/getBrandColours";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications";
import useTheme from "@calcom/lib/hooks/useTheme";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import { localStorage } from "@calcom/lib/webstorage";
@@ -1084,7 +1085,9 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
export function ShellMain(props: LayoutProps) {
const router = useRouter();
const { isLocaleReady } = useLocale();
const { isLocaleReady, t } = useLocale();
const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications();
return (
<>
@@ -1144,6 +1147,23 @@ export function ShellMain(props: LayoutProps) {
</div>
)}
{props.actions && props.actions}
{props.heading === "Bookings" && 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>
)}
</header>
)}
</div>
+1
View File
@@ -92,6 +92,7 @@ export const useNotifications = () => {
}
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;
@@ -1,6 +1,11 @@
import { subscriptionSchema } from "@calcom/features/instant-meeting/schema";
import { sendNotification } from "@calcom/features/notifications/sendNotification";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TAddNotificationsSubscriptionInputSchema } from "./addNotificationsSubscription.schema";
type AddSecondaryEmailOptions = {
@@ -10,20 +15,52 @@ type AddSecondaryEmailOptions = {
input: TAddNotificationsSubscriptionInputSchema;
};
const log = logger.getSubLogger({ prefix: ["[addNotificationsSubscriptionHandler]"] });
export const addNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => {
const { user } = ctx;
const { subscription } = input;
const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription));
if (!parsedSubscription.success) {
log.error("Invalid subscription", parsedSubscription.error, JSON.stringify(subscription));
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid subscription",
});
}
const existingSubscription = await prisma.notificationsSubscriptions.findFirst({
where: { userId: user.id, subscription },
where: { userId: user.id },
});
if (!existingSubscription) {
await prisma.notificationsSubscriptions.create({
data: { userId: user.id, subscription },
});
} else {
await prisma.notificationsSubscriptions.update({
where: { id: existingSubscription.id },
data: { userId: user.id, subscription },
});
}
// send test notification
sendNotification({
subscription: {
endpoint: parsedSubscription.data.endpoint,
keys: {
auth: parsedSubscription.data.keys.auth,
p256dh: parsedSubscription.data.keys.p256dh,
},
},
title: "Test Notification",
body: "Push Notifications activated successfully",
url: "https://app.cal.com/",
requireInteraction: false,
});
return {
message: "Subscription added successfully",
};
@@ -10,15 +10,13 @@ type AddSecondaryEmailOptions = {
input: TRemoveNotificationsSubscriptionInputSchema;
};
export const removeNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => {
export const removeNotificationsSubscriptionHandler = async ({ ctx }: 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,
},
});