Skip to content

Commit b0125e1

Browse files
authored
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
1 parent 4daad2a commit b0125e1

File tree

16 files changed

+418
-239
lines changed

16 files changed

+418
-239
lines changed

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
3535
{ name: "conferencing", href: "/settings/my-account/conferencing" },
3636
{ name: "appearance", href: "/settings/my-account/appearance" },
3737
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
38+
{ name: "push_notifications", href: "/settings/my-account/push-notifications" },
3839
// TODO
3940
// { name: "referrals", href: "/settings/my-account/referrals" },
4041
],

apps/web/app/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React from "react";
66

77
import { getLocale } from "@calcom/features/auth/lib/getLocale";
88
import { IconSprites } from "@calcom/ui";
9+
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
910

1011
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
1112
import { prepareRootMetadata } from "@lib/metadata";
@@ -124,6 +125,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
124125
]}
125126
/>
126127
<Providers dehydratedState={ssr.dehydrate()}>{children}</Providers>
128+
{!isEmbed && <NotificationSoundHandler />}
129+
<NotificationSoundHandler />
127130
</body>
128131
</html>
129132
);

apps/web/app/providers.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { TrpcProvider } from "app/_trpc/trpc-provider";
55
import { SessionProvider } from "next-auth/react";
66
import CacheProvider from "react-inlinesvg/provider";
77

8+
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
9+
810
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
911
import PlainChat from "@lib/plain/dynamicProvider";
1012

