diff --git a/src/App.tsx b/src/App.tsx
index 9f69d76..f81d39a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,7 +16,7 @@ export default function App() {
} = useGateway();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
- const { notify } = useNotifications();
+ const { notify, soundEnabled, toggleSound } = useNotifications();
const prevMessageCountRef = useRef(messages.length);
// Notify on new assistant messages when tab is not focused
@@ -75,7 +75,7 @@ export default function App() {
onClose={() => setSidebarOpen(false)}
/>
+ {onToggleSound && (
+
+ )}
{status === 'connected' ? (
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index a83e1eb..9d48889 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -1,15 +1,22 @@
import { useState, useEffect, useCallback, useRef } from 'react';
+import { playNotificationSound } from '../lib/notificationSound';
const ORIGINAL_TITLE = document.title;
+const SOUND_KEY = 'pinchchat-notification-sound';
+const hasNotificationAPI = typeof Notification !== 'undefined';
/**
- * Hook that manages browser notifications and tab title badge
- * when new messages arrive while the tab is not focused.
+ * Hook that manages browser notifications, tab title badge,
+ * and notification sounds when new messages arrive while the tab is not focused.
*/
export function useNotifications() {
const [unreadCount, setUnreadCount] = useState(0);
+ const [soundEnabled, setSoundEnabled] = useState(() => {
+ const stored = localStorage.getItem(SOUND_KEY);
+ return stored === null ? true : stored === 'true';
+ });
const isVisibleRef = useRef(!document.hidden);
- const permissionRef = useRef(Notification.permission);
+ const permissionRef = useRef(hasNotificationAPI ? Notification.permission : 'denied' as NotificationPermission);
// Track tab visibility
useEffect(() => {
@@ -34,7 +41,7 @@ export function useNotifications() {
// Request permission on first user interaction
useEffect(() => {
- if (permissionRef.current !== 'default') return;
+ if (!hasNotificationAPI || permissionRef.current !== 'default') return;
const requestOnInteraction = () => {
if (permissionRef.current === 'default') {
Notification.requestPermission().then(p => {
@@ -47,19 +54,35 @@ export function useNotifications() {
return () => document.removeEventListener('click', requestOnInteraction);
}, []);
+ // Persist sound preference
+ const toggleSound = useCallback(() => {
+ setSoundEnabled(prev => {
+ const next = !prev;
+ localStorage.setItem(SOUND_KEY, String(next));
+ // Play a preview when enabling so user knows what it sounds like
+ if (next) playNotificationSound(0.3);
+ return next;
+ });
+ }, []);
+
const notify = useCallback((title: string, body?: string) => {
if (isVisibleRef.current) return; // Tab is focused, no need
setUnreadCount(c => c + 1);
+ // Play notification sound
+ if (soundEnabled) {
+ playNotificationSound(0.3);
+ }
+
// Send browser notification if permitted
- if (permissionRef.current === 'granted') {
+ if (hasNotificationAPI && permissionRef.current === 'granted') {
try {
const n = new Notification(title, {
body: body?.slice(0, 200),
icon: '/logo.png',
tag: 'pinchchat-message', // Collapse multiple into one
- silent: false,
+ silent: soundEnabled, // Don't double-play system sound if we have our own
});
// Auto-close after 5s
setTimeout(() => n.close(), 5000);
@@ -72,7 +95,7 @@ export function useNotifications() {
// Notifications not supported (e.g. some mobile browsers)
}
}
- }, []);
+ }, [soundEnabled]);
- return { notify, unreadCount };
+ return { notify, unreadCount, soundEnabled, toggleSound };
}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 46c6bf6..80d07d8 100644
--- a/src/lib/i18n.ts
+++ b/src/lib/i18n.ts
@@ -28,6 +28,8 @@ const en = {
'header.logout': 'Logout',
'header.toggleSidebar': 'Toggle sidebar',
'header.changeLanguage': 'Change language',
+ 'header.soundOn': 'Enable notification sound',
+ 'header.soundOff': 'Disable notification sound',
// Chat
'chat.welcome': 'PinchChat',
@@ -106,6 +108,8 @@ const fr: Record = {
'header.logout': 'Déconnexion',
'header.toggleSidebar': 'Afficher/masquer la barre latérale',
'header.changeLanguage': 'Changer de langue',
+ 'header.soundOn': 'Activer le son de notification',
+ 'header.soundOff': 'Désactiver le son de notification',
'chat.welcome': 'PinchChat',
'chat.welcomeSub': 'Envoyez un message pour commencer',
diff --git a/src/lib/notificationSound.ts b/src/lib/notificationSound.ts
new file mode 100644
index 0000000..2b85c8f
--- /dev/null
+++ b/src/lib/notificationSound.ts
@@ -0,0 +1,55 @@
+/**
+ * Plays a subtle notification sound using the Web Audio API.
+ * No external audio files needed — synthesized at runtime.
+ *
+ * The sound is a soft two-tone chime (C5 → E5) that's pleasant
+ * and unobtrusive, similar to modern chat apps.
+ */
+
+let audioCtx: AudioContext | null = null;
+
+function getAudioContext(): AudioContext | null {
+ try {
+ if (!audioCtx || audioCtx.state === 'closed') {
+ audioCtx = new AudioContext();
+ }
+ // Resume if suspended (browser autoplay policy)
+ if (audioCtx.state === 'suspended') {
+ audioCtx.resume();
+ }
+ return audioCtx;
+ } catch {
+ return null;
+ }
+}
+
+export function playNotificationSound(volume = 0.3): void {
+ const ctx = getAudioContext();
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+
+ // Two-tone chime: C5 (523Hz) then E5 (659Hz)
+ const frequencies = [523.25, 659.25];
+ const duration = 0.12;
+ const gap = 0.08;
+
+ frequencies.forEach((freq, i) => {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+
+ osc.type = 'sine';
+ osc.frequency.value = freq;
+
+ const start = now + i * (duration + gap);
+ gain.gain.setValueAtTime(0, start);
+ gain.gain.linearRampToValueAtTime(volume, start + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.001, start + duration);
+
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+
+ osc.start(start);
+ osc.stop(start + duration + 0.01);
+ });
+}