Files
glchat/web/src/components/chat/Composer.tsx
T
grendervill a126f50468 feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты
- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и
  курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее,
  private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка
  без аватара и панели действий; реакции, ответы, правка и удаление работают
  как раньше, группировка не сливает нестандартные виды с обычными;
- автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью:
  @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты,
  : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются
  внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится
  со слоем подсветки;
- `<#id>` рендерится как #имя комнаты из снапшота (иначе #id);
- 287 тестов Vitest (было 262), check/lint/test/build зелёные.
2026-09-20 12:06:22 +03:00

545 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/files';
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;
/** Сообщение, на которое отвечаем (плашка над полем ввода). */
replyTo: Message | null;
onCancelReply: () => void;
/** Причина, по которой отправка запрещена (нет прав, комната только для чтения). */
disabledReason: string | null;
canAttach: boolean;
}
/** Максимальная высота автовысоты textarea (~8 строк). */
const MAX_TEXTAREA_HEIGHT_PX = 176;
const LINE_HEIGHT_PX = 22;
/**
* Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов,
* вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки
* (лимит частоты 429 с таймером, отсутствие прав).
*
* Над полем лежит зеркало (`ComposerMirror`): оно рисует разметку, поэтому
* форматирование видно ещё до отправки, а текст самой textarea прозрачный.
* Позиция каретки живёт в состоянии и обновляется на ввод, выделение, клик и
* отпускание клавиши.
*/
export function Composer({
channel,
guildId,
authors,
direct = false,
replyTo,
onCancelReply,
disabledReason,
canAttach,
}: ComposerProps) {
const { t, i18n } = useTranslation();
const [value, setValue] = useState('');
const [files, setFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<unknown>(null);
const [notice, setNotice] = useState<string | null>(null);
const [sendError, setSendError] = useState<unknown>(null);
const [sending, setSending] = useState(false);
const [denied, setDenied] = useState<string | null>(null);
const [retryAt, setRetryAt] = useState<number | null>(null);
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
const [now, setNow] = useState(() => Date.now());
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`); если он неизвестен,
// ограничение проверяет сам сервер, а мы показываем его ошибку.
const meta = useQuery({
queryKey: metaQueryKey,
queryFn: ({ signal }) => fetchMeta(signal),
staleTime: 300_000,
retry: 0,
});
const maxUploadSize = meta.data?.max_upload_size;
/** Прокрутка зеркала повторяет прокрутку поля (текст длиннее восьми строк). */
const syncScroll = useCallback(() => {
const element = textareaRef.current;
const mirror = mirrorRef.current;
if (element === null || mirror === null) {
return;
}
mirror.scrollTop = element.scrollTop;
mirror.scrollLeft = element.scrollLeft;
}, []);
/** Обновление каретки: лишних перерисовок не делаем. */
const syncSelection = useCallback((element: HTMLTextAreaElement) => {
const start = element.selectionStart;
const end = element.selectionEnd;
setSelection((current) =>
current.start === start && current.end === end ? current : { start, end },
);
}, []);
// Автовысота: от одной строки до ~8.
useEffect(() => {
const element = textareaRef.current;
if (element === null) {
return;
}
element.style.height = 'auto';
const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8);
element.style.height = `${String(Math.min(element.scrollHeight, max))}px`;
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(() => {
if (retryAt === null) {
return;
}
const tick = (): void => {
const current = Date.now();
setNow(current);
if (current >= retryAt) {
setRetryAt(null);
}
};
const timer = window.setInterval(tick, 500);
return () => {
window.clearInterval(timer);
};
}, [retryAt]);
const secondsLeft = retryAt === null ? 0 : Math.max(0, Math.ceil((retryAt - now) / 1000));
const blockedReason = disabledReason ?? denied;
const rateLimited = secondsLeft > 0;
const canSubmit =
blockedReason === null &&
!rateLimited &&
!sending &&
!uploading &&
(value.trim() !== '' || files.length > 0);
const uploadFiles = useCallback(
async (incoming: File[]) => {
if (!canAttach || incoming.length === 0) {
return;
}
setUploadError(null);
setNotice(null);
const tooLarge =
maxUploadSize === undefined
? undefined
: incoming.find((file) => file.size > maxUploadSize);
if (tooLarge !== undefined) {
setNotice(
t('chat.composer.tooLarge', {
name: tooLarge.name,
limit: formatBytes(maxUploadSize ?? 0, i18n.language),
}),
);
return;
}
setUploading(true);
try {
const uploaded: UploadedFile[] = [];
for (const file of incoming) {
uploaded.push(await uploadChannelFile(channel.id, file));
}
setFiles((current) => [...current, ...uploaded]);
} catch (error) {
setUploadError(error);
} finally {
setUploading(false);
}
},
[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)) {
return;
}
setSending(true);
setSendError(null);
try {
await useMessagesStore.getState().send({
channelId: channel.id,
content,
...(replyTo === null ? {} : { replyToId: replyTo.id }),
...(files.length === 0 ? {} : { files }),
});
setValue('');
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
setSelection({ start: 0, end: 0 });
// Подсказок после отправки нет: отказ по Escape тоже больше не нужен.
setDismissedKey(null);
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
setRetryAt(null);
onCancelReply();
} catch (error) {
// Текст и вложения остаются в композере — пользователь может повторить.
setSendError(error);
const code = errorCode(error);
if (code === 'perm.denied') {
setDenied(t('chat.composer.denied'));
}
const retry = retryAfterMs(error);
if (retry !== null) {
setNow(Date.now());
setRetryAt(Date.now() + retry);
}
} finally {
setSending(false);
}
};
const counter = value.length;
const nearLimit = counter >= MESSAGE_MAX_LENGTH - 200;
return (
<div
className="border-t border-border/40 px-3 pb-3 pt-2"
data-testid="composer"
onDragOver={(event) => {
if (!canAttach) {
return;
}
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (!canAttach) {
return;
}
event.preventDefault();
setDragging(false);
void uploadFiles([...event.dataTransfer.files]);
}}
>
{replyTo === null ? null : (
<div className="mb-2 flex items-center justify-between gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<span className="truncate">
{t('chat.reply.composer', { name: authors.resolve(replyTo.author_id).name })}
</span>
<button
type="button"
aria-label={t('chat.reply.cancel')}
className="rounded-[var(--radius-sm)] px-1 text-sm leading-none text-fg-muted hover:text-fg"
onClick={onCancelReply}
>
×
</button>
</div>
)}
{files.length === 0 ? null : (
<ul className="mb-2 flex flex-wrap gap-2" aria-label={t('chat.composer.attachments')}>
{files.map((file) => {
const url = fileContentUrl(file.file_id);
const isImage = file.content_type.startsWith('image/');
return (
<li
key={file.file_id}
className="flex items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-xs"
>
{isImage ? (
<img src={url} alt="" className="h-8 w-8 rounded object-cover" />
) : (
<span aria-hidden="true">📎</span>
)}
<span className="max-w-40 truncate">{file.filename}</span>
<span className="text-fg-muted">
{formatBytes(file.size_bytes ?? 0, i18n.language)}
</span>
<button
type="button"
aria-label={t('chat.composer.removeAttachment', { name: file.filename })}
className="rounded-[var(--radius-sm)] px-1 leading-none text-fg-muted hover:text-danger"
onClick={() => {
setFiles((current) => current.filter((item) => item.file_id !== file.file_id));
}}
>
×
</button>
</li>
);
})}
</ul>
)}
{notice === null ? null : (
<p role="status" className="mb-1 text-xs text-warning">
{notice}
</p>
)}
<div
className={`flex items-end gap-2 rounded-[var(--radius-md)] border bg-surface-2 px-2 py-1 ${
dragging ? 'border-accent' : 'border-border/60'
}`}
>
<button
type="button"
aria-label={t('chat.composer.attach')}
title={t('chat.composer.attach')}
disabled={!canAttach || uploading || blockedReason !== null}
className="rounded-[var(--radius-sm)] px-1 pb-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => fileInputRef.current?.click()}
>
📎
</button>
<input
ref={fileInputRef}
type="file"
multiple
hidden
aria-label={t('chat.composer.fileInput')}
onChange={(event) => {
const list = event.target.files;
if (list !== null) {
void uploadFiles([...list]);
}
event.target.value = '';
}}
/>
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
подсветка должны выглядеть одинаково неактивными. */}
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
<ComposerMirror
value={value}
selection={selection}
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', {
name: channel.name,
})}
placeholder={t(
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}`}
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
// Текст невидим, поэтому подчёркивание проверки орфографии рисовалось
// бы поверх подсветки.
spellCheck={false}
onChange={(event) => {
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) => {
syncSelection(event.currentTarget);
}}
onKeyUp={(event) => {
syncSelection(event.currentTarget);
}}
onClick={(event) => {
syncSelection(event.currentTarget);
}}
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();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
</div>
<button
type="button"
aria-label={t('chat.composer.send')}
title={t('chat.composer.send')}
disabled={!canSubmit || rateLimited}
className="rounded-[var(--radius-sm)] px-2 pb-1 text-lg leading-none text-accent transition-transform duration-[var(--duration-fast)] hover:opacity-90 active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => void submit()}
>
➤
</button>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-fg-muted">
<span>
{blockedReason === null
? uploading
? t('chat.composer.uploading')
: t('chat.composer.hint')
: blockedReason}
</span>
<span className={nearLimit ? 'text-warning' : undefined}>
{t('chat.composer.counter', { count: counter, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{rateLimited ? (
<p role="alert" className="mt-1 text-xs text-warning">
{t('chat.composer.rateLimited', { seconds: secondsLeft })}
</p>
) : null}
{uploadError === null ? null : <ErrorNotice className="mt-1" error={uploadError} />}
{sendError === null ? null : <ErrorNotice className="mt-1" error={sendError} />}
</div>
);
}