b01eb1f34c
Полноценный текстовый чат в веб-клиенте:
- стор сообщений по комнатам (кэш 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 зелёные.
49 lines
1.5 KiB
TypeScript
49 lines
1.5 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
|
import { useMessagesStore, type TypingEntry } from '@/stores/messages';
|
|
|
|
interface TypingIndicatorProps {
|
|
channelId: string;
|
|
typing: TypingEntry[];
|
|
authors: AuthorDirectory;
|
|
}
|
|
|
|
/** Как часто проверяем, не погасли ли события `TYPING_START`. */
|
|
const PRUNE_INTERVAL_MS = 1000;
|
|
|
|
/**
|
|
* Строка «печатает…» под лентой. Живёт 6 секунд с последнего события
|
|
* `TYPING_START` (событие приходит раз в ~3 с, пока пользователь пишет).
|
|
*/
|
|
export function TypingIndicator({ channelId, typing, authors }: TypingIndicatorProps) {
|
|
const { t } = useTranslation();
|
|
|
|
useEffect(() => {
|
|
if (typing.length === 0) {
|
|
return;
|
|
}
|
|
const timer = window.setInterval(() => {
|
|
useMessagesStore.getState().pruneTyping();
|
|
}, PRUNE_INTERVAL_MS);
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [typing.length, channelId]);
|
|
|
|
if (typing.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const names = typing
|
|
.map((entry) => authors.resolve(entry.user_id).name)
|
|
.filter((name) => name !== '');
|
|
|
|
return (
|
|
<p role="status" data-testid="typing-indicator" className="px-4 pb-1 text-xs text-fg-muted">
|
|
{t('chat.typing', { count: names.length, names: names.join(', ') })}
|
|
</p>
|
|
);
|
|
}
|