423 lines
14 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 уведомлений
*
* Этот файл должен находиться в /public/ для Next.js.
* Service Worker перехватывает push-события от браузера и показывает
* нативные уведомления даже когда страница закрыта.
*/
const SW_VERSION = '2.4.0';
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 supportsNativeInlineReply() {
const userAgent = self.navigator?.userAgent || '';
const isChromium = /(?:Chrome|Chromium|Edg|OPR)\//.test(userAgent);
const isSupportedPlatform = /(?:Windows NT|Android|CrOS)/.test(userAgent);
return isChromium && isSupportedPlatform;
}
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.type !== 'text' || supportsNativeInlineReply(),
);
}
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,
}));
}
async function sendPushQuickReply(notification, reply) {
const data = notification.data || {};
const normalizedReply = typeof reply === 'string' ? reply.trim() : '';
if (!normalizedReply) {
throw new Error('Native notification did not provide reply text');
}
if (!data.reply_url || !data.reply_token) {
throw new Error('Push notification has no signed reply action');
}
const response = await fetch(
new URL(data.reply_url, self.location.origin).toString(),
{
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: data.reply_token,
reply: normalizedReply,
}),
},
);
if (!response.ok) {
throw new Error(`Push reply failed with HTTP ${response.status}`);
}
const result = await response.json();
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_QUICK_REPLIED',
chatId: data.chat_id,
messageId: result.message_id,
replyToId: 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 === 'reply') {
if (typeof event.reply !== 'string' || !event.reply.trim()) {
console.warn('[SW] Native inline reply is unavailable on this platform');
return;
}
event.waitUntil(
sendPushQuickReply(event.notification, event.reply).catch((error) => {
console.error('[SW] Failed to send push reply:', error);
}),
);
return;
}
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);