Files
ForbiddenStarsApp/frontend/src/hooks/useServerEvents.ts
T
NotBigGhostandClaude Opus 5 a9de68f27c Simplify: общий загрузчик файлов и единые ключи на фронте
- Четыре копии «ресайз → FormData → CSRF из cookie → fetch → разбор конверта
  ошибки» (аватар, фото партии, фото из админки, иконка ачивки) сведены в
  lib/upload.ts. Иконка по-прежнему уходит оригиналом: ресайз в JPEG убил бы
  прозрачность герба.
- Три копии readCsrfToken и вторая копия resizeImage удалены — берём readCookie
  из api/client.ts и resizeImage из lib/image.ts с параметром размера.
- Ключи, протухающие от партии, собраны в matchAffectedKeys: раньше они были
  написаны строками мимо реестра qk в двух местах, и переименование ключа
  сломало бы инвалидацию молча.
- Различение «нет сессии» и «нет связи» вынесено в authProbeRetry и применено к
  обеим пробам. Прошлый заход чинил только игроцкие гварды, и RequireAdmin
  по-прежнему выкидывал админа на страницу входа при обрыве связи.
- ToastContext больше не пересоздаёт значение контекста: провайдер обёрнут
  вокруг всего приложения, и каждый тост перерисовывал всех потребителей.
- PlaceEditor не трогает DOM, пока цель подсветки не изменилась (было
  querySelectorAll на каждый pointermove).

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
2026-09-09 18:52:06 +03:00

59 lines
2.5 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { matchAffectedKeys, qk } from "../api/queryKeys";
interface ServerEvent {
type: "match" | "group" | "invitations" | "notifications";
match_id?: number;
group_id?: number;
}
/**
* Подписка на SSE-поток `/api/events` (push-to-invalidate): по событию с сервера точечно
* инвалидируем нужные запросы, и TanStack Query сам их перезапрашивает. EventSource
* переподключается автоматически. Открываем одно соединение, только когда авторизованы.
*/
export function useServerEvents(enabled: boolean) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const base = import.meta.env.VITE_API_BASE_URL || "";
const es = new EventSource(`${base}/api/events`, { withCredentials: true });
es.onmessage = (e) => {
let ev: ServerEvent;
try {
ev = JSON.parse(e.data) as ServerEvent;
} catch {
return;
}
if (ev.type === "invitations") {
qc.invalidateQueries({ queryKey: qk.invitations });
} else if (ev.type === "notifications") {
qc.invalidateQueries({ queryKey: qk.notifications });
} else if (ev.type === "match") {
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
}
// История игр и публичные профили тоже меняются от чужой партии.
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
} else if (ev.type === "group") {
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupMembers(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
}
qc.invalidateQueries({ queryKey: qk.me });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.invitations });
}
};
// onerror не логируем: EventSource переподключается сам.
return () => es.close();
}, [enabled, qc]);
}