Files
glchat/web/src/pages/app/GuildView.tsx
T
grendervill b01eb1f34c 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 зелёные.
2026-09-20 00:15:18 +03:00

160 lines
6.4 KiB
TypeScript

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';
/**
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
* для голосовых сохраняется заглушка до следующих фаз.
*/
export default function GuildView() {
const { t } = useTranslation();
const params = useParams<{ guildId?: string; channelId?: string }>();
const guild = useSessionStore((state) =>
params.guildId === undefined
? 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="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>
</Card>
</section>
);
}
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="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">
{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>
{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>
);
}