import { create } from 'zustand'; import { guildEmojisQueryKey } from '@/api/emojis'; import { connectGateway, disconnectGateway, parseGatewaySnapshot, type GatewayDispatch, type GatewayStatus, type GatewayUser, } from '@/api/gateway'; import { parseChannelDelete, parseGuildChannelsSyncEvent, parseChannelPayload, parseDmChannelEvent, parseGuildEmojisUpdate, parseGuildId, parseGuildSoundsUpdate, parseGuildUpdate, parseMessageDelete, parseMessagePayload, parsePinsUpdate, parsePresenceEvent, parseReactionEvent, parseReadStateUpdate, parseRelationshipEvent, parseRoleEvent, parseSoundboardPlay, parseTypingStart, parseUserUpdate, } from '@/api/gatewayEvents'; import { guildSoundsQueryKey } from '@/api/sounds'; import { parseSystemMessage } from '@/lib/systemMessage'; import { useFriendsStore } from '@/stores/friends'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore, type SessionGuild } from '@/stores/session'; import { playEventSound, useSoundboardStore } from '@/stores/soundboard'; import { useVoiceConnectionStore } from '@/stores/voiceConnection'; import { parseVoiceStateEvent } from '@/api/voice'; import type { Message } from '@/api/messages'; /** * Состояние соединения со шлюзом. Данные приходят в 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; /** Сессия отозвана сервером (logout-all, смена пароля, бан): экран входа. */ markInvalidated: () => 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(); // Сессия мертва: лента сообщений тоже больше не наша. useMessagesStore.getState().reset(); // Голосовую комнату покидаем без запроса: сессии уже нет. useVoiceConnectionStore.getState().reset(); void invalidateCurrentUser(); }, onResumeIncomplete: () => { // Сервер не прислал снапшот — перечитываем профиль по REST. void refetchCurrentUser(); }, onDispatch: (event) => { dispatchGatewayEvent(event); }, }, }); }, disconnect: () => { disconnectGateway(); set({ ...defaults }); }, /** markInvalidated — сессия отозвана сервером: показываем экран входа. */ markInvalidated: () => { set({ invalidated: true, status: 'disconnected' }); }, reset: () => { disconnectGateway(); set({ ...defaults }); }, })); /** Разбор события DISPATCH: READY/RESUMED, гильдии/каналы и Фаза 2 (сообщения). */ export function dispatchGatewayEvent(event: GatewayDispatch): void { const session = useSessionStore.getState(); const messages = useMessagesStore.getState(); switch (event.t) { case 'READY': { const snapshot = parseGatewaySnapshot(event.d); if (snapshot === null) { return; } session.applyReady(snapshot); // Снапшот несёт отметки прочтения — переносим их в стор сообщений. for (const raw of snapshot.read_states) { const readState = parseReadStateUpdate(raw); if (readState !== null) { messages.applyReadState(readState); } } // После этого отсутствие записи для комнаты означает «не читалась». messages.markSnapshotApplied(); 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 === null) { return; } if (update.name !== undefined) { session.renameGuild(update.guild_id, update.name); } // Оформление меняется мгновенно у всех: иконка, баннер, splash, акцент. session.applyGuildAppearance(update.guild_id, update); 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; } // Личная беседа живёт вне серверов: у неё нет guild_id. if (channel.type === 'dm') { session.upsertDmChannel(channel); return; } const guildId = channel.guild_id ?? session.selectedGuildId; if (guildId !== null) { // Права на комнату персональные: тело события их не несёт, поэтому // перечитываем список — иначе новая или переименованная комната не // появится в сайдбаре без перезагрузки (AGENT.md 11.6). void session.reloadGuildChannels(guildId); } return; } case 'CHANNEL_DELETE': { const deleted = parseChannelDelete(event.d); if (deleted === null) { return; } session.removeChannel(deleted.guild_id, deleted.channel_id); messages.dropChannel(deleted.channel_id); syncUrlWithSelection(); return; } case 'MESSAGE_CREATE': { const message = parseMessagePayload(event.d); if (message !== null) { messages.applyMessageCreate(message); // В личной беседе «последнее сообщение» — источник бейджа и порядка. session.touchDmChannel(message.channel_id, message.id, message.created_at); playMessageSound(message); } return; } case 'MESSAGE_UPDATE': { const message = parseMessagePayload(event.d); if (message !== null) { messages.applyMessageUpdate(message); } return; } case 'MESSAGE_DELETE': { const deleted = parseMessageDelete(event.d); if (deleted !== null) { messages.applyMessageDelete(deleted.channel_id, deleted.id); } return; } case 'MESSAGE_REACTION_ADD': case 'MESSAGE_REACTION_REMOVE': { const reaction = parseReactionEvent(event.d); if (reaction !== null) { messages.applyReaction( reaction.channel_id, reaction.message_id, reaction.emoji, reaction.user_id, event.t === 'MESSAGE_REACTION_ADD', ); } return; } case 'CHANNEL_PINS_UPDATE': { const update = parsePinsUpdate(event.d); if (update !== null) { messages.applyPinsUpdate(update.channel_id, update.message_id, update.pinned); } return; } case 'TYPING_START': { const typing = parseTypingStart(event.d); if (typing !== null) { messages.applyTypingStart(typing.channel_id, typing.user_id); } return; } case 'READ_STATE_UPDATE': { const readState = parseReadStateUpdate(event.d); if (readState !== null) { messages.applyReadState(readState); } return; } case 'MEMBER_UPDATE': case 'MEMBER_REMOVE': { // Роли участника влияют на права: перечитываем участников, карточку // сервера и список комнат (приватные комнаты открываются ролью). const guildId = parseGuildId(event.d); if (guildId !== null) { void invalidateMembershipQueries(guildId); void session.reloadGuildChannels(guildId); } return; } case 'ROLE_CREATE': case 'ROLE_UPDATE': case 'ROLE_DELETE': { const role = parseRoleEvent(event.d); if (role !== null) { void invalidateMembershipQueries(role.guild_id); void session.reloadGuildChannels(role.guild_id); } return; } case 'GUILD_EMOJIS_UPDATE': { // Набор эмодзи сервера изменился: заменяем его в снапшоте и помечаем // REST-кэш устаревшим — форма списка одна и та же. const update = parseGuildEmojisUpdate(event.d); if (update !== null) { session.setGuildEmojis(update.guild_id, update.emojis); void invalidateEmojiQueries(update.guild_id); } return; } case 'PRESENCE_UPDATE': { // Статус друга: обновляем список друзей и шапку личной беседы точечно. const presence = parsePresenceEvent(event.d); if (presence === null) { return; } useFriendsStore.getState().applyPresence(presence.user_id, presence); session.patchDmPresence(presence.user_id, presence.visible_status ?? presence.status); return; } case 'GUILD_SOUNDS_UPDATE': { // Набор звуков сервера изменился целиком: снапшот и REST-кэш (7.13). const update = parseGuildSoundsUpdate(event.d); if (update !== null) { session.setGuildSounds(update.guild_id, update.sounds); void invalidateSoundQueries(update.guild_id); } return; } case 'SOUNDBOARD_PLAY': { // Событие приходит только участникам комнаты: играем звук локально. const played = parseSoundboardPlay(event.d); if (played !== null) { useSoundboardStore.getState().applyGatewayPlay(played); } return; } case 'RELATIONSHIP_UPDATE': { // Связь появилась или пропала — перечитываем список целиком. const relationship = parseRelationshipEvent(event.d); if (relationship !== null) { void useFriendsStore.getState().reload(); } return; } case 'SESSION_INVALIDATED': { // Сервер отозвал сессии (logout-all, смена пароля, бан, удаление): // уходим на экран входа, не дожидаясь следующего запроса (AGENT.md 11.6). useGatewayStore.getState().markInvalidated(); session.reset(); messages.reset(); useVoiceConnectionStore.getState().reset(); void invalidateCurrentUser(); return; } case 'GUILD_CHANNELS_SYNC': { // Права комнаты изменились: видимость может поменяться у всех // участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6). const sync = parseGuildChannelsSyncEvent(event.d); if (sync !== null) { void session.reloadGuildChannels(sync.guild_id); } return; } case 'DM_CHANNEL_CREATE': { // В событии только channel_id: список бесед забираем по REST. const direct = parseDmChannelEvent(event.d); if (direct !== null) { void session.reloadDmChannels(); } return; } case 'VOICE_STATE_UPDATE': { // Лёг из комнаты или сменил флаги: единственный источник «кто в голосе». const voice = parseVoiceStateEvent(event.d); if (voice === null) { return; } const me = session.user?.id ?? null; const myChannel = useVoiceConnectionStore.getState().channelId; if (voice.kind === 'left') { // До удаления записи узнаём, из какой комнаты ушёл участник. const leftChannel = (session.voiceStates[voice.guildId] ?? []).find((item) => item.user_id === voice.userId) ?.channel_id ?? null; session.applyVoiceLeave(voice.guildId, voice.userId); if (voice.userId !== me && leftChannel !== null && leftChannel === myChannel) { playEventSound(voice.guildId, 'voice_leave'); } } else { // Участник мог быть в другой комнате — тогда это перемещение в нашу. const previous = (session.voiceStates[voice.state.guild_id] ?? []).find( (item) => item.user_id === voice.state.user_id, ); session.applyVoiceState(voice.state); if ( voice.state.user_id !== me && voice.state.channel_id === myChannel && previous?.channel_id !== myChannel ) { playEventSound(voice.state.guild_id, 'voice_join'); } } // server_mute/server_deaf и перемещение себя применяем к своей сессии. useVoiceConnectionStore.getState().syncServerState(); return; } default: // Неизвестные события игнорируем: контракт может расширяться. return; } } /** * Звук события UI-палитры для нового сообщения (AGENT.md 7.13): системные * сообщения — это вход/выход участника, остальные — упоминание или обычное * сообщение. Свои сообщения звуком не сопровождаются. */ function playMessageSound(message: Message): void { const session = useSessionStore.getState(); const guildId = guildIdOfChannel(session.guilds, message.channel_id); if (guildId === null) { return; } if (message.type === 'system') { const event = parseSystemMessage(message.content); if (event?.kind === 'member_join') { playEventSound(guildId, 'member_join'); return; } if (event?.kind === 'member_leave' || event?.kind === 'member_kick') { playEventSound(guildId, 'member_leave'); } return; } const me = session.user?.id ?? null; if (me !== null && message.author_id === me) { return; } playEventSound(guildId, me !== null && message.mentions.includes(me) ? 'mention' : 'message'); } /** Сервер, которому принадлежит комната: в личной беседе его нет. */ function guildIdOfChannel(guilds: SessionGuild[], channelId: string): string | null { for (const guild of guilds) { if (guild.channels.some((channel) => channel.id === channelId)) { return guild.id; } } return null; } /** Сервер появился (создан или найден): забираем сводку из 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) }), ]); } /** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */ async function invalidateEmojiQueries(guildId: string): Promise { // Ключ запроса берём из статического импорта: `api/emojis` не зависит от // сторов, поэтому цикла «стор → query-client → стор» здесь нет. const { getQueryClient } = await import('@/lib/queryClient'); await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) }); } /** Инвалидация списка звуков сервера после `GUILD_SOUNDS_UPDATE` (7.13). */ async function invalidateSoundQueries(guildId: string): Promise { const { getQueryClient } = await import('@/lib/queryClient'); await getQueryClient().invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) }); } /** Держит URL в согласии с выбранным сервером/комнатой после удалений. */ function syncUrlWithSelection(): void { if (typeof window === 'undefined') { return; } // Раздел друзей не зависит от выбранного сервера — не выдёргиваем из него. if (window.location.pathname.startsWith('/app/friends')) { 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 }); }