import { create } from 'zustand'; import { connectGateway, disconnectGateway, parseGatewaySnapshot, type GatewayDispatch, type GatewayStatus, type GatewayUser, } from '@/api/gateway'; import { parseChannelDelete, parseChannelPayload, parseGuildId, parseGuildUpdate, parseRoleEvent, parseUserUpdate, } from '@/api/gatewayEvents'; import { useSessionStore } from '@/stores/session'; /** * Состояние соединения со шлюзом. Данные приходят в session-store, * здесь — только транспорт: статус, heartbeat, resume и переподключение. */ interface GatewayState { status: GatewayStatus; heartbeatIntervalMs: number | null; sessionId: string | null; attempts: number; /** Признак того, что после INVALID_SESSION нужно уйти на /login. */ invalidated: boolean; connect: (userId: string) => void; disconnect: () => void; reset: () => void; } const defaults = { status: 'idle' as GatewayStatus, heartbeatIntervalMs: null, sessionId: null, attempts: 0, invalidated: false, }; export const useGatewayStore = create((set, get) => ({ ...defaults, connect: (userId) => { if (get().status !== 'idle') { return; } // userId пока не уходит на сервер (аутентификация — cookie на рукопожатии), // но фиксирует, что снапшот принадлежит именно этому пользователю. void userId; connectGateway({ handlers: { onStatus: (status) => { set({ status, attempts: status === 'reconnecting' ? get().attempts + 1 : get().attempts, }); }, onInvalidSession: () => { set({ invalidated: true, status: 'disconnected' }); useSessionStore.getState().reset(); void invalidateCurrentUser(); }, onResumeIncomplete: () => { // Сервер не прислал снапшот — перечитываем профиль по REST. void refetchCurrentUser(); }, onDispatch: (event) => { dispatchGatewayEvent(event); }, }, }); }, disconnect: () => { disconnectGateway(); set({ ...defaults }); }, reset: () => { disconnectGateway(); set({ ...defaults }); }, })); /** Разбор события DISPATCH: в Фазе 1 обрабатываем READY/RESUMED, остальные — в диспетчер. */ export function dispatchGatewayEvent(event: GatewayDispatch): void { const session = useSessionStore.getState(); switch (event.t) { case 'READY': { const snapshot = parseGatewaySnapshot(event.d); if (snapshot === null) { return; } session.applyReady(snapshot); useGatewayStore.setState({ status: 'connected', heartbeatIntervalMs: snapshot.heartbeat_interval_ms, sessionId: snapshot.session_id, attempts: 0, }); return; } case 'RESUMED': { const user = parseResumedUser(event.d); session.applyResumed(user); useGatewayStore.setState({ status: 'connected', attempts: 0 }); return; } case 'USER_UPDATE': { // Профиль текущего пользователя обновился (например, с другого устройства). const update = parseUserUpdate(event.d); if (update !== null) { session.applyUserUpdate(update.user); } return; } case 'GUILD_CREATE': { // В событии только guild_id — перечитываем список серверов по REST. const guildId = parseGuildId(event.d); if (guildId !== null) { void addGuildFromRest(guildId); } return; } case 'GUILD_UPDATE': { const update = parseGuildUpdate(event.d); if (update?.name !== undefined) { session.renameGuild(update.guild_id, update.name); } return; } case 'GUILD_DELETE': { const guildId = parseGuildId(event.d); if (guildId === null) { return; } // removeGuild сам выбирает первый оставшийся сервер (или пустой список). session.removeGuild(guildId); void invalidateGuildQueries(guildId); syncUrlWithSelection(); return; } case 'CHANNEL_CREATE': case 'CHANNEL_UPDATE': { const channel = parseChannelPayload(event.d); if (channel === null) { return; } const guildId = channel.guild_id ?? session.selectedGuildId; if (guildId !== null) { session.upsertChannel(guildId, channel); } return; } case 'CHANNEL_DELETE': { const deleted = parseChannelDelete(event.d); if (deleted === null) { return; } session.removeChannel(deleted.guild_id, deleted.channel_id); syncUrlWithSelection(); return; } case 'MEMBER_UPDATE': case 'MEMBER_REMOVE': { // Роли участника влияют на права: перечитываем участников и карточку сервера. const guildId = parseGuildId(event.d); if (guildId !== null) { void invalidateMembershipQueries(guildId); } return; } case 'ROLE_CREATE': case 'ROLE_UPDATE': case 'ROLE_DELETE': { const role = parseRoleEvent(event.d); if (role !== null) { void invalidateMembershipQueries(role.guild_id); } return; } case 'PRESENCE_UPDATE': // Присутствие других участников в Фазе 1 не отображается. return; default: // Неизвестные события игнорируем: контракт может расширяться. return; } } /** Сервер появился (создан или найден): забираем сводку из REST. */ async function addGuildFromRest(guildId: string): Promise { const [{ getQueryClient }, users] = await Promise.all([ import('@/lib/queryClient'), import('@/api/users'), ]); const client = getQueryClient(); try { const guilds = await client.fetchQuery({ queryKey: users.myGuildsQueryKey, queryFn: () => users.fetchMyGuilds(), }); const guild = guilds.find((item) => item.id === guildId); if (guild !== undefined) { useSessionStore.getState().upsertGuild(guild); } } catch { // Сеть подвела — просто помечаем данные устаревшими для следующего рендера. await client.invalidateQueries({ queryKey: users.myGuildsQueryKey }); } } /** Инвалидация REST-данных сервера и участников после событий ролей. */ async function invalidateMembershipQueries(guildId: string): Promise { const [{ getQueryClient }, guilds] = await Promise.all([ import('@/lib/queryClient'), import('@/api/guilds'), ]); const client = getQueryClient(); await Promise.all([ client.invalidateQueries({ queryKey: guilds.guildMembersQueryKey(guildId) }), client.invalidateQueries({ queryKey: guilds.guildRolesQueryKey(guildId) }), client.invalidateQueries({ queryKey: guilds.guildQueryKey(guildId) }), ]); } /** Инвалидация данных удалённого сервера (карточка, участники, каналы). */ async function invalidateGuildQueries(guildId: string): Promise { const [{ getQueryClient }, guilds] = await Promise.all([ import('@/lib/queryClient'), import('@/api/guilds'), ]); const client = getQueryClient(); await Promise.all([ client.invalidateQueries({ queryKey: guilds.guildQueryKey(guildId) }), client.invalidateQueries({ queryKey: guilds.guildChannelsQueryKey(guildId) }), client.invalidateQueries({ queryKey: guilds.guildMembersQueryKey(guildId) }), ]); } /** Держит URL в согласии с выбранным сервером/комнатой после удалений. */ function syncUrlWithSelection(): void { if (typeof window === 'undefined') { return; } const state = useSessionStore.getState(); const target = state.selectedGuildId === null ? '/app/empty' : state.selectedChannelId === null ? `/app/${state.selectedGuildId}` : `/app/${state.selectedGuildId}/${state.selectedChannelId}`; if (window.location.pathname !== target) { window.history.replaceState(null, '', target); window.dispatchEvent(new PopStateEvent('popstate')); } } function parseResumedUser(payload: unknown): GatewayUser | null { if (typeof payload !== 'object' || payload === null) { return null; } const user = (payload as { user?: unknown }).user; const snapshot = parseGatewaySnapshot({ user, guilds: [] }); return snapshot === null ? null : snapshot.user; } /** Ленивые импорты разрывают цикл «стор → query-client → стор». */ async function invalidateCurrentUser(): Promise { const [{ getQueryClient }, { currentUserQueryKey }] = await Promise.all([ import('@/lib/queryClient'), import('@/api/users'), ]); // removeQueries вместо invalidate: сессия мертва, повторный запрос не нужен. getQueryClient().removeQueries({ queryKey: currentUserQueryKey }); } async function refetchCurrentUser(): Promise { const [{ getQueryClient }, { currentUserQueryKey }] = await Promise.all([ import('@/lib/queryClient'), import('@/api/users'), ]); await getQueryClient().invalidateQueries({ queryKey: currentUserQueryKey }); }