/** * Service Worker для обработки Web Push уведомлений * * Этот файл должен находиться в /public/ для Next.js. * Service Worker перехватывает push-события от браузера и показывает * нативные уведомления даже когда страница закрыта. */ const SW_VERSION = "2.4.1"; const SW_APP_KIND = self.CORP_SW_APP_KIND || "main"; const STATIC_CACHE = `eusrr-static-${SW_VERSION}`; const PRECACHE_URLS = [ "/manifest.webmanifest", "/favicon-source.svg", "/notification-badge.png", "/logo.png", "/logo.webp", "/icon-192.png", "/icon-512.png", "/icon-512-maskable.png", "/apple-touch-icon.png", ]; // Настройки по умолчанию для уведомлений const DEFAULT_NOTIFICATION_OPTIONS = { icon: "/icon-192.png", badge: "/notification-badge.png", vibrate: [100, 50, 100], requireInteraction: false, renotify: true, }; function buildNotificationData(payload = {}) { const notificationData = { title: payload.title || payload.head || "Новое уведомление", body: payload.body || payload.message || "", icon: payload.icon || DEFAULT_NOTIFICATION_OPTIONS.icon, badge: payload.badge || DEFAULT_NOTIFICATION_OPTIONS.badge, tag: payload.tag || `notification-${Date.now()}`, requireInteraction: payload.requireInteraction ?? false, renotify: payload.renotify ?? true, silent: payload.silent === true, data: { ...(payload.data || {}), url: payload.data?.url || payload.url || payload.action_url || "/", }, }; if (!notificationData.silent) { notificationData.vibrate = payload.vibrate || DEFAULT_NOTIFICATION_OPTIONS.vibrate; } if (payload.image) notificationData.image = payload.image; if (payload.actions) { notificationData.actions = payload.actions.filter( (action) => action.action !== "reply", ); } return notificationData; } function sameIdentity(actual, expected) { if (expected === undefined || expected === null) return true; return String(actual) === String(expected); } function matchesNotificationScope(notification, scope = {}) { const data = notification.data || {}; if (Array.isArray(scope.notification_ids)) { const matchesId = scope.notification_ids.some((id) => sameIdentity(data.notification_id, id), ); if (matchesId) return true; } if ( scope.chat_id === undefined && scope.message_id === undefined && scope.notification_id === undefined ) { return false; } return ( sameIdentity(data.chat_id, scope.chat_id) && sameIdentity(data.message_id, scope.message_id) && sameIdentity(data.notification_id, scope.notification_id) ); } async function applyNotificationControl(payload) { const notifications = await self.registration.getNotifications({ includeTriggered: true, }); const matching = notifications.filter((item) => matchesNotificationScope(item, payload.scope || {}), ); matching.forEach((item) => item.close()); if ( payload.action !== "replace_message" || !payload.notification || matching.length === 0 ) { return; } const replacement = buildNotificationData(payload.notification); const { title, ...options } = replacement; await self.registration.showNotification(title, options); } async function markPushNotificationRead(notification) { const data = notification.data || {}; if (!data.mark_read_url || !data.mark_read_token) { throw new Error("Push notification has no signed read action"); } const response = await fetch( new URL(data.mark_read_url, self.location.origin).toString(), { method: "POST", credentials: "omit", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: data.mark_read_token }), }, ); if (!response.ok) { throw new Error(`Push read action failed with HTTP ${response.status}`); } const notifications = await self.registration.getNotifications({ includeTriggered: true, }); notifications .filter((item) => sameIdentity(item.data?.chat_id, data.chat_id)) .forEach((item) => item.close()); const windowClients = await clients.matchAll({ type: "window", includeUncontrolled: true, }); windowClients.forEach((client) => client.postMessage({ type: "PUSH_CHAT_MARKED_READ", chatId: data.chat_id, messageId: data.message_id, notificationId: data.notification_id, }), ); } /** * Событие установки Service Worker */ self.addEventListener("install", (event) => { console.log("[SW] Service Worker installing, version:", SW_VERSION); event.waitUntil( caches .open(STATIC_CACHE) .then((cache) => cache.addAll(PRECACHE_URLS)) .catch((error) => { console.error("[SW] Failed to precache static assets:", error); }) .then(() => self.skipWaiting()), ); }); /** * Событие активации Service Worker */ self.addEventListener("activate", (event) => { console.log("[SW] Service Worker activated, version:", SW_VERSION); event.waitUntil( caches .keys() .then((cacheKeys) => Promise.all( cacheKeys.map((cacheKey) => { if ( cacheKey !== STATIC_CACHE && cacheKey.startsWith("eusrr-static-") ) { return caches.delete(cacheKey); } return Promise.resolve(false); }), ), ) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") { return; } const url = new URL(request.url); if (url.origin !== self.location.origin) { return; } const isApiRequest = url.pathname.startsWith("/api/"); const isStaticAsset = url.pathname.startsWith("/_next/static/") || PRECACHE_URLS.includes(url.pathname) || ["image", "style", "script", "font", "worker"].includes( request.destination, ); if (isApiRequest || !isStaticAsset) { return; } event.respondWith( caches.open(STATIC_CACHE).then(async (cache) => { const cachedResponse = await cache.match(request); if (cachedResponse) { return cachedResponse; } const networkResponse = await fetch(request); if (networkResponse.ok) { cache.put(request, networkResponse.clone()).catch(() => undefined); } return networkResponse; }), ); }); /** * Обработка входящих push-уведомлений * * Формат данных с сервера: * { * "title": "Заголовок уведомления", * "body": "Текст уведомления", * "icon": "/logo.png", * "badge": "/logo.png", * "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); if (payload.type === "notification_control") { event.waitUntil(applyNotificationControl(payload)); return; } notificationData = buildNotificationData(payload); } catch { console.log("[SW] Push data is not JSON, using as text"); notificationData.body = event.data.text(); } } // Показываем уведомление const { title, ...options } = notificationData; event.waitUntil( self.registration .showNotification(title, options) .then(() => { console.log("[SW] Notification shown:", title); }) .catch((error) => { console.error("[SW] Failed to show notification:", error); }), ); }); self.addEventListener("message", (event) => { const payload = event.data || {}; if (payload.type === "SKIP_WAITING") { self.skipWaiting(); return; } if (payload.type === "NOTIFICATION_CONTROL") { event.waitUntil(applyNotificationControl(payload)); } }); /** * Обработка клика по уведомлению */ self.addEventListener("notificationclick", (event) => { console.log("[SW] Notification clicked:", event.notification.tag); if (event.action === "mark-read") { event.waitUntil( markPushNotificationRead(event.notification).catch((error) => { console.error("[SW] Failed to mark push notification as read:", error); }), ); return; } event.notification.close(); // Получаем URL из данных уведомления const urlToOpen = new URL( event.notification.data?.url || "/", self.location.origin, ).toString(); event.waitUntil( clients .matchAll({ type: "window", includeUncontrolled: true }) .then(async (windowClients) => { const exactClient = windowClients.find( (client) => client.url === urlToOpen && "focus" in client, ); if (exactClient) { return exactClient.focus(); } const sameOriginClients = windowClients.filter((client) => { const clientUrl = new URL(client.url); if (clientUrl.origin !== self.location.origin) return false; return ( SW_APP_KIND !== "messenger" || clientUrl.pathname.startsWith("/messages") ); }); const sameOriginClient = sameOriginClients.find((client) => client.focused) || sameOriginClients.find( (client) => client.visibilityState === "visible", ) || sameOriginClients[0]; if (sameOriginClient) { if ("navigate" in sameOriginClient) { try { const navigatedClient = await sameOriginClient.navigate(urlToOpen); return (navigatedClient || sameOriginClient).focus(); } catch (error) { console.warn("[SW] Failed to navigate existing client:", error); } } sameOriginClient.postMessage({ type: "NOTIFICATION_CLICK", url: urlToOpen, notificationId: event.notification.data?.notification_id, }); return sameOriginClient.focus(); } if (clients.openWindow) { return clients.openWindow(urlToOpen); } }), ); }); /** * Обработка закрытия уведомления */ self.addEventListener("notificationclose", (event) => { console.log("[SW] Notification closed:", event.notification.tag); // Можно отправить статистику на сервер if (event.notification.data?.notification_id) { // fetch('/api/v1/notifications/stats/closed/', { // method: 'POST', // body: JSON.stringify({ id: event.notification.data.notification_id }) // }); } }); console.log("[SW] Service Worker loaded, version:", SW_VERSION);