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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user