fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS, dry-run проходит.
This commit is contained in:
+9
-8
@@ -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 \
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
}
|
||||
@@ -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']),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface AutocompleteItem {
|
||||
/** Имя для аватара-заглушки (есть только у участников). */
|
||||
avatarName?: string | undefined;
|
||||
avatarFileId?: string | undefined;
|
||||
/** Файл картинки кастомного эмодзи: подсказка рисует его вместо символа. */
|
||||
emojiFileId?: string | undefined;
|
||||
}
|
||||
|
||||
/** Команда чата: сервер разбирает её сам (AGENT.md §7.6). */
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('разметка: упоминания каналов', () => {
|
||||
my_role_ids: [],
|
||||
my_permissions: [],
|
||||
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
|
||||
emojis: [],
|
||||
};
|
||||
|
||||
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
|
||||
|
||||
@@ -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('Кэрол присоединился к серверу');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user