2026-09-20 00:15:18 +03:00
|
|
|
|
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';
|
2026-09-20 11:32:50 +03:00
|
|
|
|
import {
|
|
|
|
|
|
COMPOSER_TEXT_CLASS,
|
|
|
|
|
|
ComposerMirror,
|
|
|
|
|
|
type ComposerSelection,
|
|
|
|
|
|
} from '@/components/chat/ComposerMirror';
|
2026-09-20 00:15:18 +03:00
|
|
|
|
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;
|
2026-09-20 01:39:04 +03:00
|
|
|
|
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
|
|
|
|
|
|
direct?: boolean;
|
2026-09-20 00:15:18 +03:00
|
|
|
|
/** Сообщение, на которое отвечаем (плашка над полем ввода). */
|
|
|
|
|
|
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 с таймером, отсутствие прав).
|
2026-09-20 11:32:50 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Над полем лежит зеркало (`ComposerMirror`): оно рисует разметку, поэтому
|
|
|
|
|
|
* форматирование видно ещё до отправки, а текст самой textarea прозрачный.
|
|
|
|
|
|
* Позиция каретки живёт в состоянии и обновляется на ввод, выделение, клик и
|
|
|
|
|
|
* отпускание клавиши.
|
2026-09-20 00:15:18 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export function Composer({
|
|
|
|
|
|
channel,
|
|
|
|
|
|
authors,
|
2026-09-20 01:39:04 +03:00
|
|
|
|
direct = false,
|
2026-09-20 00:15:18 +03:00
|
|
|
|
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);
|
2026-09-20 11:32:50 +03:00
|
|
|
|
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
|
|
|
|
|
|
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
|
2026-09-20 00:15:18 +03:00
|
|
|
|
|
|
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
2026-09-20 11:32:50 +03:00
|
|
|
|
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
2026-09-20 00:15:18 +03:00
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
|
|
|
|
|
|
// ограничение проверяет сам сервер, а мы показываем его ошибку.
|
|
|
|
|
|
const meta = useQuery({
|
|
|
|
|
|
queryKey: metaQueryKey,
|
|
|
|
|
|
queryFn: ({ signal }) => fetchMeta(signal),
|
|
|
|
|
|
staleTime: 300_000,
|
|
|
|
|
|
retry: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
const maxUploadSize = meta.data?.max_upload_size;
|
|
|
|
|
|
|
2026-09-20 11:32:50 +03:00
|
|
|
|
/** Прокрутка зеркала повторяет прокрутку поля (текст длиннее восьми строк). */
|
|
|
|
|
|
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 },
|
|
|
|
|
|
);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
|
// Автовысота: от одной строки до ~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`;
|
2026-09-20 11:32:50 +03:00
|
|
|
|
syncScroll();
|
|
|
|
|
|
}, [value, syncScroll]);
|
2026-09-20 00:15:18 +03:00
|
|
|
|
|
|
|
|
|
|
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
|
|
|
|
|
|
// Когда пауза истекла, снимаем `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<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('');
|
2026-09-20 11:32:50 +03:00
|
|
|
|
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
|
|
|
|
|
|
setSelection({ start: 0, end: 0 });
|
2026-09-20 00:15:18 +03:00
|
|
|
|
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 = '';
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-09-20 11:32:50 +03:00
|
|
|
|
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
|
|
|
|
|
|
подсветка должны выглядеть одинаково неактивными. */}
|
|
|
|
|
|
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
|
|
|
|
|
|
<ComposerMirror
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
selection={selection}
|
|
|
|
|
|
mirrorRef={mirrorRef}
|
|
|
|
|
|
resolveMention={authors.resolveMention}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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 },
|
|
|
|
|
|
)}
|
|
|
|
|
|
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
|
|
|
|
|
|
// остаются родными для поля ввода.
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
setValue(event.target.value);
|
|
|
|
|
|
syncSelection(event.target);
|
|
|
|
|
|
useMessagesStore.getState().notifyTyping(channel.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onSelect={(event) => {
|
|
|
|
|
|
syncSelection(event.currentTarget);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onKeyUp={(event) => {
|
|
|
|
|
|
syncSelection(event.currentTarget);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
syncSelection(event.currentTarget);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onScroll={syncScroll}
|
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
void submit();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (event.key === 'Escape' && replyTo !== null) {
|
|
|
|
|
|
onCancelReply();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-09-20 00:15:18 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|