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
+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 { useTranslation } from 'react-i18next';
@@ -6,18 +6,26 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import { ComposerAutocomplete } from '@/components/chat/ComposerAutocomplete';
import {
COMPOSER_TEXT_CLASS,
ComposerMirror,
type ComposerSelection,
} from '@/components/chat/ComposerMirror';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import {
useComposerAutocomplete,
type ComposerAutocompleteState,
} from '@/components/chat/useComposerAutocomplete';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { detectAutocomplete, type AutocompleteItem } from '@/lib/autocomplete';
import { formatBytes, errorCode } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages';
interface ComposerProps {
channel: Channel;
/** Сервер комнаты: нужен для подсказок участников и комнат. */
guildId: string | null;
authors: AuthorDirectory;
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
direct?: boolean;
@@ -45,6 +53,7 @@ const LINE_HEIGHT_PX = 22;
*/
export function Composer({
channel,
guildId,
authors,
direct = false,
replyTo,
@@ -67,10 +76,38 @@ export function Composer({
const [dragging, setDragging] = useState(false);
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
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 mirrorRef = useRef<HTMLDivElement | 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`); если он неизвестен,
// ограничение проверяет сам сервер, а мы показываем его ошибку.
@@ -114,6 +151,20 @@ export function Composer({
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` — иначе интервал тикал бы вечно.
useEffect(() => {
@@ -179,6 +230,27 @@ export function Composer({
[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 content = value.trim();
if (!canSubmit || (content === '' && files.length === 0)) {
@@ -196,6 +268,8 @@ export function Composer({
setValue('');
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
setSelection({ start: 0, end: 0 });
// Подсказок после отправки нет: отказ по Escape тоже больше не нужен.
setDismissedKey(null);
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
@@ -337,6 +411,19 @@ export function Composer({
mirrorRef={mirrorRef}
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
ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
@@ -346,6 +433,13 @@ export function Composer({
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
{ 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}`}
@@ -357,8 +451,17 @@ export function Composer({
// бы поверх подсветки.
spellCheck={false}
onChange={(event) => {
setValue(event.target.value);
const next = event.target.value;
setValue(next);
syncSelection(event.target);
// Контекст подсказки потерян (стёрли триггер) — отказ по Escape
// больше не нужен: следующая подсказка откроется сразу.
if (
dismissedKey !== null &&
detectAutocomplete(next, event.target.selectionStart) === null
) {
setDismissedKey(null);
}
useMessagesStore.getState().notifyTyping(channel.id);
}}
onSelect={(event) => {
@@ -372,6 +475,28 @@ export function Composer({
}}
onScroll={syncScroll}
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) {
event.preventDefault();
void submit();