Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -124,6 +125,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
]}
/>
<Providers dehydratedState={ssr.dehydrate()}>{children}</Providers>
{!isEmbed && <NotificationSoundHandler />}
<NotificationSoundHandler />
</body>
</html>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
142 changes: 142 additions & 0 deletions apps/web/components/notification-sound-handler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use client";

import { useEffect, useRef } from "react";

export function NotificationSoundHandler() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required to load the audio as soon as user interacts with our site.

https://developer.chrome.com/blog/autoplay

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;
}
20 changes: 20 additions & 0 deletions apps/web/modules/settings/my-account/push-notifications-view.tsx
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 8 additions & 11 deletions apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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>
);
}
Expand Down
Binary file added apps/web/public/ring.mp3
Binary file not shown.
Loading
Loading