Files
iistwin/client/public/sw.js
Ильяс Султанов 34838a302d feat(chat): онлайн-доставка как в Telegram — watchdog SSE, тихий скролл, персистентный кэш сообщений, невидимое обновление SW
- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с
- Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата)
- Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity
- SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
2026-09-21 20:29:13 +03:00

751 lines
27 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.

const CACHE_NAME = 'iistwin-pwa-v15';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.svg',
// Self-hosted шрифты — precache, чтобы PWA офлайн рендерился без FOUT/fallback
// (имена файлов должны совпадать с @font-face в client/src/index.css)
'/fonts/inter-var-latin.woff2',
'/fonts/inter-var-latin-ext.woff2',
'/fonts/inter-var-cyrillic.woff2',
'/fonts/inter-var-cyrillic-ext.woff2',
'/fonts/dm-mono-400-latin.woff2',
'/fonts/dm-mono-400-latin-ext.woff2',
'/fonts/dm-mono-500-latin.woff2',
'/fonts/dm-mono-500-latin-ext.woff2'
];
// Активный чат, о котором сообщает клиент через postMessage.
// Используется для подавления push-уведомлений, когда пользователь уже
// смотрит в соответствующий чат.
let activeChat = null; // { type: 'messenger'|'task', id: number, visible: boolean, updatedAt: number }
// ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ────
// Schema history:
// v1 – `queue` store (keyPath: id, index: enqueuedAt)
// v2 – no structural change (conflictStatus field added to queue items)
// v3 – `inbox-cache`, `task-cache`, `form-cache` added for PWA offline mode
// v4 – `directory-cache` added for offline справочник lookups
// v5 – `attachment-queue` для фоновой отправки сообщений с файлами
// v6 – synced-* stores для офлайн-кэша форм/задач/справочников
// v7 – queue token/orgId fields nullable for cookie-based auth
const IDB_NAME = 'iistwin-offline';
const IDB_VERSION = 7;
const IDB_STORE = 'queue';
function openQueueDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
// v1
if (!db.objectStoreNames.contains('queue')) {
const store = db.createObjectStore('queue', { keyPath: 'id' });
store.createIndex('enqueuedAt', 'enqueuedAt', { unique: false });
}
// v2: no structural change
// v3
if (!db.objectStoreNames.contains('inbox-cache')) {
db.createObjectStore('inbox-cache', { keyPath: 'userId' });
}
if (!db.objectStoreNames.contains('task-cache')) {
db.createObjectStore('task-cache', { keyPath: 'taskId' });
}
if (!db.objectStoreNames.contains('form-cache')) {
db.createObjectStore('form-cache', { keyPath: 'formId' });
}
// v4
if (!db.objectStoreNames.contains('directory-cache')) {
db.createObjectStore('directory-cache', { keyPath: 'tableId' });
}
// v5: attachment-queue для фоновой отправки сообщений с файлами
if (!db.objectStoreNames.contains('attachment-queue')) {
const aqStore = db.createObjectStore('attachment-queue', { keyPath: 'id' });
aqStore.createIndex('enqueuedAt', 'enqueuedAt', { unique: false });
}
// v6/v7: synced-* stores для офлайн-кэша форм и задач
const syncedStoreNames = [
'synced-forms',
'synced-tasks',
'synced-task-field-values',
'synced-data-tables',
'synced-data-table-rows',
'synced-automations',
'sync-subscriptions',
'sync-meta',
'pending-mutations',
'synced-users',
];
for (const name of syncedStoreNames) {
if (!db.objectStoreNames.contains(name)) {
if (name === 'synced-task-field-values') {
const store = db.createObjectStore(name, { keyPath: 'id' });
store.createIndex('taskId', 'taskId', { unique: false });
} else if (name === 'synced-data-table-rows') {
const store = db.createObjectStore(name, { keyPath: 'id' });
store.createIndex('tableId', 'tableId', { unique: false });
} else {
db.createObjectStore(name, { keyPath: 'id' });
}
}
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function getAllQueueItems(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readonly');
const req = tx.objectStore(IDB_STORE).index('enqueuedAt').getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function removeQueueItem(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
function isSafeToDrop(url, method) {
return method === 'POST' && /\/api\/messages\/\d+\/read$/.test(url);
}
function fetchWithTimeout(request, timeoutMs) {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
fetch(request, { signal: controller.signal })
.then((response) => {
clearTimeout(timer);
resolve(response);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
const ATTACHMENT_STORE = 'attachment-queue';
function getAllAttachmentItems(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(ATTACHMENT_STORE, 'readonly');
const req = tx.objectStore(ATTACHMENT_STORE).index('enqueuedAt').getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function removeAttachmentItem(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(ATTACHMENT_STORE, 'readwrite');
tx.objectStore(ATTACHMENT_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
function updateAttachmentItem(db, item) {
return new Promise((resolve, reject) => {
const tx = db.transaction(ATTACHMENT_STORE, 'readwrite');
tx.objectStore(ATTACHMENT_STORE).put(item);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function uploadFileFromSW(file) {
const formData = new FormData();
formData.append('file', file.blob, file.name);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Upload failed: ${res.status}`);
}
return res.json();
}
async function sendMessageFromSW(item, attachments) {
const url = item.type === 'messenger'
? `/api/messenger/conversations/${item.targetId}/messages`
: `/api/tasks/${item.targetId}/messages`;
const payload = {
message: item.message || '',
attachments,
};
if (item.replyToId || item.replyToMessageId) {
if (item.type === 'messenger') {
payload.replyToId = item.replyToId || item.replyToMessageId;
} else {
payload.replyToMessageId = item.replyToMessageId || item.replyToId;
}
}
if (item.mentionedUserIds && item.mentionedUserIds.length > 0) {
payload.mentionedUserIds = item.mentionedUserIds;
}
if (item.mentionedBotIds && item.mentionedBotIds.length > 0) {
payload.mentionedBotIds = item.mentionedBotIds;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Send failed: ${res.status}`);
}
return res.json();
}
/** Replay attachment queue directly from the SW. */
async function replayAttachmentQueueFromSW() {
let db;
try {
db = await openQueueDB();
} catch {
console.log('[SW] Cannot open IndexedDB for attachment replay');
return;
}
const items = await getAllAttachmentItems(db);
console.log(`[SW] Replaying ${items.length} attachment operation(s) from SW`);
for (const item of items) {
// Skip items already in error state (need user action or app open to retry)
if (item.status === 'error') {
console.log(`[SW] Skipping attachment ${item.id} — error state`);
continue;
}
try {
const attachments = [];
for (const file of item.files || []) {
const data = await uploadFileFromSW(file);
attachments.push({
url: data.url,
name: data.name,
size: data.size,
mimeType: file.type || undefined,
});
}
await sendMessageFromSW(item, attachments);
await removeAttachmentItem(db, item.id);
console.log(`[SW] Replayed attachment ${item.id} → success`);
} catch (err) {
console.log(`[SW] Attachment replay error ${item.id}:`, err.message);
// Network errors: stop and retry later
// 401/403: mark error (session may be expired, need app open)
// 4xx validation errors: mark error
// 5xx/429: stop and retry later
if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
item.status = 'error';
item.error = 'Требуется повторный вход';
await updateAttachmentItem(db, item);
continue;
}
if (err.message && /\b4\d\d\b/.test(err.message)) {
item.status = 'error';
item.error = err.message;
await updateAttachmentItem(db, item);
continue;
}
// Transient error — stop replay, next sync will retry
break;
}
}
}
/** Replay the offline queue directly from the SW (used when no window clients exist). */
async function replayQueueFromSW() {
let db;
try {
db = await openQueueDB();
} catch {
console.log('[SW] Cannot open IndexedDB for replay');
return;
}
const items = await getAllQueueItems(db);
console.log(`[SW] Replaying ${items.length} queued request(s) from SW`);
for (const item of items) {
try {
const headers = { 'Content-Type': 'application/json' };
const res = await fetch(item.url, {
method: item.method,
headers,
body: item.body ?? undefined,
credentials: 'include',
});
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429 && isSafeToDrop(item.url, item.method))) {
// Success or safe permanent client error — remove from queue
await removeQueueItem(db, item.id);
console.log(`[SW] Replayed ${item.method} ${item.url} → ${res.status}`);
} else {
// Server error, 429, or non-safe 4xx — stop and let the app handle it
console.log(`[SW] Replay stopped at ${item.method} ${item.url} → ${res.status}`);
break;
}
} catch {
// Network error during SW replay — stop
console.log(`[SW] Replay network error at ${item.method} ${item.url}`);
break;
}
}
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys().then((cacheNames) => {
// Старая страница может ещё работать под новым SW (reload отложен до ухода
// вкладки в фон). Если удалить её кэш сразу, ленивые чанки старой сборки
// дадут 404 — поэтому предыдущую версию кэша сохраняем.
const versionMatch = CACHE_NAME.match(/^(.*-v)(\d+)$/);
const prevCacheName = versionMatch
? `${versionMatch[1]}${Number(versionMatch[2]) - 1}`
: null;
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== prevCacheName)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
);
self.clients.claim();
});
async function handleShareTarget(request) {
try {
const formData = await request.formData();
const files = formData.getAll('media');
const title = formData.get('title') || '';
const text = formData.get('text') || '';
const url = formData.get('url') || '';
const cache = await caches.open('share-target-files');
await cache.put('/share-target/data', new Response(JSON.stringify({
title: String(title),
text: String(text) + (url ? '\n' + url : ''),
fileCount: files.length,
fileNames: files.map(f => (f instanceof File ? f.name : 'file')),
timestamp: Date.now(),
}), { headers: { 'Content-Type': 'application/json' } }));
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file instanceof Blob) {
await cache.put(`/share-target/file/${i}`, new Response(file, {
headers: { 'Content-Type': file.type || 'application/octet-stream' }
}));
}
}
} catch (err) {
console.error('[SW] Share target error:', err);
}
return Response.redirect('/share-target?shared=1', 303);
}
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/share-target') && event.request.method === 'POST') {
event.respondWith(handleShareTarget(event.request));
return;
}
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Skip API requests
if (url.pathname.startsWith('/api/')) return;
// Navigation requests (page loads): network-first with a short timeout,
// falling back to the cached shell. This guarantees that users see the
// latest build when they are online, while still keeping the app usable
// during cold PWA starts or flaky connections.
if (event.request.mode === 'navigate') {
event.respondWith(
fetchWithTimeout(event.request, 3000)
.then(async (networkResponse) => {
if (networkResponse.status === 200) {
const clone = networkResponse.clone();
const cache = await caches.open(CACHE_NAME);
await cache.put('/index.html', clone);
}
return networkResponse;
})
.catch(async () => {
const cached = await caches.match('/index.html');
if (cached) return cached;
const rootFallback = await caches.match('/');
if (rootFallback) return rootFallback;
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
})
);
return;
}
event.respondWith(
fetchWithTimeout(event.request, 5000)
.then((response) => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => caches.match(event.request))
);
});
// ── Background Sync ───────────────────────────────────────────────────────────
self.addEventListener('sync', (event) => {
if (event.tag === 'offline-queue') {
console.log('[SW] Background sync triggered: offline-queue');
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: false }).then(async (clientList) => {
if (clientList.length > 0) {
// App is open — tell it to handle sync (it has access to React Query cache)
for (const client of clientList) {
client.postMessage({ type: 'TRIGGER_SYNC' });
}
} else {
// App is closed — replay queue directly from SW
await replayQueueFromSW();
}
})
);
}
if (event.tag === 'attachment-queue') {
console.log('[SW] Background sync triggered: attachment-queue');
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: false }).then(async (clientList) => {
if (clientList.length > 0) {
// App is open — let the app handle it so React Query cache is updated
for (const client of clientList) {
client.postMessage({ type: 'TRIGGER_ATTACHMENT_SYNC' });
}
} else {
// App is closed — replay attachment queue directly from SW
await replayAttachmentQueueFromSW();
}
})
);
}
if (event.tag === 'offline-sync') {
console.log('[SW] Background sync triggered: offline-sync');
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: false }).then((clientList) => {
if (clientList.length > 0) {
// App is open — let the full sync engine run in the window context
for (const client of clientList) {
client.postMessage({ type: 'TRIGGER_SYNC' });
}
}
// When the app is closed we cannot run the full sync engine (it needs
// React Query and the authenticated fetch layer). The queued mutations
// will be replayed by the offline-queue tag instead.
})
);
}
});
// ── Push notifications ────────────────────────────────────────────────────────
async function hasVisibleClient() {
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
return clientList.some((client) => client.visibilityState === 'visible');
}
async function sendToForegroundClients(payload) {
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of clientList) {
client.postMessage({ type: 'FOREGROUND_NOTIFICATION', payload });
}
}
function shouldSuppressPushForActiveChat(payload) {
if (!activeChat || !activeChat.visible) return false;
// Игнорируем устаревшие записи.
const maxAgeMs = 60 * 1000;
if (!activeChat.updatedAt || Date.now() - activeChat.updatedAt > maxAgeMs) return false;
const nestedType = payload.data?.type;
if (nestedType === 'messenger' && activeChat.type === 'messenger') {
return parseInt(payload.data?.conversationId, 10) === activeChat.id;
}
if ((nestedType === 'chat' || payload.type === 'chat') && activeChat.type === 'task') {
return parseInt(payload.data?.taskId || payload.taskId, 10) === activeChat.id;
}
return false;
}
self.addEventListener('push', (event) => {
console.log('[SW] Push notification received');
let data = {
title: 'Уведомление',
body: 'Новое сообщение',
icon: '/icons/icon-192x192.png',
url: '/',
};
if (event.data) {
try {
data = { ...data, ...event.data.json() };
} catch {
data.body = event.data.text();
}
}
// Silent sync trigger: do not show a notification, just request a background sync.
// Support both the new top-level type and the old nested data.type shape.
if (data.type === 'SYNC_NEEDED' || data.data?.type === 'SYNC_NEEDED') {
event.waitUntil(
self.registration.sync
? self.registration.sync.register('offline-sync').catch(() => {})
: Promise.resolve()
);
return;
}
// Подавляем push, если пользователь уже смотрит в этот чат на активной вкладке.
if (shouldSuppressPushForActiveChat(data)) {
console.log('[SW] Push suppressed for active chat:', activeChat);
return;
}
event.waitUntil(
hasVisibleClient().then((visible) => {
if (visible) {
// Active tab gets an in-app toast instead of a system notification.
return sendToForegroundClients(data);
}
const options = {
body: data.body || data.message || '',
icon: data.icon || '/icons/icon-192x192.png',
badge: '/icons/icon-192x192.png',
vibrate: [100, 50, 100],
data: { url: data.url || data.click_action || '/', ...data },
actions: data.actions || [],
// Keep the notification on screen until the user interacts with it
// when the tab is not active.
requireInteraction: true,
// Use a unique tag per task so multiple task notifications stack instead of
// replacing each other, and avoid renotify which can suppress display on some OSs.
tag: data.tag || (data.taskId ? `task-${data.taskId}` : `notif-${Date.now()}`),
};
return self.registration.showNotification(data.title, options);
})
);
});
// Нормализация URL для сравнения «та же ли страница открыта»:
// сравниваем только pathname (суффикс), игнорируем query (?tab=chat) и origin.
// Матчим по суффиксу, чтобы ловить и org-префиксные роуты (/:org/tasks/<id>).
function samePage(clientUrl, targetUrl) {
try {
const targetPath = new URL(targetUrl, self.location.origin).pathname;
const clientPath = new URL(clientUrl, self.location.origin).pathname;
if (clientPath === targetPath) return true;
// суффикс: /tasks/123 или /chat/123 в конце пути
const suffixMatch = targetPath.match(/\/(tasks|chat)\/(\d+)$/);
if (suffixMatch && clientPath.endsWith(suffixMatch[0])) return true;
return false;
} catch {
return false;
}
}
// focus() штатно отклоняется по спецификации (нет transient activation, окно
// не получило фокус и т.п.) — он ВСЕГДА в отдельном try/catch, иначе его отказ
// роняет общий catch и цикл идёт навигировать следующую вкладку.
async function focusClient(client) {
try {
if ('focus' in client) await client.focus();
} catch (err) {
console.log('[SW] focus() rejected (best effort):', err?.message || err);
}
}
async function openOrFocusChat(url) {
const absoluteUrl = new URL(url, self.location.origin).href;
console.log('[SW] openOrFocusChat:', absoluteUrl);
try {
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
console.log('[SW] clients found:', clientList.length);
// 1. Задача/чат уже открыт в какой-то вкладке — просто фокусируем её.
for (const client of clientList) {
if (samePage(client.url, absoluteUrl)) {
console.log('[SW] page already open, focusing client:', client.url);
await focusClient(client);
return client;
}
}
// 2. Не открыта — открываем НОВУЮ вкладку, чужие не перехватываем.
console.log('[SW] opening new window');
const newClient = await clients.openWindow(absoluteUrl);
if (newClient) await focusClient(newClient);
return newClient;
} catch (err) {
console.error('[SW] openOrFocusChat fatal error:', err);
// Последний fallback — всё равно пытаемся открыть окно.
return clients.openWindow(absoluteUrl);
}
}
async function markNotificationRead(data) {
try {
// Поддерживаем оба формата: type на верхнем уровне и вложенный data.type
const type = data?.type || data?.data?.type;
const messageId = data?.messageId || data?.data?.messageId;
const conversationId = data?.conversationId || data?.data?.conversationId;
if (type === 'chat' && messageId) {
await fetch(`/api/messages/${messageId}/read`, {
method: 'POST',
credentials: 'include',
});
console.log('[SW] Marked chat message as read:', messageId);
} else if (type === 'messenger' && conversationId) {
await fetch(`/api/messenger/conversations/${conversationId}/read`, {
method: 'POST',
credentials: 'include',
});
console.log('[SW] Marked messenger conversation as read:', conversationId);
}
} catch (err) {
console.error('[SW] Failed to mark notification as read:', err);
}
}
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked, action:', event.action);
event.notification.close();
const data = event.notification.data || {};
console.log('[SW] Notification data:', JSON.stringify(data));
// Fallback: если url отсутствует, пытаемся восстановить его из вложенного data.
let urlToOpen = data.url || '/';
if ((!data.url || data.url === '/') && data.data) {
const nested = data.data;
if (nested.type === 'chat' && nested.taskId && nested.formId) {
urlToOpen = `/forms/${nested.formId}/tasks/${nested.taskId}${nested.isChat === 'true' ? '?tab=chat' : ''}`;
console.log('[SW] Rebuilt url from nested data:', urlToOpen);
} else if (nested.type === 'messenger' && nested.conversationId) {
urlToOpen = `/chat/${nested.conversationId}`;
console.log('[SW] Rebuilt url from nested data:', urlToOpen);
}
}
if (event.action === 'read') {
// "Прочитано": помечаем прочитанным и всё равно открываем задачу/чат, как ожидает пользователь.
event.waitUntil(
Promise.all([
navigator.clearAppBadge ? navigator.clearAppBadge() : Promise.resolve(),
markNotificationRead(data.data || data),
openOrFocusChat(urlToOpen),
])
);
return;
}
// Body click or 'reply' action: open the linked task/chat and focus an existing tab if possible.
event.waitUntil(
Promise.all([
navigator.clearAppBadge ? navigator.clearAppBadge() : Promise.resolve(),
openOrFocusChat(urlToOpen),
])
);
});
// ── Push subscription lifecycle ───────────────────────────────────────────────
self.addEventListener('pushsubscriptionchange', (event) => {
console.log('[SW] Push subscription changed');
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
if (clientList.length > 0) {
// Ask the app to resubscribe and register the new subscription with the server.
for (const client of clientList) {
client.postMessage({ type: 'PUSH_SUBSCRIPTION_CHANGE', oldEndpoint: event.oldSubscription?.endpoint });
}
} else {
// No open clients — we cannot fetch a new subscription without user interaction.
// The next app start will detect the missing/expired subscription and re-register.
console.log('[SW] No window clients to handle pushsubscriptionchange');
}
})
);
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
return;
}
if (event.data?.type === 'ACTIVE_CHAT') {
const payload = event.data?.payload || {};
activeChat = {
type: payload.type,
id: payload.id,
visible: !!payload.visible,
updatedAt: Date.now(),
};
return;
}
if (event.data?.type === 'CLEAR_ACTIVE_CHAT') {
activeChat = null;
}
});