390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
/**
|
||
* 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);
|