- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat - Сервер не шлёт Web Push/Pushy, если получатель смотрит в тот же чат на видимой вкладке - Клиент сообщает серверу и Service Worker об активном чате из Chat.tsx и TaskChat.tsx - TaskChat: входящие сообщения автоматически отмечаются прочитанными при видимой вкладке - SW: подавляет foreground/system push, если активный чат совпадает
672 lines
23 KiB
JavaScript
672 lines
23 KiB
JavaScript
const CACHE_NAME = 'iistwin-pwa-v10';
|
||
const STATIC_ASSETS = [
|
||
'/',
|
||
'/index.html',
|
||
'/manifest.json',
|
||
'/favicon.svg'
|
||
];
|
||
|
||
// Активный чат, о котором сообщает клиент через 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) => {
|
||
return Promise.all(
|
||
cacheNames
|
||
.filter((name) => name !== CACHE_NAME)
|
||
.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);
|
||
})
|
||
);
|
||
});
|
||
|
||
async function openOrFocusChat(url) {
|
||
const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||
// Prefer a client that already has the same path (ignoring query string).
|
||
const targetPath = url.split('?')[0];
|
||
for (const client of clientList) {
|
||
if (client.url.includes(self.location.origin) && client.url.split('?')[0] === targetPath && 'focus' in client) {
|
||
await client.navigate(url);
|
||
return client.focus();
|
||
}
|
||
}
|
||
// Otherwise focus any same-origin client and navigate it.
|
||
for (const client of clientList) {
|
||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||
await client.navigate(url);
|
||
return client.focus();
|
||
}
|
||
}
|
||
return clients.openWindow(url);
|
||
}
|
||
|
||
async function markNotificationRead(data) {
|
||
try {
|
||
if (data?.type === 'chat' && data?.messageId) {
|
||
await fetch(`/api/messages/${data.messageId}/read`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
});
|
||
console.log('[SW] Marked chat message as read:', data.messageId);
|
||
} else if (data?.type === 'messenger' && data?.conversationId) {
|
||
await fetch(`/api/messenger/conversations/${data.conversationId}/read`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
});
|
||
console.log('[SW] Marked messenger conversation as read:', data.conversationId);
|
||
}
|
||
} catch (err) {
|
||
console.error('[SW] Failed to mark notification as read:', err);
|
||
}
|
||
}
|
||
|
||
self.addEventListener('notificationclick', (event) => {
|
||
console.log('[SW] Notification clicked', event.action);
|
||
event.notification.close();
|
||
|
||
const data = event.notification.data || {};
|
||
const urlToOpen = data.url || '/';
|
||
|
||
if (event.action === 'read') {
|
||
event.waitUntil(markNotificationRead(data));
|
||
return;
|
||
}
|
||
|
||
// 'reply' or body click: open the 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;
|
||
}
|
||
});
|