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([]); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [notice, setNotice] = useState(null); const [sendError, setSendError] = useState(null); const [sending, setSending] = useState(false); const [denied, setDenied] = useState(null); const [retryAt, setRetryAt] = useState(null); /** Тикающее «сейчас»: по нему считается остаток лимита частоты. */ const [now, setNow] = useState(() => Date.now()); const [dragging, setDragging] = useState(false); /** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */ const [selection, setSelection] = useState({ start: 0, end: 0 }); /** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */ const [dismissedKey, setDismissedKey] = useState(null); /** * Активный вариант подсказки: выбор привязан к позиции каретки, поэтому при * её смене (новый ввод) он снова первый — без эффекта и лишнего рендера. */ const [activePick, setActivePick] = useState({ caret: 0, index: 0 }); const textareaRef = useRef(null); const mirrorRef = useRef(null); const fileInputRef = useRef(null); /** * Каретка после программной вставки подсказки. Ставим её в layout-эффекте: * к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы * каретку в конце и слой подсветки разъехался бы с полем. */ const pendingCaret = useRef(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 => { 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 (
{ 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 : (
{t('chat.reply.composer', { name: authors.resolve(replyTo.author_id).name })}
)} {files.length === 0 ? null : (
    {files.map((file) => { const url = fileContentUrl(file.file_id); const isImage = file.content_type.startsWith('image/'); return (
  • {isImage ? ( ) : ( )} {file.filename} {formatBytes(file.size_bytes ?? 0, i18n.language)}
  • ); })}
)} {notice === null ? null : (

{notice}

)}
{ const list = event.target.files; if (list !== null) { void uploadFiles([...list]); } event.target.value = ''; }} /> {/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и подсветка должны выглядеть одинаково неактивными. */}
setActivePick({ caret: selection.end, index })} onPick={(item) => { if (suggestion !== null) { insertAutocomplete(suggestion, item); } }} />