diff --git a/client/public/sw.js b/client/public/sw.js index 49dde1c..83c9e44 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'iistwin-pwa-v13'; +const CACHE_NAME = 'iistwin-pwa-v14'; const STATIC_ASSETS = [ '/', '/index.html', @@ -577,47 +577,55 @@ self.addEventListener('push', (event) => { ); }); +// Нормализация URL для сравнения «та же ли страница открыта»: +// сравниваем только pathname (суффикс), игнорируем query (?tab=chat) и origin. +// Матчим по суффиксу, чтобы ловить и org-префиксные роуты (/:org/tasks/). +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 { - // Ищем любые окна (включая неподконтрольные) — focus работает и на них, - // а navigate может не сработать на uncontrolled, поэтому оборачиваем в try/catch. const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); console.log('[SW] clients found:', clientList.length); - // Сначала ищем клиент с уже открытым этим URL — просто фокусируем. + // 1. Задача/чат уже открыт в какой-то вкладке — просто фокусируем её. for (const client of clientList) { - try { - if (client.url === absoluteUrl && 'focus' in client) { - console.log('[SW] focusing existing client with same URL'); - return await client.focus(); - } - } catch (err) { - console.error('[SW] focus same-url client error:', err); + if (samePage(client.url, absoluteUrl)) { + console.log('[SW] page already open, focusing client:', client.url); + await focusClient(client); + return client; } } - // Затем пытаемся навигировать любой доступный клиент. - for (const client of clientList) { - try { - if ('focus' in client) { - console.log('[SW] navigating client to', absoluteUrl); - await client.navigate(absoluteUrl); - return await client.focus(); - } - } catch (err) { - console.error('[SW] navigate client error:', err); - } - } - - // Нет подходящих клиентов — открываем новое окно/вкладку. + // 2. Не открыта — открываем НОВУЮ вкладку, чужие не перехватываем. console.log('[SW] opening new window'); const newClient = await clients.openWindow(absoluteUrl); - if (newClient && 'focus' in newClient) { - return await newClient.focus(); - } + if (newClient) await focusClient(newClient); return newClient; } catch (err) { console.error('[SW] openOrFocusChat fatal error:', err); diff --git a/client/src/components/TaskHistory.tsx b/client/src/components/TaskHistory.tsx index 3cf4fdd..195ec35 100644 --- a/client/src/components/TaskHistory.tsx +++ b/client/src/components/TaskHistory.tsx @@ -54,20 +54,17 @@ function formatISODateToMoscow(iso: string): string { } } -// Резолв user:17 или 17 в ФИО +// Резолв user:17 в ФИО. ТОЛЬКО явный формат user: — значения user-полей +// хранятся именно так. Голые числа (например, оценки «30»/«40») — это НЕ id +// пользователей, резолвить их в имена нельзя (иначе «30» превращается в «Артем Егоров»). function resolveUserName(val: unknown, usersMap?: Map): string | null { if (usersMap === undefined) return null; let userId: number | null = null; - if (typeof val === 'number') { - userId = val; - } else if (typeof val === 'string') { - // Формат user:17 или просто число в строке + if (typeof val === 'string') { const match = val.match(/^user:(\d+)$/); if (match) { userId = parseInt(match[1], 10); - } else if (/^\d+$/.test(val)) { - userId = parseInt(val, 10); } } diff --git a/client/src/components/chat/useMessagePolling.ts b/client/src/components/chat/useMessagePolling.ts index d25576e..eade71b 100644 --- a/client/src/components/chat/useMessagePolling.ts +++ b/client/src/components/chat/useMessagePolling.ts @@ -4,8 +4,11 @@ import { apiRequest, queryClient } from '@/lib/queryClient'; // ── Polling fallback для чатов ──────────────────────────────────────────────── // Общий цикл «GET ...messages?afterId=... → merge в кэш без дублей». // Используется в мессенджере (useChatController) и в чате задачи (TaskChat). -// Поведение: опрос только при enabled, скрытая вкладка (document.hidden) пропускается, +// Поведение: опрос только при enabled, скрытая вкладка (document.hidden) пропускает тики, // новые сообщения мержатся в кэш по id без дублей. +// Дополнительно: catch-up при возврате на вкладку (hidden→visible) — независимо от +// enabled, чтобы догрузить сообщения, пришедшие, пока вкладка была скрыта +// (JS троттлится, SSE умирает, тики пропускаются). Без дублей: merge по id. interface UseMessagePollingOptions { /** Ключ react-query с кэшем вида { messages: T[] }, куда мержатся новые сообщения. */ @@ -29,31 +32,48 @@ export function useMessagePolling({ buildUrlRef.current = buildUrl; const queryKeyJson = JSON.stringify(queryKey); + const queryKeyRef = useRef(queryKey); + queryKeyRef.current = JSON.parse(queryKeyJson); + + const tickRef = useRef<() => Promise>(async () => {}); + tickRef.current = async () => { + const key = queryKeyRef.current; + const currentData = queryClient.getQueryData(key) as any; + const currentMessages: T[] = currentData?.messages || []; + const lastServerMessage = currentMessages.filter((m) => m.id > 0).pop(); + if (!lastServerMessage) return; + try { + const res = await apiRequest('GET', buildUrlRef.current(lastServerMessage.id)); + const data = await res.json(); + if (!data.success || !Array.isArray(data.messages) || data.messages.length === 0) return; + queryClient.setQueryData(key, (old: any) => { + if (!old) return { success: true, messages: data.messages }; + const existingIds = new Set(old.messages.map((m: T) => m.id)); + const merged = [...old.messages, ...data.messages.filter((m: T) => !existingIds.has(m.id))]; + return { ...old, messages: merged }; + }); + } catch { + // ошибки опроса игнорируем — SSE или следующий тик доставят сообщения + } + }; useEffect(() => { if (!enabled) return; - const key = JSON.parse(queryKeyJson) as unknown[]; - const interval = setInterval(async () => { + const interval = setInterval(() => { if (document.hidden) return; - const currentData = queryClient.getQueryData(key) as any; - const currentMessages: T[] = currentData?.messages || []; - const lastServerMessage = currentMessages.filter((m) => m.id > 0).pop(); - if (!lastServerMessage) return; - try { - const res = await apiRequest('GET', buildUrlRef.current(lastServerMessage.id)); - const data = await res.json(); - if (!data.success || !Array.isArray(data.messages) || data.messages.length === 0) return; - queryClient.setQueryData(key, (old: any) => { - if (!old) return { success: true, messages: data.messages }; - const existingIds = new Set(old.messages.map((m: T) => m.id)); - const merged = [...old.messages, ...data.messages.filter((m: T) => !existingIds.has(m.id))]; - return { ...old, messages: merged }; - }); - } catch { - // ошибки опроса игнорируем — SSE или следующий тик доставят сообщения - } + void tickRef.current(); }, intervalMs); return () => clearInterval(interval); - // queryKey стабилизируем через JSON-представление — массив ключа пересоздаётся каждый рендер - }, [enabled, intervalMs, queryKeyJson]); + }, [enabled, intervalMs]); + + // Catch-up при возврате на вкладку: догружаем пропущенное сразу, не дожидаясь + // тика интервала или reconnect SSE. Работает и при enabled=false (полуоткрытый SSE), + // если для ключа есть кэш сообщений (чат открыт/был открыт). + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState === 'visible') void tickRef.current(); + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, []); }