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
+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;
}