Игорь Надеин 0d7c442b6b feat: implement Web Push notifications for offline browser notifications
- Add WebPushSubscription model for storing push subscriptions
- Generate VAPID keys for Web Push authentication
- Add API endpoints for push subscribe/unsubscribe
- Create Service Worker (sw.js) for handling push events
- Create JavaScript module for managing push subscriptions
- Integrate send_web_push_notification into NotificationService
- Add Service Worker registration to base.html
- Update requirements.txt with pywebpush package
2026-01-05 14:10:42 +03:00

193 lines
7.0 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.

/**
* Service Worker для обработки Web Push уведомлений
*
* Этот файл должен находиться в корне static/ для правильного scope.
* Service Worker перехватывает push-события от браузера и показывает
* нативные уведомления даже когда страница закрыта.
*/
// Версия Service Worker (для обновления кэша при изменениях)
const SW_VERSION = '1.0.0';
// Настройки по умолчанию для уведомлений
const DEFAULT_NOTIFICATION_OPTIONS = {
icon: '/static/img/logo-192.png',
badge: '/static/img/badge-72.png',
vibrate: [100, 50, 100],
requireInteraction: false,
renotify: true,
};
/**
* Событие установки Service Worker
*/
self.addEventListener('install', (event) => {
console.log('[SW] Service Worker installing, version:', SW_VERSION);
// Немедленно активировать новую версию
self.skipWaiting();
});
/**
* Событие активации Service Worker
*/
self.addEventListener('activate', (event) => {
console.log('[SW] Service Worker activated, version:', SW_VERSION);
// Захватить все клиенты без перезагрузки
event.waitUntil(self.clients.claim());
});
/**
* Обработка входящих push-уведомлений
*
* Формат данных с сервера:
* {
* "title": "Заголовок уведомления",
* "body": "Текст уведомления",
* "icon": "/static/img/...", // опционально
* "badge": "/static/img/...", // опционально
* "tag": "notification-id", // для группировки
* "data": {
* "url": "/path/to/page", // куда перейти при клике
* "notification_id": 123,
* "category": "requests",
* ...
* }
* }
*/
self.addEventListener('push', (event) => {
console.log('[SW] Push received:', event);
let notificationData = {
title: 'Новое уведомление',
body: '',
...DEFAULT_NOTIFICATION_OPTIONS,
data: {}
};
if (event.data) {
try {
const payload = event.data.json();
console.log('[SW] Push payload:', payload);
notificationData = {
...notificationData,
title: payload.title || notificationData.title,
body: payload.body || payload.message || '',
icon: payload.icon || notificationData.icon,
badge: payload.badge || notificationData.badge,
tag: payload.tag || `notification-${Date.now()}`,
data: payload.data || {}
};
// Дополнительные опции
if (payload.image) {
notificationData.image = payload.image;
}
if (payload.actions) {
notificationData.actions = payload.actions;
}
if (payload.requireInteraction !== undefined) {
notificationData.requireInteraction = payload.requireInteraction;
}
} catch (e) {
// Если данные не JSON, используем как текст
console.log('[SW] Push data is not JSON, using as text');
notificationData.body = event.data.text();
}
}
// Показываем уведомление
const promiseChain = self.registration.showNotification(
notificationData.title,
{
body: notificationData.body,
icon: notificationData.icon,
badge: notificationData.badge,
tag: notificationData.tag,
data: notificationData.data,
vibrate: notificationData.vibrate,
requireInteraction: notificationData.requireInteraction,
renotify: notificationData.renotify,
image: notificationData.image,
actions: notificationData.actions,
}
);
event.waitUntil(promiseChain);
});
/**
* Обработка клика по уведомлению
*/
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click:', event);
event.notification.close();
// Получаем URL для перехода
const notificationData = event.notification.data || {};
let urlToOpen = notificationData.url || '/';
// Обработка действий (action buttons)
if (event.action) {
console.log('[SW] Action clicked:', event.action);
// Можно добавить обработку разных действий
if (notificationData.actions && notificationData.actions[event.action]) {
urlToOpen = notificationData.actions[event.action].url || urlToOpen;
}
}
// Открываем или фокусируем окно
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Ищем уже открытое окно с нашим сайтом
for (const client of clientList) {
const clientUrl = new URL(client.url);
const targetUrl = new URL(urlToOpen, self.location.origin);
// Если найдено окно с тем же origin - фокусируем и переходим
if (clientUrl.origin === targetUrl.origin && 'focus' in client) {
return client.navigate(urlToOpen).then(() => client.focus());
}
}
// Если нет открытого окна - открываем новое
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen);
}
})
);
});
/**
* Обработка закрытия уведомления без клика
*/
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event.notification.tag);
// Можно отправить на сервер информацию о закрытии
// Например, для аналитики
const notificationData = event.notification.data || {};
if (notificationData.notification_id) {
// fetch('/api/v1/notifications/' + notificationData.notification_id + '/dismissed/', {
// method: 'POST',
// credentials: 'include'
// }).catch(() => {});
}
});
/**
* Получение сообщений от основной страницы
*/
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data);
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
console.log('[SW] Service Worker loaded, version:', SW_VERSION);