Files
iistwin/client/public/sw.js
Ильяс Султанов 7672dd98df fix(chat): подавлять push-уведомления при открытом активном чате и автоотметка прочтения в task chat
- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat

- Сервер не шлёт Web Push/Pushy, если получатель смотрит в тот же чат на видимой вкладке

- Клиент сообщает серверу и Service Worker об активном чате из Chat.tsx и TaskChat.tsx

- TaskChat: входящие сообщения автоматически отмечаются прочитанными при видимой вкладке

- SW: подавляет foreground/system push, если активный чат совпадает
2026-07-16 18:59:00 +03:00

672 lines
23 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.

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;
}
});