feat(web): чат Фазы 2 — лента, композер, реакции, ответы, поиск и вложения
Полноценный текстовый чат в веб-клиенте:
- стор сообщений по комнатам (кэш 200, оптимистичная отправка с nonce,
применение всех событий Gateway, пагинация курсором, непрочитанные и
упоминания, сброс при логауте и INVALID_SESSION);
- лента: группировка подряд идущих сообщений одного автора, разделители дат,
разделитель «новые сообщения», подгрузка истории вверх, пустые состояния;
- разметка: жирный/курсив/код/блок кода/зачёркнутый/цитата/ссылки (только
http(s)) и упоминания <@id> — безопасный рендер React-узлами без
dangerouslySetInnerHTML;
- реакции чипами с подсветкой своих, быстрые эмодзи и пикер, панель действий и
контекстное меню (ответ, реакция, закрепление, правка, удаление, копирование);
- ответы с переходом к оригиналу, inline-правка, «печатает…» с POST /typing раз
в 3 с, пины в модалке, поиск по комнате с подсветкой и переходом;
- композер: автовысота, Enter/Shift+Enter, лимит 4000, drag&drop и скрепка
(POST /channels/{id}/files → attachment_ids), обработка 429 с таймером и
perm.denied с причиной, read-only без SEND_MESSAGES;
- бейджи непрочитанных и упоминаний в сайдбаре, авто-POST /ack при активном окне;
- 176 тестов Vitest (было 100), check/lint/test/build зелёные.
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
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;
|
||||
/** Сообщение, на которое отвечаем (плашка над полем ввода). */
|
||||
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,
|
||||
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 textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
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;
|
||||
|
||||
// Автовысота: от одной строки до ~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<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('');
|
||||
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 = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
aria-label={t('chat.composer.label', { name: channel.name })}
|
||||
placeholder={t('chat.composer.placeholder', { name: channel.name })}
|
||||
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
|
||||
rows={1}
|
||||
value={value}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
disabled={blockedReason !== null}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
useMessagesStore.getState().notifyTyping(channel.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (event.key === 'Escape' && replyTo !== null) {
|
||||
onCancelReply();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user