2026-09-07 19:43:48 +03:00

390 lines
11 KiB
JavaScript
Raw Permalink 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 уведомлений
*
* Этот файл должен находиться в /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);