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 зелёные.
160 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|