feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты

- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и
  курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее,
  private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка
  без аватара и панели действий; реакции, ответы, правка и удаление работают
  как раньше, группировка не сливает нестандартные виды с обычными;
- автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью:
  @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты,
  : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются
  внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится
  со слоем подсветки;
- `<#id>` рендерится как #имя комнаты из снапшота (иначе #id);
- 287 тестов Vitest (было 262), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 12:06:22 +03:00
parent 65d0bafe0e
commit a126f50468
22 changed files with 1826 additions and 116 deletions
+21 -1
View File
@@ -30,13 +30,33 @@ export interface MessageReaction {
user_ids?: string[]; user_ids?: string[];
} }
/**
* Виды сообщений из контракта (AGENT.md §7.6): обычное, действие `/me`, шёпот,
* крик, личное `/ls` и системное. Сервер разбирает команды сам, клиент только
* оформляет пришедший `type`.
*/
export const messageTypes = [
'default',
'action',
'whisper',
'scream',
'private',
'system',
] as const;
export type MessageType = (typeof messageTypes)[number];
/** Вид сообщения с запасным `default`: контракт допускает расширение. */
export function messageTypeOf(type: string | undefined): MessageType {
return messageTypes.find((known) => known === type) ?? 'default';
}
export interface Message { export interface Message {
id: string; id: string;
channel_id: string; channel_id: string;
author_id?: string; author_id?: string;
content: string; content: string;
reply_to_id?: string; reply_to_id?: string;
/** `default` | `system` | `whisper` — контракт допускает расширение. */ /** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`. */
type: string; type: string;
edited_at?: string; edited_at?: string;
pinned: boolean; pinned: boolean;
+1
View File
@@ -218,6 +218,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} /> <TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
<Composer <Composer
channel={channel} channel={channel}
guildId={guildId}
authors={authors} authors={authors}
direct={isDirect} direct={isDirect}
replyTo={replyTo} replyTo={replyTo}
+127 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -6,18 +6,26 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
import { fetchMeta, metaQueryKey } from '@/api/meta'; import { fetchMeta, metaQueryKey } from '@/api/meta';
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages'; import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
import type { Channel } from '@/api/types'; import type { Channel } from '@/api/types';
import { ComposerAutocomplete } from '@/components/chat/ComposerAutocomplete';
import { import {
COMPOSER_TEXT_CLASS, COMPOSER_TEXT_CLASS,
ComposerMirror, ComposerMirror,
type ComposerSelection, type ComposerSelection,
} from '@/components/chat/ComposerMirror'; } from '@/components/chat/ComposerMirror';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import {
useComposerAutocomplete,
type ComposerAutocompleteState,
} from '@/components/chat/useComposerAutocomplete';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { detectAutocomplete, type AutocompleteItem } from '@/lib/autocomplete';
import { formatBytes, errorCode } from '@/lib/format'; import { formatBytes, errorCode } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages'; import { useMessagesStore } from '@/stores/messages';
interface ComposerProps { interface ComposerProps {
channel: Channel; channel: Channel;
/** Сервер комнаты: нужен для подсказок участников и комнат. */
guildId: string | null;
authors: AuthorDirectory; authors: AuthorDirectory;
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */ /** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
direct?: boolean; direct?: boolean;
@@ -45,6 +53,7 @@ const LINE_HEIGHT_PX = 22;
*/ */
export function Composer({ export function Composer({
channel, channel,
guildId,
authors, authors,
direct = false, direct = false,
replyTo, replyTo,
@@ -67,10 +76,38 @@ export function Composer({
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */ /** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 }); const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
/** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
/**
* Активный вариант подсказки: выбор привязан к позиции каретки, поэтому при
* её смене (новый ввод) он снова первый — без эффекта и лишнего рендера.
*/
const [activePick, setActivePick] = useState({ caret: 0, index: 0 });
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const mirrorRef = useRef<HTMLDivElement | null>(null); const mirrorRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
/**
* Каретка после программной вставки подсказки. Ставим её в layout-эффекте:
* к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы
* каретку в конце и слой подсветки разъехался бы с полем.
*/
const pendingCaret = useRef<number | null>(null);
const autocompleteId = useId();
const suggestion = useComposerAutocomplete({
value,
caret: selection.end,
channel,
guildId,
});
const open = suggestion !== null && suggestion.key !== dismissedKey;
const suggestionSize = suggestion?.items.length ?? 0;
const activeIndex =
!open || activePick.caret !== selection.end
? 0
: Math.min(activePick.index, suggestionSize - 1);
const activeOptionId = open ? `${autocompleteId}-option-${String(activeIndex)}` : undefined;
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен, // Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
// ограничение проверяет сам сервер, а мы показываем его ошибку. // ограничение проверяет сам сервер, а мы показываем его ошибку.
@@ -114,6 +151,20 @@ export function Composer({
syncScroll(); syncScroll();
}, [value, syncScroll]); }, [value, syncScroll]);
// Каретка сразу после вставленного фрагмента: иначе подсветка и правка
// разъедутся с полем ввода.
useLayoutEffect(() => {
const caret = pendingCaret.current;
const element = textareaRef.current;
if (caret === null || element === null) {
return;
}
pendingCaret.current = null;
element.focus();
element.setSelectionRange(caret, caret);
syncSelection(element);
}, [value, syncSelection]);
// Таймер лимита частоты: пока он идёт, отправка заблокирована. // Таймер лимита частоты: пока он идёт, отправка заблокирована.
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно. // Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
useEffect(() => { useEffect(() => {
@@ -179,6 +230,27 @@ export function Composer({
[canAttach, channel.id, i18n.language, maxUploadSize, t], [canAttach, channel.id, i18n.language, maxUploadSize, t],
); );
/**
* Вставка варианта подсказки: заменяем триггер вместе с запросом и ставим
* каретку сразу за вставленным фрагментом.
*/
const insertAutocomplete = (current: ComposerAutocompleteState, item: AutocompleteItem): void => {
pendingCaret.current = current.start + item.insert.length;
setValue(`${value.slice(0, current.start)}${item.insert}${value.slice(current.end)}`);
setActivePick({ caret: 0, index: 0 });
setDismissedKey(null);
useMessagesStore.getState().notifyTyping(channel.id);
};
/** Стрелки выбирают вариант; выбор живёт до следующего движения каретки. */
const moveAutocomplete = (step: number): void => {
setActivePick((current) => {
const base = current.caret === selection.end ? current.index : 0;
const index = Math.min(base, suggestionSize - 1);
return { caret: selection.end, index: (index + step + suggestionSize) % suggestionSize };
});
};
const submit = async (): Promise<void> => { const submit = async (): Promise<void> => {
const content = value.trim(); const content = value.trim();
if (!canSubmit || (content === '' && files.length === 0)) { if (!canSubmit || (content === '' && files.length === 0)) {
@@ -196,6 +268,8 @@ export function Composer({
setValue(''); setValue('');
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров. // Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
setSelection({ start: 0, end: 0 }); setSelection({ start: 0, end: 0 });
// Подсказок после отправки нет: отказ по Escape тоже больше не нужен.
setDismissedKey(null);
setFiles([]); setFiles([]);
setNotice(null); setNotice(null);
// Отправка прошла — лимит частоты больше не держим. // Отправка прошла — лимит частоты больше не держим.
@@ -337,6 +411,19 @@ export function Composer({
mirrorRef={mirrorRef} mirrorRef={mirrorRef}
resolveMention={authors.resolveMention} resolveMention={authors.resolveMention}
/> />
<ComposerAutocomplete
open={open}
label={suggestion === null ? '' : t(suggestion.labelKey)}
id={autocompleteId}
items={suggestion?.items ?? []}
activeIndex={activeIndex}
onActiveIndexChange={(index) => setActivePick({ caret: selection.end, index })}
onPick={(item) => {
if (suggestion !== null) {
insertAutocomplete(suggestion, item);
}
}}
/>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', { aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
@@ -346,6 +433,13 @@ export function Composer({
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder', direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
{ name: channel.name }, { name: channel.name },
)} )}
// Подсказки — это listbox над полем: фокус остаётся в textarea, а
// активный вариант помечается `aria-activedescendant`.
aria-expanded={open}
{...(open ? { 'aria-controls': autocompleteId } : {})}
{...(activeOptionId === undefined ? {} : { 'aria-activedescendant': activeOptionId })}
aria-autocomplete="list"
aria-haspopup="listbox"
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение // Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
// остаются родными для поля ввода. // остаются родными для поля ввода.
className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`} className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`}
@@ -357,8 +451,17 @@ export function Composer({
// бы поверх подсветки. // бы поверх подсветки.
spellCheck={false} spellCheck={false}
onChange={(event) => { onChange={(event) => {
setValue(event.target.value); const next = event.target.value;
setValue(next);
syncSelection(event.target); syncSelection(event.target);
// Контекст подсказки потерян (стёрли триггер) — отказ по Escape
// больше не нужен: следующая подсказка откроется сразу.
if (
dismissedKey !== null &&
detectAutocomplete(next, event.target.selectionStart) === null
) {
setDismissedKey(null);
}
useMessagesStore.getState().notifyTyping(channel.id); useMessagesStore.getState().notifyTyping(channel.id);
}} }}
onSelect={(event) => { onSelect={(event) => {
@@ -372,6 +475,28 @@ export function Composer({
}} }}
onScroll={syncScroll} onScroll={syncScroll}
onKeyDown={(event) => { onKeyDown={(event) => {
// Открытая подсказка перехватывает навигацию: стрелки выбирают
// вариант, Enter/Tab вставляют его, Escape закрывает.
if (open && suggestion !== null) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveAutocomplete(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const item = suggestion.items[activeIndex];
if (item !== undefined) {
insertAutocomplete(suggestion, item);
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
setDismissedKey(suggestion.key);
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
void submit(); void submit();
@@ -0,0 +1,112 @@
import { useTranslation } from 'react-i18next';
import { Avatar } from '@/components/ui/Avatar';
import type { AutocompleteItem } from '@/lib/autocomplete';
interface ComposerAutocompleteProps {
open: boolean;
/** Доступное имя списка (`aria-label`). */
label: string;
/** id списка: на него ссылается поле ввода через `aria-controls`. */
id: string;
items: AutocompleteItem[];
activeIndex: number;
onActiveIndexChange: (index: number) => void;
onPick: (item: AutocompleteItem) => void;
}
/** Подпись варианта: у команды — название и описание, у остальных — одна строка. */
function OptionLabel({ item }: { item: AutocompleteItem }) {
if (item.kind === 'command') {
return (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium text-fg">{item.label}</span>
{item.hint === undefined ? null : (
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
)}
</span>
);
}
if (item.kind === 'emoji') {
return (
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className="text-lg leading-none">
{item.label}
</span>
<span className="truncate text-fg-muted">{item.hint}</span>
</span>
);
}
return (
<span className="flex min-w-0 flex-col">
<span className="truncate">{item.label}</span>
{item.hint === undefined ? null : (
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
)}
</span>
);
}
/**
* Список подсказок композера: `role="listbox"` над полем ввода.
*
* Фокус остаётся в поле — активный вариант помечается `aria-selected` и
* `aria-activedescendant` у textarea, поэтому список доступен и с клавиатуры,
* и скринридеру. Клик не забирает фокус (`onMouseDown` отменяется), иначе
* каретка потерялась бы до вставки.
*/
export function ComposerAutocomplete({
open,
label,
id,
items,
activeIndex,
onActiveIndexChange,
onPick,
}: ComposerAutocompleteProps) {
const { t } = useTranslation();
if (!open || items.length === 0) {
return null;
}
return (
<div
data-testid="composer-autocomplete"
className="gl-popover absolute bottom-full left-0 z-40 mb-1 w-full max-w-80 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 p-1 shadow-[var(--shadow-3)]"
>
<p className="px-2 pb-1 text-[10px] uppercase tracking-wide text-fg-muted">
{t('chat.composer.autocomplete.hint')}
</p>
{/* Внутри listbox только варианты: подпись с подсказкой по клавишам —
соседний узел, иначе скринридер читал бы её как вариант списка. */}
<div
id={id}
role="listbox"
aria-label={label}
className="gl-stagger flex max-h-56 flex-col overflow-y-auto"
>
{items.map((item, index) => (
<div
key={item.id}
id={`${id}-option-${String(index)}`}
role="option"
aria-selected={index === activeIndex}
data-testid={`composer-option-${item.kind}`}
className={`flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
index === activeIndex ? 'bg-surface-3 text-fg' : 'text-fg-muted hover:bg-surface-2'
}`}
onMouseEnter={() => onActiveIndexChange(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPick(item)}
>
{item.avatarName === undefined ? null : (
<Avatar name={item.avatarName} seed={item.id} fileId={item.avatarFileId} size="sm" />
)}
<OptionLabel item={item} />
</div>
))}
</div>
</div>
);
}
+12 -7
View File
@@ -1,6 +1,6 @@
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react'; import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
import { useChannelNames } from '@/components/chat/useChannelNames'; import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
import { import {
hasMarkers, hasMarkers,
selectionIntersects, selectionIntersects,
@@ -53,7 +53,7 @@ interface ComposerMirrorProps {
function renderFormatted( function renderFormatted(
token: InlineToken, token: InlineToken,
content: string, content: string,
channels: ReadonlySet<string>, channels: ChannelDirectory,
resolveMention: MentionResolver | undefined, resolveMention: MentionResolver | undefined,
): ReactNode { ): ReactNode {
switch (token.kind) { switch (token.kind) {
@@ -106,9 +106,14 @@ function renderFormatted(
</> </>
); );
} }
case 'channel-link': {
// `<#id>`: как в ленте — имя комнаты из снапшота, иначе `#id`.
const name = channels.byId.get(content);
return <span className="text-accent">#{name ?? content}</span>;
}
case 'channel': { case 'channel': {
const name = token.raw.slice(1); const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? ( return channels.names.has(name.toLowerCase()) ? (
<span className="text-accent">{token.raw}</span> <span className="text-accent">{token.raw}</span>
) : ( ) : (
token.raw token.raw
@@ -122,7 +127,7 @@ function renderToken(
token: InlineToken, token: InlineToken,
line: MirrorLine, line: MirrorLine,
selection: ComposerSelection, selection: ComposerSelection,
channels: ReadonlySet<string>, channels: ChannelDirectory,
resolveMention: MentionResolver | undefined, resolveMention: MentionResolver | undefined,
key: string, key: string,
): ReactNode { ): ReactNode {
@@ -158,7 +163,7 @@ function renderToken(
function renderTokens( function renderTokens(
line: MirrorLine, line: MirrorLine,
selection: ComposerSelection, selection: ComposerSelection,
channels: ReadonlySet<string>, channels: ChannelDirectory,
resolveMention: MentionResolver | undefined, resolveMention: MentionResolver | undefined,
): ReactNode { ): ReactNode {
const start = line.start + line.prefixLength; const start = line.start + line.prefixLength;
@@ -191,7 +196,7 @@ function renderTokens(
function renderLine( function renderLine(
line: MirrorLine, line: MirrorLine,
selection: ComposerSelection, selection: ComposerSelection,
channels: ReadonlySet<string>, channels: ChannelDirectory,
resolveMention: MentionResolver | undefined, resolveMention: MentionResolver | undefined,
): ReactNode { ): ReactNode {
switch (line.kind) { switch (line.kind) {
@@ -227,7 +232,7 @@ export function ComposerMirror({
resolveMention, resolveMention,
}: ComposerMirrorProps) { }: ComposerMirrorProps) {
const lines = useMemo(() => toMirrorLines(value), [value]); const lines = useMemo(() => toMirrorLines(value), [value]);
const channels = useChannelNames(); const channels = useChannelDirectory();
return ( return (
<div <div
+18 -6
View File
@@ -1,7 +1,7 @@
import { Fragment, useMemo, useState, type ReactNode } from 'react'; import { Fragment, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useChannelNames } from '@/components/chat/useChannelNames'; import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
import { import {
parseBlocks, parseBlocks,
parseInline, parseInline,
@@ -60,7 +60,7 @@ function renderToken(
content: string, content: string,
key: string, key: string,
resolveMention: MentionResolver, resolveMention: MentionResolver,
channels: ReadonlySet<string>, channels: ChannelDirectory,
): ReactNode { ): ReactNode {
switch (token.kind) { switch (token.kind) {
case 'code': case 'code':
@@ -129,10 +129,22 @@ function renderToken(
</Fragment> </Fragment>
); );
} }
case 'channel-link': {
// `<#id>`: имя комнаты берём из снапшота, иначе показываем `#id`.
const name = channels.byId.get(content);
return (
<span
key={key}
className="rounded-[var(--radius-sm)] bg-accent/15 px-1 font-medium text-accent"
>
#{name ?? content}
</span>
);
}
case 'channel': { case 'channel': {
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом. // `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
const name = token.raw.slice(1); const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? ( return channels.names.has(name.toLowerCase()) ? (
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent"> <span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
{token.raw} {token.raw}
</span> </span>
@@ -146,7 +158,7 @@ function renderToken(
function renderInline( function renderInline(
text: string, text: string,
resolveMention: MentionResolver, resolveMention: MentionResolver,
channels: ReadonlySet<string>, channels: ChannelDirectory,
keyPrefix: string, keyPrefix: string,
): ReactNode[] { ): ReactNode[] {
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
@@ -179,8 +191,8 @@ function renderInline(
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) { export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
const blocks = useMemo(() => parseBlocks(content), [content]); const blocks = useMemo(() => parseBlocks(content), [content]);
// Имена каналов снапшота: `#канал` подсвечиваем только для них. // Комнаты снапшота: `#канал` и `<#id>` подсвечиваем только для известных.
const channels = useChannelNames(); const channels = useChannelDirectory();
return ( return (
<div className={`whitespace-pre-wrap break-words ${className}`}> <div className={`whitespace-pre-wrap break-words ${className}`}>
+140 -5
View File
@@ -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 { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files'; 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 { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent'; import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
@@ -40,6 +46,100 @@ const actionButtonClass =
*/ */
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]'; const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
/** Пометка вида сообщения: «шёпот», «громко», «лично». */
function TypeBadge({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
return (
<span
className={`inline-block rounded-full border px-1.5 align-middle text-[10px] uppercase tracking-wide ${
accent ? 'border-accent/40 text-accent' : 'border-border/60 text-fg-muted'
}`}
>
{children}
</span>
);
}
/**
* Текст сообщения по виду из контракта: обычный, действие `/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 (
<div className="flex flex-wrap items-baseline gap-x-1.5 text-sm text-fg italic">
<span className="font-semibold text-accent not-italic">{authorName}</span>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0"
/>
</div>
);
}
if (type === 'whisper') {
return (
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/50 px-2 py-1">
<TypeBadge>{t('chat.types.whisper')}</TypeBadge>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0 flex-1 text-sm text-fg-muted italic"
/>
</div>
);
}
if (type === 'scream') {
return (
<div className="flex flex-wrap items-baseline gap-x-1.5">
<TypeBadge accent>{t('chat.types.scream')}</TypeBadge>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0 flex-1 text-base font-semibold text-accent"
/>
</div>
);
}
if (type === 'private') {
// Личное сообщение: сервер отдаёт его только автору и адресату.
return (
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-accent/40 bg-accent/10 px-2 py-1">
<span aria-hidden="true" className="text-xs">
🔒
</span>
<TypeBadge accent>{t('chat.types.private')}</TypeBadge>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0 flex-1 text-sm text-fg"
/>
</div>
);
}
return (
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="text-sm text-fg"
/>
);
}
/** /**
* Сообщение ленты: текст, вложения, реакции, ответы и действия. * Сообщение ленты: текст, вложения, реакции, ответы и действия.
* *
@@ -78,6 +178,7 @@ export function MessageItem({
const canEdit = isMine && canSend && !pending; const canEdit = isMine && canSend && !pending;
const canDelete = !pending && (isMine || canManageMessages); const canDelete = !pending && (isMine || canManageMessages);
const edited = message.edited_at !== undefined; const edited = message.edited_at !== undefined;
const type = messageTypeOf(message.type);
useEffect(() => { useEffect(() => {
if (editing) { if (editing) {
@@ -166,10 +267,38 @@ export function MessageItem({
onReply(message); onReply(message);
}; };
// Системное сообщение: центрированная приглушённая строка без шапки автора,
// вложений и панели действий — реагировать и отвечать на неё нечего.
if (type === 'system') {
return (
<article
data-message-id={message.id}
data-testid={`message-${message.id}`}
data-message-type="system"
className={`flex flex-col items-center gap-0.5 px-1 py-1 text-center ${
pending ? 'opacity-70' : ''
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
>
<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"
/>
)}
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
{formatMessageTime(message.created_at, i18n.language)}
</time>
</article>
);
}
return ( return (
<article <article
data-message-id={message.id} data-message-id={message.id}
data-testid={`message-${message.id}`} data-testid={`message-${message.id}`}
data-message-type={type}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${ className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
pending ? 'opacity-70' : '' pending ? 'opacity-70' : ''
@@ -234,10 +363,11 @@ export function MessageItem({
) : ( ) : (
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
{message.content === '' ? null : ( {message.content === '' ? null : (
<MessageContent <MessageBody
content={message.content} type={type}
message={message}
authorName={author.name}
resolveMention={authors.resolveMention} resolveMention={authors.resolveMention}
className="text-sm text-fg"
/> />
)} )}
{message.attachments.length === 0 ? null : ( {message.attachments.length === 0 ? null : (
@@ -310,6 +440,11 @@ export function MessageItem({
> >
{formatMessageTime(message.created_at, i18n.language)} {formatMessageTime(message.created_at, i18n.language)}
</time> </time>
) : type === 'action' ? (
// У действия нет шапки автора — время показываем всегда.
<time dateTime={message.created_at}>
{formatMessageTime(message.created_at, i18n.language)}
</time>
) : null} ) : null}
{edited ? <span>{t('chat.edited')}</span> : null} {edited ? <span>{t('chat.edited')}</span> : null}
{pending ? <span role="status">{t('chat.pending')}</span> : null} {pending ? <span role="status">{t('chat.pending')}</span> : null}
+79 -62
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; 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 { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
@@ -215,77 +215,94 @@ export function MessageList({
if (first === undefined) { if (first === undefined) {
return null; return null;
} }
const headType = messageTypeOf(first.type);
const author = authors.resolve(row.group.authorId ?? undefined); const author = authors.resolve(row.group.authorId ?? undefined);
// У системных сообщений нет ни аватара, ни шапки автора, а действие
// `/me` рисует имя внутри строки — шапка ему тоже не нужна.
const showHeader = headType !== 'system' && headType !== 'action';
const body = (
<div className="flex min-w-0 flex-col">
{row.group.messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
channelId={channelId}
channelName={channelName}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManageMessages}
canSend={canSend}
compact={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' });
}
}}
/>
))}
</div>
);
return ( return (
<li <li
key={row.key} key={row.key}
data-testid="message-group" data-testid="message-group"
data-message-type={headType}
className="group/msg rounded-[var(--radius-md)] px-2 py-1 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2/40" className="group/msg rounded-[var(--radius-md)] px-2 py-1 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2/40"
> >
<div className="flex gap-3"> {headType === 'system' ? (
<div className="w-9 shrink-0 pt-0.5"> // Системная строка: по центру, без колонки аватара.
<Avatar <div className="flex justify-center">{body}</div>
name={author.name} ) : (
seed={author.id === '' ? row.key : author.id} <div className="flex gap-3">
fileId={author.avatarFileId} <div className="w-9 shrink-0 pt-0.5">
size="md" <Avatar
/> name={author.name}
</div> seed={author.id === '' ? row.key : author.id}
<div className="flex min-w-0 flex-1 flex-col"> fileId={author.avatarFileId}
<header className="flex flex-wrap items-baseline gap-2"> size="md"
<span />
className="text-sm font-semibold" </div>
style={author.colorHex === null ? undefined : { color: author.colorHex }} <div className="flex min-w-0 flex-1 flex-col">
> {showHeader ? (
{author.name} <header className="flex flex-wrap items-baseline gap-2">
</span> <span
{author.isInstanceAdmin ? ( className="text-sm font-semibold"
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent"> style={
{t('chat.adminBadge')} author.colorHex === null ? undefined : { color: author.colorHex }
</span>
) : null}
<time
dateTime={first.created_at}
className="text-xs text-fg-muted"
title={formatMessageDate(first.created_at, i18n.language)}
>
{formatMessageTime(first.created_at, i18n.language)}
</time>
</header>
<div className="flex min-w-0 flex-col">
{row.group.messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
channelId={channelId}
channelName={channelName}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManageMessages}
canSend={canSend}
compact={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' });
} }
}} >
/> {author.name}
))} </span>
{author.isInstanceAdmin ? (
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
{t('chat.adminBadge')}
</span>
) : null}
<time
dateTime={first.created_at}
className="text-xs text-fg-muted"
title={formatMessageDate(first.created_at, i18n.language)}
>
{formatMessageTime(first.created_at, i18n.language)}
</time>
</header>
) : null}
{body}
</div> </div>
</div> </div>
</div> )}
</li> </li>
); );
})} })}
@@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { useSessionStore } from '@/stores/session';
/** Комнаты снапшота в двух видах: по id и по имени. */
export interface ChannelDirectory {
/** `id → имя`: для ссылок на комнату `<#id>`. */
byId: ReadonlyMap<string, string>;
/** Имена в нижнем регистре: `#имя` подсвечивается только для известных комнат. */
names: ReadonlySet<string>;
}
/**
* Справочник комнат из снапшота: общий хук для ленты сообщений и зеркала
* композера. Один разбор — оба вида ссылок (`#имя` и `<#id>`) оформляются
* одинаково в предпросмотре и в ленте.
*/
export function useChannelDirectory(): ChannelDirectory {
const guilds = useSessionStore((state) => state.guilds);
return useMemo(() => {
const byId = new Map<string, string>();
const names = new Set<string>();
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]);
}
@@ -1,20 +0,0 @@
import { useMemo } from 'react';
import { useSessionStore } from '@/stores/session';
/**
* Имена каналов из снапшота: `#канал` подсвечивается только для известных
* комнат. Общий хук для ленты сообщений и зеркала композера.
*/
export function useChannelNames(): ReadonlySet<string> {
const guilds = useSessionStore((state) => state.guilds);
return useMemo(() => {
const names = new Set<string>();
for (const guild of guilds) {
for (const channel of guild.channels) {
names.add(channel.name.toLowerCase());
}
}
return names;
}, [guilds]);
}
@@ -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<Record<AutocompleteKind, string>> = {
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<ComposerAutocompleteState | null>(() => {
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]);
}
+20 -1
View File
@@ -304,6 +304,12 @@
"pending": "sending…", "pending": "sending…",
"newMessages": "New messages", "newMessages": "New messages",
"adminBadge": "admin", "adminBadge": "admin",
"types": {
"whisper": "whisper",
"scream": "loud",
"private": "private",
"system": "System message"
},
"unknownAuthor": "unknown", "unknownAuthor": "unknown",
"start": { "start": {
"title": "This is the start of #{{name}}", "title": "This is the start of #{{name}}",
@@ -365,7 +371,20 @@
"denied": "You are not allowed to send messages in this channel.", "denied": "You are not allowed to send messages in this channel.",
"readOnly": "This channel is read-only for you.", "readOnly": "This channel is read-only for you.",
"directLabel": "Message to {{name}}", "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_one": "{{names}} is typing…",
"typing_few": "{{names}} are typing…", "typing_few": "{{names}} are typing…",
+20 -1
View File
@@ -304,6 +304,12 @@
"pending": "отправляется…", "pending": "отправляется…",
"newMessages": "Новые сообщения", "newMessages": "Новые сообщения",
"adminBadge": "админ", "adminBadge": "админ",
"types": {
"whisper": "шёпот",
"scream": "громко",
"private": "лично",
"system": "Системное сообщение"
},
"unknownAuthor": "неизвестный", "unknownAuthor": "неизвестный",
"start": { "start": {
"title": "Это начало комнаты #{{name}}", "title": "Это начало комнаты #{{name}}",
@@ -365,7 +371,20 @@
"denied": "Нет права отправлять сообщения в эту комнату.", "denied": "Нет права отправлять сообщения в эту комнату.",
"readOnly": "В эту комнату нельзя писать.", "readOnly": "В эту комнату нельзя писать.",
"directLabel": "Сообщение для {{name}}", "directLabel": "Сообщение для {{name}}",
"directPlaceholder": "Написать {{name}}" "directPlaceholder": "Написать {{name}}",
"autocomplete": {
"hint": "↑/↓ — выбор, Enter — вставить, Escape — закрыть",
"mentions": "Участники сервера",
"channels": "Комнаты сервера",
"emoji": "Эмодзи",
"commands": "Команды"
},
"commands": {
"me": "действие: имя и текст курсивом",
"whisper": "шёпот: приглушённое сообщение",
"scream": "громко: крупный акцентный текст",
"ls": "личное сообщение: /ls логин текст"
}
}, },
"typing_one": "{{names}} печатает…", "typing_one": "{{names}} печатает…",
"typing_few": "{{names}} печатают…", "typing_few": "{{names}} печатают…",
+138
View File
@@ -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<Record<string, AutocompleteKind>> = {
'@': '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) };
}
+5
View File
@@ -130,6 +130,11 @@ export function canGroup(previous: MessageLike, next: MessageLike): boolean {
if (previous.author_id === undefined || previous.author_id !== next.author_id) { if (previous.author_id === undefined || previous.author_id !== next.author_id) {
return false; return false;
} }
// Действия, шёпот, крик, личные и системные сообщения оформляются отдельно:
// в общую группу они не попадают ни как следующие, ни как предыдущие.
if ((previous.type ?? 'default') !== 'default') {
return false;
}
if (next.reply_to_id !== undefined) { if (next.reply_to_id !== undefined) {
return false; return false;
} }
+155
View File
@@ -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;
}
+19 -11
View File
@@ -16,7 +16,8 @@
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`, * поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``, * `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`, * ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`. * `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
* `<#id>` и `#канал`.
* *
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую * Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть. * пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
@@ -32,6 +33,7 @@ export type InlineKind =
| 'spoiler' | 'spoiler'
| 'mark' | 'mark'
| 'mention' | 'mention'
| 'channel-link'
| 'link' | 'link'
| 'channel'; | 'channel';
@@ -53,12 +55,15 @@ export interface InlineToken {
/** /**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**` * Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался * и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек * ссылкой, а `<#id>` — раньше `#канала`, иначе угловые скобки остались бы
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри * текстом. Идентификатор в `<@id>`/`<#id>` — снежинка; допускаем и другую
* слова (`foo_bar_baz`). * запись без пробелов и угловых скобок, чтобы ссылка не разваливалась на
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
* `_` — внутри слова (`foo_bar_baz`).
*/ */
const INLINE_PATTERN = const INLINE_PATTERN =
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu; /(`[^`\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 GROUP_KINDS: readonly InlineKind[] = [ const GROUP_KINDS: readonly InlineKind[] = [
@@ -72,6 +77,7 @@ const GROUP_KINDS: readonly InlineKind[] = [
'italic', 'italic',
'mark', 'mark',
'mention', 'mention',
'channel-link',
'link', 'link',
'channel', 'channel',
]; ];
@@ -85,8 +91,10 @@ const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
strike: 2, strike: 2,
spoiler: 2, spoiler: 2,
mark: 2, mark: 2,
// У упоминаний, ссылок и каналов маркеров нет: содержимое — весь токен. // У упоминаний, ссылок на комнаты, ссылок и каналов маркеров нет:
// содержимое — весь токен.
mention: 0, mention: 0,
'channel-link': 0,
link: 0, link: 0,
channel: 0, channel: 0,
}; };
@@ -107,8 +115,8 @@ function contentBounds(
raw: string, raw: string,
start: number, start: number,
): { contentStart: number; contentEnd: number } { ): { contentStart: number; contentEnd: number } {
if (kind === 'mention') { if (kind === 'mention' || kind === 'channel-link') {
// `<@123>`: содержимое — идентификатор без `<@` и `>`. // `<@123>` и `<#123>`: содержимое — идентификатор без `<@`/`<#` и `>`.
return { contentStart: start + 2, contentEnd: start + raw.length - 1 }; return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
} }
const marker = MARKER_LENGTH[kind]; const marker = MARKER_LENGTH[kind];
@@ -141,11 +149,11 @@ export function parseInline(text: string, offset = 0): InlineToken[] {
} }
/** /**
* Есть ли у токена парные маркеры. У упоминаний, ссылок и каналов их нет: * Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
* содержимое — весь токен, прятать нечего. * каналов их нет: содержимое — весь токен, прятать нечего.
*/ */
export function hasMarkers(kind: InlineKind): boolean { export function hasMarkers(kind: InlineKind): boolean {
return kind !== 'mention' && kind !== 'link' && kind !== 'channel'; return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
} }
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */ /** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
+492
View File
@@ -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(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={channel}
guildId={guildId}
authors={authors}
direct={channel.type === 'dm'}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
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('@Боб');
});
});
+8
View File
@@ -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('строит строки ленты с разделителями дат', () => { it('строит строки ленты с разделителями дат', () => {
const rows = buildChatRows( const rows = buildChatRows(
[ [
+1
View File
@@ -52,6 +52,7 @@ function renderComposer() {
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<Composer <Composer
channel={channel} channel={channel}
guildId={null}
authors={authors} authors={authors}
replyTo={null} replyTo={null}
onCancelReply={() => undefined} onCancelReply={() => undefined}
+29
View File
@@ -158,4 +158,33 @@ describe('разметка: упоминания каналов', () => {
expect(container.querySelectorAll('span')).toHaveLength(0); expect(container.querySelectorAll('span')).toHaveLength(0);
expect(container.textContent).toBe('см. #общий'); 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(
<MessageContent
content={'<@42> пишет в <#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>');
});
}); });
+198
View File
@@ -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<Message> & { 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(
<MessageList
channelId="c-1"
channelName="общий"
messages={messages}
status="ready"
error={null}
hasMore={false}
loadingMore={false}
unreadAnchorId={null}
highlightId={null}
authors={authors}
currentUserId="user-1"
canManageMessages
canSend
onReload={() => undefined}
onLoadMore={() => undefined}
onReply={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();
});
});