Files
redlight/src/contexts/NotificationContext.jsx
Michelle 9b98803053
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m6s
fix(NotificationContext): handle user ID for notifications fetching and prevent stale responses
2026-03-13 12:43:20 +01:00

189 lines
5.9 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 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 (
<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 '🔔';
}
}