/**
* Менеджер уведомлений - подключение к WebSocket и управление
*/
import {
getNotifications,
invalidateNotifications,
} from "../api/notificationsApi.js";
class NotificationManager {
constructor() {
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();
window.addEventListener("user:ws-message", (event) => {
this.handleMessage(event.detail);
});
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);
}
}
}
handleMessage(data) {
try {
// Игнорируем list_update - это для списка чатов, не для уведомлений
if (data.type === "list_update") {
return;
}
// Игнорируем чат-события - они обрабатываются userWebSocket
if (["message_edited", "reaction_added", "reaction_removed"].includes(data.type)) {
return;
}
// Игнорируем marked_read - это для синхронизации между вкладками, обрабатывается в chatMarkRead
if (data.type === "marked_read") {
return;
}
// Игнорируем poll_update - это для опросов в чатах, обрабатывается в userWebSocket
if (data.type === "poll_update") {
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);
}
}
updateBadge(count) {
const badge = document.getElementById("notificationBadge");
if (badge) {
const oldCount = parseInt(badge.textContent) || 0;
if (count > 0) {
badge.textContent = count > 99 ? "99+" : count;
badge.style.display = '';
// Анимация pulse при увеличении
if (count > oldCount) {
badge.classList.add('pulse');
setTimeout(() => badge.classList.remove('pulse'), 600);
}
} else {
badge.style.display = 'none';
badge.classList.remove('pulse');
}
}
}
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) {
const currentPath = window.location.pathname;
const notificationPath = new URL(
notification.action_url,
window.location.origin
).pathname;
// Если это уже текущая страница, просто закрываем уведомление
if (currentPath !== notificationPath) {
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/v1/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/v1/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");
}
});