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).
This commit is contained in:
@@ -16,7 +16,7 @@ export default function App() {
|
|||||||
} = useGateway();
|
} = useGateway();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
const { notify } = useNotifications();
|
const { notify, soundEnabled, toggleSound } = useNotifications();
|
||||||
const prevMessageCountRef = useRef(messages.length);
|
const prevMessageCountRef = useRef(messages.length);
|
||||||
|
|
||||||
// Notify on new assistant messages when tab is not focused
|
// Notify on new assistant messages when tab is not focused
|
||||||
@@ -75,7 +75,7 @@ export default function App() {
|
|||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
||||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} />
|
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} />
|
||||||
<ConnectionBanner status={status} />
|
<ConnectionBanner status={status} />
|
||||||
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} onSend={sendMessage} onAbort={abort} />
|
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} onSend={sendMessage} onAbort={abort} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 type { ConnectionStatus, Session } from '../types';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { LanguageSelector } from './LanguageSelector';
|
import { LanguageSelector } from './LanguageSelector';
|
||||||
@@ -9,9 +9,11 @@ interface Props {
|
|||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
activeSessionData?: Session;
|
activeSessionData?: Session;
|
||||||
onLogout?: () => void;
|
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 t = useT();
|
||||||
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
|
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
|
||||||
|
|
||||||
@@ -32,6 +34,16 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{onToggleSound && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleSound}
|
||||||
|
aria-label={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
|
||||||
|
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
title={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
|
||||||
|
>
|
||||||
|
{soundEnabled ? <Volume2 size={16} /> : <VolumeOff size={16} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
{status === 'connected' ? (
|
{status === 'connected' ? (
|
||||||
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
|
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { playNotificationSound } from '../lib/notificationSound';
|
||||||
|
|
||||||
const ORIGINAL_TITLE = document.title;
|
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
|
* Hook that manages browser notifications, tab title badge,
|
||||||
* when new messages arrive while the tab is not focused.
|
* and notification sounds when new messages arrive while the tab is not focused.
|
||||||
*/
|
*/
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
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 isVisibleRef = useRef(!document.hidden);
|
||||||
const permissionRef = useRef(Notification.permission);
|
const permissionRef = useRef(hasNotificationAPI ? Notification.permission : 'denied' as NotificationPermission);
|
||||||
|
|
||||||
// Track tab visibility
|
// Track tab visibility
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,7 +41,7 @@ export function useNotifications() {
|
|||||||
|
|
||||||
// Request permission on first user interaction
|
// Request permission on first user interaction
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (permissionRef.current !== 'default') return;
|
if (!hasNotificationAPI || permissionRef.current !== 'default') return;
|
||||||
const requestOnInteraction = () => {
|
const requestOnInteraction = () => {
|
||||||
if (permissionRef.current === 'default') {
|
if (permissionRef.current === 'default') {
|
||||||
Notification.requestPermission().then(p => {
|
Notification.requestPermission().then(p => {
|
||||||
@@ -47,19 +54,35 @@ export function useNotifications() {
|
|||||||
return () => document.removeEventListener('click', requestOnInteraction);
|
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) => {
|
const notify = useCallback((title: string, body?: string) => {
|
||||||
if (isVisibleRef.current) return; // Tab is focused, no need
|
if (isVisibleRef.current) return; // Tab is focused, no need
|
||||||
|
|
||||||
setUnreadCount(c => c + 1);
|
setUnreadCount(c => c + 1);
|
||||||
|
|
||||||
|
// Play notification sound
|
||||||
|
if (soundEnabled) {
|
||||||
|
playNotificationSound(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
// Send browser notification if permitted
|
// Send browser notification if permitted
|
||||||
if (permissionRef.current === 'granted') {
|
if (hasNotificationAPI && permissionRef.current === 'granted') {
|
||||||
try {
|
try {
|
||||||
const n = new Notification(title, {
|
const n = new Notification(title, {
|
||||||
body: body?.slice(0, 200),
|
body: body?.slice(0, 200),
|
||||||
icon: '/logo.png',
|
icon: '/logo.png',
|
||||||
tag: 'pinchchat-message', // Collapse multiple into one
|
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
|
// Auto-close after 5s
|
||||||
setTimeout(() => n.close(), 5000);
|
setTimeout(() => n.close(), 5000);
|
||||||
@@ -72,7 +95,7 @@ export function useNotifications() {
|
|||||||
// Notifications not supported (e.g. some mobile browsers)
|
// Notifications not supported (e.g. some mobile browsers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [soundEnabled]);
|
||||||
|
|
||||||
return { notify, unreadCount };
|
return { notify, unreadCount, soundEnabled, toggleSound };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const en = {
|
|||||||
'header.logout': 'Logout',
|
'header.logout': 'Logout',
|
||||||
'header.toggleSidebar': 'Toggle sidebar',
|
'header.toggleSidebar': 'Toggle sidebar',
|
||||||
'header.changeLanguage': 'Change language',
|
'header.changeLanguage': 'Change language',
|
||||||
|
'header.soundOn': 'Enable notification sound',
|
||||||
|
'header.soundOff': 'Disable notification sound',
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
'chat.welcome': 'PinchChat',
|
'chat.welcome': 'PinchChat',
|
||||||
@@ -106,6 +108,8 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'header.logout': 'Déconnexion',
|
'header.logout': 'Déconnexion',
|
||||||
'header.toggleSidebar': 'Afficher/masquer la barre latérale',
|
'header.toggleSidebar': 'Afficher/masquer la barre latérale',
|
||||||
'header.changeLanguage': 'Changer de langue',
|
'header.changeLanguage': 'Changer de langue',
|
||||||
|
'header.soundOn': 'Activer le son de notification',
|
||||||
|
'header.soundOff': 'Désactiver le son de notification',
|
||||||
|
|
||||||
'chat.welcome': 'PinchChat',
|
'chat.welcome': 'PinchChat',
|
||||||
'chat.welcomeSub': 'Envoyez un message pour commencer',
|
'chat.welcomeSub': 'Envoyez un message pour commencer',
|
||||||
|
|||||||
55
src/lib/notificationSound.ts
Normal file
55
src/lib/notificationSound.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user