import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; import toast from 'react-hot-toast'; import { useAuth } from './AuthContext'; import api from '../services/api'; // Lazily created Audio instance — reused across calls to avoid memory churn let _audio = null; let _audioUnlocked = false; function getAudio() { if (!_audio) { _audio = new Audio('/sounds/notification.mp3'); _audio.volume = 0.5; } return _audio; } /** Called once on the first user gesture to silently play→pause the element, * which "unlocks" it so later timer-based .play() calls are not blocked. */ function unlockAudio() { if (_audioUnlocked) return; _audioUnlocked = true; const audio = getAudio(); audio.muted = true; audio.play().then(() => { audio.pause(); audio.muted = false; audio.currentTime = 0; }).catch(() => { audio.muted = false; }); } function playNotificationSound() { try { const audio = getAudio(); audio.currentTime = 0; audio.play().catch(() => { // Autoplay still blocked — silent fail }); } catch { // Ignore any other errors (e.g. unsupported format) } } const NotificationContext = createContext(); export function NotificationProvider({ children }) { const { user } = useAuth(); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const activeUserId = useRef(null); // Track seen IDs to detect genuinely new arrivals and show toasts const seenIds = useRef(new Set()); const initialized = useRef(false); const fetch = useCallback(async () => { const requestUserId = user?.id; if (!requestUserId) return; try { const res = await api.get('/notifications'); // Ignore stale responses that arrived after logout or account switch. if (activeUserId.current !== requestUserId) return; const incoming = res.data.notifications || []; setNotifications(incoming); setUnreadCount(res.data.unreadCount || 0); // First fetch: just seed the seen-set without toasting if (!initialized.current) { incoming.forEach(n => seenIds.current.add(n.id)); initialized.current = true; return; } // Subsequent fetches: toast new unread notifications const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id)); if (newItems.length > 0) { playNotificationSound(); } newItems.forEach(n => { seenIds.current.add(n.id); const icon = notificationIcon(n.type); toast(`${icon} ${n.title}`, { duration: 5000 }); // Browser notification for calendar reminders if (n.type === 'calendar_reminder' && 'Notification' in window) { const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' }); if (Notification.permission === 'granted') { fire(); } else if (Notification.permission !== 'denied') { Notification.requestPermission().then(p => { if (p === 'granted') fire(); }); } } }); } catch { /* silent – server may not be reachable */ } }, [user]); // Unlock audio playback on the first real user interaction. // Browsers block audio from timer callbacks unless the element was previously // "touched" inside a gesture handler — this one-time listener does exactly that. useEffect(() => { const events = ['click', 'keydown', 'pointerdown']; const handler = () => { unlockAudio(); events.forEach(e => window.removeEventListener(e, handler)); }; events.forEach(e => window.addEventListener(e, handler, { once: true })); return () => events.forEach(e => window.removeEventListener(e, handler)); }, []); useEffect(() => { activeUserId.current = user?.id ?? null; if (!user) { setNotifications([]); setUnreadCount(0); seenIds.current = new Set(); initialized.current = false; return; } fetch(); const interval = setInterval(fetch, 30_000); return () => clearInterval(interval); }, [user, fetch]); const markRead = async (id) => { try { await api.post(`/notifications/${id}/read`); setNotifications(prev => prev.map(n => (n.id === id ? { ...n, read: 1 } : n)), ); setUnreadCount(prev => Math.max(0, prev - 1)); } catch { /* silent */ } }; const markAllRead = async () => { try { await api.post('/notifications/read-all'); setNotifications(prev => prev.map(n => ({ ...n, read: 1 }))); setUnreadCount(0); } catch { /* silent */ } }; const deleteNotification = async (id) => { try { await api.delete(`/notifications/${id}`); setNotifications(prev => { const removed = prev.find(n => n.id === id); if (removed && !removed.read) setUnreadCount(c => Math.max(0, c - 1)); return prev.filter(n => n.id !== id); }); seenIds.current.delete(id); } catch { /* silent */ } }; const clearAll = async () => { try { await api.delete('/notifications/all'); setNotifications([]); setUnreadCount(0); seenIds.current = new Set(); } catch { /* silent */ } }; return ( {children} ); } export function useNotifications() { const ctx = useContext(NotificationContext); if (!ctx) throw new Error('useNotifications must be used within NotificationProvider'); return ctx; } function notificationIcon(type) { switch (type) { case 'room_share_added': return '🔗'; case 'room_share_removed': return '🚫'; case 'federation_invite_received': return '📩'; case 'calendar_reminder': return '🔔'; default: return '🔔'; } }