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:
@@ -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} />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+38
-1
@@ -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",
|
||||
};
|
||||
|
||||
+1
-3
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user