diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx
index 9c30b57..a95f8c2 100644
--- a/web/src/components/chat/MessageItem.tsx
+++ b/web/src/components/chat/MessageItem.tsx
@@ -1,8 +1,14 @@
-import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
+import {
+ useEffect,
+ useRef,
+ useState,
+ type MouseEvent as ReactMouseEvent,
+ type ReactNode,
+} from 'react';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
-import { MESSAGE_MAX_LENGTH, type Message } from '@/api/messages';
+import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
@@ -40,6 +46,100 @@ const actionButtonClass =
*/
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
+/** Пометка вида сообщения: «шёпот», «громко», «лично». */
+function TypeBadge({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Текст сообщения по виду из контракта: обычный, действие `/me`, шёпот, крик
+ * и личное сообщение оформляются по-разному (AGENT.md §7.6). Системные
+ * сообщения обрабатываются отдельной веткой — у них нет ни шапки автора, ни
+ * панели действий.
+ */
+function MessageBody({
+ type,
+ message,
+ authorName,
+ resolveMention,
+}: {
+ type: MessageType;
+ message: Message;
+ authorName: string;
+ resolveMention: AuthorDirectory['resolveMention'];
+}) {
+ const { t } = useTranslation();
+
+ if (type === 'action') {
+ // Действие: «Имя танцует» — имя акцентом, текст курсивом.
+ return (
+
+ {authorName}
+
+
+ );
+ }
+ if (type === 'whisper') {
+ return (
+
+ {t('chat.types.whisper')}
+
+
+ );
+ }
+ if (type === 'scream') {
+ return (
+
+ {t('chat.types.scream')}
+
+
+ );
+ }
+ if (type === 'private') {
+ // Личное сообщение: сервер отдаёт его только автору и адресату.
+ return (
+
+
+ 🔒
+
+ {t('chat.types.private')}
+
+
+ );
+ }
+ return (
+
+ );
+}
+
/**
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
*
@@ -78,6 +178,7 @@ export function MessageItem({
const canEdit = isMine && canSend && !pending;
const canDelete = !pending && (isMine || canManageMessages);
const edited = message.edited_at !== undefined;
+ const type = messageTypeOf(message.type);
useEffect(() => {
if (editing) {
@@ -166,10 +267,38 @@ export function MessageItem({
onReply(message);
};
+ // Системное сообщение: центрированная приглушённая строка без шапки автора,
+ // вложений и панели действий — реагировать и отвечать на неё нечего.
+ if (type === 'system') {
+ return (
+
+ {t('chat.types.system')}
+ {message.content === '' ? null : (
+
+ )}
+
+
+ );
+ }
+
return (
{message.content === '' ? null : (
-
)}
{message.attachments.length === 0 ? null : (
@@ -310,6 +440,11 @@ export function MessageItem({
>
{formatMessageTime(message.created_at, i18n.language)}
+ ) : type === 'action' ? (
+ // У действия нет шапки автора — время показываем всегда.
+
) : null}
{edited ? {t('chat.edited')} : null}
{pending ? {t('chat.pending')} : null}
diff --git a/web/src/components/chat/MessageList.tsx b/web/src/components/chat/MessageList.tsx
index d0bd7a2..76d0b79 100644
--- a/web/src/components/chat/MessageList.tsx
+++ b/web/src/components/chat/MessageList.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { isPendingMessage, type Message } from '@/api/messages';
+import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
import { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
@@ -215,77 +215,94 @@ export function MessageList({
if (first === undefined) {
return null;
}
+ const headType = messageTypeOf(first.type);
const author = authors.resolve(row.group.authorId ?? undefined);
+ // У системных сообщений нет ни аватара, ни шапки автора, а действие
+ // `/me` рисует имя внутри строки — шапка ему тоже не нужна.
+ const showHeader = headType !== 'system' && headType !== 'action';
+ const body = (
+
+ {row.group.messages.map((message, index) => (
+ 0}
+ {...(isPendingMessage(message) ? { pending: true } : {})}
+ replyTarget={
+ message.reply_to_id === undefined
+ ? undefined
+ : messageById.get(message.reply_to_id)
+ }
+ highlight={message.id === highlightId}
+ onReply={onReply}
+ onJumpToReply={(messageId) => {
+ const container = containerRef.current;
+ const target = container?.querySelector(
+ `[data-message-id="${escapeSelector(messageId)}"]`,
+ );
+ if (target instanceof HTMLElement) {
+ target.scrollIntoView?.({ block: 'center' });
+ }
+ }}
+ />
+ ))}
+
+ );
return (
-
-
-
-
-
- {author.name}
-
- {author.isInstanceAdmin ? (
-
- {t('chat.adminBadge')}
-
- ) : null}
-
-
-
- {row.group.messages.map((message, index) => (
-
0}
- {...(isPendingMessage(message) ? { pending: true } : {})}
- replyTarget={
- message.reply_to_id === undefined
- ? undefined
- : messageById.get(message.reply_to_id)
- }
- highlight={message.id === highlightId}
- onReply={onReply}
- onJumpToReply={(messageId) => {
- const container = containerRef.current;
- const target = container?.querySelector(
- `[data-message-id="${escapeSelector(messageId)}"]`,
- );
- if (target instanceof HTMLElement) {
- target.scrollIntoView?.({ block: 'center' });
+ {headType === 'system' ? (
+ // Системная строка: по центру, без колонки аватара.
+ {body}
+ ) : (
+
+
+
+ {showHeader ? (
+
+
- ))}
+ >
+ {author.name}
+
+ {author.isInstanceAdmin ? (
+
+ {t('chat.adminBadge')}
+
+ ) : null}
+
+
+ ) : null}
+ {body}
-
+ )}
);
})}
diff --git a/web/src/components/chat/useChannelDirectory.ts b/web/src/components/chat/useChannelDirectory.ts
new file mode 100644
index 0000000..14c8689
--- /dev/null
+++ b/web/src/components/chat/useChannelDirectory.ts
@@ -0,0 +1,31 @@
+import { useMemo } from 'react';
+
+import { useSessionStore } from '@/stores/session';
+
+/** Комнаты снапшота в двух видах: по id и по имени. */
+export interface ChannelDirectory {
+ /** `id → имя`: для ссылок на комнату `<#id>`. */
+ byId: ReadonlyMap
;
+ /** Имена в нижнем регистре: `#имя` подсвечивается только для известных комнат. */
+ names: ReadonlySet;
+}
+
+/**
+ * Справочник комнат из снапшота: общий хук для ленты сообщений и зеркала
+ * композера. Один разбор — оба вида ссылок (`#имя` и `<#id>`) оформляются
+ * одинаково в предпросмотре и в ленте.
+ */
+export function useChannelDirectory(): ChannelDirectory {
+ const guilds = useSessionStore((state) => state.guilds);
+ return useMemo(() => {
+ const byId = new Map();
+ const names = new Set();
+ for (const guild of guilds) {
+ for (const channel of guild.channels) {
+ byId.set(channel.id, channel.name);
+ names.add(channel.name.toLowerCase());
+ }
+ }
+ return { byId, names };
+ }, [guilds]);
+}
diff --git a/web/src/components/chat/useChannelNames.ts b/web/src/components/chat/useChannelNames.ts
deleted file mode 100644
index b6d4c0a..0000000
--- a/web/src/components/chat/useChannelNames.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useMemo } from 'react';
-
-import { useSessionStore } from '@/stores/session';
-
-/**
- * Имена каналов из снапшота: `#канал` подсвечивается только для известных
- * комнат. Общий хук для ленты сообщений и зеркала композера.
- */
-export function useChannelNames(): ReadonlySet {
- const guilds = useSessionStore((state) => state.guilds);
- return useMemo(() => {
- const names = new Set();
- for (const guild of guilds) {
- for (const channel of guild.channels) {
- names.add(channel.name.toLowerCase());
- }
- }
- return names;
- }, [guilds]);
-}
diff --git a/web/src/components/chat/useComposerAutocomplete.ts b/web/src/components/chat/useComposerAutocomplete.ts
new file mode 100644
index 0000000..eee04a3
--- /dev/null
+++ b/web/src/components/chat/useComposerAutocomplete.ts
@@ -0,0 +1,200 @@
+import { useCallback, useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+
+import { fetchMembers, guildMembersQueryKey } from '@/api/guilds';
+import type { Channel, GuildMember, UserSummary } from '@/api/types';
+import { fetchUser, userQueryKey } from '@/api/users';
+import {
+ detectAutocomplete,
+ CHAT_COMMANDS,
+ type AutocompleteKind,
+ type AutocompleteItem,
+} from '@/lib/autocomplete';
+import { memberDisplayName } from '@/lib/chat';
+import { searchEmoji } from '@/lib/emoji';
+import { useSessionStore } from '@/stores/session';
+
+/** Сколько подсказок показываем: список должен помещаться над полем ввода. */
+const MAX_SUGGESTIONS = 8;
+
+/** Ключи перевода заголовка списка — по виду подсказки. */
+const LABEL_KEYS: Readonly> = {
+ mention: 'chat.composer.autocomplete.mentions',
+ channel: 'chat.composer.autocomplete.channels',
+ emoji: 'chat.composer.autocomplete.emoji',
+ command: 'chat.composer.autocomplete.commands',
+};
+
+/** Открытая подсказка: контекст триггера и готовые варианты. */
+export interface ComposerAutocompleteState {
+ kind: AutocompleteKind;
+ /** Смещение символа-триггера: с него начинается заменяемый фрагмент. */
+ start: number;
+ /** Позиция каретки: конец заменяемого фрагмента. */
+ end: number;
+ /** Ключ контекста: по нему композер отличает один триггер от другого. */
+ key: string;
+ /** Ключ перевода заголовка списка. */
+ labelKey: string;
+ items: AutocompleteItem[];
+}
+
+/** Участник, которого можно упомянуть. */
+interface MentionCandidate {
+ id: string;
+ name: string;
+ username: string;
+ avatarFileId: string | undefined;
+}
+
+function membersToCandidates(members: GuildMember[] | undefined): MentionCandidate[] {
+ return (members ?? []).map((member) => ({
+ id: member.user_id,
+ name: memberDisplayName(member) ?? member.username,
+ username: member.username,
+ avatarFileId: member.avatar_file_id,
+ }));
+}
+
+function recipientToCandidate(recipient: UserSummary | undefined): MentionCandidate[] {
+ if (recipient === undefined) {
+ return [];
+ }
+ return [
+ {
+ id: recipient.id,
+ name: recipient.display_name !== '' ? recipient.display_name : recipient.username,
+ username: recipient.username,
+ avatarFileId: recipient.avatar_file_id,
+ },
+ ];
+}
+
+interface ComposerAutocompleteOptions {
+ value: string;
+ /** Позиция каретки в поле ввода. */
+ caret: number;
+ channel: Channel;
+ /** Сервер комнаты: из него берём участников и видимые текстовые комнаты. */
+ guildId: string | null;
+}
+
+/**
+ * Подсказки композера: `@` — участники сервера (в личной беседе — собеседник),
+ * `#` — видимые текстовые комнаты, `:` — эмодзи по коду, `/` — команды чата.
+ *
+ * Контекст триггера ищет `detectAutocomplete` (без DOM), здесь только данные и
+ * фильтр по префиксу. Участники читаются через тот же ключ запроса, что и
+ * справочник авторов, поэтому лишнего обращения к серверу не происходит.
+ *
+ * Хук не хранит состояние: закрытие по Escape — забота композера, а контекст
+ * триггера целиком выводится из текста и позиции каретки.
+ */
+export function useComposerAutocomplete(
+ options: ComposerAutocompleteOptions,
+): ComposerAutocompleteState | null {
+ const { value, caret, channel, guildId } = options;
+ const { t } = useTranslation();
+
+ const members = useQuery({
+ queryKey: guildMembersQueryKey(guildId ?? ''),
+ queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
+ enabled: guildId !== null,
+ staleTime: 60_000,
+ retry: 0,
+ });
+
+ const recipientId = channel.type === 'dm' ? channel.recipient_id : undefined;
+ const recipient = useQuery({
+ queryKey: userQueryKey(recipientId ?? ''),
+ queryFn: ({ signal }) => fetchUser(recipientId ?? '', signal),
+ enabled: recipientId !== undefined && recipientId !== '',
+ staleTime: 300_000,
+ retry: 0,
+ });
+
+ const guild = useSessionStore(
+ useCallback(
+ (state) =>
+ guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
+ [guildId],
+ ),
+ );
+
+ const trigger = useMemo(() => detectAutocomplete(value, caret), [value, caret]);
+
+ return useMemo(() => {
+ if (trigger === null) {
+ return null;
+ }
+ const query = trigger.query.toLowerCase();
+ let items: AutocompleteItem[];
+
+ if (trigger.kind === 'command') {
+ items = CHAT_COMMANDS.filter((command) =>
+ command.command.slice(1).toLowerCase().startsWith(query),
+ )
+ .slice(0, MAX_SUGGESTIONS)
+ .map((command) => ({
+ id: `command:${command.command}`,
+ kind: 'command',
+ insert: `${command.command} `,
+ label: command.command,
+ hint: t(command.descriptionKey),
+ }));
+ } else if (trigger.kind === 'mention') {
+ const candidates =
+ guildId === null ? recipientToCandidate(recipient.data) : membersToCandidates(members.data);
+ items = candidates
+ .filter(
+ (candidate) =>
+ candidate.username.toLowerCase().startsWith(query) ||
+ candidate.name.toLowerCase().startsWith(query),
+ )
+ .slice(0, MAX_SUGGESTIONS)
+ .map((candidate) => ({
+ id: `mention:${candidate.id}`,
+ kind: 'mention',
+ insert: `<@${candidate.id}>`,
+ label: candidate.name,
+ hint: `@${candidate.username}`,
+ avatarName: candidate.name,
+ avatarFileId: candidate.avatarFileId,
+ }));
+ } else if (trigger.kind === 'channel') {
+ items = (guild?.channels ?? [])
+ .filter((item) => item.type === 'text' && item.can_view !== false)
+ .slice()
+ .sort((left, right) => left.position - right.position)
+ .filter((item) => item.name.toLowerCase().startsWith(query))
+ .slice(0, MAX_SUGGESTIONS)
+ .map((item) => ({
+ id: `channel:${item.id}`,
+ kind: 'channel',
+ insert: `<#${item.id}>`,
+ 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}:`,
+ }));
+ }
+
+ if (items.length === 0) {
+ return null;
+ }
+ return {
+ kind: trigger.kind,
+ start: trigger.start,
+ end: trigger.end,
+ key: `${trigger.kind}:${String(trigger.start)}`,
+ labelKey: LABEL_KEYS[trigger.kind],
+ items,
+ };
+ }, [trigger, guildId, guild, members.data, recipient.data, t]);
+}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 413e139..e868cff 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -304,6 +304,12 @@
"pending": "sending…",
"newMessages": "New messages",
"adminBadge": "admin",
+ "types": {
+ "whisper": "whisper",
+ "scream": "loud",
+ "private": "private",
+ "system": "System message"
+ },
"unknownAuthor": "unknown",
"start": {
"title": "This is the start of #{{name}}",
@@ -365,7 +371,20 @@
"denied": "You are not allowed to send messages in this channel.",
"readOnly": "This channel is read-only for you.",
"directLabel": "Message to {{name}}",
- "directPlaceholder": "Message {{name}}"
+ "directPlaceholder": "Message {{name}}",
+ "autocomplete": {
+ "hint": "↑/↓ — select, Enter — insert, Escape — close",
+ "mentions": "Server members",
+ "channels": "Server channels",
+ "emoji": "Emoji",
+ "commands": "Commands"
+ },
+ "commands": {
+ "me": "action: name and italic text",
+ "whisper": "whisper: muted message",
+ "scream": "loud: large accented text",
+ "ls": "direct message: /ls username text"
+ }
},
"typing_one": "{{names}} is typing…",
"typing_few": "{{names}} are typing…",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index eaa42cb..ae5dbd7 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -304,6 +304,12 @@
"pending": "отправляется…",
"newMessages": "Новые сообщения",
"adminBadge": "админ",
+ "types": {
+ "whisper": "шёпот",
+ "scream": "громко",
+ "private": "лично",
+ "system": "Системное сообщение"
+ },
"unknownAuthor": "неизвестный",
"start": {
"title": "Это начало комнаты #{{name}}",
@@ -365,7 +371,20 @@
"denied": "Нет права отправлять сообщения в эту комнату.",
"readOnly": "В эту комнату нельзя писать.",
"directLabel": "Сообщение для {{name}}",
- "directPlaceholder": "Написать {{name}}"
+ "directPlaceholder": "Написать {{name}}",
+ "autocomplete": {
+ "hint": "↑/↓ — выбор, Enter — вставить, Escape — закрыть",
+ "mentions": "Участники сервера",
+ "channels": "Комнаты сервера",
+ "emoji": "Эмодзи",
+ "commands": "Команды"
+ },
+ "commands": {
+ "me": "действие: имя и текст курсивом",
+ "whisper": "шёпот: приглушённое сообщение",
+ "scream": "громко: крупный акцентный текст",
+ "ls": "личное сообщение: /ls логин текст"
+ }
},
"typing_one": "{{names}} печатает…",
"typing_few": "{{names}} печатают…",
diff --git a/web/src/lib/autocomplete.ts b/web/src/lib/autocomplete.ts
new file mode 100644
index 0000000..080848b
--- /dev/null
+++ b/web/src/lib/autocomplete.ts
@@ -0,0 +1,138 @@
+/**
+ * Автокомплит композера: поиск контекста триггера (`@`, `#`, `:`, `/`) и
+ * список команд чата. Разбор живёт отдельно от компонентов: правило «где
+ * вообще уместна подсказка» тестируется без DOM, а `Composer` только рисует
+ * список и вставляет выбранное.
+ *
+ * Контекст обязателен: `@` в середине слова (`почта@пример`) и любой триггер
+ * внутри `` `кода` `` или блока ``` — обычный текст, а `/` — команда только в
+ * самом начале сообщения.
+ */
+
+/** Вид подсказки. */
+export type AutocompleteKind = 'mention' | 'channel' | 'emoji' | 'command';
+
+/** Найденный контекст подсказки. */
+export interface AutocompleteTrigger {
+ kind: AutocompleteKind;
+ /** Смещение символа-триггера в тексте. */
+ start: number;
+ /** Позиция каретки: конец заменяемого запроса. */
+ end: number;
+ /** Текст между триггером и кареткой (`bo` в `@bo`). */
+ query: string;
+}
+
+/** Вариант подсказки: то, что видит пользователь, и то, что вставляется. */
+export interface AutocompleteItem {
+ /** Стабильный ключ варианта. */
+ id: string;
+ kind: AutocompleteKind;
+ /** Текст, который вставляется вместо запроса вместе с триггером. */
+ insert: string;
+ /** Основная подпись. */
+ label: string;
+ /** Дополнительная подпись: логин, код эмодзи или описание команды. */
+ hint?: string | undefined;
+ /** Имя для аватара-заглушки (есть только у участников). */
+ avatarName?: string | undefined;
+ avatarFileId?: string | undefined;
+}
+
+/** Команда чата: сервер разбирает её сам (AGENT.md §7.6). */
+export interface ChatCommand {
+ /** Команда со слэшем — ровно то, что вставляется в поле. */
+ command: string;
+ /** Ключ перевода описания. */
+ descriptionKey: string;
+}
+
+/** Команды, которые понимает сервер; порядок — как в подсказке. */
+export const CHAT_COMMANDS: readonly ChatCommand[] = [
+ { command: '/me', descriptionKey: 'chat.composer.commands.me' },
+ { command: '/whisper', descriptionKey: 'chat.composer.commands.whisper' },
+ { command: '/scream', descriptionKey: 'chat.composer.commands.scream' },
+ { command: '/ls', descriptionKey: 'chat.composer.commands.ls' },
+];
+
+/** Триггеры подсказок: символ — вид. */
+const TRIGGERS: Readonly> = {
+ '@': 'mention',
+ '#': 'channel',
+ ':': 'emoji',
+};
+
+/** Что допустимо в запросе после триггера (пробел закрывает подсказку). */
+const QUERY_CHAR = /[\p{L}\p{N}_.+-]/u;
+
+/** Строка-ограждение блока кода. */
+function isFence(line: string): boolean {
+ return line.trim().startsWith('```');
+}
+
+/**
+ * Каретка внутри кода: строка блока кода, незакрытое ограждение или
+ * непарная `` ` `` в текущей строке. Внутри кода подсказки не показываем —
+ * там `:`, `@` и `#` обычный текст.
+ */
+export function insideCode(value: string, caret: number): boolean {
+ const lines = value.slice(0, caret).split('\n');
+ const current = lines[lines.length - 1] ?? '';
+ let fenced = false;
+ for (const line of lines.slice(0, -1)) {
+ if (isFence(line)) {
+ fenced = !fenced;
+ }
+ }
+ if (fenced || isFence(current)) {
+ return true;
+ }
+ const ticks = (current.match(/`/gu) ?? []).length;
+ return ticks % 2 === 1;
+}
+
+/**
+ * Контекст подсказки по тексту и позиции каретки. `null` — подсказку
+ * показывать не нужно (нет триггера, он внутри слова или внутри кода).
+ */
+export function detectAutocomplete(value: string, caret: number): AutocompleteTrigger | null {
+ if (caret < 0 || caret > value.length) {
+ return null;
+ }
+ if (insideCode(value, caret)) {
+ return null;
+ }
+ const before = value.slice(0, caret);
+
+ // Команда: `/` только первым символом сообщения. `//` экранирует слэш —
+ // это обычный текст, сервер команду не разбирает.
+ if (before.startsWith('/')) {
+ if (before.startsWith('//')) {
+ return null;
+ }
+ const query = before.slice(1);
+ return /^[\p{L}\p{N}_-]*$/u.test(query)
+ ? { kind: 'command', start: 0, end: caret, query }
+ : null;
+ }
+
+ // Остальные триггеры: отступаем от каретки по символам запроса и смотрим,
+ // что стоит перед ними.
+ let index = caret;
+ while (index > 0 && QUERY_CHAR.test(value[index - 1] ?? '')) {
+ index -= 1;
+ }
+ const triggerIndex = index - 1;
+ if (triggerIndex < 0) {
+ return null;
+ }
+ const kind = TRIGGERS[value[triggerIndex] ?? ''];
+ if (kind === undefined) {
+ return null;
+ }
+ const previous = triggerIndex === 0 ? '' : (value[triggerIndex - 1] ?? '');
+ if (previous !== '' && !/\s/u.test(previous)) {
+ return null;
+ }
+ return { kind, start: triggerIndex, end: caret, query: value.slice(index, caret) };
+}
diff --git a/web/src/lib/chat.ts b/web/src/lib/chat.ts
index 42e1527..4ab41c3 100644
--- a/web/src/lib/chat.ts
+++ b/web/src/lib/chat.ts
@@ -130,6 +130,11 @@ export function canGroup(previous: MessageLike, next: MessageLike): boolean {
if (previous.author_id === undefined || previous.author_id !== next.author_id) {
return false;
}
+ // Действия, шёпот, крик, личные и системные сообщения оформляются отдельно:
+ // в общую группу они не попадают ни как следующие, ни как предыдущие.
+ if ((previous.type ?? 'default') !== 'default') {
+ return false;
+ }
if (next.reply_to_id !== undefined) {
return false;
}
diff --git a/web/src/lib/emoji.ts b/web/src/lib/emoji.ts
new file mode 100644
index 0000000..21eb74d
--- /dev/null
+++ b/web/src/lib/emoji.ts
@@ -0,0 +1,155 @@
+/**
+ * Словарь эмодзи для автокомплита в композере: короткий код (`:fire:`) и сам
+ * символ. Отдельный модуль, а не внешняя зависимость: инстанс автономен, а
+ * набор кодов должен совпадать с тем, что видит пользователь в подсказке.
+ *
+ * Коды намеренно «классические» (как в Slack/GitHub), чтобы привычные
+ * сокращения работали и в нашем клиенте.
+ */
+
+/** Эмодзи с коротким кодом. */
+export interface EmojiEntry {
+ /** Короткий код без двоеточий (`smile`, `thumbsup`). */
+ code: string;
+ /** Сам символ, который вставляется в сообщение. */
+ emoji: string;
+}
+
+/** Популярные эмодзи: словарь подсказок `:код:`. */
+export const EMOJI: readonly EmojiEntry[] = [
+ { code: 'smile', emoji: '😄' },
+ { code: 'grin', emoji: '😁' },
+ { code: 'joy', emoji: '😂' },
+ { code: 'rofl', emoji: '🤣' },
+ { code: 'wink', emoji: '😉' },
+ { code: 'blush', emoji: '😊' },
+ { code: 'heart_eyes', emoji: '😍' },
+ { code: 'thinking', emoji: '🤔' },
+ { code: 'neutral', emoji: '😐' },
+ { code: 'cry', emoji: '😢' },
+ { code: 'sob', emoji: '😭' },
+ { code: 'angry', emoji: '😠' },
+ { code: 'rage', emoji: '😡' },
+ { code: 'scream', emoji: '😱' },
+ { code: 'sunglasses', emoji: '😎' },
+ { code: 'sleeping', emoji: '😴' },
+ { code: 'sick', emoji: '🤒' },
+ { code: 'ghost', emoji: '👻' },
+ { code: 'robot', emoji: '🤖' },
+ { code: 'clown', emoji: '🤡' },
+ { code: 'skull', emoji: '💀' },
+ { code: 'poop', emoji: '💩' },
+ { code: 'heart', emoji: '❤️' },
+ { code: 'broken_heart', emoji: '💔' },
+ { code: 'sparkles', emoji: '✨' },
+ { code: 'star', emoji: '⭐' },
+ { code: 'star2', emoji: '🌟' },
+ { code: 'fire', emoji: '🔥' },
+ { code: 'zap', emoji: '⚡' },
+ { code: 'boom', emoji: '💥' },
+ { code: 'rainbow', emoji: '🌈' },
+ { code: 'sun', emoji: '☀️' },
+ { code: 'moon', emoji: '🌙' },
+ { code: 'cloud', emoji: '☁️' },
+ { code: 'snowflake', emoji: '❄️' },
+ { code: 'droplet', emoji: '💧' },
+ { code: 'ocean', emoji: '🌊' },
+ { code: 'earth', emoji: '🌍' },
+ { code: 'rocket', emoji: '🚀' },
+ { code: 'airplane', emoji: '✈️' },
+ { code: 'car', emoji: '🚗' },
+ { code: 'bulb', emoji: '💡' },
+ { code: 'warning', emoji: '⚠️' },
+ { code: 'check', emoji: '✅' },
+ { code: 'x', emoji: '❌' },
+ { code: 'question', emoji: '❓' },
+ { code: 'exclamation', emoji: '❗' },
+ { code: '100', emoji: '💯' },
+ { code: 'ok', emoji: '👌' },
+ { code: 'thumbsup', emoji: '👍' },
+ { code: 'thumbsdown', emoji: '👎' },
+ { code: 'clap', emoji: '👏' },
+ { code: 'pray', emoji: '🙏' },
+ { code: 'muscle', emoji: '💪' },
+ { code: 'wave', emoji: '👋' },
+ { code: 'point_up', emoji: '☝️' },
+ { code: 'point_right', emoji: '👉' },
+ { code: 'eyes', emoji: '👀' },
+ { code: 'handshake', emoji: '🤝' },
+ { code: 'salute', emoji: '🫡' },
+ { code: 'brain', emoji: '🧠' },
+ { code: 'coffee', emoji: '☕' },
+ { code: 'beer', emoji: '🍺' },
+ { code: 'pizza', emoji: '🍕' },
+ { code: 'cake', emoji: '🍰' },
+ { code: 'gift', emoji: '🎁' },
+ { code: 'tada', emoji: '🎉' },
+ { code: 'party', emoji: '🥳' },
+ { code: 'balloon', emoji: '🎈' },
+ { code: 'soccer', emoji: '⚽' },
+ { code: 'basketball', emoji: '🏀' },
+ { code: 'game', emoji: '🎮' },
+ { code: 'trophy', emoji: '🏆' },
+ { code: 'medal', emoji: '🏅' },
+ { code: 'dice', emoji: '🎲' },
+ { code: 'book', emoji: '📖' },
+ { code: 'memo', emoji: '📝' },
+ { code: 'pushpin', emoji: '📌' },
+ { code: 'paperclip', emoji: '📎' },
+ { code: 'bell', emoji: '🔔' },
+ { code: 'hourglass', emoji: '⏳' },
+ { code: 'alarm', emoji: '⏰' },
+ { code: 'lock', emoji: '🔒' },
+ { code: 'key', emoji: '🔑' },
+ { code: 'mag', emoji: '🔍' },
+ { code: 'hammer', emoji: '🔨' },
+ { code: 'wrench', emoji: '🔧' },
+ { code: 'bug', emoji: '🐛' },
+ { code: 'cat', emoji: '🐱' },
+ { code: 'dog', emoji: '🐶' },
+ { code: 'fox', emoji: '🦊' },
+ { code: 'unicorn', emoji: '🦄' },
+ { code: 'panda', emoji: '🐼' },
+ { code: 'sunflower', emoji: '🌻' },
+ { code: 'rose', emoji: '🌹' },
+ { code: 'tree', emoji: '🌳' },
+ { code: 'apple', emoji: '🍎' },
+ { code: 'watermelon', emoji: '🍉' },
+ { code: 'burger', emoji: '🍔' },
+ { code: 'fries', emoji: '🍟' },
+ { code: 'sushi', emoji: '🍣' },
+ { code: 'icecream', emoji: '🍦' },
+ { code: 'popcorn', emoji: '🍿' },
+ { code: 'music', emoji: '🎵' },
+ { code: 'guitar', emoji: '🎸' },
+ { code: 'mic', emoji: '🎤' },
+ { code: 'camera', emoji: '📷' },
+ { code: 'movie', emoji: '🎬' },
+ { code: 'computer', emoji: '💻' },
+ { code: 'phone', emoji: '📱' },
+ { code: 'battery', emoji: '🔋' },
+ { code: 'gear', emoji: '⚙️' },
+ { code: 'shield', emoji: '🛡️' },
+ { code: 'crown', emoji: '👑' },
+ { code: 'gem', emoji: '💎' },
+ { code: 'money', emoji: '💰' },
+ { code: 'chart', emoji: '📈' },
+];
+
+/**
+ * Эмодзи по префиксу кода: `fir` → 🔥. Регистр не важен, порядок словаря
+ * сохраняется — популярные эмодзи стоят в начале и попадают в список первыми.
+ */
+export function searchEmoji(query: string, limit: number): EmojiEntry[] {
+ const prefix = query.toLowerCase();
+ const found: EmojiEntry[] = [];
+ for (const entry of EMOJI) {
+ if (entry.code.startsWith(prefix)) {
+ found.push(entry);
+ if (found.length >= limit) {
+ break;
+ }
+ }
+ }
+ return found;
+}
diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts
index 4e45f6e..13642c8 100644
--- a/web/src/lib/markdown.ts
+++ b/web/src/lib/markdown.ts
@@ -16,7 +16,8 @@
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
- * `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
+ * `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
+ * `<#id>` и `#канал`.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
@@ -32,6 +33,7 @@ export type InlineKind =
| 'spoiler'
| 'mark'
| 'mention'
+ | 'channel-link'
| 'link'
| 'channel';
@@ -53,12 +55,15 @@ export interface InlineToken {
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
- * ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
- * (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
- * слова (`foo_bar_baz`).
+ * ссылкой, а `<#id>` — раньше `#канала`, иначе угловые скобки остались бы
+ * текстом. Идентификатор в `<@id>`/`<#id>` — снежинка; допускаем и другую
+ * запись без пробелов и угловых скобок, чтобы ссылка не разваливалась на
+ * «текст со скобками». Одиночная `*` не считается маркером внутри ряда
+ * звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
+ * `_` — внутри слова (`foo_bar_baz`).
*/
const INLINE_PATTERN =
- /(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
+ /(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?]+>)|(<#[^\s<>]+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
const GROUP_KINDS: readonly InlineKind[] = [
@@ -72,6 +77,7 @@ const GROUP_KINDS: readonly InlineKind[] = [
'italic',
'mark',
'mention',
+ 'channel-link',
'link',
'channel',
];
@@ -85,8 +91,10 @@ const MARKER_LENGTH: Readonly> = {
strike: 2,
spoiler: 2,
mark: 2,
- // У упоминаний, ссылок и каналов маркеров нет: содержимое — весь токен.
+ // У упоминаний, ссылок на комнаты, ссылок и каналов маркеров нет:
+ // содержимое — весь токен.
mention: 0,
+ 'channel-link': 0,
link: 0,
channel: 0,
};
@@ -107,8 +115,8 @@ function contentBounds(
raw: string,
start: number,
): { contentStart: number; contentEnd: number } {
- if (kind === 'mention') {
- // `<@123>`: содержимое — идентификатор без `<@` и `>`.
+ if (kind === 'mention' || kind === 'channel-link') {
+ // `<@123>` и `<#123>`: содержимое — идентификатор без `<@`/`<#` и `>`.
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
}
const marker = MARKER_LENGTH[kind];
@@ -141,11 +149,11 @@ export function parseInline(text: string, offset = 0): InlineToken[] {
}
/**
- * Есть ли у токена парные маркеры. У упоминаний, ссылок и каналов их нет:
- * содержимое — весь токен, прятать нечего.
+ * Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
+ * каналов их нет: содержимое — весь токен, прятать нечего.
*/
export function hasMarkers(kind: InlineKind): boolean {
- return kind !== 'mention' && kind !== 'link' && kind !== 'channel';
+ return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
}
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
diff --git a/web/tests/autocomplete.test.tsx b/web/tests/autocomplete.test.tsx
new file mode 100644
index 0000000..ef8dde1
--- /dev/null
+++ b/web/tests/autocomplete.test.tsx
@@ -0,0 +1,492 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClientProvider } from '@tanstack/react-query';
+
+import type { Channel, GuildMember, UserSummary } from '@/api/types';
+import { Composer } from '@/components/chat/Composer';
+import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
+import type { SessionGuild } from '@/stores/session';
+import { useSessionStore } from '@/stores/session';
+import {
+ createTestQueryClient,
+ findRequest,
+ installFetch,
+ installGatewaySocket,
+ json,
+ makeMessage,
+ makeReadySnapshot,
+ makeUser,
+ messagesRoutes,
+ renderApp,
+ resetStores,
+ type FetchRoute,
+} from './helpers';
+
+/**
+ * Автокомплиты композера: `@` — участники, `#` — комнаты, `:` — эмодзи,
+ * `/` — команды. Проверяем появление по триггеру, фильтр, вставку, навигацию
+ * с клавиатуры и правила контекста (слово, код, начало сообщения).
+ */
+
+const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
+const dmChannel: Channel = {
+ id: 'dm-1',
+ name: 'Боб',
+ type: 'dm',
+ position: 0,
+ recipient_id: '102',
+};
+
+const members: GuildMember[] = [
+ {
+ user_id: '101',
+ username: 'alice',
+ display_name: 'Аля',
+ status: 'online',
+ is_instance_admin: false,
+ joined_at: '2026-09-01T10:00:00Z',
+ role_ids: [],
+ },
+ {
+ user_id: '102',
+ username: 'bob',
+ display_name: 'Боб',
+ status: 'online',
+ is_instance_admin: false,
+ joined_at: '2026-09-02T10:00:00Z',
+ role_ids: [],
+ },
+ {
+ user_id: '103',
+ username: 'carol',
+ display_name: 'Кэрол',
+ status: 'online',
+ is_instance_admin: false,
+ joined_at: '2026-09-03T10:00:00Z',
+ role_ids: [],
+ },
+];
+
+const recipient: UserSummary = {
+ id: '102',
+ username: 'bob',
+ display_name: 'Боб',
+ is_instance_admin: false,
+ badges: [],
+};
+
+const guild: SessionGuild = {
+ id: 'g-1',
+ name: 'Сервер',
+ owner_id: 'user-1',
+ is_main: false,
+ member_count: 3,
+ my_role_ids: [],
+ my_permissions: [],
+ channels: [
+ textChannel,
+ { id: 'c-2', name: 'новости', type: 'text', position: 1 },
+ { id: 'c-3', name: 'Голосовая', type: 'voice', position: 2 },
+ { id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false },
+ ],
+};
+
+const authors: AuthorDirectory = {
+ resolve: () => ({
+ id: '102',
+ name: 'Боб',
+ avatarFileId: undefined,
+ colorHex: null,
+ isInstanceAdmin: false,
+ isMember: true,
+ }),
+ resolveMention: (userId) => ({
+ name: members.find((member) => member.user_id === userId)?.display_name ?? userId,
+ isMe: false,
+ }),
+ loaded: true,
+};
+
+function renderComposer(channel: Channel = textChannel, guildId: string | null = 'g-1') {
+ const routes: FetchRoute[] = [
+ { 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/users/102', response: () => json({ user: recipient }) },
+ { match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
+ {
+ match: '/api/v1/channels/c-1/messages',
+ method: 'POST',
+ response: () =>
+ json({ message: makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-1' }) }),
+ },
+ ];
+ const fetchMock = installFetch(routes);
+ const view = render(
+
+ undefined}
+ disabledReason={null}
+ canAttach
+ />
+ ,
+ );
+ return { ...view, fetchMock };
+}
+
+/** Поле ввода: у комнаты подпись «Сообщение в комнату …», у беседы — «… для …». */
+function field(name = 'Сообщение в комнату общий'): HTMLTextAreaElement {
+ return screen.getByLabelText(name);
+}
+
+/** Слой подсветки: вставка обязана обновить и его. */
+function layer(): HTMLElement {
+ return screen.getByTestId('composer-mirror');
+}
+
+/** Список подсказок (может ещё не появиться). */
+function listbox(): HTMLElement | null {
+ return screen.queryByTestId('composer-autocomplete');
+}
+
+/**
+ * Ставит каретку и сообщает об этом React — так же, как это делает браузер
+ * (`select` синтезируется из `keyup`/`selectionchange`).
+ */
+function placeCaret(input: HTMLTextAreaElement, start: number, end = start): void {
+ input.setSelectionRange(start, end);
+ fireEvent.select(input);
+ fireEvent.keyUp(input);
+}
+
+/** Ввод значения без набора по символу: для проверок контекста. */
+function setValue(input: HTMLTextAreaElement, value: string): void {
+ fireEvent.change(input, { target: { value } });
+ placeCaret(input, value.length);
+}
+
+beforeEach(() => {
+ resetStores();
+ useSessionStore.setState({ guilds: [guild] });
+});
+
+describe('автокомплит: участники (@)', () => {
+ it('открывает список с именами и логинами, фильтрует и вставляет <@id>', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+
+ await visitor.type(input, '@');
+ const list = await screen.findByTestId('composer-autocomplete');
+ const box = within(list).getByRole('listbox');
+ expect(box).toHaveAttribute('aria-label', 'Участники сервера');
+ // Список раскрывается над полем ввода и не перекрывает текст.
+ expect(list).toHaveClass('bottom-full', 'gl-popover');
+ expect(input).toHaveAttribute('aria-expanded', 'true');
+ expect(input).toHaveAttribute('aria-controls', box.id);
+ // Активный вариант помечен и связан с полем через aria-activedescendant.
+ const active = list.querySelector('[aria-selected="true"]');
+ expect(active).not.toBeNull();
+ expect(input).toHaveAttribute('aria-activedescendant', active?.id ?? '');
+
+ // Показываем имя и @логин.
+ expect(within(list).getByText('Боб')).toBeVisible();
+ expect(within(list).getByText('@bob')).toBeVisible();
+ expect(within(list).getByText('Кэрол')).toBeVisible();
+
+ // Фильтр по префиксу логина.
+ await visitor.type(input, 'bo');
+ expect(within(list).getByText('Боб')).toBeVisible();
+ expect(within(list).queryByText('Кэрол')).toBeNull();
+
+ await visitor.keyboard('{Enter}');
+
+ expect(input).toHaveValue('<@102>');
+ // Каретка сразу после вставленного фрагмента — подсветка не разъезжается.
+ expect(input.selectionStart).toBe('<@102>'.length);
+ expect(layer().textContent).toBe('@Боб');
+ expect(listbox()).toBeNull();
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('фильтрует по отображаемому имени и не срабатывает внутри слова', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, '@кэр');
+ const list = await screen.findByTestId('composer-autocomplete');
+ expect(within(list).getByText('Кэрол')).toBeVisible();
+ expect(within(list).queryByText('Боб')).toBeNull();
+
+ await visitor.keyboard('{Escape}');
+ setValue(input, 'почта@пример');
+ expect(listbox()).toBeNull();
+
+ // Внутри кода упоминание — обычный текст.
+ setValue(input, '`@bo`');
+ expect(listbox()).toBeNull();
+ });
+
+ it('вставляет выбранный стрелками вариант и закрывается по Escape', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, '@');
+ const list = await screen.findByTestId('composer-autocomplete');
+
+ // ↓ — второй вариант, ↑ — возврат к первому.
+ await visitor.keyboard('{ArrowDown}');
+ const second = list.querySelectorAll('[role="option"]')[1];
+ expect(second).toHaveAttribute('aria-selected', 'true');
+ expect(input).toHaveAttribute('aria-activedescendant', second?.id ?? '');
+ await visitor.keyboard('{ArrowUp}');
+ expect(list.querySelectorAll('[role="option"]')[0]).toHaveAttribute('aria-selected', 'true');
+
+ await visitor.keyboard('{Enter}');
+ expect(input).toHaveValue('<@101>');
+
+ // Escape закрывает подсказку и она не всплывает снова на том же триггере.
+ setValue(input, '@');
+ await screen.findByTestId('composer-autocomplete');
+ await visitor.keyboard('{Escape}');
+ expect(listbox()).toBeNull();
+ await visitor.type(input, 'bo');
+ expect(listbox()).toBeNull();
+
+ // Новый триггер после стёртого текста снова открывает список.
+ await visitor.clear(input);
+ await visitor.type(input, '@b');
+ expect(await screen.findByTestId('composer-autocomplete')).toBeVisible();
+ });
+
+ it('вставляет вариант по клику мышью', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, '@bo');
+ const list = await screen.findByTestId('composer-autocomplete');
+ await visitor.click(within(list).getByText('@bob'));
+
+ expect(input).toHaveValue('<@102>');
+ expect(listbox()).toBeNull();
+ });
+
+ it('Tab вставляет, а Enter с открытым списком не отправляет сообщение', async () => {
+ const visitor = userEvent.setup();
+ const { fetchMock } = renderComposer();
+ const input = field();
+
+ await visitor.type(input, '@bo');
+ await screen.findByTestId('composer-autocomplete');
+ await visitor.keyboard('{Tab}');
+
+ expect(input).toHaveValue('<@102>');
+ expect(
+ findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }),
+ ).toBeUndefined();
+ });
+
+ it('в личной беседе предлагает собеседника', async () => {
+ const visitor = userEvent.setup();
+ renderComposer(dmChannel, null);
+ const input = field('Сообщение для Боб');
+
+ await visitor.type(input, '@');
+ expect(await screen.findByText('@bob')).toBeVisible();
+ expect(screen.queryByText('Кэрол')).toBeNull();
+ });
+});
+
+describe('автокомплит: комнаты (#)', () => {
+ it('показывает видимые текстовые комнаты и вставляет <#id>', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, '#');
+ const list = await screen.findByTestId('composer-autocomplete');
+ expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Комнаты сервера');
+ expect(within(list).getByText('#общий')).toBeVisible();
+ expect(within(list).getByText('#новости')).toBeVisible();
+ // Голосовая комната и скрытая в подсказках не участвуют.
+ expect(within(list).queryByText('#Голосовая')).toBeNull();
+ expect(within(list).queryByText('#скрытая')).toBeNull();
+
+ await visitor.type(input, 'нов');
+ expect(within(list).queryByText('#общий')).toBeNull();
+
+ await visitor.keyboard('{Enter}');
+ expect(input).toHaveValue('<#c-2>');
+ expect(input.selectionStart).toBe('<#c-2>'.length);
+ // Слой подсветки показывает имя комнаты из снапшота.
+ expect(layer().textContent).toBe('#новости');
+ });
+
+ it('не срабатывает внутри слова', () => {
+ renderComposer();
+ const input = field();
+
+ setValue(input, 'задача#12');
+ expect(listbox()).toBeNull();
+ });
+});
+
+describe('автокомплит: эмодзи (:)', () => {
+ it('показывает символ и код, вставляет эмодзи', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, ':fir');
+ const list = await screen.findByTestId('composer-autocomplete');
+ expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Эмодзи');
+ expect(within(list).getByText(':fire:')).toBeVisible();
+ expect(within(list).getByText('🔥')).toBeVisible();
+
+ await visitor.type(input, 'ezzz');
+ expect(listbox()).toBeNull();
+
+ setValue(input, ':fir');
+ await visitor.keyboard('{Enter}');
+ expect(input).toHaveValue('🔥');
+ expect(input.selectionStart).toBe(2);
+ });
+
+ it('не срабатывает внутри кода', () => {
+ renderComposer();
+ const input = field();
+
+ setValue(input, '`код :fir`');
+ expect(listbox()).toBeNull();
+
+ setValue(input, 'текст\n```\n:fir\n```');
+ placeCaret(input, 'текст\n```\n:fir'.length);
+ expect(listbox()).toBeNull();
+
+ // Вне кода подсказка снова работает.
+ setValue(input, 'текст :fir');
+ expect(listbox()).not.toBeNull();
+ });
+});
+
+describe('автокомплит: команды (/)', () => {
+ it('показывает команды с описанием и вставляет команду с пробелом', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, '/');
+ const list = await screen.findByTestId('composer-autocomplete');
+ expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Команды');
+ expect(within(list).getByText('/me')).toBeVisible();
+ expect(within(list).getByText('действие: имя и текст курсивом')).toBeVisible();
+
+ await visitor.type(input, 'sc');
+ expect(within(list).queryByText('/me')).toBeNull();
+ expect(within(list).getByText('/scream')).toBeVisible();
+
+ await visitor.keyboard('{Enter}');
+ expect(input).toHaveValue('/scream ');
+ expect(input.selectionStart).toBe('/scream '.length);
+ });
+
+ it('срабатывает только первым символом сообщения', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ setValue(input, 'путь /me');
+ expect(listbox()).toBeNull();
+
+ // `//` экранирует слэш — это обычный текст.
+ setValue(input, '//me');
+ expect(listbox()).toBeNull();
+
+ await visitor.clear(input);
+ await visitor.type(input, '/me');
+ expect(await screen.findByTestId('composer-autocomplete')).toBeVisible();
+ });
+});
+
+describe('автокомплит: закрытие по потере контекста', () => {
+ it('пробел после запроса закрывает список', async () => {
+ const visitor = userEvent.setup();
+ renderComposer();
+ const input = field();
+
+ await visitor.type(input, '@bo');
+ await screen.findByTestId('composer-autocomplete');
+
+ await visitor.type(input, ' ');
+ await waitFor(() => {
+ expect(listbox()).toBeNull();
+ });
+ });
+
+ it('отправка закрывает список и очищает поле', async () => {
+ const visitor = userEvent.setup();
+ const { fetchMock } = renderComposer();
+ const input = field();
+
+ await visitor.type(input, 'привет');
+ await visitor.keyboard('{Enter}');
+
+ await waitFor(() => {
+ expect(
+ findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }),
+ ).toBeDefined();
+ });
+ await waitFor(() => {
+ expect(input).toHaveValue('');
+ });
+ expect(listbox()).toBeNull();
+ });
+});
+
+describe('автокомплит в приложении', () => {
+ it('предлагает участников сервера в настоящем чате и вставляет упоминание', async () => {
+ const visitor = userEvent.setup();
+ installFetch([
+ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
+ { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
+ { 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', []),
+ ]);
+ const gateway = installGatewaySocket(
+ makeReadySnapshot([
+ {
+ id: 'g-1',
+ name: 'Сервер',
+ is_main: true,
+ my_permissions: ['SEND_MESSAGES'],
+ channels: [{ id: 'c-1', name: 'общий' }],
+ },
+ ]),
+ );
+ renderApp('/app/g-1/c-1');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+
+ const input = await screen.findByLabelText('Сообщение в комнату общий');
+ await visitor.type(input, '@б');
+
+ expect(await screen.findByTestId('composer-autocomplete')).toBeVisible();
+ await visitor.keyboard('{Enter}');
+
+ expect(input).toHaveValue('<@102>');
+ expect(screen.getByTestId('composer-mirror').textContent).toBe('@Боб');
+ });
+});
diff --git a/web/tests/chatHelpers.test.ts b/web/tests/chatHelpers.test.ts
index 5385864..4e215f4 100644
--- a/web/tests/chatHelpers.test.ts
+++ b/web/tests/chatHelpers.test.ts
@@ -57,6 +57,14 @@ describe('группировка сообщений', () => {
);
});
+ it('не присоединяет обычное сообщение к действию, шёпоту и личному', () => {
+ const next = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 1) });
+ for (const type of ['action', 'whisper', 'scream', 'private', 'system']) {
+ const previous = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0), type });
+ expect(canGroup(previous, next)).toBe(false);
+ }
+ });
+
it('строит строки ленты с разделителями дат', () => {
const rows = buildChatRows(
[
diff --git a/web/tests/composerFormat.test.tsx b/web/tests/composerFormat.test.tsx
index 968ac1d..90e12a0 100644
--- a/web/tests/composerFormat.test.tsx
+++ b/web/tests/composerFormat.test.tsx
@@ -52,6 +52,7 @@ function renderComposer() {
undefined}
diff --git a/web/tests/markdown.test.tsx b/web/tests/markdown.test.tsx
index 57d12f0..e26403c 100644
--- a/web/tests/markdown.test.tsx
+++ b/web/tests/markdown.test.tsx
@@ -158,4 +158,33 @@ describe('разметка: упоминания каналов', () => {
expect(container.querySelectorAll('span')).toHaveLength(0);
expect(container.textContent).toBe('см. #общий');
});
+
+ it('рендерит ссылку на комнату <#id> именем из снапшота, иначе #id', () => {
+ useSessionStore.setState({ guilds: [guild] });
+
+ const { container } = markup('см. <#c-1> и <#999>');
+
+ const spans = Array.from(container.querySelectorAll('span'));
+ expect(spans.some((span) => span.textContent === '#общий')).toBe(true);
+ expect(spans.some((span) => span.textContent === '#999')).toBe(true);
+ // Угловые скобки не остаются текстом.
+ expect(container.textContent).toBe('см. #общий и #999');
+ });
+
+ it('не ломает разметку рядом с упоминанием, ссылкой и кодом', () => {
+ useSessionStore.setState({ guilds: [guild] });
+
+ const { container } = render(
+ пишет в <#c-1>: https://example.com/#якорь\n`<#c-1>`'}
+ resolveMention={() => ({ name: 'Аля', isMe: false })}
+ />,
+ );
+
+ expect(container.textContent).toBe('@Аля пишет в #общий: https://example.com/#якорь\n<#c-1>');
+ // Ссылка осталась ссылкой целиком, вместе с якорем.
+ expect(container.querySelector('a')).toHaveAttribute('href', 'https://example.com/#якорь');
+ // Внутри кода разметка не разбирается.
+ expect(container.querySelector('code')).toHaveTextContent('<#c-1>');
+ });
});
diff --git a/web/tests/messageTypes.test.tsx b/web/tests/messageTypes.test.tsx
new file mode 100644
index 0000000..5270d64
--- /dev/null
+++ b/web/tests/messageTypes.test.tsx
@@ -0,0 +1,198 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import type { Message } from '@/api/messages';
+import { MessageList } from '@/components/chat/MessageList';
+import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
+
+/**
+ * Виды сообщений из контракта (AGENT.md §7.6): обычное, действие `/me`, шёпот,
+ * крик, личное `/ls` и системное. Лента оформляет их по-разному, но реакции,
+ * ответы и правка работают у всех, кроме системных.
+ */
+
+const authors: AuthorDirectory = {
+ resolve: (userId) => ({
+ id: userId ?? '',
+ name: userId === 'user-1' ? 'Аля' : 'Bob',
+ avatarFileId: undefined,
+ colorHex: null,
+ isInstanceAdmin: false,
+ isMember: true,
+ }),
+ resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
+ loaded: true,
+};
+
+function message(overrides: Partial & { id: string }): Message {
+ return {
+ channel_id: 'c-1',
+ author_id: 'user-2',
+ content: 'текст',
+ type: 'default',
+ pinned: false,
+ attachments: [],
+ mentions: [],
+ reactions: [],
+ created_at: '2026-09-19T10:00:00Z',
+ ...overrides,
+ };
+}
+
+function renderList(messages: Message[], onReply = vi.fn()) {
+ const view = render(
+ undefined}
+ onLoadMore={() => undefined}
+ onReply={onReply}
+ />,
+ );
+ return { ...view, onReply };
+}
+
+/** Сообщение ленты по id. */
+function article(id: string): HTMLElement {
+ return screen.getByTestId(`message-${id}`);
+}
+
+/** Панель действий: реакции, ответ и меню. */
+function expectActions(id: string): void {
+ const body = article(id);
+ expect(within(body).getByRole('button', { name: 'Поставить реакцию 👍' })).toBeVisible();
+ expect(within(body).getByRole('button', { name: 'Ответить' })).toBeVisible();
+ expect(within(body).getByRole('button', { name: 'Действия с сообщением' })).toBeVisible();
+}
+
+describe('виды сообщений: оформление', () => {
+ it('действие /me показывает имя акцентом, а текст курсивом', () => {
+ renderList([message({ id: 'm-1', type: 'action', content: 'танцует' })]);
+
+ const body = article('m-1');
+ expect(body).toHaveAttribute('data-message-type', 'action');
+ expect(body).toHaveTextContent('Bobтанцует');
+
+ // Имя автора — акцентным цветом, вся строка — курсивом.
+ expect(within(body).getByText('Bob')).toHaveClass('text-accent');
+ expect(body.querySelector('.italic')).not.toBeNull();
+ // Шапки группы нет: имя рисует само действие, а не заголовок.
+ expect(within(body).getAllByText('Bob')).toHaveLength(1);
+ expectActions('m-1');
+ });
+
+ it('шёпот приглушён, помечен подписью и остаётся обычным сообщением', () => {
+ renderList([message({ id: 'm-1', type: 'whisper', content: 'только между нами' })]);
+
+ const body = article('m-1');
+ expect(body).toHaveAttribute('data-message-type', 'whisper');
+ expect(within(body).getByText('шёпот')).toBeVisible();
+ // Приглушённый блок: полупрозрачный фон и курсив.
+ const block = body.querySelector('.bg-surface-2\\/50');
+ expect(block).not.toBeNull();
+ expect(block).toHaveTextContent('только между нами');
+ expect(body.querySelector('.italic')).not.toBeNull();
+ // Шапка автора на месте (она часть строки группы, а не самого сообщения).
+ expect(within(screen.getByTestId('message-group')).getByText('Bob')).toBeVisible();
+ expectActions('m-1');
+ });
+
+ it('крик крупнее обычного текста и помечен подписью «громко»', () => {
+ renderList([message({ id: 'm-1', type: 'scream', content: 'ОЧЕНЬ ГРОМКО' })]);
+
+ const body = article('m-1');
+ expect(body).toHaveAttribute('data-message-type', 'scream');
+ expect(within(body).getByText('громко')).toBeVisible();
+
+ const text = body.querySelector('.text-base');
+ expect(text).toHaveTextContent('ОЧЕНЬ ГРОМКО');
+ expect(text).toHaveClass('font-semibold', 'text-accent');
+ expectActions('m-1');
+ });
+
+ it('личное сообщение выделено фоном с акцентом, замком и подписью «лично»', () => {
+ renderList([message({ id: 'm-1', type: 'private', content: 'секрет для тебя' })]);
+
+ const body = article('m-1');
+ expect(body).toHaveAttribute('data-message-type', 'private');
+ expect(within(body).getByText('лично')).toBeVisible();
+ expect(body.textContent).toContain('🔒');
+
+ const block = body.querySelector('.bg-accent\\/10');
+ expect(block).not.toBeNull();
+ expect(block).toHaveTextContent('секрет для тебя');
+ expectActions('m-1');
+ });
+
+ it('системное сообщение центрировано, без аватара и панели действий', () => {
+ // У системного сообщения автора может не быть вовсе.
+ const system: Message = {
+ id: 'm-1',
+ channel_id: 'c-1',
+ content: 'Аля вошла',
+ type: 'system',
+ pinned: false,
+ attachments: [],
+ mentions: [],
+ reactions: [],
+ created_at: '2026-09-19T10:00:00Z',
+ };
+ renderList([system]);
+
+ const body = article('m-1');
+ expect(body).toHaveAttribute('data-message-type', 'system');
+ expect(body).toHaveClass('items-center', 'text-center');
+ expect(within(body).getByText('Системное сообщение')).toHaveClass('sr-only');
+ expect(body).toHaveTextContent('Аля вошла');
+ // Ни реакций, ни ответа, ни меню.
+ expect(within(body).queryByRole('button')).toBeNull();
+
+ // И ни аватара, ни шапки автора в строке группы.
+ const group = screen.getByTestId('message-group');
+ expect(group).toHaveAttribute('data-message-type', 'system');
+ expect(within(group).queryByText('Bob')).toBeNull();
+ expect(group.querySelector('.inline-flex.shrink-0')).toBeNull();
+ });
+});
+
+describe('виды сообщений: действия', () => {
+ it('ответ доступен у действия, шёпота, крика и личного сообщения', async () => {
+ const visitor = userEvent.setup();
+ const types = ['action', 'whisper', 'scream', 'private'] as const;
+ const { onReply } = renderList(
+ types.map((type, index) => message({ id: `m-${String(index)}`, type, content: 'текст' })),
+ );
+
+ for (const [index, type] of types.entries()) {
+ const id = `m-${String(index)}`;
+ expect(article(id)).toHaveAttribute('data-message-type', type);
+ await visitor.click(within(article(id)).getByRole('button', { name: 'Ответить' }));
+ expect(onReply).toHaveBeenLastCalledWith(expect.objectContaining({ id, type }));
+ }
+ });
+
+ it('обычное сообщение после системного не сливается с ним в группу', () => {
+ renderList([
+ message({ id: 'm-1', type: 'system', content: 'Аля вошла' }),
+ message({ id: 'm-2', type: 'default', content: 'привет' }),
+ ]);
+
+ const groups = screen.getAllByTestId('message-group');
+ expect(groups).toHaveLength(2);
+ expect(groups[0]).toHaveAttribute('data-message-type', 'system');
+ // Обычное сообщение снова с шапкой автора.
+ expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible();
+ });
+});