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:
@@ -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;
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user