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
+169 -6
View File
@@ -3,9 +3,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { vi } from 'vitest';
import { GatewayOp } from '@/api/gateway';
import type { Message } from '@/api/messages';
import { routes } from '@/router';
import { setQueryClient } from '@/lib/queryClient';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import type { User } from '@/api/types';
@@ -110,13 +113,10 @@ export function findRequest(
);
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
export function renderApp(initialPath = '/') {
const queryClient = createTestQueryClient();
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
// поэтому подменяем его на тестовый.
setQueryClient(queryClient);
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
export function resetStores(): void {
useSessionStore.getState().reset();
useMessagesStore.getState().reset();
useGatewayStore.setState({
status: 'idle',
heartbeatIntervalMs: null,
@@ -124,6 +124,15 @@ export function renderApp(initialPath = '/') {
attempts: 0,
invalidated: false,
});
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
export function renderApp(initialPath = '/') {
const queryClient = createTestQueryClient();
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
// поэтому подменяем его на тестовый.
setQueryClient(queryClient);
resetStores();
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
const view = render(
@@ -225,3 +234,157 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
heartbeat_interval_ms: 45_000,
};
}
/* ------------------------------------------------------------------ */
/* Шлюз и сообщения Фазы 2 */
/* ------------------------------------------------------------------ */
/** Подставной WebSocket: рукопожатие и события под контролем теста. */
class FakeGatewaySocket {
static instances: FakeGatewaySocket[] = [];
readyState = 0;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
/** Кадры, отправленные клиентом (IDENTIFY, HEARTBEAT, RESUME). */
readonly sent: { op: number; d: unknown }[] = [];
constructor(readonly url: string) {
FakeGatewaySocket.instances.push(this);
}
send(raw: string): void {
this.sent.push(JSON.parse(raw) as { op: number; d: unknown });
}
close(): void {
this.readyState = 3;
}
open(): void {
this.readyState = 1;
this.onopen?.(new Event('open'));
}
emit(packet: unknown): void {
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
}
dispatch(type: string, payload: unknown, seq = 1): void {
this.emit({ op: GatewayOp.DISPATCH, t: type, s: seq, d: payload });
}
}
export interface GatewayHarness {
/** Открывает сокет, отвечает HELLO и присылает READY со снапшотом. */
greet: () => Promise<void>;
/** Событие DISPATCH от сервера. */
dispatch: (type: string, payload: unknown) => void;
/** Кадры, отправленные клиентом. */
sent: () => { op: number; d: unknown }[];
}
/**
* Подменяет WebSocket подставным и отдаёт управление рукопожатием.
* Снапшот READY уходит после `greet()`: до этого момента шлюз «подключается».
*/
export function installGatewaySocket(snapshot: unknown): GatewayHarness {
FakeGatewaySocket.instances = [];
vi.stubGlobal('WebSocket', FakeGatewaySocket);
const current = (): FakeGatewaySocket => {
const socket = FakeGatewaySocket.instances[0];
if (socket === undefined) {
throw new Error('шлюз не подключился');
}
return socket;
};
return {
async greet() {
await waitFor(() => {
expect(FakeGatewaySocket.instances.length).toBeGreaterThan(0);
});
const socket = current();
socket.open();
socket.emit({
op: GatewayOp.HELLO,
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
});
socket.dispatch('READY', snapshot);
},
dispatch(type, payload) {
current().dispatch(type, payload);
},
sent() {
return current().sent;
},
};
}
/** Отложенный ответ: тест сам решает, когда сервер «ответит». */
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
export function deferred<T>(): Deferred<T> {
let settle!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return { promise, resolve: (value) => settle(value) };
}
/** Сообщение для моков и тестов стора. */
export function makeMessage(
overrides: Partial<Message> & { id: string; channel_id: string },
): Message {
return {
content: 'привет',
type: 'default',
pinned: false,
attachments: [],
mentions: [],
reactions: [],
created_at: '2026-09-19T10:00:00Z',
...overrides,
};
}
/** Мок `GET /channels/{id}/messages` и других ручек комнаты. */
export function messagesRoutes(
channelId: string,
messages: Message[],
extra: FetchRoute[] = [],
): FetchRoute[] {
// Дополнительные маршруты идут первыми: сортировка в installFetch устойчива,
// поэтому при одинаковом шаблоне выигрывает маршрут теста.
return [
...extra,
{
match: `/api/v1/channels/${channelId}/messages/search`,
response: () => json({ messages: [] }),
},
{
match: `/api/v1/channels/${channelId}/pins`,
response: () => json({ messages: messages.filter((message) => message.pinned) }),
},
{
match: `/api/v1/channels/${channelId}/messages`,
response: () => json({ messages: [...messages].reverse() }),
},
{
match: `/api/v1/channels/${channelId}/ack`,
method: 'POST',
response: () => json({ ok: true }),
},
{
match: `/api/v1/channels/${channelId}/typing`,
method: 'POST',
response: () => json({ ok: true }),
},
];
}