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
+6 -2
View File
@@ -156,8 +156,12 @@ export default function AppLayout() {
/>
<ChannelSidebar guildId={guildId} channelId={channelId} />
<div className="flex min-w-0 flex-1 flex-col">
{/* Ключ по пути: смена комнаты/сервера появляется с анимацией. */}
<div key={location.pathname} className="gl-page min-h-0 flex-1 overflow-auto">
{/*
Ключ по пути: смена комнаты/сервера появляется с анимацией.
`relative` нужен чату: его область позиционируется absolute inset-0
и скроллится изнутри, а не всей страницей.
*/}
<div key={location.pathname} className="gl-page relative min-h-0 flex-1 overflow-auto">
<Outlet />
</div>
<UserPanel />
+52 -14
View File
@@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next';
import { Link, NavLink, useNavigate } from 'react-router';
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
import type { Channel } from '@/api/types';
import { ChannelBadge } from '@/components/chat/ChannelBadge';
import { useChannelUnread } from '@/components/chat/useChannelUnread';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
@@ -20,6 +23,50 @@ interface ChannelSidebarProps {
channelId: string | null;
}
/**
* Ссылка на комнату с бейджем непрочитанного. Отдельный компонент нужен,
* чтобы подписка на стор сообщений жила на каждую комнату, а не на весь
* сайдбар (иначе любое новое сообщение перерисовывало бы список целиком).
*/
function ChannelLink({
guildId,
channel,
selected,
}: {
guildId: string | null;
channel: Channel;
selected: boolean;
}) {
const { t } = useTranslation();
const { unread, mentions } = useChannelUnread(channel.id);
const highlighted = !selected && (unread > 0 || mentions > 0);
// aria-label ссылки перекрывает её содержимое, поэтому счётчики озвучиваем здесь.
const label =
mentions > 0
? t('channels.selectMentions', { name: channel.name, count: mentions })
: unread > 0
? t('channels.selectUnread', { name: channel.name, count: unread })
: t('channels.select', { name: channel.name });
return (
<NavLink
to={`/app/${guildId ?? ''}/${channel.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={label}
title={channel.name}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
} ${highlighted ? 'font-semibold text-fg' : ''}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
<ChannelBadge channelId={channel.id} selected={selected} />
</NavLink>
);
}
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const { t } = useTranslation();
@@ -158,20 +205,11 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
<ul className="gl-stagger flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<NavLink
to={`/app/${guildId}/${channel.id}`}
aria-current={channel.id === channelId ? 'page' : undefined}
aria-label={t('channels.select', { name: channel.name })}
title={channel.name}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
channel.id === channelId
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
</NavLink>
<ChannelLink
guildId={guildId}
channel={channel}
selected={channel.id === channelId}
/>
</li>
))}
</ul>
+117 -17
View File
@@ -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>
);
}
+3
View File
@@ -11,6 +11,7 @@ import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/M
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/** Статусы, которые можно выставить из меню: «не в сети» сервер выводит сам. */
@@ -48,6 +49,8 @@ export function UserPanel() {
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
disconnect();
useSessionStore.getState().reset();
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
useMessagesStore.getState().reset();
queryClient.clear();
void navigate('/login', { replace: true });
},