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:
+117
-17
@@ -1,12 +1,20 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import type { Message } from '@/api/messages';
|
||||
import { ChatView } from '@/components/chat/ChatView';
|
||||
import { PinsPanel } from '@/components/chat/PinsPanel';
|
||||
import { SearchPanel } from '@/components/chat/SearchPanel';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { canManageMessages, canSendMessages } from '@/lib/identity';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Область контента комнаты. В Фазе 1 — заглушка: сообщения, голос и видео
|
||||
* появятся в следующих фазах, но маршрутизация и выбор комнаты уже работают.
|
||||
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
||||
* для голосовых сохраняется заглушка до следующих фаз.
|
||||
*/
|
||||
export default function GuildView() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,11 +24,42 @@ export default function GuildView() {
|
||||
? null
|
||||
: (state.guilds.find((item) => item.id === params.guildId) ?? null),
|
||||
);
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
|
||||
const channelId = channel?.id ?? null;
|
||||
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [pinsOpen, setPinsOpen] = useState(false);
|
||||
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
|
||||
|
||||
const messages = useMessagesStore((state) =>
|
||||
channelId === null ? undefined : state.channels[channelId]?.messages,
|
||||
);
|
||||
const pins = useMessagesStore((state) =>
|
||||
channelId === null ? undefined : state.channels[channelId]?.pins,
|
||||
);
|
||||
const pinsLoaded = useMessagesStore(
|
||||
(state) => channelId !== null && state.channels[channelId]?.pinsLoaded === true,
|
||||
);
|
||||
|
||||
// Справочник авторов для поиска и пинов: id берём из загруженной ленты.
|
||||
const authorIds = useMemo(
|
||||
() =>
|
||||
(messages ?? [])
|
||||
.map((message: Message) => message.author_id)
|
||||
.filter((id): id is string => id !== undefined),
|
||||
[messages],
|
||||
);
|
||||
const authors = useAuthorDirectory(guild?.id ?? null, authorIds);
|
||||
|
||||
const onJumpHandled = useCallback(() => setJump(null), []);
|
||||
const goToMessage = useCallback((id: string) => {
|
||||
setJump({ id, token: Date.now() });
|
||||
}, []);
|
||||
|
||||
if (params.channelId === undefined || channel === null) {
|
||||
return (
|
||||
<section id="app-content" className="flex h-full items-center justify-center p-6">
|
||||
<section id="app-content" className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<h2 className="text-lg font-semibold">{t('channels.noSelection.title')}</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('channels.noSelection.body')}</p>
|
||||
@@ -30,30 +69,91 @@ export default function GuildView() {
|
||||
}
|
||||
|
||||
const isVoice = channel.type === 'voice';
|
||||
const permissions = guild?.my_permissions ?? [];
|
||||
const isAdmin = sessionUser?.is_instance_admin === true;
|
||||
const canPin = canManageMessages(permissions, isAdmin) && canSendMessages(permissions, isAdmin);
|
||||
// Счётчик пинов: пока панель не открывали, ориентируемся на загруженную ленту.
|
||||
const pinCount = pinsLoaded
|
||||
? (pins?.length ?? 0)
|
||||
: (messages ?? []).filter((message: Message) => message.pinned).length;
|
||||
|
||||
return (
|
||||
<section id="app-content" className="flex h-full flex-col">
|
||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-3">
|
||||
<span aria-hidden="true" className="text-fg-muted">
|
||||
{isVoice ? '🔊' : '#'}
|
||||
</span>
|
||||
<h2 className="text-sm font-semibold">{channel.name}</h2>
|
||||
{channel.slowmode_seconds === undefined || channel.slowmode_seconds === 0 ? null : (
|
||||
<span className="text-xs text-fg-muted">slowmode {channel.slowmode_seconds}s</span>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('channels.slowmode', { seconds: channel.slowmode_seconds })}
|
||||
</span>
|
||||
)}
|
||||
{isVoice ? null : (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat.search.open')}
|
||||
title={t('chat.search.open')}
|
||||
aria-expanded={searchOpen}
|
||||
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||
onClick={() => setSearchOpen((open) => !open)}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat.pins.open')}
|
||||
title={t('chat.pins.open')}
|
||||
className="flex items-center gap-1 rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||
onClick={() => setPinsOpen(true)}
|
||||
>
|
||||
📌
|
||||
{pinCount === 0 ? null : <span className="text-xs font-medium">{pinCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t(isVoice ? 'channels.voicePlaceholder.title' : 'channels.chatPlaceholder.title')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
{t(isVoice ? 'channels.voicePlaceholder.body' : 'channels.chatPlaceholder.body', {
|
||||
name: channel.name,
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isVoice ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<h3 className="text-lg font-semibold">{t('channels.voicePlaceholder.title')}</h3>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
{t('channels.voicePlaceholder.body', { name: channel.name })}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SearchPanel
|
||||
channelId={channel.id}
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
authors={authors}
|
||||
onJump={goToMessage}
|
||||
/>
|
||||
<ChatView
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
guildId={guild?.id ?? null}
|
||||
jumpTo={jump}
|
||||
onJumpHandled={onJumpHandled}
|
||||
/>
|
||||
<PinsPanel
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
open={pinsOpen}
|
||||
onClose={() => setPinsOpen(false)}
|
||||
authors={authors}
|
||||
canManageMessages={canPin}
|
||||
onJump={(id) => {
|
||||
setPinsOpen(false);
|
||||
goToMessage(id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user