feat: sound on browser push notification (#18548)

* feat: sound on notification

* refactor: add load audio

* chore: remove

* fix: type error

* fix: notification

* fix: prevent multiple initialization
=

* feat: create notification page

* chore: update the mp3 file
This commit is contained in:
Udit Takkar
2025-03-11 11:49:51 +05:30
committed by GitHub
parent 4daad2ac08
commit b0125e1a05
16 changed files with 416 additions and 237 deletions
@@ -35,6 +35,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
{ name: "conferencing", href: "/settings/my-account/conferencing" },
{ name: "appearance", href: "/settings/my-account/appearance" },
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
{ name: "push_notifications", href: "/settings/my-account/push-notifications" },
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
+3
View File
@@ -6,6 +6,7 @@ import React from "react";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { IconSprites } from "@calcom/ui";
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { prepareRootMetadata } from "@lib/metadata";
@@ -124,6 +125,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
]}
/>
<Providers dehydratedState={ssr.dehydrate()}>{children}</Providers>
{!isEmbed && <NotificationSoundHandler />}
<NotificationSoundHandler />
</body>
</html>
);
+5 -1
View File
@@ -5,6 +5,8 @@ import { TrpcProvider } from "app/_trpc/trpc-provider";
import { SessionProvider } from "next-auth/react";
import CacheProvider from "react-inlinesvg/provider";
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import PlainChat from "@lib/plain/dynamicProvider";
@@ -20,7 +22,9 @@ export function Providers({ children, dehydratedState }: ProvidersProps) {
<TrpcProvider dehydratedState={dehydratedState}>
{!isBookingPage ? <PlainChat /> : null}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>{children}</CacheProvider>
<CacheProvider>
<WebPushProvider>{children}</WebPushProvider>
</CacheProvider>
</TrpcProvider>
</SessionProvider>
);
@@ -0,0 +1,27 @@
import { getTranslate } from "app/_utils";
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import PushNotificationsView from "~/settings/my-account/push-notifications-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("push_notifications"),
(t) => t("push_notifications_description")
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader
title={t("push_notifications")}
description={t("push_notifications_description")}
borderInShellHeader={true}>
<PushNotificationsView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useRef } from "react";
export function NotificationSoundHandler() {
const audioContextRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
const initializeAudioSystem = async () => {
try {
// Only create a new context if we don't have one or if it's closed
if (!audioContextRef.current || audioContextRef.current.state === "closed") {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
if (audioContextRef.current.state === "suspended") {
await audioContextRef.current.resume();
}
if (!audioBufferRef.current) {
const response = await fetch("/ring.mp3");
const arrayBuffer = await response.arrayBuffer();
audioBufferRef.current = await audioContextRef.current.decodeAudioData(arrayBuffer);
console.log("Audio file loaded and decoded");
}
return true;
} catch (error) {
console.error("Failed to initialize audio system:", error);
return false;
}
};
const playSound = async () => {
try {
// Ensure audio system is initialized
if (!audioContextRef.current || audioContextRef.current.state === "closed") {
const initialized = await initializeAudioSystem();
if (!initialized) return;
}
if (!audioContextRef.current) return;
// Resume context if suspended
if (audioContextRef.current.state === "suspended") {
await audioContextRef.current.resume();
}
// Stop any currently playing sound
if (sourceRef.current) {
sourceRef.current.stop();
sourceRef.current.disconnect();
sourceRef.current = null;
}
// Create and play new sound
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBufferRef.current;
source.connect(audioContextRef.current.destination);
sourceRef.current = source;
source.loop = true;
source.start(0);
console.log("Sound started playing");
setTimeout(() => {
if (sourceRef.current === source) {
source.stop();
source.disconnect();
sourceRef.current = null;
}
}, 7000);
} catch (error) {
console.error("Error playing sound:", error);
// Try to reinitialize on error
audioContextRef.current = null;
audioBufferRef.current = null;
}
};
const stopSound = () => {
if (sourceRef.current) {
sourceRef.current.stop();
sourceRef.current.disconnect();
sourceRef.current = null;
}
};
useEffect(() => {
// Don't automatically initialize - wait for user interaction
return () => {
stopSound();
};
}, []);
useEffect(() => {
if (!("serviceWorker" in navigator)) {
console.log("ServiceWorker not available");
return;
}
const messageHandler = async (event: MessageEvent) => {
if (event.data.type === "PLAY_NOTIFICATION_SOUND") {
// Only initialize if not already initialized
if (!audioBufferRef.current) {
await initializeAudioSystem();
}
await playSound();
}
if (event.data.type === "STOP_NOTIFICATION_SOUND") {
stopSound();
}
};
navigator.serviceWorker.addEventListener("message", messageHandler);
return () => navigator.serviceWorker.removeEventListener("message", messageHandler);
}, []);
// Single click handler for initial audio setup
useEffect(() => {
const handleFirstInteraction = async () => {
// Only initialize if not already initialized
if (!audioBufferRef.current) {
await initializeAudioSystem();
}
document.removeEventListener("click", handleFirstInteraction);
document.removeEventListener("touchstart", handleFirstInteraction);
};
document.addEventListener("click", handleFirstInteraction);
document.addEventListener("touchstart", handleFirstInteraction);
return () => {
document.removeEventListener("click", handleFirstInteraction);
document.removeEventListener("touchstart", handleFirstInteraction);
};
}, []);
return null;
}
@@ -0,0 +1,20 @@
"use client";
import { useWebPush } from "@calcom/features/notifications/WebPushContext";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
const PushNotificationsView = () => {
const { t } = useLocale();
const { subscribe, unsubscribe, isSubscribed, isLoading } = useWebPush();
return (
<div className="border-subtle rounded-b-xl border-x border-b px-4 pb-10 pt-8 sm:px-6">
<Button color="primary" onClick={isSubscribed ? unsubscribe : subscribe} disabled={isLoading}>
{isSubscribed ? t("disable_browser_notifications") : t("allow_browser_notifications")}
</Button>
</div>
);
};
export default PushNotificationsView;
+8 -11
View File
@@ -1,9 +1,10 @@
import type { IncomingMessage } from "http";
import { SessionProvider } from "next-auth/react";
import type { AppContextType } from "next/dist/shared/lib/utils";
import React, { useEffect } from "react";
import React from "react";
import CacheProvider from "react-inlinesvg/provider";
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
import { trpc } from "@calcom/trpc/react";
import type { AppProps } from "@lib/app-providers";
@@ -13,18 +14,14 @@ import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps } = props;
useEffect(() => {
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
}
}, []);
return (
<SessionProvider session={pageProps.session ?? undefined}>
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
{Component.PageWrapper ? <Component.PageWrapper {...props} /> : <Component {...pageProps} />}
</CacheProvider>
<WebPushProvider>
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
{Component.PageWrapper ? <Component.PageWrapper {...props} /> : <Component {...pageProps} />}
</CacheProvider>
</WebPushProvider>
</SessionProvider>
);
}
Binary file not shown.
+80 -31
View File
@@ -1,4 +1,6 @@
self.addEventListener("push", async (event) => {
if (!event.data) return
let notificationData = event.data.json();
const allClients = await clients.matchAll({
@@ -11,43 +13,82 @@ self.addEventListener("push", async (event) => {
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()}`,
};
const title = notificationData.title || "New Cal.com Notification";
const image = notificationData.icon || "https://cal.com/api/logo?type=icon";
const existingNotifications = await self.registration.getNotifications();
event.waitUntil(
(async () => {
try {
// Close notifications with the same tag if it exists
const existingNotifications = await self.registration.getNotifications({
tag: notificationData.tag
});
// 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);
});
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,
};
// Show the new notification
self.registration.showNotification(title, newNotificationOptions);
self.registration.showNotification(notification.title, options);
});
// Special handling for instant meetings
if (notificationData.data?.type === "INSTANT_MEETING") {
allClients.forEach(client => {
client.postMessage({
type: 'PLAY_NOTIFICATION_SOUND'
});
});
}
const notificationOptions = {
body: notificationData.body,
icon: image,
badge: image,
data: notificationData.data,
tag: notificationData.tag || `cal-notification-${Date.now()}`,
renotify: true,
requireInteraction: notificationData.requireInteraction ?? true,
actions: notificationData.actions || [],
vibrate: [200, 100, 200],
urgency: 'high'
};
console.log("notificationOptions", notificationOptions);
await self.registration.showNotification(title, notificationOptions);
console.log("Notification shown successfully");
} catch (error) {
console.error("Error showing notification:", error);
}
})()
);
});
self.addEventListener("notificationclick", (event) => {
if (event.notification.data?.type === "INSTANT_MEETING") {
const stopSound = async () => {
const allClients = await clients.matchAll({
type: "window",
includeUncontrolled: true,
});
allClients.forEach(client => {
client.postMessage({
type: 'STOP_NOTIFICATION_SOUND'
});
});
};
}
if (!event.action) {
// Normal Notification Click
event.notification.close();
@@ -63,3 +104,11 @@ self.addEventListener("notificationclick", (event) => {
break;
}
});
self.addEventListener('install', (event) => {
console.log('Service Worker installing.');
});
self.addEventListener('activate', (event) => {
console.log('Service Worker activated.');
});
@@ -494,6 +494,8 @@
"browser_notifications_turned_off": "Browser Notifications turned off",
"browser_notifications_denied": "Browser Notifications denied",
"please_allow_notifications": "Please allow notifications from the prompt",
"push_notifications": "Push Notifications",
"push_notifications_description": "Receive push notifications when booker submits instant meeting booking.",
"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",
@@ -143,14 +143,8 @@ const triggerBrowserNotifications = async (args: {
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",
},
],
type: "INSTANT_MEETING",
requireInteraction: false,
});
});
@@ -0,0 +1,120 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui";
interface WebPushContextProps {
permission: NotificationPermission;
isLoading: boolean;
isSubscribed: boolean;
subscribe: () => Promise<void>;
unsubscribe: () => Promise<void>;
}
const WebPushContext = createContext<WebPushContextProps | null>(null);
interface ProviderProps {
children: React.ReactNode;
}
export function WebPushProvider({ children }: ProviderProps) {
const [permission, setPermission] = useState<NotificationPermission>(() =>
typeof window !== "undefined" && "Notification" in window ? Notification.permission : "denied"
);
const [pushManager, setPushManager] = useState<PushManager | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const { mutate: addSubscription } = trpc.viewer.addNotificationsSubscription.useMutation();
const { mutate: removeSubscription } = trpc.viewer.removeNotificationsSubscription.useMutation();
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker
.register("/service-worker.js")
.then(async (registration) => {
if ("pushManager" in registration) {
setPushManager(registration.pushManager);
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
}
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}, []);
const contextValue = useMemo(
() => ({
permission,
isLoading,
isSubscribed,
subscribe: async () => {
try {
setIsLoading(true);
const newPermission = await Notification.requestPermission();
setPermission(newPermission);
if (newPermission === "granted" && pushManager) {
const subscription = await pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ""),
});
addSubscription({ subscription: JSON.stringify(subscription) });
setIsSubscribed(true);
showToast("Notifications enabled successfully", "success");
}
} catch (error) {
console.error("Failed to subscribe:", error);
showToast("Failed to enable notifications", "error");
} finally {
setIsLoading(false);
}
},
unsubscribe: async () => {
if (!pushManager) return;
try {
setIsLoading(true);
const subscription = await pushManager.getSubscription();
if (subscription) {
const subscriptionJson = JSON.stringify(subscription);
await subscription.unsubscribe();
removeSubscription({ subscription: subscriptionJson });
setIsSubscribed(false);
showToast("Notifications disabled successfully", "success");
}
} catch (error) {
console.error("Failed to unsubscribe:", error);
showToast("Failed to disable notifications", "error");
} finally {
setIsLoading(false);
}
},
}),
[permission, isLoading, isSubscribed, pushManager, addSubscription, removeSubscription]
);
return <WebPushContext.Provider value={contextValue}>{children}</WebPushContext.Provider>;
}
export function useWebPush() {
const context = useContext(WebPushContext);
if (!context) {
throw new Error("useWebPush must be used within a WebPushProvider");
}
return context;
}
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;
};
@@ -6,7 +6,7 @@ const vapidKeys = {
};
// 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);
webpush.setVapidDetails("mailto:support@cal.com", vapidKeys.publicKey, vapidKeys.privateKey);
type Subscription = {
endpoint: string;
@@ -24,6 +24,7 @@ export const sendNotification = async ({
url,
actions,
requireInteraction,
type = "INSTANT_MEETING",
}: {
subscription: Subscription;
title: string;
@@ -32,6 +33,7 @@ export const sendNotification = async ({
url?: string;
actions?: { action: string; title: string; type: string; image: string | null }[];
requireInteraction?: boolean;
type?: string;
}) => {
try {
const payload = JSON.stringify({
@@ -40,9 +42,11 @@ export const sendNotification = async ({
icon,
data: {
url,
type,
},
actions,
requireInteraction,
tag: `cal-notification-${Date.now()}`,
});
await webpush.sendNotification(subscription, payload);
} catch (error) {
-20
View File
@@ -13,7 +13,6 @@ import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog
import { APP_NAME } from "@calcom/lib/constants";
import { useFormbricks } from "@calcom/lib/formbricks-client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useNotifications } from "@calcom/lib/hooks/useNotifications";
import { Button, ErrorBoundary, HeadSeo, SkeletonText } from "@calcom/ui";
import classNames from "@calcom/ui/classNames";
@@ -137,8 +136,6 @@ export function ShellMain(props: LayoutProps) {
const router = useRouter();
const { isLocaleReady, t } = useLocale();
const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications();
return (
<>
{(props.heading || !!props.backPath) && (
@@ -195,23 +192,6 @@ export function ShellMain(props: LayoutProps) {
</div>
)}
{props.actions && props.actions}
{/* TODO: temporary hide push notifications {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>
-165
View File
@@ -1,165 +0,0 @@
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;
};
@@ -59,6 +59,7 @@ export const addNotificationsSubscriptionHandler = async ({ ctx, input }: AddSec
body: "Push Notifications activated successfully",
url: "https://app.cal.com/",
requireInteraction: false,
type: "TEST_NOTIFICATION",
});
return {