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

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