Files
glchat/web/src/components/chat/Composer.tsx
T
grendervill d02ef7527a fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
2026-09-20 01:39:04 +03:00

357 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
name: channel.name,
})}
placeholder={t(direct ? 'chat.composer.directPlaceholder' : '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>
);
}