a126f50468
- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее, private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка без аватара и панели действий; реакции, ответы, правка и удаление работают как раньше, группировка не сливает нестандартные виды с обычными; - автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью: @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты, : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится со слоем подсветки; - `<#id>` рендерится как #имя комнаты из снапшота (иначе #id); - 287 тестов Vitest (было 262), check/lint/test/build зелёные.
545 lines
22 KiB
TypeScript
545 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|