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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user