From 8301cba339feb300fec4ccf68734bbef5c87e52c Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Thu, 12 Feb 2026 07:58:23 +0000 Subject: [PATCH] fix: guard Notification API for unsupported browsers Check typeof Notification before accessing .permission or requestPermission(). Fixes crash on browsers/contexts where the Notification API is unavailable (e.g. some WebViews). --- src/App.tsx | 4 +-- src/components/Header.tsx | 16 ++++++++-- src/hooks/useNotifications.ts | 39 ++++++++++++++++++++----- src/lib/i18n.ts | 4 +++ src/lib/notificationSound.ts | 55 +++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 src/lib/notificationSound.ts 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)} />
-
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} /> +
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 698e3fc..3634eaf 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { Menu, Sparkles, LogOut } from 'lucide-react'; +import { Menu, Sparkles, LogOut, Volume2, VolumeOff } from 'lucide-react'; import type { ConnectionStatus, Session } from '../types'; import { useT } from '../hooks/useLocale'; import { LanguageSelector } from './LanguageSelector'; @@ -9,9 +9,11 @@ interface Props { onToggleSidebar: () => void; activeSessionData?: Session; onLogout?: () => void; + soundEnabled?: boolean; + onToggleSound?: () => void; } -export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout }: Props) { +export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound }: Props) { const t = useT(); const sessionLabel = sessionKey.split(':').pop() || sessionKey; @@ -32,6 +34,16 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
+ {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); + }); +}