All checks were successful
Build & Push Docker Image / build (push) Successful in 6m6s
189 lines
5.9 KiB
JavaScript
189 lines
5.9 KiB
JavaScript
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 '🔔';
|
||
}
|
||
}
|