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:
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user