diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 85aeb46..872d241 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -40,6 +40,31 @@ export function apiErrorCode(error: unknown): string | null { return isApiErrorPayload(payload) ? payload.code : null; } +/** + * Дополнительное поле конверта ошибки (например, `retry_after_ms` у 429). + * + * Сервер кладёт детали рядом с `code`/`message`, а не во вложенный `details`, + * поэтому ищем значение в исходном конверте; `details` остаётся запасным + * вариантом для ошибок, собранных вручную. + */ +export function apiErrorField(error: unknown, key: string): unknown { + if (error === null || typeof error !== 'object') { + return undefined; + } + const record = error as { apiError?: unknown; details?: unknown }; + if (isApiErrorPayload(record.apiError)) { + const value = (record.apiError as unknown as Record)[key]; + if (value !== undefined) { + return value; + } + } + const details = record.details; + if (typeof details === 'object' && details !== null) { + return (details as Record)[key]; + } + return undefined; +} + export interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; body?: unknown; diff --git a/web/src/api/files.ts b/web/src/api/files.ts new file mode 100644 index 0000000..928aea5 --- /dev/null +++ b/web/src/api/files.ts @@ -0,0 +1,65 @@ +import { ApiError, isApiErrorPayload, parsePayload } from './client'; + +/** + * Загрузка файлов в комнату (AGENT.md §7.7). + * + * `request()` из `client.ts` умеет только JSON, а здесь тело — multipart, + * поэтому запрос собирается напрямую; разбор ошибок остаётся общим + * (`ApiError` + конверт `{"error":{"code","message"}}`). + */ + +export interface UploadedFile { + file_id: string; + filename: string; + content_type: string; + size_bytes: number; + /** URL из ответа сервера (может вести на files.<домен>). */ + url: string; +} + +const API_PREFIX = '/api/v1'; + +export async function uploadChannelFile( + channelId: string, + file: File, + signal?: AbortSignal, +): Promise { + const form = new FormData(); + form.append('file', file); + + const response = await fetch(`${API_PREFIX}/channels/${encodeURIComponent(channelId)}/files`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + body: form, + ...(signal === undefined ? {} : { signal }), + }); + const text = await response.text(); + const payload = parsePayload(text, response.headers.get('Content-Type')); + + if (!response.ok) { + const envelope = + typeof payload === 'object' && payload !== null + ? (payload as { error?: unknown }).error + : undefined; + if (isApiErrorPayload(envelope)) { + throw new ApiError(response.status, envelope.code, envelope.message, envelope.details); + } + throw new ApiError(response.status, 'internal.error', 'unexpected response'); + } + + const uploaded = (payload as { file?: UploadedFile } | null)?.file; + if (uploaded === undefined || typeof uploaded.file_id !== 'string') { + throw new ApiError(response.status, 'internal.error', 'unexpected response'); + } + return uploaded; +} + +/** + * Ссылка на содержимое файла. Файловый сервис отдаётся тем же origin, а URL + * из ответа может указывать на отдельный поддомен, поэтому предпочитаем + * относительный путь — он работает и в dev-прокси, и на стенде. + */ +export function fileContentUrl(fileId: string): string { + return `/files/${encodeURIComponent(fileId)}`; +} diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 11d53d4..5fcd708 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -1,5 +1,6 @@ import { userStatuses, type Channel, type UserStatus } from './types'; import type { GatewayUser } from './gateway'; +import type { Message, MessageAttachment, MessageReaction } from './messages'; /** * Разбор payload'ов событий шлюза (AGENT.md §8.3). @@ -227,3 +228,196 @@ export function parseChannelPayload(value: unknown): Channel | null { } return channel; } + +/* ------------------------------------------------------------------ */ +/* События сообщений Фазы 2 (AGENT.md §8.3) */ +/* ------------------------------------------------------------------ */ + +export interface MessageDeleteEvent { + id: string; + channel_id: string; +} + +export interface ReactionEvent { + channel_id: string; + message_id: string; + user_id: string; + emoji: string; +} + +export interface PinsUpdateEvent { + channel_id: string; + message_id: string; + pinned: boolean; +} + +export interface TypingStartEvent { + channel_id: string; + user_id: string; +} + +export interface ReadStateEvent { + channel_id: string; + last_message_id?: string; + mention_count: number; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function parseAttachment(value: unknown): MessageAttachment | null { + const record = asRecord(value); + const fileId = asString(record?.['file_id']); + const filename = asString(record?.['filename']); + if (record === null || fileId === undefined || filename === undefined) { + return null; + } + const attachment: MessageAttachment = { file_id: fileId, filename }; + const contentType = asString(record['content_type']); + const sizeBytes = asNumber(record['size_bytes']); + const width = asNumber(record['width']); + const height = asNumber(record['height']); + if (contentType !== undefined) { + attachment.content_type = contentType; + } + if (sizeBytes !== undefined) { + attachment.size_bytes = sizeBytes; + } + if (width !== undefined) { + attachment.width = width; + } + if (height !== undefined) { + attachment.height = height; + } + return attachment; +} + +function parseReaction(value: unknown): MessageReaction | null { + const record = asRecord(value); + const emoji = asString(record?.['emoji']); + if (record === null || emoji === undefined) { + return null; + } + const reaction: MessageReaction = { + emoji, + count: asNumber(record['count']) ?? 0, + me: record['me'] === true, + }; + const userIds = asStringArray(record['user_ids']); + if (userIds !== undefined) { + reaction.user_ids = userIds; + } + return reaction; +} + +/** Сообщение — та же форма, что в REST (payload `MESSAGE_CREATE`/`MESSAGE_UPDATE`). */ +export function parseMessagePayload(value: unknown): Message | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const channelId = asString(record['channel_id']); + const createdAt = asString(record['created_at']); + if (id === undefined || channelId === undefined || createdAt === undefined) { + return null; + } + const message: Message = { + id, + channel_id: channelId, + content: typeof record['content'] === 'string' ? record['content'] : '', + type: asString(record['type']) ?? 'default', + pinned: record['pinned'] === true, + attachments: Array.isArray(record['attachments']) + ? record['attachments'] + .map(parseAttachment) + .filter((item): item is MessageAttachment => item !== null) + : [], + mentions: asStringArray(record['mentions']) ?? [], + reactions: Array.isArray(record['reactions']) + ? record['reactions'] + .map(parseReaction) + .filter((item): item is MessageReaction => item !== null) + : [], + created_at: createdAt, + }; + const authorId = asString(record['author_id']); + const replyToId = asString(record['reply_to_id']); + const editedAt = asString(record['edited_at']); + if (authorId !== undefined) { + message.author_id = authorId; + } + if (replyToId !== undefined) { + message.reply_to_id = replyToId; + } + if (editedAt !== undefined) { + message.edited_at = editedAt; + } + return message; +} + +export function parseMessageDelete(value: unknown): MessageDeleteEvent | null { + const record = asRecord(value); + const id = asString(record?.['id']); + const channelId = asString(record?.['channel_id']); + if (record === null || id === undefined || channelId === undefined) { + return null; + } + return { id, channel_id: channelId }; +} + +export function parseReactionEvent(value: unknown): ReactionEvent | null { + const record = asRecord(value); + const channelId = asString(record?.['channel_id']); + const messageId = asString(record?.['message_id']); + const userId = asString(record?.['user_id']); + const emoji = asString(record?.['emoji']); + if ( + record === null || + channelId === undefined || + messageId === undefined || + userId === undefined || + emoji === undefined + ) { + return null; + } + return { channel_id: channelId, message_id: messageId, user_id: userId, emoji }; +} + +export function parsePinsUpdate(value: unknown): PinsUpdateEvent | null { + const record = asRecord(value); + const channelId = asString(record?.['channel_id']); + const messageId = asString(record?.['message_id']); + if (record === null || channelId === undefined || messageId === undefined) { + return null; + } + return { channel_id: channelId, message_id: messageId, pinned: record['pinned'] === true }; +} + +export function parseTypingStart(value: unknown): TypingStartEvent | null { + const record = asRecord(value); + const channelId = asString(record?.['channel_id']); + const userId = asString(record?.['user_id']); + if (record === null || channelId === undefined || userId === undefined) { + return null; + } + return { channel_id: channelId, user_id: userId }; +} + +export function parseReadStateUpdate(value: unknown): ReadStateEvent | null { + const record = asRecord(value); + const channelId = asString(record?.['channel_id']); + if (record === null || channelId === undefined) { + return null; + } + const event: ReadStateEvent = { + channel_id: channelId, + mention_count: asNumber(record['mention_count']) ?? 0, + }; + const lastMessageId = asString(record['last_message_id']); + if (lastMessageId !== undefined) { + event.last_message_id = lastMessageId; + } + return event; +} diff --git a/web/src/api/messages.ts b/web/src/api/messages.ts new file mode 100644 index 0000000..cb8c85a --- /dev/null +++ b/web/src/api/messages.ts @@ -0,0 +1,238 @@ +import { apiErrorField, request } from './client'; + +/** + * Ручки сообщений Фазы 2 (AGENT.md §7.6, §7.16). + * + * Сообщения приходят с сервера от новых к старым (`GET /messages`) — порядок + * нормализует стор (`stores/messages.ts`), здесь только транспорт и типы. + */ + +export const MESSAGE_PAGE_SIZE = 50; +/** Лимит поиска из контракта (`limit=25`). */ +export const MESSAGE_SEARCH_LIMIT = 25; +/** Предельная длина сообщения (совпадает с `maxLength` сервера). */ +export const MESSAGE_MAX_LENGTH = 4000; + +export interface MessageAttachment { + file_id: string; + filename: string; + content_type?: string; + size_bytes?: number; + width?: number; + height?: number; +} + +export interface MessageReaction { + emoji: string; + count: number; + /** Поставил ли реакцию текущий пользователь. */ + me: boolean; + user_ids?: string[]; +} + +export interface Message { + id: string; + channel_id: string; + author_id?: string; + content: string; + reply_to_id?: string; + /** `default` | `system` | `whisper` — контракт допускает расширение. */ + type: string; + edited_at?: string; + pinned: boolean; + attachments: MessageAttachment[]; + mentions: string[]; + reactions: MessageReaction[]; + created_at: string; +} + +/** Оптимистичное сообщение: сервер ещё не подтвердил отправку. */ +export interface PendingMessage extends Message { + pending: true; + nonce: string; +} + +export function isPendingMessage(message: Message): message is PendingMessage { + return (message as { pending?: unknown }).pending === true; +} + +export interface SendMessageInput { + content: string; + reply_to_id?: string; + attachment_ids?: string[]; + /** Клиентский идентификатор для сопоставления оптимистичного сообщения. */ + nonce?: string; +} + +export interface FetchMessagesOptions { + /** Курсор истории: id самого старого загруженного сообщения. */ + before?: string; + limit?: number; +} + +function messagesOf(payload: { messages?: Message[] }): Message[] { + return Array.isArray(payload.messages) ? payload.messages : []; +} + +/** История комнаты: от новых к старым, `before` — курсор вверх. */ +export async function fetchChannelMessages( + channelId: string, + options: FetchMessagesOptions = {}, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams(); + params.set('limit', String(options.limit ?? MESSAGE_PAGE_SIZE)); + if (options.before !== undefined && options.before !== '') { + params.set('before', options.before); + } + const payload = await request<{ messages: Message[] }>( + `/channels/${encodeURIComponent(channelId)}/messages?${params.toString()}`, + signal === undefined ? {} : { signal }, + ); + return messagesOf(payload); +} + +export async function sendChannelMessage( + channelId: string, + input: SendMessageInput, +): Promise { + const payload = await request<{ message: Message }>( + `/channels/${encodeURIComponent(channelId)}/messages`, + { method: 'POST', body: input }, + ); + return payload.message; +} + +export async function searchChannelMessages( + channelId: string, + query: string, + limit = MESSAGE_SEARCH_LIMIT, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams(); + params.set('q', query); + params.set('limit', String(limit)); + const payload = await request<{ messages: Message[] }>( + `/channels/${encodeURIComponent(channelId)}/messages/search?${params.toString()}`, + signal === undefined ? {} : { signal }, + ); + return messagesOf(payload); +} + +export async function editChannelMessage( + channelId: string, + messageId: string, + content: string, +): Promise { + const payload = await request<{ message: Message }>( + `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`, + { method: 'PATCH', body: { content } }, + ); + return payload.message; +} + +export async function deleteChannelMessage(channelId: string, messageId: string): Promise { + await request<{ ok: true }>( + `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`, + { method: 'DELETE' }, + ); +} + +/** Реакция: эмодзи едет в пути, поэтому его нужно кодировать. */ +export async function addMessageReaction( + channelId: string, + messageId: string, + emoji: string, +): Promise { + await request<{ ok: true }>(reactionPath(channelId, messageId, emoji), { method: 'PUT' }); +} + +export async function removeMessageReaction( + channelId: string, + messageId: string, + emoji: string, +): Promise { + await request<{ ok: true }>(reactionPath(channelId, messageId, emoji), { method: 'DELETE' }); +} + +function reactionPath(channelId: string, messageId: string, emoji: string): string { + return `/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent( + messageId, + )}/reactions/${encodeURIComponent(emoji)}`; +} + +export async function fetchChannelPins( + channelId: string, + signal?: AbortSignal, +): Promise { + const payload = await request<{ messages: Message[] }>( + `/channels/${encodeURIComponent(channelId)}/pins`, + signal === undefined ? {} : { signal }, + ); + return messagesOf(payload); +} + +export async function pinChannelMessage(channelId: string, messageId: string): Promise { + await request<{ ok: true }>( + `/channels/${encodeURIComponent(channelId)}/pins/${encodeURIComponent(messageId)}`, + { method: 'PUT' }, + ); +} + +export async function unpinChannelMessage(channelId: string, messageId: string): Promise { + await request<{ ok: true }>( + `/channels/${encodeURIComponent(channelId)}/pins/${encodeURIComponent(messageId)}`, + { method: 'DELETE' }, + ); +} + +/** «Печатает…»: клиент шлёт не чаще раза в ~3 с, пока идёт ввод. */ +export async function sendTypingIndicator(channelId: string): Promise { + await request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/typing`, { + method: 'POST', + }); +} + +/** Отметка прочтения комнаты (без `last_message_id` — «прочитано всё»). */ +export async function acknowledgeChannel(channelId: string, lastMessageId?: string): Promise { + await request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/ack`, { + method: 'POST', + body: lastMessageId === undefined ? {} : { last_message_id: lastMessageId }, + }); +} + +/** + * Пауза из ответа 429. Сервер кладёт `retry_after_ms` рядом с `code`/`message` + * в конверте ошибки, поэтому читаем поле через `apiErrorField`. + */ +export function retryAfterMs(error: unknown): number | null { + const value = apiErrorField(error, 'retry_after_ms'); + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.round(value); + } + return null; +} + +/** Идентификатор оптимистичного сообщения до подтверждения сервером. */ +export function createNonce(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + try { + return crypto.randomUUID(); + } catch { + // Некоторые окружения запрещают randomUUID вне secure context. + } + } + return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** Упоминания вида `<@123>`: сервер извлекает их тем же шаблоном. */ +export function extractMentions(content: string): string[] { + const found = new Set(); + for (const match of content.matchAll(/<@(\d+)>/gu)) { + const id = match[1]; + if (id !== undefined) { + found.add(id); + } + } + return [...found]; +} diff --git a/web/src/components/chat/ChannelBadge.tsx b/web/src/components/chat/ChannelBadge.tsx new file mode 100644 index 0000000..3301ab8 --- /dev/null +++ b/web/src/components/chat/ChannelBadge.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next'; + +import { useChannelUnread } from '@/components/chat/useChannelUnread'; + +interface ChannelBadgeProps { + channelId: string; + /** Активная комната не показывает счётчик: она уже прочитана. */ + selected: boolean; +} + +/** Бейдж комнаты в сайдбаре: упоминания важнее непрочитанных. */ +export function ChannelBadge({ channelId, selected }: ChannelBadgeProps) { + const { t } = useTranslation(); + const { unread, mentions } = useChannelUnread(channelId); + + if (selected) { + return null; + } + if (mentions > 0) { + return ( + + {mentions} + + ); + } + if (unread > 0) { + return ( + + {unread > 99 ? '99+' : unread} + + ); + } + return null; +} diff --git a/web/src/components/chat/ChatView.tsx b/web/src/components/chat/ChatView.tsx new file mode 100644 index 0000000..5566d4d --- /dev/null +++ b/web/src/components/chat/ChatView.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isPendingMessage, type Message } from '@/api/messages'; +import type { Channel } from '@/api/types'; +import { Composer } from '@/components/chat/Composer'; +import { MessageList } from '@/components/chat/MessageList'; +import { TypingIndicator } from '@/components/chat/TypingIndicator'; +import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { useWindowActive } from '@/lib/hooks'; +import { + PERMISSION_ATTACH_FILES, + canManageMessages as canManageMessagesPermission, + canSendMessages, + hasPermission, +} from '@/lib/identity'; +import { useSessionStore } from '@/stores/session'; +import { channelState, useMessagesStore } from '@/stores/messages'; + +interface ChatViewProps { + channel: Channel; + /** Сервер комнаты: нужен для участников и ролей (цвет ника). */ + guildId: string | null; + /** Сообщение, к которому нужно перейти (из поиска или панели пинов). */ + jumpTo?: { id: string; token: number } | null; + onJumpHandled?: () => void; +} + +/** Задержка перед `POST /ack`: успевает собрать пачку новых сообщений. */ +const ACK_DEBOUNCE_MS = 500; +/** Сколько держится подсветка сообщения, к которому перешли. */ +const HIGHLIGHT_MS = 2000; +/** Предел подгрузки истории при переходе к сообщению из поиска или пинов. */ +const MAX_JUMP_PAGES = 5; + +/** + * Чат комнаты: лента, «печатает…», композер и отметка прочтения. + * + * Загрузку истории и события шлюза ведёт `stores/messages.ts`; компонент + * подписывается на запись комнаты и передаёт действия вниз. + */ +export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: ChatViewProps) { + const { t } = useTranslation(); + const channelId = channel.id; + const sessionUser = useSessionStore((state) => state.user); + const guild = useSessionStore( + useCallback( + (state) => + guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null), + [guildId], + ), + ); + const record = useMessagesStore( + useCallback((state) => channelState(state, channelId), [channelId]), + ); + const snapshotApplied = useMessagesStore((state) => state.snapshotApplied); + + const [replyTo, setReplyTo] = useState(null); + const [highlightId, setHighlightId] = useState(null); + /** + * Разделитель «новые сообщения» фиксируем при первой загрузке комнаты: + * после автоматического ack отметка прочтения сдвигается, а линия должна + * остаться на месте. `null` — отметка ещё не зафиксирована. + */ + const [unreadAnchor, setUnreadAnchor] = useState<{ + channelId: string; + messageId: string | null; + } | null>(null); + const handledJump = useRef(null); + /** Размонтирован ли компонент: незавершённый переход не должен трогать стор. */ + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const messages = useMemo( + () => [...record.messages, ...record.pending], + [record.messages, record.pending], + ); + const authorIds = useMemo(() => { + const ids = new Set(); + for (const message of messages) { + if (message.author_id !== undefined) { + ids.add(message.author_id); + } + } + for (const entry of record.typing) { + ids.add(entry.user_id); + } + return [...ids]; + }, [messages, record.typing]); + const authors = useAuthorDirectory(guildId, authorIds); + + const currentUserId = sessionUser?.id ?? null; + const permissions = guild?.my_permissions ?? []; + const isAdmin = sessionUser?.is_instance_admin === true; + const canManage = canManageMessagesPermission(permissions, isAdmin); + const canSend = canSendMessages(permissions, isAdmin, channel.can_send); + const canAttach = canSend && (isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES)); + const disabledReason = canSend ? null : t('chat.composer.readOnly'); + const windowActive = useWindowActive(); + + const newest = messages.at(-1) ?? null; + const newestId = newest?.id ?? null; + const newestPending = newest !== null && isPendingMessage(newest); + + // Первичная загрузка истории при открытии комнаты. + useEffect(() => { + void useMessagesStore.getState().ensureChannel(channelId); + }, [channelId]); + + // Отметка прочтения на момент открытия комнаты (для разделителя). + // Подстройка состояния во время рендера — документированный приём React: + // эффект дал бы лишний кадр без разделителя. + // Ждём, пока станет известна отметка прочтения: либо пришла запись + // read state, либо применён снапшот READY (значит, записи для комнаты нет). + // Иначе разделитель «новые сообщения» терялся бы при быстрой загрузке истории. + const readStateKnown = record.readStateKnown || snapshotApplied; + if (unreadAnchor === null && record.status === 'ready' && readStateKnown) { + setUnreadAnchor({ channelId, messageId: record.lastReadMessageId }); + } + const unreadAnchorId = + unreadAnchor !== null && unreadAnchor.channelId === channelId ? unreadAnchor.messageId : null; + + // Отметка прочтения: комната открыта и окно активно. + useEffect(() => { + if (newestId === null || newestPending) { + return; + } + if (record.lastReadMessageId === newestId) { + return; + } + if (!windowActive || !readStateKnown) { + return; + } + const timer = window.setTimeout(() => { + void useMessagesStore.getState().acknowledge(channelId, newestId); + }, ACK_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + }; + }, [channelId, newestId, newestPending, record.lastReadMessageId, windowActive, readStateKnown]); + + // Подсветка перехода гаснет сама: иначе следующее новое сообщение снова + // прокручивало бы ленту к старому месту. + useEffect(() => { + if (highlightId === null) { + return; + } + const timer = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS); + return () => { + window.clearTimeout(timer); + }; + }, [highlightId]); + + // Переход к сообщению: если его нет в загруженной истории — подгружаем вверх. + useEffect(() => { + if (jumpTo === null || handledJump.current === jumpTo.token) { + return; + } + handledJump.current = jumpTo.token; + setHighlightId(jumpTo.id); + const seek = async (attempt: number): Promise => { + if (!mounted.current) { + return; + } + const state = useMessagesStore.getState(); + const current = channelState(state, channelId); + if (current.messages.some((message) => message.id === jumpTo.id)) { + setHighlightId(jumpTo.id); + return; + } + if (!current.hasMore || attempt >= MAX_JUMP_PAGES) { + return; + } + await state.loadMore(channelId); + await seek(attempt + 1); + }; + // Цель может быть глубже одной страницы — подгружаем историю до неё + // (не больше MAX_JUMP_PAGES страниц за один переход). + void seek(0).finally(() => onJumpHandled?.()); + }, [jumpTo, channelId, onJumpHandled]); + + return ( +
+ { + void useMessagesStore.getState().reloadChannel(channelId); + }} + onLoadMore={() => { + void useMessagesStore.getState().loadMore(channelId); + }} + onReply={setReplyTo} + /> + + setReplyTo(null)} + disabledReason={disabledReason} + canAttach={canAttach} + /> +
+ ); +} diff --git a/web/src/components/chat/Composer.tsx b/web/src/components/chat/Composer.tsx new file mode 100644 index 0000000..2ed8017 --- /dev/null +++ b/web/src/components/chat/Composer.tsx @@ -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([]); + 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 = ''; + }} + /> +