From ca34d05377b0df2c9a0a1d5fdfb9655c50792550 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:13:04 +0300 Subject: [PATCH] =?UTF-8?q?fix(install):=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20Liv?= =?UTF-8?q?eKit=20=D0=B2=D1=8B=D1=87=D0=B8=D1=81=D0=BB=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20TLS=5FENABLED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS, dry-run проходит. --- deploy/install.sh | 17 +- web/src/api/emojis.ts | 140 +++++ web/src/api/gateway.ts | 7 +- web/src/api/gatewayEvents.ts | 21 + web/src/components/chat/ChatView.tsx | 12 +- web/src/components/chat/Composer.tsx | 42 ++ .../components/chat/ComposerAutocomplete.tsx | 18 + web/src/components/chat/ComposerMirror.tsx | 54 +- web/src/components/chat/CustomEmojiImage.tsx | 31 + web/src/components/chat/EmojiPicker.tsx | 82 ++- web/src/components/chat/MessageContent.tsx | 28 + web/src/components/chat/MessageItem.tsx | 123 +++- web/src/components/chat/MessageList.tsx | 4 + web/src/components/chat/useAuthorDirectory.ts | 13 + .../chat/useComposerAutocomplete.ts | 53 +- web/src/components/chat/useGuildEmojis.ts | 54 ++ web/src/i18n/locales/en.json | 58 +- web/src/i18n/locales/ru.json | 58 +- web/src/lib/autocomplete.ts | 2 + web/src/lib/customEmoji.ts | 60 ++ web/src/lib/identity.ts | 24 +- web/src/lib/markdown.ts | 40 +- web/src/lib/systemMessage.ts | 57 ++ web/src/pages/settings/GuildEmojisSection.tsx | 360 ++++++++++++ web/src/pages/settings/GuildSettingsPage.tsx | 17 +- web/src/stores/gateway.ts | 20 + web/src/stores/session.ts | 58 +- web/tests/autocomplete.test.tsx | 1 + web/tests/guildEmojis.test.tsx | 554 ++++++++++++++++++ web/tests/guildEmojisSettings.test.tsx | 336 +++++++++++ web/tests/helpers.tsx | 9 + web/tests/markdown.test.tsx | 1 + web/tests/systemMessages.test.tsx | 222 +++++++ 33 files changed, 2505 insertions(+), 71 deletions(-) create mode 100644 web/src/api/emojis.ts create mode 100644 web/src/components/chat/CustomEmojiImage.tsx create mode 100644 web/src/components/chat/useGuildEmojis.ts create mode 100644 web/src/lib/customEmoji.ts create mode 100644 web/src/lib/systemMessage.ts create mode 100644 web/src/pages/settings/GuildEmojisSection.tsx create mode 100644 web/tests/guildEmojis.test.tsx create mode 100644 web/tests/guildEmojisSettings.test.tsx create mode 100644 web/tests/systemMessages.test.tsx diff --git a/deploy/install.sh b/deploy/install.sh index 6c96b97..7fbfc6f 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -355,14 +355,6 @@ resolve_derived_values() { GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}" GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}" GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}" - # Публичный адрес LiveKit: клиенты ходят через тот же домен по /rtc. - if [ -z "${LIVEKIT_PUBLIC_URL:-}" ]; then - if [ "${TLS_ENABLED}" = "true" ]; then - LIVEKIT_PUBLIC_URL="wss://${DOMAIN}/rtc" - else - LIVEKIT_PUBLIC_URL="ws://${DOMAIN}/rtc" - fi - fi # TLS считается включённым и когда сертификаты обслуживает внешний прокси # (--external-proxy --tls-terminated-by-proxy): ссылки в API должны быть https/wss. if [ "$SKIP_TLS" = "1" ] && [ "$TLS_BY_PROXY" != "true" ]; then @@ -370,6 +362,15 @@ resolve_derived_values() { else TLS_ENABLED="true" fi + # Публичный адрес LiveKit: клиенты ходят через тот же домен по /rtc. + # Вычисляется после TLS_ENABLED, иначе set -u роняет установщик. + if [ -z "${LIVEKIT_PUBLIC_URL:-}" ]; then + if [ "$TLS_ENABLED" = "true" ]; then + LIVEKIT_PUBLIC_URL="wss://${DOMAIN}/rtc" + else + LIVEKIT_PUBLIC_URL="ws://${DOMAIN}/rtc" + fi + fi GLCHAT_APP_BIND="${APP_BIND_ADDR}" GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}" export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \ diff --git a/web/src/api/emojis.ts b/web/src/api/emojis.ts new file mode 100644 index 0000000..9282114 --- /dev/null +++ b/web/src/api/emojis.ts @@ -0,0 +1,140 @@ +import { CUSTOM_EMOJI_NAME_PATTERN, customEmojiToken } from '@/lib/customEmoji'; +import { request } from './client'; + +/** + * Кастомные эмодзи сервера (AGENT.md 7.12). + * + * Контракт один и тот же в трёх местах: `GET /guilds/{id}/emojis`, снапшот + * READY (`guilds[].emojis`) и событие шлюза `GUILD_EMOJIS_UPDATE`. Эмодзи + * в тексте и в реакции — токен `<:имя:file_id>` (`` у анимированных), + * поэтому разбор payload'а общий для всех источников. + */ + +export interface GuildEmoji { + id: string; + name: string; + file_id: string; + animated: boolean; + /** Токен для сообщения и реакции: `<:имя:file_id>` или ``. */ + token: string; + /** Ссылка на картинку (REST); в снапшоте READY её нет. */ + url?: string; + /** Размер файла: сервер присылает его не во всех ответах. */ + size_bytes?: number; +} + +/** Лимиты из контракта: 100 эмодзи на сервер, 512 КБ на файл. */ +export const EMOJI_LIMIT_PER_GUILD = 100; +export const EMOJI_MAX_BYTES = 512 * 1024; + +/** Форматы, которые принимает сервер. */ +export const EMOJI_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; + +/** Разрешён ли файл по формату (размер проверяется отдельно). */ +export function isAllowedEmojiType(file: File): boolean { + return (EMOJI_CONTENT_TYPES as readonly string[]).includes(file.type); +} + +/** Проходит ли имя клиентскую проверку (та же, что на сервере). */ +export function isValidEmojiName(name: string): boolean { + return CUSTOM_EMOJI_NAME_PATTERN.test(name); +} + +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; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** + * Разбор одного эмодзи. Токен берём из ответа, а если сервер его не прислал — + * собираем сами: формат один и тот же, а без токена эмодзи бесполезен. + */ +export function parseGuildEmoji(value: unknown): GuildEmoji | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const name = asString(record['name']); + const fileId = asString(record['file_id']); + if (id === undefined || name === undefined || fileId === undefined) { + return null; + } + const animated = record['animated'] === true; + const emoji: GuildEmoji = { + id, + name, + file_id: fileId, + animated, + token: asString(record['token']) ?? customEmojiToken(name, fileId, animated), + }; + const url = asString(record['url']); + const sizeBytes = record['size_bytes']; + if (url !== undefined) { + emoji.url = url; + } + if (typeof sizeBytes === 'number' && Number.isFinite(sizeBytes) && sizeBytes >= 0) { + emoji.size_bytes = sizeBytes; + } + return emoji; +} + +/** Разбор списка эмодзи: лишние и битые записи отбрасываются. */ +export function parseGuildEmojiList(value: unknown): GuildEmoji[] { + if (!Array.isArray(value)) { + return []; + } + return value.map(parseGuildEmoji).filter((item): item is GuildEmoji => item !== null); +} + +export async function fetchGuildEmojis( + guildId: string, + signal?: AbortSignal, +): Promise { + const payload = await request<{ emojis?: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/emojis`, + signal === undefined ? {} : { signal }, + ); + return parseGuildEmojiList(payload.emojis); +} + +/** Загрузка: multipart с полями `name` и `file` (право MANAGE_EMOJIS). */ +export async function uploadGuildEmoji( + guildId: string, + name: string, + file: File, +): Promise { + const form = new FormData(); + form.append('name', name); + form.append('file', file); + const payload = await request<{ emoji?: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/emojis`, + { method: 'POST', form }, + ); + return parseGuildEmoji(payload.emoji); +} + +export async function renameGuildEmoji( + guildId: string, + emojiId: string, + name: string, +): Promise { + const payload = await request<{ emoji?: unknown }>( + `/guilds/${encodeURIComponent(guildId)}/emojis/${encodeURIComponent(emojiId)}`, + { method: 'PATCH', body: { name } }, + ); + return parseGuildEmoji(payload.emoji); +} + +export async function deleteGuildEmoji(guildId: string, emojiId: string): Promise { + await request<{ ok: true }>( + `/guilds/${encodeURIComponent(guildId)}/emojis/${encodeURIComponent(emojiId)}`, + { method: 'DELETE' }, + ); +} diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index 759a94d..5923a5e 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -1,6 +1,8 @@ +import { parseGuildEmojiList } from './emojis'; import { parseChannelPayload } from './gatewayEvents'; import { describeUnknown } from '@/lib/http'; +import type { GuildEmoji } from './emojis'; import type { Channel, Role, UserStatus } from './types'; /** @@ -34,7 +36,7 @@ export interface GatewayHello { session_id: string; } -/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */ +/** Сервер в снапшоте READY: каналы/роли/права/эмодзи приходят сразу. */ export interface GatewayGuild { id: string; name: string; @@ -43,6 +45,8 @@ export interface GatewayGuild { icon_file_id?: string; channels: Channel[]; roles: Role[]; + /** Кастомные эмодзи сервера (AGENT.md 7.12). */ + emojis: GuildEmoji[]; member_ids: string[]; my_role_ids: string[]; my_nickname?: string; @@ -231,6 +235,7 @@ function parseGuild(value: unknown): GatewayGuild | null { is_main: record['is_main'] === true, channels, roles, + emojis: parseGuildEmojiList(record['emojis']), member_ids: asStringArray(record['member_ids']), my_role_ids: asStringArray(record['my_role_ids']), my_permissions: asStringArray(record['my_permissions']), diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index fe3c3d7..2be8b10 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -1,5 +1,7 @@ +import { parseGuildEmojiList } from './emojis'; import { userStatuses, type Channel, type UserStatus } from './types'; import type { GatewayUser } from './gateway'; +import type { GuildEmoji } from './emojis'; import type { Message, MessageAttachment, MessageReaction } from './messages'; /** @@ -51,6 +53,11 @@ export interface RoleEvent { role_id: string; } +export interface GuildEmojisUpdateEvent { + guild_id: string; + emojis: GuildEmoji[]; +} + function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; } @@ -182,6 +189,20 @@ export function parseRoleEvent(value: unknown): RoleEvent | null { return { guild_id: guildId, role_id: roleId }; } +/** + * `GUILD_EMOJIS_UPDATE`: набор эмодзи сервера изменился (загрузка, + * переименование, удаление). Список приходит целиком — тем же форматом, что + * и в снапшоте READY, поэтому заменяем его в сторе, а не мержим. + */ +export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent | null { + const record = asRecord(value); + const guildId = asString(record?.['guild_id']); + if (record === null || guildId === undefined) { + return null; + } + return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) }; +} + /** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */ export function parseChannelPayload(value: unknown): Channel | null { const record = asRecord(value); diff --git a/web/src/components/chat/ChatView.tsx b/web/src/components/chat/ChatView.tsx index 368f9dd..993a15f 100644 --- a/web/src/components/chat/ChatView.tsx +++ b/web/src/components/chat/ChatView.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { isPendingMessage, type Message } from '@/api/messages'; +import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages'; import type { Channel } from '@/api/types'; import { Composer } from '@/components/chat/Composer'; import { MessageList } from '@/components/chat/MessageList'; @@ -14,6 +14,7 @@ import { canSendMessages, hasPermission, } from '@/lib/identity'; +import { systemMessageUserId } from '@/lib/systemMessage'; import { useSessionStore } from '@/stores/session'; import { channelState, useMessagesStore } from '@/stores/messages'; @@ -86,6 +87,14 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha if (message.author_id !== undefined) { ids.add(message.author_id); } + // Системные сообщения пишет сервер: имя участника события лежит в + // JSON-payload'е, поэтому добираем его тем же справочником. + if (messageTypeOf(message.type) === 'system') { + const userId = systemMessageUserId(message.content); + if (userId !== null) { + ids.add(userId); + } + } } for (const entry of record.typing) { ids.add(entry.user_id); @@ -195,6 +204,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha Date.now()); const [dragging, setDragging] = useState(false); + /** Открыт пикер эмодзи: вставка идёт в позицию каретки. */ + const [emojiOpen, setEmojiOpen] = useState(false); /** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */ const [selection, setSelection] = useState({ start: 0, end: 0 }); /** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */ @@ -87,6 +90,7 @@ export function Composer({ const textareaRef = useRef(null); const mirrorRef = useRef(null); const fileInputRef = useRef(null); + const emojiAnchorRef = useRef(null); /** * Каретка после программной вставки подсказки. Ставим её в layout-эффекте: * к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы @@ -251,6 +255,20 @@ export function Composer({ }); }; + /** + * Вставка эмодзи из пикера: заменяем выделение и ставим каретку сразу за + * вставленным фрагментом (`pendingCaret` сделает это в layout-эффекте). + * Кастомный эмодзи приходит токеном `<:имя:file_id>` — как из автокомплита. + */ + const insertEmoji = (emoji: string): void => { + const { start, end } = selection; + pendingCaret.current = start + emoji.length; + setValue(`${value.slice(0, start)}${emoji}${value.slice(end)}`); + setEmojiOpen(false); + textareaRef.current?.focus(); + useMessagesStore.getState().notifyTyping(channel.id); + }; + const submit = async (): Promise => { const content = value.trim(); if (!canSubmit || (content === '' && files.length === 0)) { @@ -402,6 +420,30 @@ export function Composer({ event.target.value = ''; }} /> + {/* Эмодзи: тот же пикер, что и у реакций, — юникод и эмодзи сервера. */} + + + setEmojiOpen(false)} + onPick={insertEmoji} + placement="top" + guildId={guildId} + /> + {/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и подсветка должны выглядеть одинаково неактивными. */}
diff --git a/web/src/components/chat/ComposerAutocomplete.tsx b/web/src/components/chat/ComposerAutocomplete.tsx index 323a3d6..433ead2 100644 --- a/web/src/components/chat/ComposerAutocomplete.tsx +++ b/web/src/components/chat/ComposerAutocomplete.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; +import { fileContentUrl } from '@/api/files'; import { Avatar } from '@/components/ui/Avatar'; import type { AutocompleteItem } from '@/lib/autocomplete'; @@ -28,6 +29,23 @@ function OptionLabel({ item }: { item: AutocompleteItem }) { ); } if (item.kind === 'emoji') { + // Кастомный эмодзи сервера: картинка вместо символа и `:имя:` подписью. + if (item.emojiFileId !== undefined) { + return ( + + + {item.label} + {item.hint === undefined ? null : ( + {item.hint} + )} + + ); + } return (