/**
* Менеджер уведомлений - подключение к WebSocket и управление
*/
import { getNotifications, invalidateNotifications } from '../api/notificationsApi.js';
class NotificationManager {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.isConnected = false;
this.notificationPermission = 'default';
this.soundEnabled = this.loadSoundPreference();
this.init();
}
/**
* Загрузить настройку звука из localStorage
*/
loadSoundPreference() {
const saved = localStorage.getItem('notificationSoundEnabled');
return saved === null ? true : saved === 'true';
}
/**
* Сохранить настройку звука
*/
saveSoundPreference(enabled) {
this.soundEnabled = enabled;
localStorage.setItem('notificationSoundEnabled', enabled.toString());
}
/**
* Воспроизвести звук уведомления
* Приоритет: 1) MP3 файл, 2) Web Audio API (fallback)
*/
playNotificationSound() {
if (!this.soundEnabled) {
console.log('[Notifications] Sound disabled');
return;
}
try {
// Попытка воспроизвести MP3 файл
const audio = new Audio('/static/sounds/notification.mp3');
audio.volume = 0.3;
console.log('[Notifications] Playing MP3 sound');
audio.play().then(() => {
console.log('[Notifications] MP3 sound played successfully');
}).catch((err) => {
console.warn('[Notifications] MP3 playback failed, using fallback:', err);
// Fallback на Web Audio API если MP3 недоступен
this.playFallbackSound();
});
} catch (error) {
console.warn('[Notifications] MP3 loading failed, using fallback:', error);
// Fallback на Web Audio API
this.playFallbackSound();
}
}
/**
* Резервный метод: синтетический звук через Web Audio API
*/
playFallbackSound() {
console.log('[Notifications] Playing fallback Web Audio API sound');
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // Частота звука
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
console.log('[Notifications] Fallback sound completed');
} catch (error) {
console.warn('[Notifications] Sound playback failed:', error);
}
}
init() {
this.requestNotificationPermission();
this.connectWebSocket();
this.loadInitialNotifications();
this.setupDropdown();
}
/**
* Запросить разрешение на показ браузерных уведомлений
*/
async requestNotificationPermission() {
if (!('Notification' in window)) {
console.warn('[Notifications] Browser does not support notifications');
return;
}
if (Notification.permission === 'granted') {
this.notificationPermission = 'granted';
console.log('[Notifications] Permission already granted');
return;
}
if (Notification.permission !== 'denied') {
try {
const permission = await Notification.requestPermission();
this.notificationPermission = permission;
console.log('[Notifications] Permission:', permission);
} catch (error) {
console.error('[Notifications] Error requesting permission:', error);
}
}
}
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Используем универсальный WebSocket endpoint (realtime architecture)
const wsUrl = `${protocol}//${window.location.host}/ws/`;
console.log('[Notifications] Connecting to:', wsUrl);
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('[Notifications] WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.updateConnectionStatus(true);
};
this.ws.onmessage = (event) => {
this.handleMessage(event);
};
this.ws.onclose = () => {
console.log('[Notifications] WebSocket disconnected');
this.isConnected = false;
this.updateConnectionStatus(false);
this.attemptReconnect();
};
this.ws.onerror = (error) => {
console.error('[Notifications] WebSocket error:', error);
};
} catch (error) {
console.error('[Notifications] Failed to create WebSocket:', error);
this.attemptReconnect();
}
}
handleMessage(event) {
try {
const data = JSON.parse(event.data);
// Игнорируем ping сообщения для keepalive (не логируем)
if (data.type === 'ping') {
return;
}
console.log('[Notifications] Received:', data);
switch (data.type) {
case 'unread_count':
this.updateBadge(data.count);
break;
case 'notification': // UserConsumer использует "notification"
this.showNewNotification(data.notification);
// Инвалидируем кеш при получении нового уведомления
invalidateNotifications();
break;
case 'new_notification': // Backwards compatibility
this.showNewNotification(data.notification);
this.updateBadge(data.notification.unread_count);
// Инвалидируем кеш при получении нового уведомления
invalidateNotifications();
break;
case 'count_update':
this.updateBadge(data.count);
// Инвалидируем кеш при изменении счетчика
invalidateNotifications();
break;
default:
console.warn('[Notifications] Unknown message type:', data.type);
}
} catch (error) {
console.error('[Notifications] Error parsing message:', error);
}
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`[Notifications] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
this.connectWebSocket();
}, delay);
} else {
console.error('[Notifications] Max reconnect attempts reached');
}
}
updateConnectionStatus(connected) {
// Визуальная индикация статуса подключения (опционально)
const badge = document.getElementById('notificationBadge');
if (badge) {
if (!connected) {
badge.style.backgroundColor = '#dc3545'; // красный при отключении
} else {
badge.style.backgroundColor = ''; // вернуть стандартный цвет
}
}
}
updateBadge(count) {
const badge = document.getElementById('notificationBadge');
if (badge) {
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
}
}
async loadInitialNotifications() {
try {
// Используем кешированный API вместо прямого fetch
const data = await getNotifications({
page: 1,
page_size: 5,
unread_only: true
});
console.log('[Notifications] Loaded data:', data);
this.renderNotifications(data.notifications || []);
this.updateBadge(data.total || 0);
} catch (error) {
console.error('[Notifications] Error loading notifications:', error);
// Показываем заглушку при ошибке
const list = document.getElementById('notificationList');
if (list) {
list.innerHTML = `
Ошибка загрузки уведомлений
`;
}
}
}
renderNotifications(notifications) {
const listElement = document.getElementById('notificationList');
if (!listElement) return;
if (notifications.length === 0) {
listElement.innerHTML = `
`;
return;
}
listElement.innerHTML = notifications.map(n => this.renderNotificationItem(n)).join('');
// Добавить обработчики событий
listElement.querySelectorAll('[data-notification-id]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const notificationId = item.dataset.notificationId;
const actionUrl = item.dataset.actionUrl;
this.markAsRead(notificationId).then(() => {
this.closeDropdown(); // Закрыть dropdown
if (actionUrl) {
window.location.href = actionUrl;
}
});
});
});
}
renderNotificationItem(notification) {
const iconColorClass = `text-${notification.color}`;
const timeAgo = this.getTimeAgo(notification.created_at);
return `
${this.escapeHtml(notification.title)}
${this.escapeHtml(notification.message)}
${timeAgo}
${!notification.is_read ? '
' : ''}
`;
}
showNewNotification(notification) {
// Показать нативное браузерное уведомление
this.showBrowserNotification(notification);
// Обновить список в dropdown
this.loadInitialNotifications();
// Воспроизвести звук (опционально)
this.playNotificationSound();
}
/**
* Показать нативное браузерное уведомление
*/
showBrowserNotification(notification) {
if (!('Notification' in window)) {
console.warn('[Notifications] Browser notifications not supported');
// Fallback to toast
this.showToast(notification);
return;
}
if (Notification.permission !== 'granted') {
console.warn('[Notifications] Permission not granted');
// Fallback to toast
this.showToast(notification);
return;
}
// Определить иконку по категории
const iconMap = {
'communications': '💬',
'documents': '📄',
'requests': '📋',
'calendar': '📅',
'department': '👥',
'profile': '👤',
'feed': '📰',
'system': '⚙️'
};
const options = {
body: notification.message,
icon: '/static/img/logo.png', // Можно заменить на специальную иконку
badge: '/static/img/favicon-32.png',
tag: `notification-${notification.id}`,
requireInteraction: notification.priority === 'urgent',
silent: false,
data: {
notificationId: notification.id,
actionUrl: notification.action_url
}
};
try {
const browserNotification = new Notification(
`${iconMap[notification.category] || '🔔'} ${notification.title}`,
options
);
// Обработать клик по уведомлению
browserNotification.onclick = (event) => {
event.preventDefault();
window.focus();
// Отметить как прочитанное
this.markAsRead(notification.id);
// Перейти по ссылке
if (notification.action_url) {
window.location.href = notification.action_url;
}
browserNotification.close();
};
// Автозакрытие через 10 секунд (если не urgent)
if (notification.priority !== 'urgent') {
setTimeout(() => {
browserNotification.close();
}, 10000);
}
console.log('[Notifications] Browser notification shown:', notification.title);
} catch (error) {
console.error('[Notifications] Error showing browser notification:', error);
// Fallback to toast
this.showToast(notification);
}
}
showToast(notification) {
// Создать toast элемент
const toastHtml = `
${this.escapeHtml(notification.title)}
${this.escapeHtml(notification.message)}
`;
// Добавить в контейнер
let toastContainer = document.getElementById('toastContainer');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toastContainer';
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '9999';
document.body.appendChild(toastContainer);
}
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
// Показать toast
const toastElement = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Удалить после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
async markAsRead(notificationId) {
try {
const response = await fetch(`/api/notifications/${notificationId}/read/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
console.log('[Notifications] Marked as read:', notificationId);
}
} catch (error) {
console.error('[Notifications] Error marking as read:', error);
}
}
async markAllAsRead() {
try {
const response = await fetch('/api/notifications/read-all/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (response.ok) {
console.log('[Notifications] All marked as read');
this.loadInitialNotifications();
}
} catch (error) {
console.error('[Notifications] Error marking all as read:', error);
}
}
getTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'только что';
if (seconds < 3600) return `${Math.floor(seconds / 60)} мин назад`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} ч назад`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)} дн назад`;
return date.toLocaleDateString('ru-RU');
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
}
/**
* Управление dropdown меню
*/
setupDropdown() {
const toggle = document.getElementById('notificationToggle');
const dropdown = document.getElementById('notificationDropdown');
if (!toggle || !dropdown) return;
// Клик по кнопке
toggle.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleDropdown();
});
// Клик вне dropdown - закрыть
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && !toggle.contains(e.target)) {
this.closeDropdown();
}
});
// Позиционирование при открытии
this.positionDropdown();
window.addEventListener('resize', () => this.positionDropdown());
window.addEventListener('scroll', () => this.positionDropdown());
}
toggleDropdown() {
const dropdown = document.getElementById('notificationDropdown');
if (dropdown.style.display === 'none' || !dropdown.style.display) {
this.openDropdown();
} else {
this.closeDropdown();
}
}
openDropdown() {
const dropdown = document.getElementById('notificationDropdown');
dropdown.style.display = 'flex';
this.positionDropdown();
}
closeDropdown() {
const dropdown = document.getElementById('notificationDropdown');
dropdown.style.display = 'none';
}
positionDropdown() {
const toggle = document.getElementById('notificationToggle');
const dropdown = document.getElementById('notificationDropdown');
if (!toggle || !dropdown || dropdown.style.display === 'none') return;
const rect = toggle.getBoundingClientRect();
const dropdownWidth = 360;
const margin = 16; // отступ от края экрана
// Позиция по вертикали
dropdown.style.top = `${rect.bottom + 8}px`;
// Позиция по горизонтали - адаптивная
const isMobile = window.innerWidth < 768;
if (isMobile) {
// На мобильных - центрируем с отступами от краёв
dropdown.style.left = `${margin}px`;
dropdown.style.right = `${margin}px`;
dropdown.style.width = 'auto';
} else {
// На десктопе - справа от кнопки
const rightPosition = window.innerWidth - rect.right;
// Проверяем, не выходит ли за левый край
if (rect.right - dropdownWidth < margin) {
// Если выходит - выравниваем по левому краю с отступом
dropdown.style.left = `${margin}px`;
dropdown.style.right = 'auto';
} else {
// Обычное выравнивание по правому краю кнопки
dropdown.style.right = `${rightPosition}px`;
dropdown.style.left = 'auto';
}
dropdown.style.width = `${dropdownWidth}px`;
}
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('notificationBell')) {
window.notificationManager = new NotificationManager();
console.log('[Notifications] Manager initialized');
}
});