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