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
+25
View File
@@ -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<string, unknown>)[key];
if (value !== undefined) {
return value;
}
}
const details = record.details;
if (typeof details === 'object' && details !== null) {
return (details as Record<string, unknown>)[key];
}
return undefined;
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
+65
View File
@@ -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<UploadedFile> {
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)}`;
}
+194
View File
@@ -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;
}
+238
View File
@@ -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<Message[]> {
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<Message> {
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<Message[]> {
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<Message> {
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<void> {
await request<{ ok: true }>(
`/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`,
{ method: 'DELETE' },
);
}
/** Реакция: эмодзи едет в пути, поэтому его нужно кодировать. */
export async function addMessageReaction(
channelId: string,
messageId: string,
emoji: string,
): Promise<void> {
await request<{ ok: true }>(reactionPath(channelId, messageId, emoji), { method: 'PUT' });
}
export async function removeMessageReaction(
channelId: string,
messageId: string,
emoji: string,
): Promise<void> {
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<Message[]> {
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<void> {
await request<{ ok: true }>(
`/channels/${encodeURIComponent(channelId)}/pins/${encodeURIComponent(messageId)}`,
{ method: 'PUT' },
);
}
export async function unpinChannelMessage(channelId: string, messageId: string): Promise<void> {
await request<{ ok: true }>(
`/channels/${encodeURIComponent(channelId)}/pins/${encodeURIComponent(messageId)}`,
{ method: 'DELETE' },
);
}
/** «Печатает…»: клиент шлёт не чаще раза в ~3 с, пока идёт ввод. */
export async function sendTypingIndicator(channelId: string): Promise<void> {
await request<{ ok: true }>(`/channels/${encodeURIComponent(channelId)}/typing`, {
method: 'POST',
});
}
/** Отметка прочтения комнаты (без `last_message_id` — «прочитано всё»). */
export async function acknowledgeChannel(channelId: string, lastMessageId?: string): Promise<void> {
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<string>();
for (const match of content.matchAll(/<@(\d+)>/gu)) {
const id = match[1];
if (id !== undefined) {
found.add(id);
}
}
return [...found];
}