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: Added notifications feature #14716

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/web/lib/hooks/useNotifications.tsx
@@ -0,0 +1,114 @@
import { useState, useEffect, useCallback } from "react";
import type { PermissionState } from "web-notifications";

import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui";

const NOTIFY_AGAIN_AFTER = 1000 * 60 * 60;

export const useNotifications = () => {
const [currentNotificationPermission, setCurrentNotificationPermission] =
useState<PermissionState>("granted");

useEffect(() => {
if ("Notification" in window) {
setCurrentNotificationPermission(Notification.permission);
} else {
setCurrentNotificationPermission("default");
}
}, []);

const notify = useCallback(
({
title,
body,
href,
notificationId,
}: {
title: string;
body: string;
href: string;
notificationId: string;
}) => {
if (currentNotificationPermission === "granted" && shouldNotify(notificationId)) {
const notification = new Notification(title, {
icon: "/cal-com-icon.svg",
body,
tag: notificationId,
});

storeEventNotification(notificationId);

notification.onclick = () => {
window.open(href);
};
}
},
[currentNotificationPermission]
);

const { data: unConfirmedBookings } = trpc.viewer.bookings.getUnconfirmedBookings.useQuery();

if (unConfirmedBookings) {
console.log(currentNotificationPermission);
for (const booking of unConfirmedBookings) {
notify({
title: "Unconfirmed Booking",
body: booking.title,
href: "/bookings/unconfirmed",
notificationId: booking.id,
});
}
}

const requestNotificationsPermission = async () => {
if ("Notification" in window) {
const permissionResponse = await Notification.requestPermission();
setCurrentNotificationPermission(permissionResponse);

if (permissionResponse === "granted") {
showToast("Notifications turned on", "success");
} else if (permissionResponse === "denied") {
showToast("You denied the notifications", "warning");
} else {
showToast("Please allow the notifications from prompt window", "warning");
}
} else {
showToast("Your browser does not support Notifications. Please update your browser.", "error");
}
};

return {
currentNotificationPermission,
requestNotificationsPermission,
};
};

const NOTIFICATION_DATA_KEY = "notifications_data";

// Helper function to get the stored notification data from localStorage
function getStoredNotificationData(): EventNotificationData {
const storedData = localStorage.getItem(NOTIFICATION_DATA_KEY);
return storedData ? JSON.parse(storedData) : {};
}

// Helper function to store the notification data in localStorage
function storeNotificationData(data: EventNotificationData) {
localStorage.setItem(NOTIFICATION_DATA_KEY, JSON.stringify(data));
}

// Function to store the notification ID and last notification time
function storeEventNotification(notificationId: string) {
const currentTime = Date.now();
const storedData = getStoredNotificationData();
storedData[notificationId] = currentTime;
storeNotificationData(storedData);
}

// Function to check if it's time to notify the user again for a particular event
function shouldNotify(notificationId: string) {
const storedData = getStoredNotificationData();
const lastNotifiedTimestamp = storedData[notificationId] || 0;
const currentTime = Date.now();
return currentTime - lastNotifiedTimestamp >= NOTIFY_AGAIN_AFTER;
}
8 changes: 8 additions & 0 deletions packages/features/shell/Shell.tsx
Expand Up @@ -85,6 +85,8 @@ import {
} from "@calcom/ui";
import { Discord } from "@calcom/ui/components/icon/Discord";

import { useNotifications } from "@lib/hooks/useNotifications";

import { useOrgBranding } from "../ee/organizations/context/provider";
import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
import { TeamInviteBadge } from "./TeamInviteBadge";
Expand Down Expand Up @@ -1004,6 +1006,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
export function ShellMain(props: LayoutProps) {
const router = useRouter();
const { isLocaleReady } = useLocale();
const { currentNotificationPermission, requestNotificationsPermission } = useNotifications();

return (
<>
Expand Down Expand Up @@ -1062,6 +1065,11 @@ export function ShellMain(props: LayoutProps) {
</div>
)}
{props.actions && props.actions}
{props.heading === "Bookings" && currentNotificationPermission !== "granted" && (
<Button color="primary" onClick={requestNotificationsPermission}>
Allow Notifications
</Button>
)}
</header>
)}
</div>
Expand Down
18 changes: 18 additions & 0 deletions packages/trpc/server/routers/viewer/bookings/_router.tsx
Expand Up @@ -18,6 +18,7 @@ type BookingsRouterHandlerCache = {
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
find?: typeof import("./find.handler").getHandler;
getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler;
getUnconfirmedBookings?: typeof import("./getUnconfirmedBookings.handler").getHandler;
};

const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
Expand Down Expand Up @@ -146,4 +147,21 @@ export const bookingsRouter = router({
input,
});
}),

getUnconfirmedBookings: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getUnconfirmedBookings) {
UNSTABLE_HANDLER_CACHE.getUnconfirmedBookings = await import("./getUnconfirmedBookings.handler").then(
(mod) => mod.getHandler
);
}

// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getUnconfirmedBookings) {
throw new Error("Failed to load handler");
}

return UNSTABLE_HANDLER_CACHE.getUnconfirmedBookings({
ctx,
});
}),
});
@@ -0,0 +1,32 @@
import type { PrismaClient } from "@calcom/prisma";

type GetOptions = {
ctx: {
prisma: PrismaClient;
};
};

export const getHandler = async ({ ctx }: GetOptions) => {
const { prisma, user } = ctx;

const bookings = await prisma.booking.findMany({
where: {
userId: user.id,
status: "PENDING",
},
select: {
id: true,
uid: true,
startTime: true,
endTime: true,
title: true,
description: true,
status: true,
paid: true,
eventTypeId: true,
},
});

// Don't leak anything private from the booking
return bookings;
};