diff --git a/web/src/pages/settings/GuildEmojisSection.tsx b/web/src/pages/settings/GuildEmojisSection.tsx index 70b9a11..79c014c 100644 --- a/web/src/pages/settings/GuildEmojisSection.tsx +++ b/web/src/pages/settings/GuildEmojisSection.tsx @@ -40,9 +40,10 @@ type LocalError = { kind: 'file.too_large' | 'emoji.invalid_type' | 'emoji.inval * «Эмодзи»: загрузка, переименование и удаление кастомных эмодзи сервера * (AGENT.md 7.12, право MANAGE_EMOJIS). * - * Список читается по REST (`GET /guilds/{id}/emojis`), а после операций - * инвалидируется: снапшот обновляет и событие шлюза `GUILD_EMOJIS_UPDATE`, - * и точечные правки в сторе сессии — лента и пикер видят эмодзи сразу. + * Список читается по REST (`GET /guilds/{id}/emojis`), но событие шлюза + * `GUILD_EMOJIS_UPDATE` несёт набор целиком — стор шлюза кладёт его прямо в + * кэш запроса (как у звуков, AGENT.md 7.12), поэтому повторный запрос нужен + * только после собственных операций. */ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) { const { t, i18n } = useTranslation(); @@ -63,6 +64,9 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) { }); const emojis = list.data ?? []; + // Ошибка перезапроса не должна прятать уже полученный список: под нагрузкой + // ручка отвечает 429, а список из `GUILD_EMOJIS_UPDATE` при этом свежий (7.12). + const failed = list.isError && list.data === undefined; const full = emojis.length >= EMOJI_LIMIT_PER_GUILD; /** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */ @@ -173,7 +177,7 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) { {list.isPending ? ( - ) : list.isError ? ( + ) : failed ? ( void list.refetch()} /> ) : emojis.length === 0 ? (

{t('settings.servers.emojis.empty')}

diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index 47d72e6..ba1b3b9 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { guildEmojisQueryKey } from '@/api/emojis'; +import { guildEmojisQueryKey, type GuildEmoji } from '@/api/emojis'; import { connectGateway, disconnectGateway, @@ -314,12 +314,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { return; } case 'GUILD_EMOJIS_UPDATE': { - // Набор эмодзи сервера изменился: заменяем его в снапшоте и помечаем - // REST-кэш устаревшим — форма списка одна и та же. + // Набор эмодзи сервера изменился целиком: кладём его и в снапшот, и в + // кэш REST-запроса раздела настроек — повторный запрос не нужен (7.12). const update = parseGuildEmojisUpdate(event.d); if (update !== null) { session.setGuildEmojis(update.guild_id, update.emojis); - void invalidateEmojiQueries(update.guild_id); + void applyGuildEmojis(update.guild_id, update.emojis); } return; } @@ -569,12 +569,16 @@ async function invalidateGuildQueries(guildId: string): Promise { ]); } -/** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */ -async function invalidateEmojiQueries(guildId: string): Promise { +/** + * Кладёт набор эмодзи из `GUILD_EMOJIS_UPDATE` в кэш REST-запроса: событие + * несёт список целиком, поэтому повторный запрос не нужен, а его отказ под + * нагрузкой (429) оставлял раздел настроек со старым списком (AGENT.md 7.12). + */ +async function applyGuildEmojis(guildId: string, emojis: GuildEmoji[]): Promise { // Ключ запроса берём из статического импорта: `api/emojis` не зависит от // сторов, поэтому цикла «стор → query-client → стор» здесь нет. const { getQueryClient } = await import('@/lib/queryClient'); - await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) }); + getQueryClient().setQueryData(guildEmojisQueryKey(guildId), emojis); } /** diff --git a/web/tests/guildEmojisSettings.test.tsx b/web/tests/guildEmojisSettings.test.tsx index 2165049..c47c7ca 100644 --- a/web/tests/guildEmojisSettings.test.tsx +++ b/web/tests/guildEmojisSettings.test.tsx @@ -6,7 +6,9 @@ import { apiError, findRequest, installFetch, + installGatewaySocket, json, + makeReadySnapshot, makeUser, recordedRequests, renderApp, @@ -18,8 +20,9 @@ import { /** * Секция «Эмодзи» в настройках сервера (AGENT.md 7.12): список загруженных * эмодзи со счётчиком, загрузка с клиентскими проверками, переименование и - * удаление. Лента и пикер видят изменения сразу — запросы эмодзи - * инвалидируются, а снапшот обновляет событие `GUILD_EMOJIS_UPDATE`. + * удаление. Лента и пикер видят изменения сразу: событие `GUILD_EMOJIS_UPDATE` + * несёт набор целиком и попадает прямо в кэш запроса, а свои операции + * инвалидируют список. */ const user = makeUser(); @@ -351,4 +354,31 @@ describe('настройки сервера: секция «Эмодзи»', () expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'DELETE' })).toBeDefined(); }); }); + it('показывает эмодзи из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался', async () => { + // Под нагрузкой ручка списка отвечает 429 (AGENT.md 8.6), а раздел должен + // обновляться снапшотом из события шлюза, а не повтором запроса (7.12). + const gateway = installGatewaySocket( + makeReadySnapshot([{ id: 'g-1', name: 'Сервер', my_permissions: ['MANAGE_EMOJIS'] }]), + ); + installFetch( + routes({ + extra: [ + { + match: '/api/v1/guilds/g-1/emojis', + method: 'GET', + response: () => apiError('ratelimit.hit', 429), + }, + ], + }), + ); + renderApp('/settings/servers/g-1'); + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + await gateway.greet(); + + const fresh = { id: 'e-9', name: 'fresh', file_id: '920', animated: false, url: '/files/920' }; + gateway.dispatch('GUILD_EMOJIS_UPDATE', { guild_id: 'g-1', emojis: [pepe, fresh] }); + + expect(await within(dialog).findByTestId('guild-emoji-e-1')).toHaveTextContent(':pepe:'); + expect(within(dialog).getByTestId('guild-emoji-e-9')).toHaveTextContent(':fresh:'); + }); });