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