fix(chat): catch-up сообщений при возврате на вкладку; fix(sw): фокус на открытой вкладке вместо навигации всех; fix(history): числа не резолвятся в имена пользователей

This commit is contained in:
2026-09-21 17:03:01 +03:00
parent 1f878b61a1
commit 2f9d049718
3 changed files with 82 additions and 57 deletions

View File

@@ -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/<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 {
// Ищем любые окна (включая неподконтрольные) — 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);

View File

@@ -54,20 +54,17 @@ function formatISODateToMoscow(iso: string): string {
}
}
// Резолв user:17 или 17 в ФИО
// Резолв user:17 в ФИО. ТОЛЬКО явный формат user:<id> — значения user-полей
// хранятся именно так. Голые числа (например, оценки «30»/«40») — это НЕ id
// пользователей, резолвить их в имена нельзя (иначе «30» превращается в «Артем Егоров»).
function resolveUserName(val: unknown, usersMap?: Map<number, string>): 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);
}
}

View File

@@ -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<T extends { id: number }>({
buildUrlRef.current = buildUrl;
const queryKeyJson = JSON.stringify(queryKey);
const queryKeyRef = useRef(queryKey);
queryKeyRef.current = JSON.parse(queryKeyJson);
const tickRef = useRef<() => Promise<void>>(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);
}, []);
}