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:
@@ -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': {
|
||||
// Роли участника влияют на права: перечитываем участников и карточку сервера.
|
||||
|
||||
Reference in New Issue
Block a user