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