From 2749a0e8310d70bdc46a8cbfd9fb946c1b67bee2 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:19:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(install):=20LIVEKIT=5FURL=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B2=20compose=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаблон compose подставляет переменные из явного списка: LIVEKIT_PUBLIC_URL не был в нём, поэтому приложение получало пустой адрес LiveKit и голос оставался выключенным. Добавлены LIVEKIT_API_KEY/SECRET/PUBLIC_URL в рендер compose. --- deploy/install.sh | 2 ++ web/src/api/emojis.ts | 3 +-- web/src/components/chat/ComposerMirror.tsx | 5 +--- web/src/components/chat/EmojiPicker.tsx | 9 ++++--- web/src/components/chat/MessageContent.tsx | 5 +--- web/src/components/chat/MessageItem.tsx | 12 ++------- web/src/lib/customEmoji.ts | 5 ---- web/src/lib/identity.ts | 2 +- web/src/pages/settings/GuildEmojisSection.tsx | 10 ++++--- web/src/stores/gateway.ts | 10 +++---- web/tests/guildEmojis.test.tsx | 25 +++++++++--------- web/tests/guildEmojisSettings.test.tsx | 26 ++++++++++++++++--- web/tests/systemMessages.test.tsx | 19 ++++++++++---- 13 files changed, 73 insertions(+), 60 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 7fbfc6f..17da8ca 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1069,6 +1069,8 @@ render_all_templates() { "MAX_UPLOAD_SIZE=$((EFFECTIVE_UPLOAD_MB * 1024 * 1024))" \ "APP_CPUS=${PROFILE_APP_CPUS}" "APP_MEMORY_LIMIT=${PROFILE_APP_LIMIT}" \ "LIVEKIT_CPUS=${PROFILE_LIVEKIT_CPUS}" "LIVEKIT_MEMORY_LIMIT=${PROFILE_LIVEKIT_LIMIT}" \ + "LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \ + "LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}" \ "CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \ "CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}" "LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}" \ "GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \ diff --git a/web/src/api/emojis.ts b/web/src/api/emojis.ts index 9282114..106d0e3 100644 --- a/web/src/api/emojis.ts +++ b/web/src/api/emojis.ts @@ -40,8 +40,7 @@ export function isValidEmojiName(name: string): boolean { return CUSTOM_EMOJI_NAME_PATTERN.test(name); } -export const guildEmojisQueryKey = (guildId: string) => - ['guilds', guildId, 'emojis'] as const; +export const guildEmojisQueryKey = (guildId: string) => ['guilds', guildId, 'emojis'] as const; function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; diff --git a/web/src/components/chat/ComposerMirror.tsx b/web/src/components/chat/ComposerMirror.tsx index 0288e12..ef127b3 100644 --- a/web/src/components/chat/ComposerMirror.tsx +++ b/web/src/components/chat/ComposerMirror.tsx @@ -2,10 +2,7 @@ import { Fragment, useMemo, type ReactNode, type RefObject } from 'react'; import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage'; import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory'; -import { - useGuildEmojiDirectory, - type GuildEmojiDirectory, -} from '@/components/chat/useGuildEmojis'; +import { useGuildEmojiDirectory, type GuildEmojiDirectory } from '@/components/chat/useGuildEmojis'; import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji'; import { hasMarkers, diff --git a/web/src/components/chat/EmojiPicker.tsx b/web/src/components/chat/EmojiPicker.tsx index 5471705..0f58fba 100644 --- a/web/src/components/chat/EmojiPicker.tsx +++ b/web/src/components/chat/EmojiPicker.tsx @@ -42,18 +42,21 @@ export function EmojiPicker({ const { t } = useTranslation(); const panelRef = useRef(null); const [query, setQuery] = useState(''); + const [prevOpen, setPrevOpen] = useState(open); const serverEmojis = useGuildEmojis(guildId); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; }, [onClose]); - // Поиск сбрасываем при каждом открытии: иначе список «залипал» бы от прошлого раза. - useEffect(() => { + // Поиск сбрасываем при открытии: подстройка состояния во время рендера — + // документированный приём React, эффект дал бы лишний кадр со старым списком. + if (prevOpen !== open) { + setPrevOpen(open); if (open) { setQuery(''); } - }, [open]); + } const found = useMemo(() => { const needle = query.trim().toLowerCase(); diff --git a/web/src/components/chat/MessageContent.tsx b/web/src/components/chat/MessageContent.tsx index 0d80ffa..f19e203 100644 --- a/web/src/components/chat/MessageContent.tsx +++ b/web/src/components/chat/MessageContent.tsx @@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next'; import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage'; import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory'; -import { - useGuildEmojiDirectory, - type GuildEmojiDirectory, -} from '@/components/chat/useGuildEmojis'; +import { useGuildEmojiDirectory, type GuildEmojiDirectory } from '@/components/chat/useGuildEmojis'; import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji'; import { parseBlocks, diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index 8d9ba12..e52a1bc 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -153,13 +153,7 @@ function MessageBody({ * подставляем «участник». Неизвестное событие и мусорный JSON показываем как * есть: ничего не теряем и не падаем. */ -function SystemMessageBody({ - message, - authors, -}: { - message: Message; - authors: AuthorDirectory; -}) { +function SystemMessageBody({ message, authors }: { message: Message; authors: AuthorDirectory }) { const { t } = useTranslation(); const event = parseSystemMessage(message.content); @@ -345,9 +339,7 @@ export function MessageItem({ } ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`} > {t('chat.types.system')} - {message.content === '' ? null : ( - - )} + {message.content === '' ? null : } diff --git a/web/src/lib/customEmoji.ts b/web/src/lib/customEmoji.ts index 8e0b63d..725ee62 100644 --- a/web/src/lib/customEmoji.ts +++ b/web/src/lib/customEmoji.ts @@ -53,8 +53,3 @@ export function customEmojiShortcode(name: string): string { export function customEmojiToken(name: string, fileId: string, animated = false): string { return `<${animated ? 'a' : ''}:${name}:${fileId}>`; } - -/** Является ли строка токеном кастомного эмодзи. */ -export function isCustomEmojiToken(value: string): boolean { - return parseCustomEmoji(value) !== null; -} diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index 2f4fa76..a72ebe3 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -103,7 +103,7 @@ export function canAdministerGuild( * Может ли пользователь модерировать сообщения (удалять и закреплять чужие). * Права приходят на уровне сервера, поэтому это приближение: сервер всё равно * проверит права в конкретной комнате. - */export function canManageMessages( + */ export function canManageMessages( permissions: readonly string[], isInstanceAdmin = false, ): boolean { diff --git a/web/src/pages/settings/GuildEmojisSection.tsx b/web/src/pages/settings/GuildEmojisSection.tsx index adad320..3847994 100644 --- a/web/src/pages/settings/GuildEmojisSection.tsx +++ b/web/src/pages/settings/GuildEmojisSection.tsx @@ -183,9 +183,7 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) { {formatBytes(emoji.size_bytes, i18n.language)} )} - {emoji.animated ? ( - {t('settings.servers.emojis.animated')} - ) : null} + {emoji.animated ? {t('settings.servers.emojis.animated')} : null} diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index 7a611d2..7a5b404 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; +import { guildEmojisQueryKey } from '@/api/emojis'; import { connectGateway, disconnectGateway, @@ -366,11 +367,10 @@ async function invalidateGuildQueries(guildId: string): Promise { /** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */ async function invalidateEmojiQueries(guildId: string): Promise { - const [{ getQueryClient }, emojis] = await Promise.all([ - import('@/lib/queryClient'), - import('@/api/emojis'), - ]); - await getQueryClient().invalidateQueries({ queryKey: emojis.guildEmojisQueryKey(guildId) }); + // Ключ запроса берём из статического импорта: `api/emojis` не зависит от + // сторов, поэтому цикла «стор → query-client → стор» здесь нет. + const { getQueryClient } = await import('@/lib/queryClient'); + await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) }); } /** Держит URL в согласии с выбранным сервером/комнатой после удалений. */ diff --git a/web/tests/guildEmojis.test.tsx b/web/tests/guildEmojis.test.tsx index 36b5c43..db7545f 100644 --- a/web/tests/guildEmojis.test.tsx +++ b/web/tests/guildEmojis.test.tsx @@ -198,11 +198,7 @@ describe('кастомные эмодзи: зеркало композера', ( const mirrorRef = createRef(); render( - , + , ); expect(mirrorRef.current?.querySelector('img')).toBeNull(); @@ -217,7 +213,12 @@ describe('кастомные эмодзи: пикер', () => { const picked: string[] = []; render( - undefined} onPick={(emoji) => picked.push(emoji)} />, + undefined} + onPick={(emoji) => picked.push(emoji)} + />, ); const section = screen.getByTestId('emoji-picker-server'); @@ -229,16 +230,16 @@ describe('кастомные эмодзи: пикер', () => { await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party'); expect(within(section).queryByText(':pepe:')).toBeNull(); - await visitor.click(within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' })); + await visitor.click( + within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }), + ); expect(picked).toEqual(['']); }); it('без сервера секция эмодзи сервера не показывается', () => { useSessionStore.setState({ guilds: [guildWithEmojis()] }); - render( - undefined} onPick={() => undefined} />, - ); + render( undefined} onPick={() => undefined} />); expect(screen.queryByTestId('emoji-picker-server')).toBeNull(); }); @@ -473,9 +474,7 @@ describe('кастомные эмодзи: реакции', () => { describe('кастомные эмодзи: снапшот и событие шлюза', () => { it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => { - const snapshot = makeReadySnapshot([ - { id: 'g-1', name: 'Сервер', emojis: [pepe] }, - ]); + const snapshot = makeReadySnapshot([{ id: 'g-1', name: 'Сервер', emojis: [pepe] }]); dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: snapshot }); expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe]); diff --git a/web/tests/guildEmojisSettings.test.tsx b/web/tests/guildEmojisSettings.test.tsx index 45c2fe0..2165049 100644 --- a/web/tests/guildEmojisSettings.test.tsx +++ b/web/tests/guildEmojisSettings.test.tsx @@ -68,7 +68,8 @@ function routes(options: RoutesOptions = {}) { { match: '/api/v1/guilds/g-1/emojis', method: 'POST', - response: options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })), + response: + options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })), }, { match: '/api/v1/users/@me/guilds', @@ -169,6 +170,25 @@ describe('настройки сервера: секция «Эмодзи»', () }); }); + it('принимает файл перетаскиванием в область загрузки', async () => { + const visitor = userEvent.setup(); + const fetchMock = installFetch(routes()); + + const { dialog } = await openEmojisSection(); + fireEvent.drop(within(dialog).getByTestId('emoji-dropzone'), { + dataTransfer: { files: [emojiFile('drop.png', 'image/png')] }, + }); + // Имя файла видно в области загрузки — файл принят. + expect(await within(dialog).findByText(/drop\.png/)).toBeVisible(); + + await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'drop'); + await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); + + await waitFor(() => { + expect(uploadForm(fetchMock)?.get('name')).toBe('drop'); + }); + }); + it('не отправляет файл неподдерживаемого формата', async () => { const fetchMock = installFetch(routes()); @@ -213,9 +233,7 @@ describe('настройки сервера: секция «Эмодзи»', () ); await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' })); - expect(await within(dialog).findByRole('alert')).toHaveTextContent( - 'Имя эмодзи: 2–32 символа', - ); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('Имя эмодзи: 2–32 символа'); expect(uploadForm(fetchMock)).toBeUndefined(); }); diff --git a/web/tests/systemMessages.test.tsx b/web/tests/systemMessages.test.tsx index 3ff840a..5ff24c3 100644 --- a/web/tests/systemMessages.test.tsx +++ b/web/tests/systemMessages.test.tsx @@ -5,7 +5,16 @@ import type { Message } from '@/api/messages'; import { MessageList } from '@/components/chat/MessageList'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { parseSystemMessage, systemMessageUserId } from '@/lib/systemMessage'; -import { makeMessage, makeUser, json, installFetch, installGatewaySocket, messagesRoutes, renderApp, resetStores } from './helpers'; +import { + makeMessage, + makeUser, + json, + installFetch, + installGatewaySocket, + messagesRoutes, + renderApp, + resetStores, +} from './helpers'; /** * Системные сообщения (AGENT.md §7.6): сервер пишет их с `type: "system"`, @@ -104,9 +113,7 @@ describe('системные сообщения: лента', () => { it('member_kick — «{имя} покинул сервер (исключён)»', () => { renderList([systemMessage('{"event":"member_kick","user_id":"user-2"}')]); - expect(screen.getByTestId('message-m-1')).toHaveTextContent( - 'Боб покинул сервер (исключён)', - ); + expect(screen.getByTestId('message-m-1')).toHaveTextContent('Боб покинул сервер (исключён)'); }); it('channel_created — комната и голосовая комната', () => { @@ -127,7 +134,9 @@ describe('системные сообщения: лента', () => { systemMessage('Аля вошла', 'm-2'), ]); - expect(screen.getByTestId('message-m-1')).toHaveTextContent('{"event":"guild_boost","level":2}'); + expect(screen.getByTestId('message-m-1')).toHaveTextContent( + '{"event":"guild_boost","level":2}', + ); expect(screen.getByTestId('message-m-2')).toHaveTextContent('Аля вошла'); });