Files
redlight/src/contexts/NotificationContext.jsx
Michelle a0a972b53a
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m32s
fix(NotificationContext): ensure audio playback is unlocked only for authenticated users
2026-03-13 13:00:54 +01:00

190 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 only for authenticated sessions.
// This avoids any audio interaction while logged out (e.g. anonymous/incognito tabs).
useEffect(() => {
if (!user?.id) return;
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));
}, [user?.id]);
useEffect(() => {
activeUserId.current = user?.id ?? null;
if (!user) {
_audioUnlocked = false;
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 (
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll, refresh: fetch }}>
{children}
</NotificationContext.Provider>
);
}
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 '🔔';
}
}