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
+77 -1
View File
@@ -13,9 +13,16 @@ import {
parseChannelPayload,
parseGuildId,
parseGuildUpdate,
parseMessageDelete,
parseMessagePayload,
parsePinsUpdate,
parseReactionEvent,
parseReadStateUpdate,
parseRoleEvent,
parseTypingStart,
parseUserUpdate,
} from '@/api/gatewayEvents';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/**
@@ -63,6 +70,8 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
onInvalidSession: () => {
set({ invalidated: true, status: 'disconnected' });
useSessionStore.getState().reset();
// Сессия мертва: лента сообщений тоже больше не наша.
useMessagesStore.getState().reset();
void invalidateCurrentUser();
},
onResumeIncomplete: () => {
@@ -87,9 +96,10 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
},
}));
/** Разбор события DISPATCH: в Фазе 1 обрабатываем READY/RESUMED, остальные — в диспетчер. */
/** Разбор события DISPATCH: READY/RESUMED, гильдии/каналы и Фаза 2 (сообщения). */
export function dispatchGatewayEvent(event: GatewayDispatch): void {
const session = useSessionStore.getState();
const messages = useMessagesStore.getState();
switch (event.t) {
case 'READY': {
const snapshot = parseGatewaySnapshot(event.d);
@@ -97,6 +107,15 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
return;
}
session.applyReady(snapshot);
// Снапшот несёт отметки прочтения — переносим их в стор сообщений.
for (const raw of snapshot.read_states) {
const readState = parseReadStateUpdate(raw);
if (readState !== null) {
messages.applyReadState(readState);
}
}
// После этого отсутствие записи для комнаты означает «не читалась».
messages.markSnapshotApplied();
useGatewayStore.setState({
status: 'connected',
heartbeatIntervalMs: snapshot.heartbeat_interval_ms,
@@ -163,9 +182,66 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
return;
}
session.removeChannel(deleted.guild_id, deleted.channel_id);
messages.dropChannel(deleted.channel_id);
syncUrlWithSelection();
return;
}
case 'MESSAGE_CREATE': {
const message = parseMessagePayload(event.d);
if (message !== null) {
messages.applyMessageCreate(message);
}
return;
}
case 'MESSAGE_UPDATE': {
const message = parseMessagePayload(event.d);
if (message !== null) {
messages.applyMessageUpdate(message);
}
return;
}
case 'MESSAGE_DELETE': {
const deleted = parseMessageDelete(event.d);
if (deleted !== null) {
messages.applyMessageDelete(deleted.channel_id, deleted.id);
}
return;
}
case 'MESSAGE_REACTION_ADD':
case 'MESSAGE_REACTION_REMOVE': {
const reaction = parseReactionEvent(event.d);
if (reaction !== null) {
messages.applyReaction(
reaction.channel_id,
reaction.message_id,
reaction.emoji,
reaction.user_id,
event.t === 'MESSAGE_REACTION_ADD',
);
}
return;
}
case 'CHANNEL_PINS_UPDATE': {
const update = parsePinsUpdate(event.d);
if (update !== null) {
messages.applyPinsUpdate(update.channel_id, update.message_id, update.pinned);
}
return;
}
case 'TYPING_START': {
const typing = parseTypingStart(event.d);
if (typing !== null) {
messages.applyTypingStart(typing.channel_id, typing.user_id);
}
return;
}
case 'READ_STATE_UPDATE': {
const readState = parseReadStateUpdate(event.d);
if (readState !== null) {
messages.applyReadState(readState);
}
return;
}
case 'MEMBER_UPDATE':
case 'MEMBER_REMOVE': {
// Роли участника влияют на права: перечитываем участников и карточку сервера.