fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED

Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL
стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS,
dry-run проходит.
This commit is contained in:
2026-09-20 18:13:04 +03:00
parent ecd52c11d3
commit ca34d05377
33 changed files with 2505 additions and 71 deletions
+9 -8
View File
@@ -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 \
+140
View File
@@ -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>` (`<a:…>` у анимированных),
* поэтому разбор payload'а общий для всех источников.
*/
export interface GuildEmoji {
id: string;
name: string;
file_id: string;
animated: boolean;
/** Токен для сообщения и реакции: `<:имя:file_id>` или `<a:имя: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<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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<GuildEmoji[]> {
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<GuildEmoji | null> {
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<GuildEmoji | null> {
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<void> {
await request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/emojis/${encodeURIComponent(emojiId)}`,
{ method: 'DELETE' },
);
}
+6 -1
View File
@@ -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']),
+21
View File
@@ -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<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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);
+11 -1
View File
@@ -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
<MessageList
channelId={channelId}
channelName={channel.name}
guildId={guildId}
direct={isDirect}
messages={messages}
status={record.status}
+42
View File
@@ -12,6 +12,7 @@ import {
ComposerMirror,
type ComposerSelection,
} from '@/components/chat/ComposerMirror';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import {
useComposerAutocomplete,
@@ -74,6 +75,8 @@ export function Composer({
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
const [now, setNow] = useState(() => Date.now());
const [dragging, setDragging] = useState(false);
/** Открыт пикер эмодзи: вставка идёт в позицию каретки. */
const [emojiOpen, setEmojiOpen] = useState(false);
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
/** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */
@@ -87,6 +90,7 @@ export function Composer({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const mirrorRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const emojiAnchorRef = useRef<HTMLButtonElement | null>(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<void> => {
const content = value.trim();
if (!canSubmit || (content === '' && files.length === 0)) {
@@ -402,6 +420,30 @@ export function Composer({
event.target.value = '';
}}
/>
{/* Эмодзи: тот же пикер, что и у реакций, — юникод и эмодзи сервера. */}
<span className="relative">
<button
ref={emojiAnchorRef}
type="button"
aria-label={t('chat.composer.emoji')}
title={t('chat.composer.emoji')}
aria-haspopup="dialog"
aria-expanded={emojiOpen}
disabled={blockedReason !== null}
className="rounded-[var(--radius-sm)] px-1 pb-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setEmojiOpen((open) => !open)}
>
😀
</button>
<EmojiPicker
open={emojiOpen}
anchorRef={emojiAnchorRef}
onClose={() => setEmojiOpen(false)}
onPick={insertEmoji}
placement="top"
guildId={guildId}
/>
</span>
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
подсветка должны выглядеть одинаково неактивными. */}
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
@@ -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 (
<span className="flex min-w-0 items-center gap-2">
<img
src={fileContentUrl(item.emojiFileId)}
alt=""
loading="lazy"
className="h-5 w-5 shrink-0"
/>
<span className="truncate">{item.label}</span>
{item.hint === undefined ? null : (
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
)}
</span>
);
}
return (
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className="text-lg leading-none">
+51 -3
View File
@@ -1,6 +1,12 @@
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 { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
import {
hasMarkers,
selectionIntersects,
@@ -54,6 +60,7 @@ function renderFormatted(
token: InlineToken,
content: string,
channels: ChannelDirectory,
emojis: GuildEmojiDirectory,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (token.kind) {
@@ -111,6 +118,20 @@ function renderFormatted(
const name = channels.byId.get(content);
return <span className="text-accent">#{name ?? content}</span>;
}
case 'custom-emoji': {
// Картинка 20×20 не меняет высоту строки (text-sm/leading-5), поэтому
// подсветка остаётся на месте textarea. Нет файла в снапшоте — `:имя:`.
const parsed = parseCustomEmoji(token.raw);
const emoji = parsed === null ? null : emojis.resolve(token.raw);
if (parsed === null) {
return token.raw;
}
return emoji === null ? (
customEmojiShortcode(parsed.name)
) : (
<CustomEmojiImage emoji={emoji} />
);
}
case 'channel': {
const name = token.raw.slice(1);
return channels.names.has(name.toLowerCase()) ? (
@@ -128,6 +149,7 @@ function renderToken(
line: MirrorLine,
selection: ComposerSelection,
channels: ChannelDirectory,
emojis: GuildEmojiDirectory,
resolveMention: MentionResolver | undefined,
key: string,
): ReactNode {
@@ -137,6 +159,27 @@ function renderToken(
// было видно и удобно править. Иначе маркеры скрыты.
const active = hasMarkers(token.kind) && selectionIntersects(selection, token.start, token.end);
if (token.kind === 'custom-emoji') {
// У эмодзи «содержимое» — весь токен, поэтому при каретке внутри
// показываем исходный текст `<:имя:file_id>`: так его видно и можно
// править. Вне токена — картинка, как в ленте.
return active ? (
<span key={key} className="text-fg-muted">
{token.raw}
</span>
) : (
<Fragment key={key}>
{renderFormatted(
token,
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
channels,
emojis,
resolveMention,
)}
</Fragment>
);
}
return (
<Fragment key={key}>
{active ? (
@@ -148,6 +191,7 @@ function renderToken(
token,
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
channels,
emojis,
resolveMention,
)}
{active ? (
@@ -164,6 +208,7 @@ function renderTokens(
line: MirrorLine,
selection: ComposerSelection,
channels: ChannelDirectory,
emojis: GuildEmojiDirectory,
resolveMention: MentionResolver | undefined,
): ReactNode {
const start = line.start + line.prefixLength;
@@ -180,6 +225,7 @@ function renderTokens(
line,
selection,
channels,
emojis,
resolveMention,
`${String(line.start)}-${String(index)}`,
),
@@ -197,6 +243,7 @@ function renderLine(
line: MirrorLine,
selection: ComposerSelection,
channels: ChannelDirectory,
emojis: GuildEmojiDirectory,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (line.kind) {
@@ -213,11 +260,11 @@ function renderLine(
return (
<>
<span className="text-fg-muted">{line.text.slice(0, line.prefixLength)}</span>
{renderTokens(line, selection, channels, resolveMention)}
{renderTokens(line, selection, channels, emojis, resolveMention)}
</>
);
case 'text':
return renderTokens(line, selection, channels, resolveMention);
return renderTokens(line, selection, channels, emojis, resolveMention);
}
}
@@ -233,6 +280,7 @@ export function ComposerMirror({
}: ComposerMirrorProps) {
const lines = useMemo(() => toMirrorLines(value), [value]);
const channels = useChannelDirectory();
const emojis = useGuildEmojiDirectory();
return (
<div
@@ -243,7 +291,7 @@ export function ComposerMirror({
>
{lines.map((line, index) => (
<Fragment key={`${String(line.start)}-${String(index)}`}>
{renderLine(line, selection, channels, resolveMention)}
{renderLine(line, selection, channels, emojis, resolveMention)}
{index < lines.length - 1 ? '\n' : null}
</Fragment>
))}
@@ -0,0 +1,31 @@
import { fileContentUrl } from '@/api/files';
import type { GuildEmoji } from '@/api/emojis';
import { customEmojiShortcode } from '@/lib/customEmoji';
interface CustomEmojiImageProps {
emoji: GuildEmoji;
/**
* Размер картинки. По умолчанию 20×20 (`h-5 w-5`) с вертикальным
* выравниванием под строку текста: те же метрики у ленты и у зеркала
* композера, иначе подсветка разъехалась бы с полем ввода.
*/
className?: string;
}
/**
* Кастомный эмодзи сервера картинкой. Анимированные (GIF/WebP) анимируются
* сами: отдельного плеера не нужно. Подпись `:имя:` уходит и в `alt`, и в
* `title` — скринридер и подсказка показывают то же, что юникод-эмодзи.
*/
export function CustomEmojiImage({ emoji, className = '' }: CustomEmojiImageProps) {
const shortcode = customEmojiShortcode(emoji.name);
return (
<img
src={fileContentUrl(emoji.file_id)}
alt={shortcode}
title={shortcode}
loading="lazy"
className={`inline-block h-5 w-5 align-[-0.15em] ${className}`}
/>
);
}
+80 -2
View File
@@ -1,7 +1,10 @@
import { useEffect, useRef, type RefObject } from 'react';
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import { useGuildEmojis } from '@/components/chat/useGuildEmojis';
import { POPULAR_EMOJIS, QUICK_REACTIONS } from '@/lib/chat';
import { customEmojiShortcode } from '@/lib/customEmoji';
interface EmojiPickerProps {
open: boolean;
@@ -11,11 +14,19 @@ interface EmojiPickerProps {
anchorRef?: RefObject<HTMLElement | null>;
/** Куда раскрывается панель: под якорем или над ним. */
placement?: 'top' | 'bottom';
/** Сервер, чьи кастомные эмодзи показываем: в беседе их нет. */
guildId?: string | null;
className?: string;
}
/**
* Выпадающий пикер эмодзи: ряд быстрых реакций и сетка популярных.
* Выпадающий пикер эмодзи: ряд быстрых реакций, эмодзи сервера и сетка
* популярных. Пикер общий для ленты (реакции) и композера (вставка в текст):
* `onPick` получает либо юникод-эмодзи, либо токен `<:имя:file_id>`.
*
* Эмодзи сервера ищутся по имени; список пуст, если сервера нет (личная
* беседа) или на нём ещё не загрузили ни одного эмодзи.
*
* Закрывается по Escape и клику вне панели; фокус остаётся в пределах
* tab-порядка страницы.
*/
@@ -25,15 +36,33 @@ export function EmojiPicker({
onPick,
anchorRef,
placement = 'bottom',
guildId = null,
className = '',
}: EmojiPickerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const [query, setQuery] = useState('');
const serverEmojis = useGuildEmojis(guildId);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
// Поиск сбрасываем при каждом открытии: иначе список «залипал» бы от прошлого раза.
useEffect(() => {
if (open) {
setQuery('');
}
}, [open]);
const found = useMemo(() => {
const needle = query.trim().toLowerCase();
if (needle === '') {
return serverEmojis;
}
return serverEmojis.filter((emoji) => emoji.name.toLowerCase().includes(needle));
}, [serverEmojis, query]);
useEffect(() => {
if (!open) {
return;
@@ -90,6 +119,55 @@ export function EmojiPicker({
</button>
))}
</div>
{serverEmojis.length === 0 ? null : (
<div
role="group"
aria-label={t('chat.emoji.server')}
data-testid="emoji-picker-server"
className="mt-2 border-t border-border/40 pt-2"
>
<input
type="search"
value={query}
aria-label={t('chat.emoji.search')}
placeholder={t('chat.emoji.searchPlaceholder')}
onChange={(event) => setQuery(event.target.value)}
className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-xs text-fg placeholder:text-fg-muted/70"
/>
{found.length === 0 ? (
<p className="mt-1 px-1 text-xs text-fg-muted">{t('chat.emoji.notFound')}</p>
) : (
<ul className="mt-1 flex max-h-40 flex-col gap-0.5 overflow-y-auto">
{found.map((emoji) => (
<li key={emoji.id}>
<button
type="button"
title={customEmojiShortcode(emoji.name)}
aria-label={t('chat.emoji.pickServer', { name: emoji.name })}
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-1 py-0.5 text-left text-xs text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-[0.98]"
onClick={() => onPick(emoji.token)}
>
<img
src={fileContentUrl(emoji.file_id)}
alt=""
loading="lazy"
className="h-6 w-6 shrink-0"
/>
<span className="truncate">{customEmojiShortcode(emoji.name)}</span>
{emoji.animated ? (
<span className="ml-auto shrink-0 text-[10px] uppercase text-fg-muted">
{t('chat.emoji.animated')}
</span>
) : null}
</button>
</li>
))}
</ul>
)}
</div>
)}
<div
role="group"
aria-label={t('chat.emoji.popular')}
@@ -1,7 +1,13 @@
import { Fragment, useMemo, useState, type ReactNode } from 'react';
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 { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
import {
parseBlocks,
parseInline,
@@ -61,6 +67,7 @@ function renderToken(
key: string,
resolveMention: MentionResolver,
channels: ChannelDirectory,
emojis: GuildEmojiDirectory,
): ReactNode {
switch (token.kind) {
case 'code':
@@ -141,6 +148,21 @@ function renderToken(
</span>
);
}
case 'custom-emoji': {
// `<:имя:file_id>`: картинку отдаёт файловый сервис. Если эмодзи нет в
// снапшоте сервера (удалён, чужой сервер), показываем `:имя:` — разметка
// остаётся целой, а текст читаемым.
const parsed = parseCustomEmoji(token.raw);
if (parsed === null) {
return <Fragment key={key}>{token.raw}</Fragment>;
}
const emoji = emojis.resolve(token.raw);
return emoji === null ? (
<Fragment key={key}>{customEmojiShortcode(parsed.name)}</Fragment>
) : (
<CustomEmojiImage key={key} emoji={emoji} />
);
}
case 'channel': {
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
const name = token.raw.slice(1);
@@ -159,6 +181,7 @@ function renderInline(
text: string,
resolveMention: MentionResolver,
channels: ChannelDirectory,
emojis: GuildEmojiDirectory,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
@@ -178,6 +201,7 @@ function renderInline(
key,
resolveMention,
channels,
emojis,
),
);
lastIndex = token.end;
@@ -193,6 +217,8 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
const blocks = useMemo(() => parseBlocks(content), [content]);
// Комнаты снапшота: `#канал` и `<#id>` подсвечиваем только для известных.
const channels = useChannelDirectory();
// Эмодзи снапшота: токен без файла остаётся текстом `:имя:`.
const emojis = useGuildEmojiDirectory();
return (
<div className={`whitespace-pre-wrap break-words ${className}`}>
@@ -215,6 +241,7 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
block.lines.map((line) => line.text.slice(QUOTE_PREFIX.length)).join('\n'),
resolveMention,
channels,
emojis,
key,
)}
</blockquote>
@@ -226,6 +253,7 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
block.lines.map((line) => line.text).join('\n'),
resolveMention,
channels,
emojis,
key,
)}
</p>
+97 -26
View File
@@ -9,19 +9,25 @@ import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { useGuildEmojiDirectory } from '@/components/chat/useGuildEmojis';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat';
import { customEmojiShortcode } from '@/lib/customEmoji';
import { formatBytes } from '@/lib/format';
import { parseSystemMessage } from '@/lib/systemMessage';
import { useMessagesStore } from '@/stores/messages';
interface MessageItemProps {
message: Message;
channelId: string;
channelName: string;
/** Сервер комнаты: из него пикер берёт кастомные эмодзи. */
guildId?: string | null;
authors: AuthorDirectory;
currentUserId: string | null;
canManageMessages: boolean;
@@ -140,6 +146,62 @@ function MessageBody({
);
}
/**
* Системное сообщение: сервер присылает JSON о событии, лента показывает его
* человекочитаемой строкой через i18n. Имя участника резолвит тот же
* справочник авторов; если пользователь неизвестен (вышел, удалён) —
* подставляем «участник». Неизвестное событие и мусорный JSON показываем как
* есть: ничего не теряем и не падаем.
*/
function SystemMessageBody({
message,
authors,
}: {
message: Message;
authors: AuthorDirectory;
}) {
const { t } = useTranslation();
const event = parseSystemMessage(message.content);
if (event === null) {
return (
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="text-xs text-fg-muted"
/>
);
}
const nameOf = (userId: string): string =>
authors.resolveMemberName?.(userId) ?? t('chat.system.member');
if (event.kind === 'member_join') {
return (
<p className="text-xs text-fg-muted">
{t('chat.system.memberJoin', { name: nameOf(event.userId) })}
</p>
);
}
if (event.kind === 'member_kick') {
return (
<p className="text-xs text-fg-muted">
{t('chat.system.memberKick', { name: nameOf(event.userId) })}
</p>
);
}
return (
<p className="text-xs text-fg-muted">
{t(
event.channelType === 'voice'
? 'chat.system.voiceChannelCreated'
: 'chat.system.channelCreated',
{ name: event.name },
)}
</p>
);
}
/**
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
*
@@ -150,6 +212,7 @@ export function MessageItem({
message,
channelId,
channelName,
guildId = null,
authors,
currentUserId,
canManageMessages,
@@ -162,6 +225,8 @@ export function MessageItem({
onJumpToReply,
}: MessageItemProps) {
const { t, i18n } = useTranslation();
// Кастомные эмодзи: реакции и пикер показывают их картинкой.
const emojis = useGuildEmojiDirectory();
const [menuOpen, setMenuOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [editing, setEditing] = useState(false);
@@ -281,11 +346,7 @@ export function MessageItem({
>
<span className="sr-only">{t('chat.types.system')}</span>
{message.content === '' ? null : (
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="text-xs text-fg-muted"
/>
<SystemMessageBody message={message} authors={authors} />
)}
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
{formatMessageTime(message.created_at, i18n.language)}
@@ -408,27 +469,36 @@ export function MessageItem({
{message.reactions.length === 0 ? null : (
<ul className="flex flex-wrap gap-1" aria-label={t('chat.reactions.title')}>
{message.reactions.map((reaction) => (
<li key={reaction.emoji}>
<button
type="button"
aria-pressed={reaction.me}
aria-label={t('chat.reactions.toggle', {
emoji: reaction.emoji,
count: reaction.count,
})}
onClick={() => react(reaction.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors duration-[var(--duration-fast)] ${
reaction.me
? 'border-accent/60 bg-accent/15 text-fg'
: 'border-border/60 bg-surface-2 text-fg-muted hover:bg-surface-3'
}`}
>
<span aria-hidden="true">{reaction.emoji}</span>
<span>{reaction.count}</span>
</button>
</li>
))}
{message.reactions.map((reaction) => {
// Кастомный эмодзи едет токеном `<:имя:file_id>`: показываем
// картинку, а в подписи — читаемое `:имя:`.
const custom = emojis.resolve(reaction.emoji);
return (
<li key={reaction.emoji}>
<button
type="button"
aria-pressed={reaction.me}
aria-label={t('chat.reactions.toggle', {
emoji: custom === null ? reaction.emoji : customEmojiShortcode(custom.name),
count: reaction.count,
})}
onClick={() => react(reaction.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors duration-[var(--duration-fast)] ${
reaction.me
? 'border-accent/60 bg-accent/15 text-fg'
: 'border-border/60 bg-surface-2 text-fg-muted hover:bg-surface-3'
}`}
>
{custom === null ? (
<span aria-hidden="true">{reaction.emoji}</span>
) : (
<CustomEmojiImage emoji={custom} className="h-4 w-4" />
)}
<span>{reaction.count}</span>
</button>
</li>
);
})}
</ul>
)}
@@ -506,6 +576,7 @@ export function MessageItem({
onClose={() => setPickerOpen(false)}
onPick={react}
placement={dropUp ? 'top' : 'bottom'}
guildId={guildId}
/>
<Menu
open={menuOpen}
+4
View File
@@ -19,6 +19,8 @@ import {
interface MessageListProps {
channelId: string;
channelName: string;
/** Сервер комнаты: пикер реакций берёт из него кастомные эмодзи. */
guildId?: string | null;
/** Личная беседа: тексты без «#» и без упоминания комнаты. */
direct?: boolean;
/** Лента с оптимистичными сообщениями в конце. */
@@ -50,6 +52,7 @@ const STICK_THRESHOLD_PX = 120;
export function MessageList({
channelId,
channelName,
guildId = null,
direct = false,
messages,
status,
@@ -228,6 +231,7 @@ export function MessageList({
message={message}
channelId={channelId}
channelName={channelName}
guildId={guildId}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManageMessages}
@@ -28,6 +28,12 @@ export interface ChatAuthor {
export interface AuthorDirectory {
resolve: (userId: string | undefined) => ChatAuthor;
resolveMention: (userId: string) => { name: string; isMe: boolean };
/**
* Имя участника или `null`, если он неизвестен (например, вышел с сервера
* и `GET /users/{id}` его не знает). Нужно системным сообщениям: они
* показывают «участник», а не «неизвестный».
*/
resolveMemberName?: ((userId: string) => string | null) | undefined;
/** Загружены ли участники: до этого момента имена могут быть неполными. */
loaded: boolean;
}
@@ -161,6 +167,13 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
const author = resolve(userId);
return { name: author.name, isMe: userId === currentUserId };
},
resolveMemberName: (userId) => {
const member = memberMap.get(userId);
if (member !== undefined) {
return memberDisplayName(member);
}
return userNameMap.get(userId) ?? null;
},
loaded: membersLoaded,
};
}, [memberMap, rolesData, userNameMap, userAvatarMap, currentUserId, membersLoaded, t]);
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchMembers, guildMembersQueryKey } from '@/api/guilds';
import type { GuildEmoji } from '@/api/emojis';
import type { Channel, GuildMember, UserSummary } from '@/api/types';
import { fetchUser, userQueryKey } from '@/api/users';
import {
@@ -12,8 +13,10 @@ import {
type AutocompleteItem,
} from '@/lib/autocomplete';
import { memberDisplayName } from '@/lib/chat';
import { customEmojiShortcode } from '@/lib/customEmoji';
import { searchEmoji } from '@/lib/emoji';
import { useSessionStore } from '@/stores/session';
import { useGuildEmojis } from '@/components/chat/useGuildEmojis';
/** Сколько подсказок показываем: список должен помещаться над полем ввода. */
const MAX_SUGGESTIONS = 8;
@@ -71,6 +74,33 @@ function recipientToCandidate(recipient: UserSummary | undefined): MentionCandid
];
}
/**
* Подсказки кастомных эмодзи сервера: сначала точное совпадение по имени
* (`:fire:` при вводе `:fire`), затем остальные по алфавиту. Вставка — токен
* `<:имя:file_id>`: ровно то, что понимает сервер в тексте и реакции.
*/
function serverEmojiItems(
emojis: readonly GuildEmoji[],
query: string,
animatedLabel: string,
): AutocompleteItem[] {
return emojis
.filter((emoji) => emoji.name.toLowerCase().startsWith(query))
.sort((left, right) => {
const leftExact = left.name.toLowerCase() === query ? 0 : 1;
const rightExact = right.name.toLowerCase() === query ? 0 : 1;
return leftExact - rightExact || left.name.localeCompare(right.name);
})
.map((emoji) => ({
id: `emoji:${emoji.id}`,
kind: 'emoji' as const,
insert: emoji.token,
label: customEmojiShortcode(emoji.name),
...(emoji.animated ? { hint: animatedLabel } : {}),
emojiFileId: emoji.file_id,
}));
}
interface ComposerAutocompleteOptions {
value: string;
/** Позиция каретки в поле ввода. */
@@ -121,6 +151,8 @@ export function useComposerAutocomplete(
[guildId],
),
);
// Кастомные эмодзи сервера: в личной беседе список пуст.
const serverEmojis = useGuildEmojis(guildId);
const trigger = useMemo(() => detectAutocomplete(value, caret), [value, caret]);
@@ -176,13 +208,18 @@ export function useComposerAutocomplete(
label: `#${item.name}`,
}));
} else {
items = searchEmoji(query, MAX_SUGGESTIONS).map((entry) => ({
id: `emoji:${entry.code}`,
kind: 'emoji',
insert: entry.emoji,
label: entry.emoji,
hint: `:${entry.code}:`,
}));
// Порядок важен: эмодзи сервера идут первыми (точное совпадение по имени
// выше остальных), затем дополняет юникод-словарь.
items = [
...serverEmojiItems(serverEmojis, query, t('chat.emoji.animated')),
...searchEmoji(query, MAX_SUGGESTIONS).map((entry) => ({
id: `emoji:${entry.code}`,
kind: 'emoji' as const,
insert: entry.emoji,
label: entry.emoji,
hint: `:${entry.code}:`,
})),
].slice(0, MAX_SUGGESTIONS);
}
if (items.length === 0) {
@@ -196,5 +233,5 @@ export function useComposerAutocomplete(
labelKey: LABEL_KEYS[trigger.kind],
items,
};
}, [trigger, guildId, guild, members.data, recipient.data, t]);
}, [trigger, guildId, guild, members.data, recipient.data, serverEmojis, t]);
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useMemo } from 'react';
import type { GuildEmoji } from '@/api/emojis';
import { useSessionStore } from '@/stores/session';
/**
* Эмодзи сервера из снапшота (READY и `GUILD_EMOJIS_UPDATE`).
*
* Один источник данных для всех потребителей: лента и зеркало композера
* рисуют токены `<:имя:file_id>`, пикер и автокомплит `:` подставляют их
* в текст. Логику разбора токена держит `lib/customEmoji`, здесь — только
* доступ к снапшоту.
*/
/** Пустой список — стабильная ссылка: селектор не должен перерисовывать зря. */
const NO_EMOJIS: GuildEmoji[] = [];
/** Эмодзи выбранного сервера: в личной беседе и без сервера их нет. */
export function useGuildEmojis(guildId: string | null): GuildEmoji[] {
return useSessionStore(
useCallback(
(state) =>
guildId === null
? NO_EMOJIS
: (state.guilds.find((guild) => guild.id === guildId)?.emojis ?? NO_EMOJIS),
[guildId],
),
);
}
/** Справочник эмодзи для рендера: токен → эмодзи. */
export interface GuildEmojiDirectory {
/** Эмодзи по токену `<:имя:file_id>`; `null` — файла нет в снапшоте. */
resolve: (token: string) => GuildEmoji | null;
}
/**
* Поиск эмодзи по токену среди всех серверов снапшота. Лента и зеркало не
* знают, какому серверу принадлежит сообщение (в личной беседе сервера нет
* вовсе), а `file_id` уникален на инстансе — поэтому индекс общий. Токен,
* которого нет в снапшоте, остаётся текстом `:имя:`.
*/
export function useGuildEmojiDirectory(): GuildEmojiDirectory {
const guilds = useSessionStore((state) => state.guilds);
return useMemo(() => {
const byToken = new Map<string, GuildEmoji>();
for (const guild of guilds) {
for (const emoji of guild.emojis) {
byToken.set(emoji.token, emoji);
}
}
return { resolve: (token: string) => byToken.get(token) ?? null };
}, [guilds]);
}
+53 -5
View File
@@ -59,7 +59,15 @@
"file.not_found": "File not found.",
"invite.expired": "This invite has expired.",
"invite.exhausted": "This invite has no uses left.",
"user.not_found": "No user with that username was found."
"user.not_found": "No user with that username was found.",
"emoji": {
"name_taken": "An emoji with that name already exists.",
"invalid_type": "PNG, JPEG, WebP and GIF are supported.",
"invalid_name": "Emoji name: 2–32 characters — latin letters, digits and “_”."
},
"emojis": {
"limit_reached": "The server has reached its emoji limit."
}
},
"theme": {
"switchToLight": "Light theme",
@@ -347,7 +355,13 @@
"title": "Emoji picker",
"quick": "Quick reactions",
"popular": "Popular emoji",
"pick": "React with {{emoji}}"
"pick": "React with {{emoji}}",
"server": "Server emojis",
"search": "Search server emojis",
"searchPlaceholder": "Emoji name",
"notFound": "Nothing found.",
"pickServer": "Pick emoji {{name}}",
"animated": "animated"
},
"edit": {
"label": "Edit message",
@@ -384,7 +398,8 @@
"whisper": "whisper: muted message",
"scream": "loud: large accented text",
"ls": "direct message: /ls username text"
}
},
"emoji": "Emoji"
},
"typing_one": "{{names}} is typing…",
"typing_few": "{{names}} are typing…",
@@ -410,7 +425,14 @@
"show": "Reveal spoiler",
"hide": "Hide spoiler"
},
"logDirect": "Direct conversation with {{name}}"
"logDirect": "Direct conversation with {{name}}",
"system": {
"member": "a member",
"memberJoin": "{{name}} joined the server",
"memberKick": "{{name}} left the server (kicked)",
"channelCreated": "Room #{{name}} created",
"voiceChannelCreated": "Voice room {{name}} created"
}
},
"user": {
"panelLabel": "User panel",
@@ -654,7 +676,33 @@
"deleteConfirmBody": "“{{name}}” will be deleted permanently.",
"deleteConfirm": "Delete"
},
"selectLabel": "Choose a server"
"selectLabel": "Choose a server",
"emojis": {
"title": "Emojis",
"description": "Custom server emojis: up to {{limit}}, 512 KB each.",
"counter": "{{count}} of {{limit}}",
"empty": "The server has no emojis yet.",
"animated": "animated",
"rename": "Rename",
"renameLabel": "Rename emoji {{name}}",
"renameTitle": "Rename emoji",
"delete": "Delete",
"deleteLabel": "Delete emoji {{name}}",
"deleteConfirmTitle": "Delete emoji?",
"deleteConfirmBody": "Emoji {{name}} will be deleted permanently.",
"deleteConfirm": "Delete",
"uploadTitle": "Upload an emoji",
"uploadHint": "Drop a file below or pick one: PNG, JPEG, WebP or GIF up to {{limit}}.",
"name": "Emoji name",
"namePlaceholder": "for example, pepe_happy",
"nameHint": "2–32 characters: latin letters, digits and “_”.",
"dropHint": "Drop a file here",
"chooseFile": "Choose file",
"file": "Emoji file",
"fileSelected": "{{name}} · {{size}}",
"upload": "Upload",
"uploading": "Uploading…"
}
},
"navLabel": "Settings sections",
"notifications": {
+53 -5
View File
@@ -59,7 +59,15 @@
"file.not_found": "Файл не найден.",
"invite.expired": "Срок действия приглашения истёк.",
"invite.exhausted": "У приглашения больше не осталось использований.",
"user.not_found": "Пользователь с таким логином не найден."
"user.not_found": "Пользователь с таким логином не найден.",
"emoji": {
"name_taken": "Эмодзи с таким именем уже есть.",
"invalid_type": "Поддерживаются PNG, JPEG, WebP и GIF.",
"invalid_name": "Имя эмодзи: 2–32 символа, латиница, цифры и «_»."
},
"emojis": {
"limit_reached": "На сервере достигнут лимит эмодзи."
}
},
"theme": {
"switchToLight": "Светлая тема",
@@ -347,7 +355,13 @@
"title": "Выбор эмодзи",
"quick": "Быстрые реакции",
"popular": "Популярные эмодзи",
"pick": "Поставить реакцию {{emoji}}"
"pick": "Поставить реакцию {{emoji}}",
"server": "Эмодзи сервера",
"search": "Поиск эмодзи сервера",
"searchPlaceholder": "Имя эмодзи",
"notFound": "Ничего не найдено.",
"pickServer": "Выбрать эмодзи {{name}}",
"animated": "анимированный"
},
"edit": {
"label": "Редактирование сообщения",
@@ -384,7 +398,8 @@
"whisper": "шёпот: приглушённое сообщение",
"scream": "громко: крупный акцентный текст",
"ls": "личное сообщение: /ls логин текст"
}
},
"emoji": "Эмодзи"
},
"typing_one": "{{names}} печатает…",
"typing_few": "{{names}} печатают…",
@@ -410,7 +425,14 @@
"show": "Показать спойлер",
"hide": "Скрыть спойлер"
},
"logDirect": "Личная беседа с {{name}}"
"logDirect": "Личная беседа с {{name}}",
"system": {
"member": "участник",
"memberJoin": "{{name}} присоединился к серверу",
"memberKick": "{{name}} покинул сервер (исключён)",
"channelCreated": "Комната #{{name}} создана",
"voiceChannelCreated": "Голосовая комната {{name}} создана"
}
},
"user": {
"panelLabel": "Панель пользователя",
@@ -654,7 +676,33 @@
"deleteConfirmBody": "Сервер «{{name}}» будет удалён без возможности восстановления.",
"deleteConfirm": "Удалить"
},
"selectLabel": "Выбрать сервер"
"selectLabel": "Выбрать сервер",
"emojis": {
"title": "Эмодзи",
"description": "Кастомные эмодзи сервера: до {{limit}} штук, каждый до 512 КБ.",
"counter": "{{count}} из {{limit}}",
"empty": "На сервере пока нет эмодзи.",
"animated": "анимированный",
"rename": "Переименовать",
"renameLabel": "Переименовать эмодзи {{name}}",
"renameTitle": "Переименовать эмодзи",
"delete": "Удалить",
"deleteLabel": "Удалить эмодзи {{name}}",
"deleteConfirmTitle": "Удалить эмодзи?",
"deleteConfirmBody": "Эмодзи {{name}} будет удалён без возможности восстановления.",
"deleteConfirm": "Удалить",
"uploadTitle": "Загрузить эмодзи",
"uploadHint": "Перетащите файл в область ниже или выберите его: PNG, JPEG, WebP или GIF до {{limit}}.",
"name": "Имя эмодзи",
"namePlaceholder": "например, pepe_happy",
"nameHint": "2–32 символа: латиница, цифры и «_».",
"dropHint": "Перетащите файл сюда",
"chooseFile": "Выбрать файл",
"file": "Файл эмодзи",
"fileSelected": "{{name}} · {{size}}",
"upload": "Загрузить",
"uploading": "Загружаем…"
}
},
"navLabel": "Пункты настроек",
"notifications": {
+2
View File
@@ -37,6 +37,8 @@ export interface AutocompleteItem {
/** Имя для аватара-заглушки (есть только у участников). */
avatarName?: string | undefined;
avatarFileId?: string | undefined;
/** Файл картинки кастомного эмодзи: подсказка рисует его вместо символа. */
emojiFileId?: string | undefined;
}
/** Команда чата: сервер разбирает её сам (AGENT.md §7.6). */
+60
View File
@@ -0,0 +1,60 @@
/**
* Токен кастомного эмодзи сервера (AGENT.md 7.12).
*
* В тексте сообщения и в реакции эмодзи едет токеном `<:имя:file_id>`
* (у анимированных — `<a:имя:file_id>`): имя разрешено только из латиницы,
* цифр и подчёркиваний, `file_id` — снежинка. Единое описание формата живёт
* здесь, чтобы разбор в разметке (`lib/markdown.ts`), подсказки композера и
* настройки сервера не расходились.
*/
/** Имя эмодзи: ровно то, что принимает сервер при загрузке и переименовании. */
export const CUSTOM_EMOJI_NAME_SOURCE = '[a-zA-Z0-9_]{2,32}';
/** Токен целиком без якорей — вставляется в общий шаблон разметки. */
export const CUSTOM_EMOJI_TOKEN_SOURCE = `<a?:${CUSTOM_EMOJI_NAME_SOURCE}:\\d+>`;
/** Проверка имени эмодзи на клиенте: до запроса, чтобы не гонять сервер. */
export const CUSTOM_EMOJI_NAME_PATTERN = new RegExp(`^${CUSTOM_EMOJI_NAME_SOURCE}$`, 'u');
const CUSTOM_EMOJI_TOKEN_PATTERN = new RegExp(
`^<(?<animated>a?):(?<name>${CUSTOM_EMOJI_NAME_SOURCE}):(?<fileId>\\d+)>$`,
'u',
);
/** Разобранный токен: имя, файл и признак анимации. */
export interface CustomEmojiToken {
name: string;
fileId: string;
animated: boolean;
}
/**
* Разбор токена. `null` — строка не является токеном эмодзи (битый или
* неизвестный формат): вызывающий код показывает её как обычный текст.
*/
export function parseCustomEmoji(raw: string): CustomEmojiToken | null {
const match = CUSTOM_EMOJI_TOKEN_PATTERN.exec(raw);
const groups = match?.groups;
const name = groups?.['name'];
const fileId = groups?.['fileId'];
if (name === undefined || fileId === undefined) {
return null;
}
return { name, fileId, animated: groups?.['animated'] === 'a' };
}
/** Короткий код эмодзи — то, что видит пользователь: `:имя:`. */
export function customEmojiShortcode(name: string): string {
return `:${name}:`;
}
/** Токен для вставки в текст сообщения или реакцию. */
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;
}
+22 -2
View File
@@ -40,6 +40,7 @@ export const PERMISSION_ADMINISTRATOR = 'ADMINISTRATOR';
export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
@@ -102,8 +103,7 @@ export function canAdministerGuild(
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
* проверит права в конкретной комнате.
*/
export function canManageMessages(
*/export function canManageMessages(
permissions: readonly string[],
isInstanceAdmin = false,
): boolean {
@@ -116,6 +116,26 @@ export function canManageMessages(
);
}
/**
* Может ли пользователь управлять эмодзи сервера (AGENT.md 7.12): владелец,
* ADMINISTRATOR, MANAGE_EMOJIS или администратор инстанса. `MANAGE_GUILD`
* сюда не входит: сервер проверяет именно `MANAGE_EMOJIS`.
*/
export function canManageEmojis(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_EMOJIS)
);
}
/** Может ли пользователь отправлять сообщения в комнату. */
export function canSendMessages(
permissions: readonly string[],
+35 -5
View File
@@ -17,12 +17,14 @@
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
* `<#id>` и `#канал`.
* `<#id>`, `#канал` и кастомные эмодзи сервера `<:имя:file_id>`.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
*/
import { CUSTOM_EMOJI_TOKEN_SOURCE } from '@/lib/customEmoji';
/** Вид строчного токена. */
export type InlineKind =
| 'code'
@@ -34,6 +36,7 @@ export type InlineKind =
| 'mark'
| 'mention'
| 'channel-link'
| 'custom-emoji'
| 'link'
| 'channel';
@@ -61,9 +64,30 @@ export interface InlineToken {
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
* `_` — внутри слова (`foo_bar_baz`).
*
* Токен кастомного эмодзи `<:имя:file_id>` (`<a:…>` — анимированный) стоит
* отдельной альтернативой: с `<@id>` и `<#id>` он не пересекается, а строгий
* формат (латиница/цифры/подчёркивания в имени, снежинка в id) оставляет
* битые токены обычным текстом.
*/
const INLINE_PATTERN =
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@[^\s<>]+>)|(<#[^\s<>]+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
const INLINE_ALTERNATIVES = [
'(`[^`\\n]+`)',
'(\\*\\*\\*[^*\\n]+\\*\\*\\*)',
'(\\*\\*[^*\\n]+\\*\\*)',
'(__[^_\\n]+__)',
'(~~[^~\\n]+~~)',
'(\\|\\|[^|\\n]+\\|\\|)',
'((?<!\\*)\\*(?!\\*)[^*\\n]+(?<!\\*)\\*(?!\\*))',
'((?<![\\p{L}\\p{N}_])_(?!_)[^_\\n]+(?<!_)_(?![\\p{L}\\p{N}_]))',
'(==[^=\\n]+==)',
'(<@[^\\s<>]+>)',
'(<#[^\\s<>]+>)',
`(${CUSTOM_EMOJI_TOKEN_SOURCE})`,
'(https?:\\/\\/[^\\s<>()]+)',
'(#[\\p{L}\\p{N}_-]+)',
];
const INLINE_PATTERN = new RegExp(INLINE_ALTERNATIVES.join('|'), 'gu');
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
const GROUP_KINDS: readonly InlineKind[] = [
@@ -78,6 +102,7 @@ const GROUP_KINDS: readonly InlineKind[] = [
'mark',
'mention',
'channel-link',
'custom-emoji',
'link',
'channel',
];
@@ -95,6 +120,9 @@ const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
// содержимое — весь токен.
mention: 0,
'channel-link': 0,
// Токен эмодзи `<:имя:id>` целиком считается содержимым: маркеры `<:имя:`
// и `>` рисует зеркало композера, когда каретка внутри токена.
'custom-emoji': 0,
link: 0,
channel: 0,
};
@@ -149,8 +177,10 @@ export function parseInline(text: string, offset = 0): InlineToken[] {
}
/**
* Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
* каналов их нет: содержимое — весь токен, прятать нечего.
* Есть ли у токена маркеры, которые зеркало прячет вне каретки. У упоминаний,
* ссылок на комнаты, ссылок и каналов их нет: содержимое — весь токен, прятать
* нечего. У кастомного эмодзи маркеры есть (`<:имя:` и `>`), но «содержимым»
* остаётся весь токен — зеркало показывает его текстом, пока каретка внутри.
*/
export function hasMarkers(kind: InlineKind): boolean {
return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
+57
View File
@@ -0,0 +1,57 @@
/**
* Системные сообщения (AGENT.md §7.6).
*
* Сервер пишет их с `type: "system"`, а `content` — JSON-строка о событии:
* `{"event":"member_join","user_id":"123"}`, `{"event":"member_kick",…}` или
* `{"event":"channel_created","name":"общий","type":"text"}`. Разбор живёт
* отдельно от компонентов: лента рисует человекочитаемую строку, а
* справочник авторов по нему же добирает идентификаторы участников.
*
* Неизвестное событие и невалидный JSON возвращают `null` — вызывающий код
* показывает исходный текст сообщения как есть, ничего не теряя.
*/
/** Событие системного сообщения. */
export type SystemMessageEvent =
| { kind: 'member_join'; userId: string }
| { kind: 'member_kick'; userId: string }
| { kind: 'channel_created'; name: string; channelType: string };
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
/** Разбор `content` системного сообщения. */
export function parseSystemMessage(content: string): SystemMessageEvent | null {
let payload: unknown;
try {
payload = JSON.parse(content) as unknown;
} catch {
// Не JSON — показываем текст как есть.
return null;
}
const record = asRecord(payload);
const event = record?.['event'];
const userId = record?.['user_id'];
if (event === 'member_join' || event === 'member_kick') {
return typeof userId === 'string' && userId !== '' ? { kind: event, userId } : null;
}
if (event === 'channel_created') {
const name = record?.['name'];
if (typeof name !== 'string' || name === '') {
return null;
}
const type = record?.['type'];
return { kind: 'channel_created', name, channelType: typeof type === 'string' ? type : 'text' };
}
return null;
}
/**
* Идентификатор пользователя из системного сообщения. Нужен ленте: имя
* автора события резолвится тем же справочником, что и авторы сообщений.
*/
export function systemMessageUserId(content: string): string | null {
const event = parseSystemMessage(content);
return event !== null && event.kind !== 'channel_created' ? event.userId : null;
}
@@ -0,0 +1,360 @@
import { useRef, useState, type DragEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
EMOJI_LIMIT_PER_GUILD,
EMOJI_MAX_BYTES,
deleteGuildEmoji,
fetchGuildEmojis,
guildEmojisQueryKey,
isAllowedEmojiType,
isValidEmojiName,
renameGuildEmoji,
uploadGuildEmoji,
type GuildEmoji,
} from '@/api/emojis';
import { fileContentUrl } from '@/api/files';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { customEmojiShortcode } from '@/lib/customEmoji';
import { formatBytes } from '@/lib/format';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildEmojisSectionProps {
guildId: string;
}
/**
* Локальная ошибка проверки файла: текст показываем сразу, на сервер не ходим.
* Ключ перевода — как у серверных ошибок (`errors.<code>`), кроме имени.
*/
type LocalError = { kind: 'file.too_large' | 'emoji.invalid_type' | 'emoji.invalid_name' };
/**
* «Эмодзи»: загрузка, переименование и удаление кастомных эмодзи сервера
* (AGENT.md 7.12, право MANAGE_EMOJIS).
*
* Список читается по REST (`GET /guilds/{id}/emojis`), а после операций
* инвалидируется: снапшот обновляет и событие шлюза `GUILD_EMOJIS_UPDATE`,
* и точечные правки в сторе сессии — лента и пикер видят эмодзи сразу.
*/
export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [file, setFile] = useState<File | null>(null);
const [localError, setLocalError] = useState<LocalError | null>(null);
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState<GuildEmoji | null>(null);
const [renameValue, setRenameValue] = useState('');
const [removing, setRemoving] = useState<GuildEmoji | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const list = useQuery({
queryKey: guildEmojisQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildEmojis(guildId, signal),
retry: 0,
});
const emojis = list.data ?? [];
const full = emojis.length >= EMOJI_LIMIT_PER_GUILD;
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
const refresh = (): void => {
void queryClient.invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
};
const upload = useMutation({
mutationFn: (input: { name: string; file: File }) =>
uploadGuildEmoji(guildId, input.name, input.file),
onSuccess: (emoji) => {
if (emoji !== null) {
useSessionStore.getState().upsertGuildEmoji(guildId, emoji);
}
setName('');
setFile(null);
setLocalError(null);
refresh();
},
});
const rename = useMutation({
mutationFn: (input: { emojiId: string; name: string }) =>
renameGuildEmoji(guildId, input.emojiId, input.name),
onSuccess: (emoji) => {
if (emoji !== null) {
useSessionStore.getState().upsertGuildEmoji(guildId, emoji);
}
setRenaming(null);
refresh();
},
});
const remove = useMutation({
mutationFn: (emojiId: string) => deleteGuildEmoji(guildId, emojiId),
onSuccess: (_result, emojiId) => {
useSessionStore.getState().removeGuildEmoji(guildId, emojiId);
setRemoving(null);
refresh();
},
});
/** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */
const pickFile = (next: File | undefined): void => {
if (next === undefined) {
return;
}
upload.reset();
if (!isAllowedEmojiType(next)) {
setFile(null);
setLocalError({ kind: 'emoji.invalid_type' });
return;
}
if (next.size > EMOJI_MAX_BYTES) {
setFile(null);
setLocalError({ kind: 'file.too_large' });
return;
}
setLocalError(null);
setFile(next);
};
const submit = (): void => {
if (file === null) {
setLocalError({ kind: 'emoji.invalid_type' });
return;
}
if (!isValidEmojiName(name)) {
setLocalError({ kind: 'emoji.invalid_name' });
return;
}
setLocalError(null);
upload.mutate({ name, file });
};
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
setDragging(false);
pickFile(event.dataTransfer.files[0]);
};
return (
<SettingsSection
title={t('settings.servers.emojis.title')}
description={t('settings.servers.emojis.description', { limit: EMOJI_LIMIT_PER_GUILD })}
>
<p className="mt-3 text-sm text-fg-muted" data-testid="guild-emojis-counter">
{t('settings.servers.emojis.counter', {
count: emojis.length,
limit: EMOJI_LIMIT_PER_GUILD,
})}
</p>
{list.isPending ? (
<SkeletonLines className="mt-4" rows={3} />
) : list.isError ? (
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
) : emojis.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.emojis.empty')}</p>
) : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-emojis">
{emojis.map((emoji) => (
<li
key={emoji.id}
data-testid={`guild-emoji-${emoji.id}`}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
<img
src={fileContentUrl(emoji.file_id)}
alt={customEmojiShortcode(emoji.name)}
loading="lazy"
className="h-8 w-8 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{customEmojiShortcode(emoji.name)}
</span>
{emoji.size_bytes === undefined ? null : (
<span className="text-xs text-fg-muted">
{formatBytes(emoji.size_bytes, i18n.language)}
</span>
)}
{emoji.animated ? (
<Badge>{t('settings.servers.emojis.animated')}</Badge>
) : null}
<Button
variant="ghost"
aria-label={t('settings.servers.emojis.renameLabel', {
name: customEmojiShortcode(emoji.name),
})}
onClick={() => {
rename.reset();
setRenameValue(emoji.name);
setRenaming(emoji);
}}
>
{t('settings.servers.emojis.rename')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
aria-label={t('settings.servers.emojis.deleteLabel', {
name: customEmojiShortcode(emoji.name),
})}
onClick={() => {
remove.reset();
setRemoving(emoji);
}}
>
{t('settings.servers.emojis.delete')}
</Button>
</li>
))}
</ul>
)}
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
<div className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4">
<h3 className="text-sm font-semibold">{t('settings.servers.emojis.uploadTitle')}</h3>
<p className="text-xs text-fg-muted">
{t('settings.servers.emojis.uploadHint', {
limit: formatBytes(EMOJI_MAX_BYTES, i18n.language),
})}
</p>
{full ? <p className="text-xs text-warning">{t('errors.emojis.limit_reached')}</p> : null}
<Field
label={t('settings.servers.emojis.name')}
name="emoji_name"
value={name}
maxLength={32}
placeholder={t('settings.servers.emojis.namePlaceholder')}
hint={t('settings.servers.emojis.nameHint')}
onChange={(event) => setName(event.target.value)}
/>
<div
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${
dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40'
}`}
data-testid="emoji-dropzone"
>
<span className="text-fg-muted">{t('settings.servers.emojis.dropHint')}</span>
<input
ref={fileInputRef}
type="file"
hidden
accept="image/png,image/jpeg,image/webp,image/gif"
aria-label={t('settings.servers.emojis.file')}
onChange={(event) => {
pickFile(event.target.files?.[0]);
event.target.value = '';
}}
/>
<Button variant="ghost" onClick={() => fileInputRef.current?.click()}>
{t('settings.servers.emojis.chooseFile')}
</Button>
{file === null ? null : (
<span className="text-xs text-fg-muted">
{t('settings.servers.emojis.fileSelected', {
name: file.name,
size: formatBytes(file.size, i18n.language),
})}
</span>
)}
</div>
{localError === null ? null : (
<p role="alert" className="text-xs text-danger">
{t(`errors.${localError.kind}`)}
</p>
)}
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
<div className="flex justify-end">
<Button
disabled={upload.isPending || file === null || name.trim() === '' || full}
onClick={submit}
>
{t(upload.isPending ? 'settings.servers.emojis.uploading' : 'settings.servers.emojis.upload')}
</Button>
</div>
</div>
<Modal
open={renaming !== null}
title={t('settings.servers.emojis.renameTitle')}
onClose={() => setRenaming(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
<Button
disabled={rename.isPending || !isValidEmojiName(renameValue.trim())}
onClick={() => {
if (renaming !== null) {
rename.mutate({ emojiId: renaming.id, name: renameValue.trim() });
}
}}
>
{t(rename.isPending ? 'common.saving' : 'common.save')}
</Button>
</>
}
>
<Field
label={t('settings.servers.emojis.name')}
name="emoji_rename"
value={renameValue}
maxLength={32}
hint={t('settings.servers.emojis.nameHint')}
onChange={(event) => setRenameValue(event.target.value)}
/>
{rename.isError ? <ErrorNotice className="mt-3" error={rename.error} /> : null}
</Modal>
<Modal
open={removing !== null}
title={t('settings.servers.emojis.deleteConfirmTitle')}
onClose={() => setRemoving(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRemoving(null)}>
{t('common.cancel')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={remove.isPending}
onClick={() => {
if (removing !== null) {
remove.mutate(removing.id);
}
}}
>
{t('settings.servers.emojis.deleteConfirm')}
</Button>
</>
}
>
<p className="text-sm text-fg-muted">
{t('settings.servers.emojis.deleteConfirmBody', {
name: removing === null ? '' : customEmojiShortcode(removing.name),
})}
</p>
</Modal>
</SettingsSection>
);
}
+15 -2
View File
@@ -7,10 +7,11 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canAdministerGuild } from '@/lib/identity';
import { canAdministerGuild, canManageEmojis } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
@@ -18,7 +19,7 @@ import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
/**
* Пункт «Сервер» окна настроек: администрирование одного сервера —
* общее (имя и описание), участники, роли и «Опасная зона».
* общее (имя и описание), участники, роли, эмодзи и «Опасная зона».
*
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
@@ -140,6 +141,15 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
currentUser.data?.is_instance_admin === true,
);
// «Эмодзи» сервер разрешает владельцу, админу инстанса, ADMINISTRATOR
// и MANAGE_EMOJIS: остальным секция не показывается.
const canManageEmojiSet = canManageEmojis(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
return (
<>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
@@ -150,6 +160,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
onRetry={() => void members.refetch()}
/>
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
{canManageEmojiSet ? (
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
) : null}
<GuildDangerSection
key={`${guild.id}-danger`}
guildId={guild.id}
+20
View File
@@ -12,6 +12,7 @@ import {
parseChannelDelete,
parseChannelPayload,
parseDmChannelEvent,
parseGuildEmojisUpdate,
parseGuildId,
parseGuildUpdate,
parseMessageDelete,
@@ -271,6 +272,16 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
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);
@@ -353,6 +364,15 @@ async function invalidateGuildQueries(guildId: string): Promise<void> {
]);
}
/** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */
async function invalidateEmojiQueries(guildId: string): Promise<void> {
const [{ getQueryClient }, emojis] = await Promise.all([
import('@/lib/queryClient'),
import('@/api/emojis'),
]);
await getQueryClient().invalidateQueries({ queryKey: emojis.guildEmojisQueryKey(guildId) });
}
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
function syncUrlWithSelection(): void {
if (typeof window === 'undefined') {
+55 -3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import type { GuildEmoji } from '@/api/emojis';
import { fetchDirectChannels } from '@/api/friends';
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
@@ -10,9 +11,11 @@ import type { Channel, GuildSummary, User, UserStatus } from '@/api/types';
* за состояние, которое приходит событиями шлюза.
*/
/** Сервер снапшота: сводка для рейки + каналы/роли для сайдбара. */
/** Сервер снапшота: сводка для рейки + каналы/роли/эмодзи для интерфейса. */
export interface SessionGuild extends GuildSummary {
channels: Channel[];
/** Кастомные эмодзи сервера: READY и `GUILD_EMOJIS_UPDATE` (AGENT.md 7.12). */
emojis: GuildEmoji[];
my_nickname?: string;
}
@@ -42,6 +45,15 @@ interface SessionState {
removeGuild: (guildId: string) => void;
setGuildChannels: (guildId: string, channels: Channel[]) => void;
upsertChannel: (guildId: string, channel: Channel) => void;
/**
* Набор эмодзи сервера целиком: READY и `GUILD_EMOJIS_UPDATE` шлют его
* одним и тем же списком.
*/
setGuildEmojis: (guildId: string, emojis: GuildEmoji[]) => void;
/** Эмодзи загружен или переименован: заменяем запись по id. */
upsertGuildEmoji: (guildId: string, emoji: GuildEmoji) => void;
/** Эмодзи удалён: убираем его из снапшота (реакции остаются как есть). */
removeGuildEmoji: (guildId: string, emojiId: string) => void;
/** Личные беседы: список целиком (READY или `GET /users/@me/channels`). */
setDmChannels: (channels: Channel[]) => void;
upsertDmChannel: (channel: Channel) => void;
@@ -92,6 +104,7 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild {
my_role_ids: guild.my_role_ids,
my_permissions: guild.my_permissions ?? [],
channels: guild.channels,
emojis: guild.emojis,
};
if (guild.icon_file_id !== undefined) {
summary.icon_file_id = guild.icon_file_id;
@@ -176,14 +189,20 @@ export const useSessionStore = create<SessionState>((set, get) => ({
const guilds = get().guilds;
const index = guilds.findIndex((item) => item.id === guild.id);
if (index === -1) {
set({ guilds: [...guilds, { ...guild, channels: [] }] });
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [] }] });
return;
}
const existing = guilds[index];
if (existing === undefined) {
return;
}
const merged: SessionGuild = { ...existing, ...guild, channels: existing.channels };
// Сводка из REST не знает ни комнат, ни эмодзи — накопленное не теряем.
const merged: SessionGuild = {
...existing,
...guild,
channels: existing.channels,
emojis: existing.emojis,
};
const next = [...guilds];
next[index] = merged;
set({ guilds: next });
@@ -243,6 +262,39 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
setGuildEmojis: (guildId, emojis) => {
set({
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, emojis } : guild)),
});
},
upsertGuildEmoji: (guildId, emoji) => {
set({
guilds: get().guilds.map((guild) => {
if (guild.id !== guildId) {
return guild;
}
const index = guild.emojis.findIndex((item) => item.id === emoji.id);
if (index === -1) {
return { ...guild, emojis: [...guild.emojis, emoji] };
}
const emojis = [...guild.emojis];
emojis[index] = emoji;
return { ...guild, emojis };
}),
});
},
removeGuildEmoji: (guildId, emojiId) => {
set({
guilds: get().guilds.map((guild) =>
guild.id === guildId
? { ...guild, emojis: guild.emojis.filter((emoji) => emoji.id !== emojiId) }
: guild,
),
});
},
setDmChannels: (channels) => {
set({ dmChannels: channels });
},
+1
View File
@@ -90,6 +90,7 @@ const guild: SessionGuild = {
{ id: 'c-3', name: 'Голосовая', type: 'voice', position: 2 },
{ id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false },
],
emojis: [],
};
const authors: AuthorDirectory = {
+554
View File
@@ -0,0 +1,554 @@
import { createRef } from 'react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import type { Message } from '@/api/messages';
import { parseGuildEmojisUpdate } from '@/api/gatewayEvents';
import { parseGuildEmojiList } from '@/api/emojis';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { ComposerMirror } from '@/components/chat/ComposerMirror';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
import { parseInline } from '@/lib/markdown';
import { dispatchGatewayEvent } from '@/stores/gateway';
import { useSessionStore, type SessionGuild } from '@/stores/session';
import {
createTestQueryClient,
findRequest,
installFetch,
installGatewaySocket,
json,
makeMessage,
makeReadySnapshot,
makeUser,
messagesRoutes,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Кастомные эмодзи сервера (AGENT.md 7.12): токен `<:имя:file_id>` в разметке,
* лента и зеркало рисуют картинку, пикер и автокомплит `:` вставляют токен,
* реакции ходят тем же токеном, а набор эмодзи живёт в снапшоте и обновляется
* событием `GUILD_EMOJIS_UPDATE`.
*/
const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' };
const parrot = {
id: 'e-2',
name: 'party_parrot',
file_id: '901',
animated: true,
token: '<a:party_parrot:901>',
};
/** Сервер снапшота с кастомными эмодзи. */
function guildWithEmojis(emojis: SessionGuild['emojis'] = [pepe, parrot]): SessionGuild {
return {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis,
};
}
const authors: AuthorDirectory = {
resolve: () => ({
id: 'user-2',
name: 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: true,
}),
resolveMention: (userId) => ({ name: userId, isMe: false }),
resolveMemberName: (userId) => `user ${userId}`,
loaded: true,
};
const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
beforeEach(() => {
resetStores();
});
afterEach(() => {
resetStores();
});
describe('кастомные эмодзи: разбор токена', () => {
it('распознаёт <:имя:id> и <a:имя:id>', () => {
const tokens = parseInline('привет <:pepe:900> и <a:party_parrot:901>');
expect(tokens.map((token) => token.kind)).toEqual(['custom-emoji', 'custom-emoji']);
expect(tokens.map((token) => token.raw)).toEqual(['<:pepe:900>', '<a:party_parrot:901>']);
expect(parseCustomEmoji('<:pepe:900>')).toEqual({
name: 'pepe',
fileId: '900',
animated: false,
});
expect(parseCustomEmoji('<a:party_parrot:901>')).toEqual({
name: 'party_parrot',
fileId: '901',
animated: true,
});
expect(customEmojiShortcode('pepe')).toBe(':pepe:');
});
it('битый токен остаётся текстом и не ломает разметку', () => {
const content = '**жирный** <:pepe:> <:p:1> <:pepe:abc> <:имя:1>';
expect(parseInline(content)).toHaveLength(1);
expect(parseInline(content)[0]?.kind).toBe('bold');
expect(parseCustomEmoji('<:pepe:>')).toBeNull();
expect(parseCustomEmoji('<:pepe:abc>')).toBeNull();
expect(parseCustomEmoji('<:p:1>')).toBeNull();
});
it('не конфликтует с упоминаниями и ссылками на комнаты', () => {
const kinds = parseInline('<@42> <#7> <:pepe:900>').map((token) => token.kind);
expect(kinds).toEqual(['mention', 'channel-link', 'custom-emoji']);
});
it('разбирает список эмодзи из ответа сервера', () => {
const parsed = parseGuildEmojiList([
{ id: 'e-1', name: 'pepe', file_id: '900', animated: false, token: '<:pepe:900>' },
// Токен не пришёл — собираем сами по тому же формату.
{ id: 'e-2', name: 'kek', file_id: '902', animated: true },
// Битая запись отбрасывается.
{ id: 'e-3', name: 'no_file' },
]);
expect(parsed.map((emoji) => emoji.token)).toEqual(['<:pepe:900>', '<a:kek:902>']);
});
});
describe('кастомные эмодзи: лента сообщений', () => {
it('рисует картинку, если эмодзи есть в снапшоте сервера', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const { container } = render(<MessageContent content={'привет <:pepe:900>!'} />);
const image = container.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveAttribute('alt', ':pepe:');
expect(image).toHaveAttribute('title', ':pepe:');
expect(image).toHaveAttribute('loading', 'lazy');
expect(image).toHaveClass('h-5', 'w-5');
expect(container.textContent).toBe('привет !');
});
it('неизвестный файл показывается текстом :имя:, разметка остаётся целой', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const { container } = render(
<MessageContent content={'**жирный** и <:pepe:900> с <a:party_parrot:901>'} />,
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('strong')).toHaveTextContent('жирный');
expect(container.textContent).toBe('жирный и :pepe: с :party_parrot:');
});
it('без снапшота сервера (личная беседа) токен остаётся текстом', () => {
const { container } = render(<MessageContent content="<:pepe:900>" />);
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: зеркало композера', () => {
it('вне каретки рисует картинку, внутри токена — исходный текст', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const mirrorRef = createRef<HTMLDivElement>();
const value = 'эмодзи <:pepe:900>';
const { rerender } = render(
<ComposerMirror value={value} selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
);
const image = mirrorRef.current?.querySelector('img');
expect(image).toHaveAttribute('src', '/files/900');
expect(image).toHaveClass('h-5', 'w-5', 'align-[-0.15em]');
// Каретка внутри токена — показываем маркеры (весь токен) для правки.
rerender(
<ComposerMirror value={value} selection={{ start: 10, end: 10 }} mirrorRef={mirrorRef} />,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(value);
});
it('неизвестный эмодзи показывается текстом :имя:', () => {
useSessionStore.setState({ guilds: [guildWithEmojis([])] });
const mirrorRef = createRef<HTMLDivElement>();
render(
<ComposerMirror
value="<:pepe:900>"
selection={{ start: 0, end: 0 }}
mirrorRef={mirrorRef}
/>,
);
expect(mirrorRef.current?.querySelector('img')).toBeNull();
expect(mirrorRef.current?.textContent).toBe(':pepe:');
});
});
describe('кастомные эмодзи: пикер', () => {
it('показывает эмодзи сервера, ищет по имени и отдаёт токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
const picked: string[] = [];
render(
<EmojiPicker open guildId="g-1" onClose={() => undefined} onPick={(emoji) => picked.push(emoji)} />,
);
const section = screen.getByTestId('emoji-picker-server');
expect(within(section).getByText(':pepe:')).toBeVisible();
expect(within(section).getByText(':party_parrot:')).toBeVisible();
expect(section.querySelector('img[src="/files/900"]')).not.toBeNull();
expect(within(section).getByText('анимированный')).toBeVisible();
await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party');
expect(within(section).queryByText(':pepe:')).toBeNull();
await visitor.click(within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }));
expect(picked).toEqual(['<a:party_parrot:901>']);
});
it('без сервера секция эмодзи сервера не показывается', () => {
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(
<EmojiPicker open guildId={null} onClose={() => undefined} onPick={() => undefined} />,
);
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
});
});
describe('кастомные эмодзи: пикер в композере', () => {
it('вставляет токен по клику в позицию каретки', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId="g-1"
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.click(screen.getByRole('button', { name: 'Эмодзи' }));
await visitor.click(screen.getByRole('button', { name: 'Выбрать эмодзи pepe' }));
expect(input).toHaveValue('<:pepe:900>');
// Зеркало сразу показывает картинку — предпросмотр совпадает с лентой.
await waitFor(() => {
expect(screen.getByTestId('composer-mirror').querySelector('img')).toHaveAttribute(
'src',
'/files/900',
);
});
});
});
describe('кастомные эмодзи: автокомплит (:)', () => {
function renderComposer(guildId: string | null = 'g-1') {
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
const view = render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={textChannel}
guildId={guildId}
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
return { ...view, fetchMock };
}
it('дополняет список эмодзи сервера и вставляет токен', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
const list = await screen.findByTestId('composer-autocomplete');
expect(within(list).getByText(':pepe:')).toBeVisible();
expect(list.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:pepe:900>');
});
it('точное совпадение по имени выше юникод-словаря', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({
guilds: [
guildWithEmojis([
{ id: 'e-3', name: 'fire', file_id: '950', animated: false, token: '<:fire:950>' },
]),
],
});
renderComposer();
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':fire');
const options = await screen.findAllByTestId('composer-option-emoji');
expect(options[0]).toHaveTextContent(':fire:');
await visitor.keyboard('{Enter}');
expect(input).toHaveValue('<:fire:950>');
});
it('в личной беседе кастомных эмодзи в подсказке нет', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
renderComposer(null);
const input = screen.getByLabelText('Сообщение в комнату общий');
await visitor.type(input, ':pep');
// Юникод-словарь по `pep` пуст, кастомных эмодзи без сервера нет.
await waitFor(() => {
expect(screen.queryByTestId('composer-autocomplete')).toBeNull();
});
});
});
describe('кастомные эмодзи: реакции', () => {
const user = makeUser();
const members = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
nickname: 'Аля',
status: 'online' as const,
is_instance_admin: false,
joined_at: '2026-09-01T10:00:00Z',
role_ids: [],
},
];
/** Приложение на маршруте комнаты сервера с кастомными эмодзи в READY. */
async function boot(messages: Message[], extraRoutes: FetchRoute[] = []) {
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: ['SEND_MESSAGES', 'ADD_REACTIONS'],
channels: [{ id: 'c-1', name: 'общий' }],
emojis: [pepe, parrot],
},
]);
const fetchMock = installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', messages, extraRoutes),
]);
const gateway = installGatewaySocket(snapshot);
const view = renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
return { ...view, fetchMock, gateway };
}
it('рисует чип реакции картинкой и ходит тем же токеном', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[
makeMessage({
id: 'm-1',
channel_id: 'c-1',
author_id: 'user-2',
content: 'реагируй',
reactions: [{ emoji: '<:pepe:900>', count: 1, me: false, user_ids: ['user-2'] }],
}),
],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
const chip = await screen.findByRole('button', { name: 'Реакция :pepe:: 1' });
expect(chip.querySelector('img[src="/files/900"]')).not.toBeNull();
await visitor.click(chip);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :pepe:: 2' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
// Токен уезжает в пути закодированным (%3C%3Apepe%3A900%3E).
const request = findRequest(fetchMock, { url: '/reactions/', method: 'PUT' });
expect(request?.url).toContain(encodeURIComponent('<:pepe:900>'));
});
it('ставит реакцию кастомным эмодзи из пикера', async () => {
const visitor = userEvent.setup();
const { fetchMock } = await boot(
[makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-2', content: 'меню' })],
[
{
match: '/api/v1/channels/c-1/messages/m-1/reactions/',
method: 'PUT',
response: () => json({ ok: true }),
},
],
);
await screen.findByText('меню');
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
await visitor.click(
within(screen.getByTestId('emoji-picker-server')).getByRole('button', {
name: 'Выбрать эмодзи party_parrot',
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Реакция :party_parrot:: 1' })).toBeVisible();
});
expect(findRequest(fetchMock, { url: '/reactions/', method: 'PUT' })?.url).toContain(
encodeURIComponent('<a:party_parrot:901>'),
);
});
});
describe('кастомные эмодзи: снапшот и событие шлюза', () => {
it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => {
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]);
dispatchGatewayEvent({
op: 0,
t: 'GUILD_EMOJIS_UPDATE',
s: 2,
d: { guild_id: 'g-1', emojis: [pepe, parrot] },
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe, parrot]);
});
it('разбирает payload события и отбрасывает битые записи', () => {
const parsed = parseGuildEmojisUpdate({
guild_id: 'g-1',
emojis: [{ id: 'e-1', name: 'pepe', file_id: '900', animated: false }],
});
expect(parsed?.guild_id).toBe('g-1');
expect(parsed?.emojis[0]?.token).toBe('<:pepe:900>');
expect(parseGuildEmojisUpdate({ emojis: [] })).toBeNull();
});
it('без события эмодзи сервера остаются пустыми', () => {
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 1,
d: makeReadySnapshot([{ id: 'g-1', name: 'Сервер' }]),
});
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([]);
});
});
describe('кастомные эмодзи: беседа без сервера', () => {
it('в личной беседе пикер реакций не показывает эмодзи сервера', async () => {
const visitor = userEvent.setup();
useSessionStore.setState({ guilds: [guildWithEmojis()] });
render(
<MessageList
channelId="dm-1"
channelName="Боб"
direct
messages={[
makeMessage({ id: 'm-1', channel_id: 'dm-1', author_id: 'user-2', content: 'привет' }),
]}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages={false}
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
expect(screen.getByText('привет')).toBeVisible();
// Сервер комнаты неизвестен, значит кастомных эмодзи в пикере нет.
const article = screen.getByTestId('message-m-1');
await visitor.click(within(article).getByRole('button', { name: 'Действия с сообщением' }));
await visitor.click(within(article).getByRole('menuitem', { name: 'Реакция' }));
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
expect(screen.getByRole('dialog', { name: 'Выбор эмодзи' })).toBeVisible();
});
});
+336
View File
@@ -0,0 +1,336 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
apiError,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
requestUrl,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Секция «Эмодзи» в настройках сервера (AGENT.md 7.12): список загруженных
* эмодзи со счётчиком, загрузка с клиентскими проверками, переименование и
* удаление. Лента и пикер видят изменения сразу — запросы эмодзи
* инвалидируются, а снапшот обновляет событие `GUILD_EMOJIS_UPDATE`.
*/
const user = makeUser();
const pepe = { id: 'e-1', name: 'pepe', file_id: '900', animated: false, url: '/files/900' };
const parrot = {
id: 'e-2',
name: 'party_parrot',
file_id: '901',
animated: true,
url: '/files/901',
size_bytes: 2048,
};
function guildDetail(permissions: string[]) {
return {
id: 'g-1',
name: 'Сервер',
description: '',
owner_id: 'user-9',
is_main: true,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: permissions,
};
}
interface RoutesOptions {
permissions?: string[];
emojis?: unknown[];
extra?: FetchRoute[];
uploadResponse?: () => Response;
}
/** Маршруты оболочки, карточки сервера и ручек эмодзи. */
function routes(options: RoutesOptions = {}) {
const permissions = options.permissions ?? ['MANAGE_EMOJIS'];
return [
...(options.extra ?? []),
{
match: '/api/v1/guilds/g-1/emojis',
method: 'GET',
response: () => json({ emojis: options.emojis ?? [pepe, parrot] }),
},
{
match: '/api/v1/guilds/g-1/emojis',
method: 'POST',
response: options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })),
},
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: [guildDetail(permissions)] }),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) },
] satisfies FetchRoute[];
}
/** Открывает настройки сервера и дожидается секции «Эмодзи». */
async function openEmojisSection() {
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
const counter = await within(dialog).findByTestId('guild-emojis-counter');
return { dialog, counter };
}
/** Тело multipart-запроса на загрузку эмодзи. */
function uploadForm(fetchMock: ReturnType<typeof installFetch>): FormData | undefined {
const call = fetchMock.mock.calls.find(([input, init]) => {
const method = (init?.method ?? 'GET').toUpperCase();
return method === 'POST' && requestUrl(input).includes('/guilds/g-1/emojis');
});
return call?.[1]?.body as FormData | undefined;
}
/** Один эмодзи нужного размера и формата. */
function emojiFile(name = 'kek.png', type = 'image/png', size?: number): File {
const file = new File(['картинка'], name, { type });
if (size !== undefined) {
Object.defineProperty(file, 'size', { value: size });
}
return file;
}
beforeEach(() => {
resetStores();
});
describe('настройки сервера: секция «Эмодзи»', () => {
it('показывает список, счётчик и пометку анимированного', async () => {
installFetch(routes());
const { dialog } = await openEmojisSection();
const list = await within(dialog).findByTestId('guild-emojis');
expect(within(list).getByText(':pepe:')).toBeVisible();
expect(within(list).getByText(':party_parrot:')).toBeVisible();
expect(list.querySelector('img[src="/files/900"]')).not.toBeNull();
expect(list.querySelector('img[alt=":pepe:"]')).not.toBeNull();
expect(within(list).getByText('анимированный')).toBeVisible();
// Размер показываем, когда сервер его прислал.
expect(within(list).getByText('2 KB')).toBeVisible();
expect(within(dialog).getByTestId('guild-emojis-counter')).toHaveTextContent('2 из 100');
});
it('скрыта без права MANAGE_EMOJIS', async () => {
installFetch(routes({ permissions: ['MANAGE_GUILD'] }));
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(await within(dialog).findByLabelText('Название сервера')).toBeVisible();
expect(within(dialog).queryByText('Эмодзи')).toBeNull();
expect(within(dialog).queryByTestId('guild-emojis-counter')).toBeNull();
});
it('загружает эмодзи multipart-запросом и перечитывает список', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'kek');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('kek.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
await waitFor(() => {
expect(uploadForm(fetchMock)).toBeDefined();
});
const form = uploadForm(fetchMock);
expect(form?.get('name')).toBe('kek');
expect((form?.get('file') as File | null)?.name).toBe('kek.png');
// Список перечитан: инвалидация после успешной загрузки.
await waitFor(() => {
expect(
recordedRequests(fetchMock).filter(
(request) => request.url.includes('/guilds/g-1/emojis') && request.method === 'GET',
).length,
).toBeGreaterThan(1);
});
});
it('не отправляет файл неподдерживаемого формата', async () => {
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
// Поле принимает только картинки, поэтому подкладываем файл напрямую —
// как это делает перетаскивание в область загрузки.
fireEvent.change(within(dialog).getByLabelText('Файл эмодзи'), {
target: { files: [emojiFile('kek.txt', 'text/plain')] },
});
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Поддерживаются PNG, JPEG, WebP и GIF.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('не отправляет файл больше 512 КБ', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('big.png', 'image/png', 512 * 1024 + 1),
);
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Файл больше допустимого размера.',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('проверяет имя по формату сервера', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(routes());
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'x');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('kek.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Имя эмодзи: 2–32 символа',
);
expect(uploadForm(fetchMock)).toBeUndefined();
});
it('показывает ошибки сервера понятным текстом', async () => {
const visitor = userEvent.setup();
installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis',
method: 'POST',
response: () => apiError('emoji.name_taken', 409),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'pepe');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('pepe.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Эмодзи с таким именем уже есть.',
);
});
it('показывает ошибку лимита эмодзи', async () => {
const visitor = userEvent.setup();
installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis',
method: 'POST',
response: () => apiError('emojis.limit_reached', 422),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'kek');
await visitor.upload(
within(dialog).getByLabelText('Файл эмодзи'),
emojiFile('kek.png', 'image/png'),
);
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'На сервере достигнут лимит эмодзи.',
);
});
it('переименовывает эмодзи через PATCH', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis/e-1',
method: 'PATCH',
response: () => json({ emoji: { ...pepe, name: 'pepe_happy' } }),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.click(
await within(dialog).findByRole('button', { name: 'Переименовать эмодзи :pepe:' }),
);
const modal = await screen.findByRole('dialog', { name: 'Переименовать эмодзи' });
const field = within(modal).getByLabelText('Имя эмодзи');
expect(field).toHaveValue('pepe');
await visitor.clear(field);
await visitor.type(field, 'pepe_happy');
await visitor.click(within(modal).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'PATCH' })?.body).toEqual({
name: 'pepe_happy',
});
});
});
it('удаляет эмодзи после подтверждения', async () => {
const visitor = userEvent.setup();
const fetchMock = installFetch(
routes({
extra: [
{
match: '/api/v1/guilds/g-1/emojis/e-1',
method: 'DELETE',
response: () => json({ ok: true }),
},
],
}),
);
const { dialog } = await openEmojisSection();
await visitor.click(
await within(dialog).findByRole('button', { name: 'Удалить эмодзи :pepe:' }),
);
const modal = await screen.findByRole('dialog', { name: 'Удалить эмодзи?' });
expect(modal).toHaveTextContent(':pepe:');
await visitor.click(within(modal).getByRole('button', { name: 'Удалить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'DELETE' })).toBeDefined();
});
});
});
+9
View File
@@ -203,6 +203,8 @@ interface SnapshotGuildInput {
icon_file_id?: string;
my_permissions?: string[];
my_nickname?: string;
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
emojis?: { id: string; name: string; file_id: string; animated?: boolean; token?: string }[];
channels?: {
id: string;
name: string;
@@ -260,6 +262,13 @@ export function makeReadySnapshot(
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: guild.my_permissions ?? [],
emojis: (guild.emojis ?? []).map((emoji) => ({
id: emoji.id,
name: emoji.name,
file_id: emoji.file_id,
animated: emoji.animated ?? false,
token: emoji.token ?? `<:${emoji.name}:${emoji.file_id}>`,
})),
})),
dm_channels: dmChannels.map((channel) => ({
id: channel.id,
+1
View File
@@ -137,6 +137,7 @@ describe('разметка: упоминания каналов', () => {
my_role_ids: [],
my_permissions: [],
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
emojis: [],
};
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
+222
View File
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
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';
/**
* Системные сообщения (AGENT.md §7.6): сервер пишет их с `type: "system"`,
* а в `content` — JSON о событии. Лента показывает человекочитаемую строку
* с именем участника, а неизвестное событие и мусорный JSON — как есть.
*/
const authors: AuthorDirectory = {
resolve: (userId) => ({
id: userId ?? '',
name: userId === 'user-2' ? 'Боб' : 'неизвестный',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: userId === 'user-2',
}),
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null),
loaded: true,
};
function systemMessage(content: string, id = 'm-1'): Message {
return makeMessage({
id,
channel_id: 'c-1',
content,
type: 'system',
});
}
function renderList(messages: Message[]) {
return render(
<MessageList
channelId="c-1"
channelName="общий"
messages={messages}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>,
);
}
beforeEach(() => {
resetStores();
});
describe('системные сообщения: разбор payload', () => {
it('читает события участника и комнаты', () => {
expect(parseSystemMessage('{"event":"member_join","user_id":"123"}')).toEqual({
kind: 'member_join',
userId: '123',
});
expect(parseSystemMessage('{"event":"member_kick","user_id":"123"}')).toEqual({
kind: 'member_kick',
userId: '123',
});
expect(parseSystemMessage('{"event":"channel_created","name":"общий","type":"text"}')).toEqual({
kind: 'channel_created',
name: 'общий',
channelType: 'text',
});
expect(systemMessageUserId('{"event":"member_kick","user_id":"123"}')).toBe('123');
expect(systemMessageUserId('{"event":"channel_created","name":"общий"}')).toBeNull();
});
it('не падает на мусоре и неизвестном событии', () => {
expect(parseSystemMessage('Аля вошла')).toBeNull();
expect(parseSystemMessage('{"event":"guild_boost"}')).toBeNull();
expect(parseSystemMessage('{"event":"member_join"}')).toBeNull();
expect(parseSystemMessage('"строка"')).toBeNull();
expect(parseSystemMessage('null')).toBeNull();
});
});
describe('системные сообщения: лента', () => {
it('member_join — «{имя} присоединился к серверу»', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-2"}')]);
const body = screen.getByTestId('message-m-1');
expect(body).toHaveTextContent('Боб присоединился к серверу');
// Сырой JSON в ленте не показываем.
expect(body.textContent).not.toContain('member_join');
});
it('member_kick — «{имя} покинул сервер (исключён)»', () => {
renderList([systemMessage('{"event":"member_kick","user_id":"user-2"}')]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent(
'Боб покинул сервер (исключён)',
);
});
it('channel_created — комната и голосовая комната', () => {
renderList([
systemMessage('{"event":"channel_created","name":"общий","type":"text"}', 'm-1'),
systemMessage('{"event":"channel_created","name":"Голосовая","type":"voice"}', 'm-2'),
]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Комната #общий создана');
expect(screen.getByTestId('message-m-2')).toHaveTextContent(
'Голосовая комната Голосовая создана',
);
});
it('неизвестное событие и невалидный JSON показываются как есть', () => {
renderList([
systemMessage('{"event":"guild_boost","level":2}', 'm-1'),
systemMessage('Аля вошла', 'm-2'),
]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('{"event":"guild_boost","level":2}');
expect(screen.getByTestId('message-m-2')).toHaveTextContent('Аля вошла');
});
it('неизвестный участник подписан «участник»', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-9"}')]);
expect(screen.getByTestId('message-m-1')).toHaveTextContent('участник присоединился к серверу');
});
it('остаётся центрированной строкой без аватара и действий', () => {
renderList([systemMessage('{"event":"member_join","user_id":"user-2"}')]);
const body = screen.getByTestId('message-m-1');
expect(body).toHaveClass('items-center', 'text-center');
expect(body).toHaveAttribute('data-message-type', 'system');
expect(within(body).queryByRole('button')).toBeNull();
expect(screen.getByTestId('message-group').querySelector('.inline-flex.shrink-0')).toBeNull();
});
});
describe('системные сообщения: имя через справочник авторов', () => {
it('добирает имя участника по GET /users/{id}', async () => {
const user = makeUser();
const snapshot = {
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
roles: [],
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: ['SEND_MESSAGES'],
emojis: [],
},
],
dm_channels: [],
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,
};
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
// Участника нет в списке сервера — имя приходит из GET /users/{id}.
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{
match: '/api/v1/users/user-9',
response: () =>
json({
user: {
id: 'user-9',
username: 'carol',
display_name: 'Кэрол',
is_instance_admin: false,
badges: [],
},
}),
},
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
...messagesRoutes('c-1', [
makeMessage({
id: 'm-1',
channel_id: 'c-1',
type: 'system',
content: '{"event":"member_join","user_id":"user-9"}',
}),
]),
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await screen.findByTestId('guild-rail');
await gateway.greet();
await waitFor(() => {
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Кэрол присоединился к серверу');
});
});
});