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

@@ -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);
}, []);
}