import { useCallback, useEffect, 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 type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { formatBytes, errorCode } from '@/lib/format'; import { useMessagesStore } from '@/stores/messages'; interface ComposerProps { channel: Channel; 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 с таймером, отсутствие прав). */ export function Composer({ channel, 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 textareaRef = useRef(null); const fileInputRef = useRef(null); // Предельный размер файла знает сервер (`GET /meta`); если он неизвестен, // ограничение проверяет сам сервер, а мы показываем его ошибку. const meta = useQuery({ queryKey: metaQueryKey, queryFn: ({ signal }) => fetchMeta(signal), staleTime: 300_000, retry: 0, }); const maxUploadSize = meta.data?.max_upload_size; // Автовысота: от одной строки до ~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`; }, [value]); // Таймер лимита частоты: пока он идёт, отправка заблокирована. // Когда пауза истекла, снимаем `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 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(''); 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 = ''; }} />