fix(chat): catch-up сообщений при возврате на вкладку; fix(sw): фокус на открытой вкладке вместо навигации всех; fix(history): числа не резолвятся в имена пользователей
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user