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:
2026-09-20 00:15:18 +03:00
parent b22db8f048
commit b01eb1f34c
34 changed files with 6417 additions and 52 deletions
+349
View File
@@ -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>
);
}