@@ -20,7 +22,9 @@ export function Providers({ children, dehydratedState }: ProvidersProps) {
2022
<TrpcProvider dehydratedState={dehydratedState}>
2123
{!isBookingPage ? <PlainChat /> : null}
2224
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
23-
<CacheProvider>{children}</CacheProvider>
25+
<CacheProvider>
26+
<WebPushProvider>{children}</WebPushProvider>
27+
</CacheProvider>
2428
</TrpcProvider>
2529
</SessionProvider>
2630
);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getTranslate } from "app/_utils";
2+
import { _generateMetadata } from "app/_utils";
3+
4+
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
5+
6+
import PushNotificationsView from "~/settings/my-account/push-notifications-view";
7+
8+
export const generateMetadata = async () =>
9+
await _generateMetadata(
10+
(t) => t("push_notifications"),
11+
(t) => t("push_notifications_description")
12+
);
13+
14+
const Page = async () => {
15+
const t = await getTranslate();
16+
17+
return (
18+
<SettingsHeader
19+
title={t("push_notifications")}
20+
description={t("push_notifications_description")}
21+
borderInShellHeader={true}>
22+
<PushNotificationsView />
23+
</SettingsHeader>
24+
);
25+
};
26+
27+
export default Page;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
5+
export function NotificationSoundHandler() {
6+
const audioContextRef = useRef<AudioContext | null>(null);
7+
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
8+
const audioBufferRef = useRef<AudioBuffer | null>(null);
9+
10+
const initializeAudioSystem = async () => {
11+
try {
12+
// Only create a new context if we don't have one or if it's closed
13+
if (!audioContextRef.current || audioContextRef.current.state === "closed") {
14+
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
15+
}
16+
17+
if (audioContextRef.current.state === "suspended") {
18+
await audioContextRef.current.resume();
19+
}
20+
21+
if (!audioBufferRef.current) {
22+
const response = await fetch("/ring.mp3");
23+
const arrayBuffer = await response.arrayBuffer();
24+
audioBufferRef.current = await audioContextRef.current.decodeAudioData(arrayBuffer);
25+
console.log("Audio file loaded and decoded");
26+
}
27+
28+
return true;
29+
} catch (error) {
30+
console.error("Failed to initialize audio system:", error);
31+
return false;
32+
}
33+
};
34+
35+
const playSound = async () => {
36+
try {
37+
// Ensure audio system is initialized
38+
if (!audioContextRef.current || audioContextRef.current.state === "closed") {
39+
const initialized = await initializeAudioSystem();
40+
if (!initialized) return;
41+
}
42+
43+
if (!audioContextRef.current) return;
44+
45+
// Resume context if suspended
46+
if (audioContextRef.current.state === "suspended") {
47+
await audioContextRef.current.resume();
48+
}
49+
50+
// Stop any currently playing sound
51+
if (sourceRef.current) {
52+
sourceRef.current.stop();
53+
sourceRef.current.disconnect();
54+
sourceRef.current = null;
55+
}
56+
57+
// Create and play new sound
58+
const source = audioContextRef.current.createBufferSource();
59+
source.buffer = audioBufferRef.current;
60+
source.connect(audioContextRef.current.destination);
61+
sourceRef.current = source;
62+
63+
source.loop = true;
64+
source.start(0);
65+
console.log("Sound started playing");
66+
67+
setTimeout(() => {
68+
if (sourceRef.current === source) {
69+
source.stop();
70+
source.disconnect();
71+
sourceRef.current = null;
72+
}
73+
}, 7000);
74+
} catch (error) {
75+
console.error("Error playing sound:", error);
76+
// Try to reinitialize on error
77+
audioContextRef.current = null;
78+
audioBufferRef.current = null;
79+
}
80+
};
81+
82+
const stopSound = () => {
83+
if (sourceRef.current) {
84+
sourceRef.current.stop();
85+
sourceRef.current.disconnect();
86+
sourceRef.current = null;
87+
}
88+
};
89+
90+
useEffect(() => {
91+
// Don't automatically initialize - wait for user interaction
92+
return () => {
93+
stopSound();
94+
};
95+
}, []);
96+
97+
useEffect(() => {
98+
if (!("serviceWorker" in navigator)) {
99+
console.log("ServiceWorker not available");
100+
return;
101+
}
102+
103+
const messageHandler = async (event: MessageEvent) => {
104+
if (event.data.type === "PLAY_NOTIFICATION_SOUND") {
105+
// Only initialize if not already initialized
106+
if (!audioBufferRef.current) {
107+
await initializeAudioSystem();
108+
}
109+
await playSound();
110+
}
111+
112+
if (event.data.type === "STOP_NOTIFICATION_SOUND") {
113+
stopSound();
114+
}
115+
};
116+
117+
navigator.serviceWorker.addEventListener("message", messageHandler);
118+
return () => navigator.serviceWorker.removeEventListener("message", messageHandler);
119+
}, []);
120+
121+
// Single click handler for initial audio setup
122+
useEffect(() => {
123+
const handleFirstInteraction = async () => {
124+
// Only initialize if not already initialized
125+
if (!audioBufferRef.current) {
126+
await initializeAudioSystem();
127+
}
128+
document.removeEventListener("click", handleFirstInteraction);
129+
document.removeEventListener("touchstart", handleFirstInteraction);
130+
};
131+
132+
document.addEventListener("click", handleFirstInteraction);
133+
document.addEventListener("touchstart", handleFirstInteraction);
134+
135+
return () => {
136+
document.removeEventListener("click", handleFirstInteraction);
137+
document.removeEventListener("touchstart", handleFirstInteraction);
138+
};
139+
}, []);
140+
141+
return null;
142+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"use client";
2+
3+
import { useWebPush } from "@calcom/features/notifications/WebPushContext";
4+
import { useLocale } from "@calcom/lib/hooks/useLocale";
5+
import { Button } from "@calcom/ui";
6+
7+
const PushNotificationsView = () => {
8+
const { t } = useLocale();
9+
const { subscribe, unsubscribe, isSubscribed, isLoading } = useWebPush();
10+
11+
return (
12+
<div className="border-subtle rounded-b-xl border-x border-b px-4 pb-10 pt-8 sm:px-6">
13+
<Button color="primary" onClick={isSubscribed ? unsubscribe : subscribe} disabled={isLoading}>
14+
{isSubscribed ? t("disable_browser_notifications") : t("allow_browser_notifications")}
15+
</Button>
16+
</div>
17+
);
18+
};
19+
20+
export default PushNotificationsView;

apps/web/pages/_app.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { IncomingMessage } from "http";
22
import { SessionProvider } from "next-auth/react";
33
import type { AppContextType } from "next/dist/shared/lib/utils";
4-
import React, { useEffect } from "react";
4+
import React from "react";
55
import CacheProvider from "react-inlinesvg/provider";
66

7+
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
78
import { trpc } from "@calcom/trpc/react";
89

910
import type { AppProps } from "@lib/app-providers";
@@ -13,18 +14,14 @@ import "../styles/globals.css";
1314
function MyApp(props: AppProps) {
1415
const { Component, pageProps } = props;
1516

16-
useEffect(() => {
17-
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
18-
navigator.serviceWorker.register("/service-worker.js");
19-
}
20-
}, []);
21-
2217
return (
2318
<SessionProvider session={pageProps.session ?? undefined}>
24-
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
25-
<CacheProvider>
26-
{Component.PageWrapper ? <Component.PageWrapper {...props} /> : <Component {...pageProps} />}
27-
</CacheProvider>
19+
<WebPushProvider>
20+
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
21+
<CacheProvider>
22+
{Component.PageWrapper ? <Component.PageWrapper {...props} /> : <Component {...pageProps} />}
23+
</CacheProvider>
24+
</WebPushProvider>
2825
</SessionProvider>
2926
);
3027
}

apps/web/public/ring.mp3

262 KB
Binary file not shown.

0 commit comments

Comments
 (0)