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
+42
View File
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { useChannelUnread } from '@/components/chat/useChannelUnread';
interface ChannelBadgeProps {
channelId: string;
/** Активная комната не показывает счётчик: она уже прочитана. */
selected: boolean;
}
/** Бейдж комнаты в сайдбаре: упоминания важнее непрочитанных. */
export function ChannelBadge({ channelId, selected }: ChannelBadgeProps) {
const { t } = useTranslation();
const { unread, mentions } = useChannelUnread(channelId);
if (selected) {
return null;
}
if (mentions > 0) {
return (
<span
data-testid={`channel-mentions-${channelId}`}
title={t('chat.mentions', { count: mentions })}
className="ml-auto rounded-full bg-danger px-1.5 text-[10px] font-semibold leading-4 text-white"
>
{mentions}
</span>
);
}
if (unread > 0) {
return (
<span
data-testid={`channel-unread-${channelId}`}
title={t('chat.unread', { count: unread })}
className="ml-auto rounded-full bg-surface-3 px-1.5 text-[10px] font-semibold leading-4 text-fg"
>
{unread > 99 ? '99+' : unread}
</span>
);
}
return null;
}
+222
View File
@@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isPendingMessage, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { MessageList } from '@/components/chat/MessageList';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { useWindowActive } from '@/lib/hooks';
import {
PERMISSION_ATTACH_FILES,
canManageMessages as canManageMessagesPermission,
canSendMessages,
hasPermission,
} from '@/lib/identity';
import { useSessionStore } from '@/stores/session';
import { channelState, useMessagesStore } from '@/stores/messages';
interface ChatViewProps {
channel: Channel;
/** Сервер комнаты: нужен для участников и ролей (цвет ника). */
guildId: string | null;
/** Сообщение, к которому нужно перейти (из поиска или панели пинов). */
jumpTo?: { id: string; token: number } | null;
onJumpHandled?: () => void;
}
/** Задержка перед `POST /ack`: успевает собрать пачку новых сообщений. */
const ACK_DEBOUNCE_MS = 500;
/** Сколько держится подсветка сообщения, к которому перешли. */
const HIGHLIGHT_MS = 2000;
/** Предел подгрузки истории при переходе к сообщению из поиска или пинов. */
const MAX_JUMP_PAGES = 5;
/**
* Чат комнаты: лента, «печатает…», композер и отметка прочтения.
*
* Загрузку истории и события шлюза ведёт `stores/messages.ts`; компонент
* подписывается на запись комнаты и передаёт действия вниз.
*/
export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: ChatViewProps) {
const { t } = useTranslation();
const channelId = channel.id;
const sessionUser = useSessionStore((state) => state.user);
const guild = useSessionStore(
useCallback(
(state) =>
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
[guildId],
),
);
const record = useMessagesStore(
useCallback((state) => channelState(state, channelId), [channelId]),
);
const snapshotApplied = useMessagesStore((state) => state.snapshotApplied);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
/**
* Разделитель «новые сообщения» фиксируем при первой загрузке комнаты:
* после автоматического ack отметка прочтения сдвигается, а линия должна
* остаться на месте. `null` — отметка ещё не зафиксирована.
*/
const [unreadAnchor, setUnreadAnchor] = useState<{
channelId: string;
messageId: string | null;
} | null>(null);
const handledJump = useRef<number | null>(null);
/** Размонтирован ли компонент: незавершённый переход не должен трогать стор. */
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const messages = useMemo(
() => [...record.messages, ...record.pending],
[record.messages, record.pending],
);
const authorIds = useMemo(() => {
const ids = new Set<string>();
for (const message of messages) {
if (message.author_id !== undefined) {
ids.add(message.author_id);
}
}
for (const entry of record.typing) {
ids.add(entry.user_id);
}
return [...ids];
}, [messages, record.typing]);
const authors = useAuthorDirectory(guildId, authorIds);
const currentUserId = sessionUser?.id ?? null;
const permissions = guild?.my_permissions ?? [];
const isAdmin = sessionUser?.is_instance_admin === true;
const canManage = canManageMessagesPermission(permissions, isAdmin);
const canSend = canSendMessages(permissions, isAdmin, channel.can_send);
const canAttach = canSend && (isAdmin || hasPermission(permissions, PERMISSION_ATTACH_FILES));
const disabledReason = canSend ? null : t('chat.composer.readOnly');
const windowActive = useWindowActive();
const newest = messages.at(-1) ?? null;
const newestId = newest?.id ?? null;
const newestPending = newest !== null && isPendingMessage(newest);
// Первичная загрузка истории при открытии комнаты.
useEffect(() => {
void useMessagesStore.getState().ensureChannel(channelId);
}, [channelId]);
// Отметка прочтения на момент открытия комнаты (для разделителя).
// Подстройка состояния во время рендера — документированный приём React:
// эффект дал бы лишний кадр без разделителя.
// Ждём, пока станет известна отметка прочтения: либо пришла запись
// read state, либо применён снапшот READY (значит, записи для комнаты нет).
// Иначе разделитель «новые сообщения» терялся бы при быстрой загрузке истории.
const readStateKnown = record.readStateKnown || snapshotApplied;
if (unreadAnchor === null && record.status === 'ready' && readStateKnown) {
setUnreadAnchor({ channelId, messageId: record.lastReadMessageId });
}
const unreadAnchorId =
unreadAnchor !== null && unreadAnchor.channelId === channelId ? unreadAnchor.messageId : null;
// Отметка прочтения: комната открыта и окно активно.
useEffect(() => {
if (newestId === null || newestPending) {
return;
}
if (record.lastReadMessageId === newestId) {
return;
}
if (!windowActive || !readStateKnown) {
return;
}
const timer = window.setTimeout(() => {
void useMessagesStore.getState().acknowledge(channelId, newestId);
}, ACK_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [channelId, newestId, newestPending, record.lastReadMessageId, windowActive, readStateKnown]);
// Подсветка перехода гаснет сама: иначе следующее новое сообщение снова
// прокручивало бы ленту к старому месту.
useEffect(() => {
if (highlightId === null) {
return;
}
const timer = window.setTimeout(() => setHighlightId(null), HIGHLIGHT_MS);
return () => {
window.clearTimeout(timer);
};
}, [highlightId]);
// Переход к сообщению: если его нет в загруженной истории — подгружаем вверх.
useEffect(() => {
if (jumpTo === null || handledJump.current === jumpTo.token) {
return;
}
handledJump.current = jumpTo.token;
setHighlightId(jumpTo.id);
const seek = async (attempt: number): Promise<void> => {
if (!mounted.current) {
return;
}
const state = useMessagesStore.getState();
const current = channelState(state, channelId);
if (current.messages.some((message) => message.id === jumpTo.id)) {
setHighlightId(jumpTo.id);
return;
}
if (!current.hasMore || attempt >= MAX_JUMP_PAGES) {
return;
}
await state.loadMore(channelId);
await seek(attempt + 1);
};
// Цель может быть глубже одной страницы — подгружаем историю до неё
// (не больше MAX_JUMP_PAGES страниц за один переход).
void seek(0).finally(() => onJumpHandled?.());
}, [jumpTo, channelId, onJumpHandled]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
channelId={channelId}
channelName={channel.name}
messages={messages}
status={record.status}
error={record.error}
hasMore={record.hasMore}
loadingMore={record.loadingMore}
unreadAnchorId={unreadAnchorId}
highlightId={highlightId}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManage}
canSend={canSend}
onReload={() => {
void useMessagesStore.getState().reloadChannel(channelId);
}}
onLoadMore={() => {
void useMessagesStore.getState().loadMore(channelId);
}}
onReply={setReplyTo}
/>
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
<Composer
channel={channel}
authors={authors}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
disabledReason={disabledReason}
canAttach={canAttach}
/>
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/files';
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { formatBytes, errorCode } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages';
interface ComposerProps {
channel: Channel;
authors: AuthorDirectory;
/** Сообщение, на которое отвечаем (плашка над полем ввода). */
replyTo: Message | null;
onCancelReply: () => void;
/** Причина, по которой отправка запрещена (нет прав, комната только для чтения). */
disabledReason: string | null;
canAttach: boolean;
}
/** Максимальная высота автовысоты textarea (~8 строк). */
const MAX_TEXTAREA_HEIGHT_PX = 176;
const LINE_HEIGHT_PX = 22;
/**
* Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов,
* вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки
* (лимит частоты 429 с таймером, отсутствие прав).
*/
export function Composer({
channel,
authors,
replyTo,
onCancelReply,
disabledReason,
canAttach,
}: ComposerProps) {
const { t, i18n } = useTranslation();
const [value, setValue] = useState('');
const [files, setFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<unknown>(null);
const [notice, setNotice] = useState<string | null>(null);
const [sendError, setSendError] = useState<unknown>(null);
const [sending, setSending] = useState(false);
const [denied, setDenied] = useState<string | null>(null);
const [retryAt, setRetryAt] = useState<number | null>(null);
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
const [now, setNow] = useState(() => Date.now());
const [dragging, setDragging] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
// ограничение проверяет сам сервер, а мы показываем его ошибку.
const meta = useQuery({
queryKey: metaQueryKey,
queryFn: ({ signal }) => fetchMeta(signal),
staleTime: 300_000,
retry: 0,
});
const maxUploadSize = meta.data?.max_upload_size;
// Автовысота: от одной строки до ~8.
useEffect(() => {
const element = textareaRef.current;
if (element === null) {
return;
}
element.style.height = 'auto';
const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8);
element.style.height = `${String(Math.min(element.scrollHeight, max))}px`;
}, [value]);
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
useEffect(() => {
if (retryAt === null) {
return;
}
const tick = (): void => {
const current = Date.now();
setNow(current);
if (current >= retryAt) {
setRetryAt(null);
}
};
const timer = window.setInterval(tick, 500);
return () => {
window.clearInterval(timer);
};
}, [retryAt]);
const secondsLeft = retryAt === null ? 0 : Math.max(0, Math.ceil((retryAt - now) / 1000));
const blockedReason = disabledReason ?? denied;
const rateLimited = secondsLeft > 0;
const canSubmit =
blockedReason === null &&
!rateLimited &&
!sending &&
!uploading &&
(value.trim() !== '' || files.length > 0);
const uploadFiles = useCallback(
async (incoming: File[]) => {
if (!canAttach || incoming.length === 0) {
return;
}
setUploadError(null);
setNotice(null);
const tooLarge =
maxUploadSize === undefined
? undefined
: incoming.find((file) => file.size > maxUploadSize);
if (tooLarge !== undefined) {
setNotice(
t('chat.composer.tooLarge', {
name: tooLarge.name,
limit: formatBytes(maxUploadSize ?? 0, i18n.language),
}),
);
return;
}
setUploading(true);
try {
const uploaded: UploadedFile[] = [];
for (const file of incoming) {
uploaded.push(await uploadChannelFile(channel.id, file));
}
setFiles((current) => [...current, ...uploaded]);
} catch (error) {
setUploadError(error);
} finally {
setUploading(false);
}
},
[canAttach, channel.id, i18n.language, maxUploadSize, t],
);
const submit = async (): Promise<void> => {
const content = value.trim();
if (!canSubmit || (content === '' && files.length === 0)) {
return;
}
setSending(true);
setSendError(null);
try {
await useMessagesStore.getState().send({
channelId: channel.id,
content,
...(replyTo === null ? {} : { replyToId: replyTo.id }),
...(files.length === 0 ? {} : { files }),
});
setValue('');
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
setRetryAt(null);
onCancelReply();
} catch (error) {
// Текст и вложения остаются в композере — пользователь может повторить.
setSendError(error);
const code = errorCode(error);
if (code === 'perm.denied') {
setDenied(t('chat.composer.denied'));
}
const retry = retryAfterMs(error);
if (retry !== null) {
setNow(Date.now());
setRetryAt(Date.now() + retry);
}
} finally {
setSending(false);
}
};
const counter = value.length;
const nearLimit = counter >= MESSAGE_MAX_LENGTH - 200;
return (
<div
className="border-t border-border/40 px-3 pb-3 pt-2"
data-testid="composer"
onDragOver={(event) => {
if (!canAttach) {
return;
}
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (!canAttach) {
return;
}
event.preventDefault();
setDragging(false);
void uploadFiles([...event.dataTransfer.files]);
}}
>
{replyTo === null ? null : (
<div className="mb-2 flex items-center justify-between gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<span className="truncate">
{t('chat.reply.composer', { name: authors.resolve(replyTo.author_id).name })}
</span>
<button
type="button"
aria-label={t('chat.reply.cancel')}
className="rounded-[var(--radius-sm)] px-1 text-sm leading-none text-fg-muted hover:text-fg"
onClick={onCancelReply}
>
×
</button>
</div>
)}
{files.length === 0 ? null : (
<ul className="mb-2 flex flex-wrap gap-2" aria-label={t('chat.composer.attachments')}>
{files.map((file) => {
const url = fileContentUrl(file.file_id);
const isImage = file.content_type.startsWith('image/');
return (
<li
key={file.file_id}
className="flex items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-xs"
>
{isImage ? (
<img src={url} alt="" className="h-8 w-8 rounded object-cover" />
) : (
<span aria-hidden="true">📎</span>
)}
<span className="max-w-40 truncate">{file.filename}</span>
<span className="text-fg-muted">
{formatBytes(file.size_bytes ?? 0, i18n.language)}
</span>
<button
type="button"
aria-label={t('chat.composer.removeAttachment', { name: file.filename })}
className="rounded-[var(--radius-sm)] px-1 leading-none text-fg-muted hover:text-danger"
onClick={() => {
setFiles((current) => current.filter((item) => item.file_id !== file.file_id));
}}
>
×
</button>
</li>
);
})}
</ul>
)}
{notice === null ? null : (
<p role="status" className="mb-1 text-xs text-warning">
{notice}
</p>
)}
<div
className={`flex items-end gap-2 rounded-[var(--radius-md)] border bg-surface-2 px-2 py-1 ${
dragging ? 'border-accent' : 'border-border/60'
}`}
>
<button
type="button"
aria-label={t('chat.composer.attach')}
title={t('chat.composer.attach')}
disabled={!canAttach || uploading || blockedReason !== null}
className="rounded-[var(--radius-sm)] px-1 pb-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => fileInputRef.current?.click()}
>
📎
</button>
<input
ref={fileInputRef}
type="file"
multiple
hidden
aria-label={t('chat.composer.fileInput')}
onChange={(event) => {
const list = event.target.files;
if (list !== null) {
void uploadFiles([...list]);
}
event.target.value = '';
}}
/>
<textarea
ref={textareaRef}
aria-label={t('chat.composer.label', { name: channel.name })}
placeholder={t('chat.composer.placeholder', { name: channel.name })}
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
onChange={(event) => {
setValue(event.target.value);
useMessagesStore.getState().notifyTyping(channel.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
<button
type="button"
aria-label={t('chat.composer.send')}
title={t('chat.composer.send')}
disabled={!canSubmit || rateLimited}
className="rounded-[var(--radius-sm)] px-2 pb-1 text-lg leading-none text-accent transition-transform duration-[var(--duration-fast)] hover:opacity-90 active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => void submit()}
>
➤
</button>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-fg-muted">
<span>
{blockedReason === null
? uploading
? t('chat.composer.uploading')
: t('chat.composer.hint')
: blockedReason}
</span>
<span className={nearLimit ? 'text-warning' : undefined}>
{t('chat.composer.counter', { count: counter, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{rateLimited ? (
<p role="alert" className="mt-1 text-xs text-warning">
{t('chat.composer.rateLimited', { seconds: secondsLeft })}
</p>
) : null}
{uploadError === null ? null : <ErrorNotice className="mt-1" error={uploadError} />}
{sendError === null ? null : <ErrorNotice className="mt-1" error={sendError} />}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { POPULAR_EMOJIS, QUICK_REACTIONS } from '@/lib/chat';
interface EmojiPickerProps {
open: boolean;
onClose: () => void;
onPick: (emoji: string) => void;
/** Якорь: клик по нему не считается кликом «вне пикера». */
anchorRef?: RefObject<HTMLElement | null>;
/** Куда раскрывается панель: под якорем или над ним. */
placement?: 'top' | 'bottom';
className?: string;
}
/**
* Выпадающий пикер эмодзи: ряд быстрых реакций и сетка популярных.
* Закрывается по Escape и клику вне панели; фокус остаётся в пределах
* tab-порядка страницы.
*/
export function EmojiPicker({
open,
onClose,
onPick,
anchorRef,
placement = 'bottom',
className = '',
}: EmojiPickerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: Event): void => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (panelRef.current?.contains(target) === true) {
return;
}
if (anchorRef?.current?.contains(target) === true) {
return;
}
onCloseRef.current();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onCloseRef.current();
}
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open, anchorRef]);
if (!open) {
return null;
}
return (
<div
ref={panelRef}
role="dialog"
aria-label={t('chat.emoji.title')}
className={`absolute right-0 z-40 w-64 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 shadow-[var(--shadow-3)] ${
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
} ${className}`}
>
<div className="flex flex-wrap gap-1" role="group" aria-label={t('chat.emoji.quick')}>
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.emoji.pick', { emoji })}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
<div
role="group"
aria-label={t('chat.emoji.popular')}
className="mt-2 grid max-h-40 grid-cols-6 gap-1 overflow-y-auto border-t border-border/40 pt-2"
>
{POPULAR_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.emoji.pick', { emoji })}
className="rounded-[var(--radius-sm)] py-0.5 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { Fragment, type ReactNode } from 'react';
/**
* Лёгкий безопасный рендер разметки сообщения.
*
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `*курсив*`, `` `код` ``, ```блок кода```, `~~зачёркнутый~~`, `> цитата`,
* ссылки `http(s)://…` и упоминания `<@id>`.
*/
export interface MessageContentProps {
content: string;
/** Как показать упоминание: имя и подсветка «меня». */
resolveMention?: (userId: string) => { name: string; isMe: boolean } | null;
className?: string;
}
const INLINE_PATTERN =
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(<@\d+>)|(https?:\/\/[^\s<>()]+)/gu;
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
function splitLink(raw: string): { href: string; trailing: string } {
let href = raw;
let trailing = '';
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
trailing = (href[href.length - 1] ?? '') + trailing;
href = href.slice(0, -1);
}
return { href, trailing };
}
type MentionResolver = MessageContentProps['resolveMention'];
function renderInline(
text: string,
resolveMention: MentionResolver,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
for (const match of text.matchAll(INLINE_PATTERN)) {
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push(text.slice(lastIndex, start));
}
const token = match[0];
const key = `${keyPrefix}-${String(index)}`;
index += 1;
if (token.startsWith('`')) {
nodes.push(
<code
key={key}
className="rounded-[var(--radius-sm)] bg-surface-3 px-1 py-0.5 font-mono text-[0.85em]"
>
{token.slice(1, -1)}
</code>,
);
} else if (token.startsWith('**')) {
nodes.push(
<strong key={key} className="font-semibold">
{token.slice(2, -2)}
</strong>,
);
} else if (token.startsWith('~~')) {
nodes.push(
<s key={key} className="opacity-70">
{token.slice(2, -2)}
</s>,
);
} else if (token.startsWith('*')) {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
} else if (token.startsWith('<@')) {
const userId = token.slice(2, -1);
const mention = resolveMention?.(userId) ?? null;
nodes.push(
<span
key={key}
className={`rounded-[var(--radius-sm)] px-1 font-medium ${
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
}`}
>
@{mention?.name ?? userId}
</span>,
);
} else {
const { href, trailing } = splitLink(token);
nodes.push(
<Fragment key={key}>
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
>
{href}
</a>
{trailing}
</Fragment>,
);
}
lastIndex = start + token.length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
interface Block {
kind: 'text' | 'quote' | 'code';
lines: string[];
}
/** Разбиение на блоки: цитаты, огороженный код и обычный текст. */
function toBlocks(content: string): Block[] {
const lines = content.split('\n');
const blocks: Block[] = [];
let fence: Block | null = null;
for (const line of lines) {
const fenceMatch = /^```(\w*)\s*$/u.exec(line.trim());
if (fence !== null) {
if (fenceMatch !== null) {
blocks.push(fence);
fence = null;
} else {
fence.lines.push(line);
}
continue;
}
if (fenceMatch !== null) {
fence = { kind: 'code', lines: [] };
continue;
}
if (line.startsWith('> ')) {
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'quote') {
previous.lines.push(line.slice(2));
} else {
blocks.push({ kind: 'quote', lines: [line.slice(2)] });
}
continue;
}
const previous = blocks[blocks.length - 1];
if (previous !== undefined && previous.kind === 'text') {
previous.lines.push(line);
} else {
blocks.push({ kind: 'text', lines: [line] });
}
}
if (fence !== null) {
// Незакрытый блок кода — показываем как код, а не теряем текст.
blocks.push(fence);
}
return blocks;
}
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
const blocks = toBlocks(content);
return (
<div className={`whitespace-pre-wrap break-words ${className}`}>
{blocks.map((block, blockIndex) => {
const key = `block-${String(blockIndex)}`;
if (block.kind === 'code') {
return (
<pre
key={key}
className="my-1 overflow-x-auto rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 font-mono text-xs"
>
<code>{block.lines.join('\n')}</code>
</pre>
);
}
if (block.kind === 'quote') {
return (
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
{renderInline(block.lines.join('\n'), resolveMention, key)}
</blockquote>
);
}
return (
<p key={key} className="min-w-0">
{renderInline(block.lines.join('\n'), resolveMention, key)}
</p>
);
})}
</div>
);
}
+436
View File
@@ -0,0 +1,436 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import { MESSAGE_MAX_LENGTH, type Message } from '@/api/messages';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat';
import { formatBytes } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages';
interface MessageItemProps {
message: Message;
channelId: string;
channelName: string;
authors: AuthorDirectory;
currentUserId: string | null;
canManageMessages: boolean;
canSend: boolean;
/** Не первое сообщение группы: время показываем только при наведении. */
compact: boolean;
/** Оптимистичное сообщение ещё не подтверждено сервером. */
pending?: boolean;
/** Автор сообщения, на которое отвечают (если он есть в загруженной истории). */
replyTarget?: Message | undefined;
highlight?: boolean;
onReply: (message: Message) => void;
onJumpToReply: (messageId: string) => void;
}
const actionButtonClass =
'rounded-[var(--radius-sm)] px-1.5 py-0.5 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95';
/**
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
*
* Действия доступны по hover (быстрые реакции, ответ, меню) и из контекстного
* меню; редактирование — inline, удаление — с подтверждением.
*/
export function MessageItem({
message,
channelId,
channelName,
authors,
currentUserId,
canManageMessages,
canSend,
compact,
pending = false,
replyTarget,
highlight = false,
onReply,
onJumpToReply,
}: MessageItemProps) {
const { t, i18n } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.content);
const [actionError, setActionError] = useState<unknown>(null);
const [copied, setCopied] = useState(false);
/** Свободного места снизу мало — меню и пикер раскрываем вверх. */
const [dropUp, setDropUp] = useState(false);
const menuAnchor = useRef<HTMLButtonElement | null>(null);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const author = authors.resolve(message.author_id);
const isMine = currentUserId !== null && message.author_id === currentUserId;
const canEdit = isMine && canSend && !pending;
const canDelete = !pending && (isMine || canManageMessages);
const edited = message.edited_at !== undefined;
useEffect(() => {
if (editing) {
editorRef.current?.focus();
const length = editorRef.current?.value.length ?? 0;
editorRef.current?.setSelectionRange(length, length);
}
}, [editing]);
useEffect(() => {
if (!copied) {
return;
}
const timer = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timer);
}, [copied]);
/** Направление поповеров считаем по положению кнопки в окне. */
const updateDropDirection = (): void => {
const rect = menuAnchor.current?.getBoundingClientRect();
const spaceBelow = window.innerHeight - (rect?.bottom ?? 0);
setDropUp(spaceBelow < 320);
};
const react = (emoji: string): void => {
setPickerOpen(false);
setMenuOpen(false);
void useMessagesStore
.getState()
.toggleReaction(channelId, message.id, emoji)
.catch((error: unknown) => setActionError(error));
};
const saveEdit = (): void => {
const content = draft.trim();
if (content === '' || content === message.content) {
setEditing(false);
return;
}
void useMessagesStore
.getState()
.edit(channelId, message.id, content)
.then(() => {
setEditing(false);
setActionError(null);
})
.catch((error: unknown) => setActionError(error));
};
const remove = (): void => {
setMenuOpen(false);
if (!window.confirm(t('chat.actions.deleteConfirm'))) {
return;
}
void useMessagesStore
.getState()
.remove(channelId, message.id)
.catch((error: unknown) => setActionError(error));
};
const togglePin = (): void => {
setMenuOpen(false);
void useMessagesStore
.getState()
.setPinned(channelId, message.id, !message.pinned)
.catch((error: unknown) => setActionError(error));
};
return (
<article
data-message-id={message.id}
data-testid={`message-${message.id}`}
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
pending ? 'opacity-70' : ''
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
>
{message.reply_to_id === undefined ? null : (
<button
type="button"
className="flex max-w-full items-center gap-1 truncate text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg"
onClick={() => onJumpToReply(message.reply_to_id ?? '')}
>
<span aria-hidden="true">↩</span>
<span className="truncate">
{t('chat.reply.to', {
name:
replyTarget === undefined
? t('chat.reply.missing')
: authors.resolve(replyTarget.author_id).name,
})}
</span>
</button>
)}
{editing ? (
<div className="flex flex-col gap-1">
<textarea
ref={editorRef}
aria-label={t('chat.edit.label')}
className="w-full resize-none rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
value={draft}
maxLength={MESSAGE_MAX_LENGTH}
rows={Math.min(8, draft.split('\n').length)}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveEdit();
}
if (event.key === 'Escape') {
setEditing(false);
setDraft(message.content);
}
}}
/>
<p className="text-xs text-fg-muted">{t('chat.edit.hint')}</p>
<div className="flex gap-2">
<button type="button" className={actionButtonClass} onClick={saveEdit}>
{t('common.save')}
</button>
<button
type="button"
className={actionButtonClass}
onClick={() => {
setEditing(false);
setDraft(message.content);
}}
>
{t('common.cancel')}
</button>
</div>
</div>
) : (
<div className="flex min-w-0 flex-col gap-1">
{message.content === '' ? null : (
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="text-sm text-fg"
/>
)}
{message.attachments.length === 0 ? null : (
<ul className="flex flex-col gap-2">
{message.attachments.map((attachment) => {
const url = fileContentUrl(attachment.file_id);
const isImage = (attachment.content_type ?? '').startsWith('image/');
return (
<li key={attachment.file_id}>
{isImage ? (
<a href={url} target="_blank" rel="noreferrer noopener">
<img
src={url}
alt={attachment.filename}
loading="lazy"
className="max-h-80 max-w-full rounded-[var(--radius-md)] border border-border/40"
/>
</a>
) : (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3"
>
<span aria-hidden="true">📎</span>
<span className="truncate">{attachment.filename}</span>
<span className="text-xs text-fg-muted">
{formatBytes(attachment.size_bytes ?? 0, i18n.language)}
</span>
</a>
)}
</li>
);
})}
</ul>
)}
{message.reactions.length === 0 ? null : (
<ul className="flex flex-wrap gap-1" aria-label={t('chat.reactions.title')}>
{message.reactions.map((reaction) => (
<li key={reaction.emoji}>
<button
type="button"
aria-pressed={reaction.me}
aria-label={t('chat.reactions.toggle', {
emoji: reaction.emoji,
count: reaction.count,
})}
onClick={() => react(reaction.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors duration-[var(--duration-fast)] ${
reaction.me
? 'border-accent/60 bg-accent/15 text-fg'
: 'border-border/60 bg-surface-2 text-fg-muted hover:bg-surface-3'
}`}
>
<span aria-hidden="true">{reaction.emoji}</span>
<span>{reaction.count}</span>
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2 text-xs text-fg-muted">
{compact ? (
<time
dateTime={message.created_at}
className="opacity-0 group-hover/message:opacity-100"
>
{formatMessageTime(message.created_at, i18n.language)}
</time>
) : null}
{edited ? <span>{t('chat.edited')}</span> : null}
{pending ? <span role="status">{t('chat.pending')}</span> : null}
{copied ? <span role="status">{t('common.copied')}</span> : null}
</div>
</div>
)}
{actionError === null ? null : <ErrorNotice error={actionError} className="mt-1" />}
<div
hidden={pending}
className={`absolute -top-3 right-2 z-10 flex items-center gap-0.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-1 py-0.5 shadow-[var(--shadow-2)] transition-opacity duration-[var(--duration-fast)] ${
menuOpen || pickerOpen
? 'opacity-100'
: 'opacity-0 group-hover/message:opacity-100 focus-within:opacity-100'
}`}
>
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.reactions.add', { emoji })}
className={actionButtonClass}
onClick={() => react(emoji)}
>
{emoji}
</button>
))}
<button
type="button"
aria-label={t('chat.actions.reply')}
title={t('chat.actions.reply')}
className={actionButtonClass}
disabled={!canSend}
onClick={() => onReply(message)}
>
↩
</button>
<button
ref={menuAnchor}
type="button"
aria-label={t('chat.actions.menu')}
aria-haspopup="menu"
aria-expanded={menuOpen}
className={actionButtonClass}
onClick={() => {
updateDropDirection();
setPickerOpen(false);
setMenuOpen((open) => !open);
}}
>
⋯
</button>
<EmojiPicker
open={pickerOpen}
anchorRef={menuAnchor}
onClose={() => setPickerOpen(false)}
onPick={react}
placement={dropUp ? 'top' : 'bottom'}
/>
<Menu
open={menuOpen}
label={t('chat.actions.menu')}
anchorRef={menuAnchor}
placement={dropUp ? 'top' : 'bottom'}
align="right"
onClose={() => setMenuOpen(false)}
>
{/* Быстрые реакции прямо в контекстном меню. */}
<div className="flex items-center justify-between gap-0.5 px-1 pb-1">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
tabIndex={-1}
aria-label={t('chat.reactions.add', { emoji })}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => react(emoji)}
>
{emoji}
</button>
))}
</div>
<MenuSeparator />
<MenuItem
icon="↩"
onClick={() => {
setMenuOpen(false);
onReply(message);
}}
>
{t('chat.actions.reply')}
</MenuItem>
<MenuItem
icon="😀"
onClick={() => {
updateDropDirection();
setMenuOpen(false);
setPickerOpen(true);
}}
>
{t('chat.actions.react')}
</MenuItem>
<MenuItem
icon="📋"
onClick={() => {
setMenuOpen(false);
void copyText(message.content).then((ok) => setCopied(ok));
}}
>
{t('chat.actions.copy')}
</MenuItem>
{canManageMessages ? (
<MenuItem icon="📌" onClick={togglePin}>
{t(message.pinned ? 'chat.actions.unpin' : 'chat.actions.pin')}
</MenuItem>
) : null}
{canEdit ? (
<MenuItem
icon="✏"
onClick={() => {
setMenuOpen(false);
setDraft(message.content);
setEditing(true);
}}
>
{t('chat.actions.edit')}
</MenuItem>
) : null}
{canDelete ? (
<>
<MenuSeparator />
<MenuItem icon="🗑" danger onClick={remove}>
{t('chat.actions.delete')}
</MenuItem>
</>
) : null}
</Menu>
</div>
<span className="sr-only">
{t('chat.message.by', { name: author.name, channel: channelName })}
{' · '}
{formatMessageDateTime(message.created_at, i18n.language)}
</span>
</article>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { isPendingMessage, type Message } from '@/api/messages';
import { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import {
buildChatRows,
dayLabel,
escapeSelector,
formatMessageDate,
formatMessageTime,
} from '@/lib/chat';
interface MessageListProps {
channelId: string;
channelName: string;
/** Лента с оптимистичными сообщениями в конце. */
messages: Message[];
status: 'idle' | 'loading' | 'ready' | 'error';
error: unknown;
hasMore: boolean;
loadingMore: boolean;
/** id последнего прочитанного сообщения на момент открытия комнаты. */
unreadAnchorId: string | null;
highlightId: string | null;
authors: AuthorDirectory;
currentUserId: string | null;
canManageMessages: boolean;
canSend: boolean;
onReload: () => void;
onLoadMore: () => void;
onReply: (message: Message) => void;
}
/** Расстояние до низа, при котором автопрокрутка считается уместной. */
const STICK_THRESHOLD_PX = 120;
/**
* Лента сообщений: разделители дат, группировка подряд идущих сообщений
* одного автора, разделитель «новые сообщения», подгрузка истории вверх
* и автопрокрутка к свежим сообщениям.
*/
export function MessageList({
channelId,
channelName,
messages,
status,
error,
hasMore,
loadingMore,
unreadAnchorId,
highlightId,
authors,
currentUserId,
canManageMessages,
canSend,
onReload,
onLoadMore,
onReply,
}: MessageListProps) {
const { t, i18n } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null);
const stickToBottom = useRef(true);
const previousCount = useRef(messages.length);
const messageById = useMemo(() => {
const map = new Map<string, Message>();
for (const message of messages) {
map.set(message.id, message);
}
return map;
}, [messages]);
const rows = useMemo(
() => buildChatRows(messages, { unreadAnchorId }),
[messages, unreadAnchorId],
);
// Прокрутка вниз при новых сообщениях, если пользователь был у нижнего края.
useEffect(() => {
const previous = previousCount.current;
previousCount.current = messages.length;
const container = containerRef.current;
if (container === null) {
return;
}
const isFirstRender = previous === 0;
if (isFirstRender || stickToBottom.current) {
container.scrollTop = container.scrollHeight;
}
}, [messages.length, channelId]);
// Переход к сообщению (ответ, поиск, пины).
useEffect(() => {
if (highlightId === null) {
return;
}
const container = containerRef.current;
const target = container?.querySelector(`[data-message-id="${escapeSelector(highlightId)}"]`);
if (target instanceof HTMLElement) {
target.scrollIntoView?.({ block: 'center' });
}
}, [highlightId, messages.length]);
const onScroll = useCallback(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
stickToBottom.current = distance <= STICK_THRESHOLD_PX;
if (container.scrollTop <= 80 && hasMore && !loadingMore) {
onLoadMore();
}
}, [hasMore, loadingMore, onLoadMore]);
if (status === 'loading' || status === 'idle') {
return (
<div className="flex-1 px-4 py-4">
<SkeletonLines rows={6} />
</div>
);
}
if (status === 'error') {
return (
<div className="flex-1 p-4">
<ErrorNotice error={error} onRetry={onReload} />
</div>
);
}
return (
<div
ref={containerRef}
onScroll={onScroll}
role="log"
aria-label={t('chat.log', { name: channelName })}
data-testid="message-list"
className="min-h-0 flex-1 overflow-y-auto px-3 py-3"
>
{hasMore ? (
<div className="flex justify-center pb-2">
<Button variant="ghost" disabled={loadingMore} onClick={onLoadMore}>
{t(loadingMore ? 'chat.loadingMore' : 'chat.loadMore')}
</Button>
</div>
) : null}
{error !== null && status === 'ready' ? (
<ErrorNotice className="mb-2" error={error} onRetry={onLoadMore} />
) : null}
{messages.length === 0 ? (
<Card className="mx-auto mt-6 max-w-md text-center" data-testid="chat-start">
<h3 className="text-base font-semibold">
{t('chat.start.title', { name: channelName })}
</h3>
<p className="mt-2 text-sm text-fg-muted">{t('chat.start.body')}</p>
</Card>
) : (
<ol className="flex flex-col">
{rows.map((row) => {
if (row.kind === 'date') {
const label = dayLabel(row.iso);
return (
<li
key={row.key}
className="my-2 flex items-center gap-3 text-xs text-fg-muted"
data-testid="date-separator"
>
<span aria-hidden="true" className="h-px flex-1 bg-border/60" />
<time dateTime={row.iso}>
{label === 'today'
? t('chat.today')
: label === 'yesterday'
? t('chat.yesterday')
: formatMessageDate(row.iso, i18n.language)}
</time>
<span aria-hidden="true" className="h-px flex-1 bg-border/60" />
</li>
);
}
if (row.kind === 'unread') {
return (
<li
key={row.key}
role="separator"
aria-label={t('chat.newMessages')}
data-testid="unread-separator"
className="my-1 flex items-center gap-2 text-xs font-medium text-danger"
>
<span aria-hidden="true" className="h-px flex-1 bg-danger/60" />
{t('chat.newMessages')}
<span aria-hidden="true" className="h-px flex-1 bg-danger/60" />
</li>
);
}
const first = row.group.messages[0];
if (first === undefined) {
return null;
}
const author = authors.resolve(row.group.authorId ?? undefined);
return (
<li
key={row.key}
data-testid="message-group"
className="group/msg rounded-[var(--radius-md)] px-2 py-1 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2/40"
>
<div className="flex gap-3">
<div className="w-9 shrink-0 pt-0.5">
<Avatar
name={author.name}
seed={author.id === '' ? row.key : author.id}
fileId={author.avatarFileId}
size="md"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-baseline gap-2">
<span
className="text-sm font-semibold"
style={author.colorHex === null ? undefined : { color: author.colorHex }}
>
{author.name}
</span>
{author.isInstanceAdmin ? (
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
{t('chat.adminBadge')}
</span>
) : null}
<time
dateTime={first.created_at}
className="text-xs text-fg-muted"
title={formatMessageDate(first.created_at, i18n.language)}
>
{formatMessageTime(first.created_at, i18n.language)}
</time>
</header>
<div className="flex min-w-0 flex-col">
{row.group.messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
channelId={channelId}
channelName={channelName}
authors={authors}
currentUserId={currentUserId}
canManageMessages={canManageMessages}
canSend={canSend}
compact={index > 0}
{...(isPendingMessage(message) ? { pending: true } : {})}
replyTarget={
message.reply_to_id === undefined
? undefined
: messageById.get(message.reply_to_id)
}
highlight={message.id === highlightId}
onReply={onReply}
onJumpToReply={(messageId) => {
const container = containerRef.current;
const target = container?.querySelector(
`[data-message-id="${escapeSelector(messageId)}"]`,
);
if (target instanceof HTMLElement) {
target.scrollIntoView?.({ block: 'center' });
}
}}
/>
))}
</div>
</div>
</div>
</li>
);
})}
</ol>
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Message } from '@/api/messages';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatMessageDateTime } from '@/lib/chat';
import { useMessagesStore } from '@/stores/messages';
interface PinsPanelProps {
channelId: string;
channelName: string;
open: boolean;
onClose: () => void;
authors: AuthorDirectory;
canManageMessages: boolean;
onJump: (messageId: string) => void;
}
/** Панель закреплённых сообщений комнаты (`GET /channels/{id}/pins`). */
export function PinsPanel({
channelId,
channelName,
open,
onClose,
authors,
canManageMessages,
onJump,
}: PinsPanelProps) {
const { t, i18n } = useTranslation();
const [actionError, setActionError] = useState<unknown>(null);
const record = useMessagesStore((state) => state.channels[channelId]);
const pins = record?.pins ?? [];
const loaded = record?.pinsLoaded ?? false;
const error = record?.pinsError ?? null;
useEffect(() => {
if (open) {
void useMessagesStore.getState().loadPins(channelId);
}
}, [open, channelId]);
const unpin = (message: Message): void => {
setActionError(null);
void useMessagesStore
.getState()
.setPinned(channelId, message.id, false)
.catch((error: unknown) => setActionError(error));
};
return (
<Modal
open={open}
title={t('chat.pins.title', { name: channelName })}
onClose={onClose}
footer={
<Button variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{error === null ? null : <ErrorNotice className="mb-2" error={error} />}
{actionError === null ? null : <ErrorNotice className="mb-2" error={actionError} />}
{!loaded && error === null ? (
<SkeletonLines rows={3} />
) : pins.length === 0 ? (
<p className="text-sm text-fg-muted">{t('chat.pins.empty')}</p>
) : (
<ul className="flex max-h-80 flex-col gap-2 overflow-y-auto">
{pins.map((message) => (
<li
key={message.id}
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium">
{authors.resolve(message.author_id).name}
</span>
<time dateTime={message.created_at} className="text-xs text-fg-muted">
{formatMessageDateTime(message.created_at, i18n.language)}
</time>
</div>
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="mt-1 line-clamp-3 text-sm"
/>
<div className="mt-1 flex gap-2">
<Button variant="ghost" onClick={() => onJump(message.id)}>
{t('chat.pins.jump')}
</Button>
{canManageMessages ? (
<Button variant="ghost" onClick={() => unpin(message)}>
{t('chat.actions.unpin')}
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
</Modal>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatMessageDateTime } from '@/lib/chat';
import { useMessagesStore } from '@/stores/messages';
interface SearchPanelProps {
channelId: string;
open: boolean;
onClose: () => void;
authors: AuthorDirectory;
onJump: (messageId: string) => void;
}
/** Пауза перед запросом к FTS5: на сервере лимит 10 запросов в минуту. */
const SEARCH_DEBOUNCE_MS = 400;
/**
* Поиск по комнате (`GET /channels/{id}/messages/search`): строка ввода,
* результаты с подсветкой совпадений и переходом к сообщению.
*/
export function SearchPanel({ channelId, open, onClose, authors, onJump }: SearchPanelProps) {
const { t, i18n } = useTranslation();
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
const record = useMessagesStore((state) => state.channels[channelId]);
const status = record?.searchStatus ?? 'idle';
const results = record?.searchResults ?? [];
const error = record?.searchError ?? null;
useEffect(() => {
if (open) {
inputRef.current?.focus();
}
}, [open]);
// Дебаунс: сервер ограничивает поиск 10 запросами в минуту.
useEffect(() => {
if (!open) {
return;
}
const timer = window.setTimeout(() => {
void useMessagesStore.getState().search(channelId, query);
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [open, channelId, query]);
useEffect(() => {
if (!open) {
useMessagesStore.getState().clearSearch(channelId);
}
}, [open, channelId]);
if (!open) {
return null;
}
const terms = query
.split(/\s+/u)
.map((term) => term.trim())
.filter((term) => term.length > 1);
return (
<section
aria-label={t('chat.search.title')}
data-testid="search-panel"
className="border-b border-border/40 bg-surface-1 px-3 py-2"
>
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="search"
value={query}
aria-label={t('chat.search.label')}
placeholder={t('chat.search.placeholder')}
className="w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-fg-muted/70"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
/>
<button
type="button"
aria-label={t('common.close')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
onClick={onClose}
>
×
</button>
</div>
<div className="mt-2 max-h-72 overflow-y-auto">
{error !== null ? (
<ErrorNotice error={error} />
) : status === 'searching' ? (
<SkeletonLines rows={2} />
) : query.trim() === '' ? (
<p className="text-xs text-fg-muted">{t('chat.search.hint')}</p>
) : status === 'ready' && results.length === 0 ? (
<p className="text-xs text-fg-muted">{t('chat.search.empty')}</p>
) : (
<ul className="flex flex-col gap-1" data-testid="search-results">
{results.map((message) => (
<li key={message.id}>
<button
type="button"
className="w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 text-left transition-colors duration-[var(--duration-fast)] hover:bg-surface-3"
onClick={() => {
onJump(message.id);
onClose();
}}
>
<span className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium">
{authors.resolve(message.author_id).name}
</span>
<time dateTime={message.created_at} className="text-xs text-fg-muted">
{formatMessageDateTime(message.created_at, i18n.language)}
</time>
</span>
<span className="mt-1 block text-sm text-fg-muted">
{highlight(message.content, terms)}
</span>
</button>
</li>
))}
</ul>
)}
</div>
</section>
);
}
/** Подсветка совпадений в результатах поиска (без HTML-инъекций). */
function highlight(content: string, terms: string[]): ReactNode {
if (terms.length === 0) {
return content;
}
const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'giu');
const parts = content.split(pattern);
return parts.map((part, index) =>
terms.some((term) => term.toLowerCase() === part.toLowerCase()) ? (
<mark key={`${String(index)}-${part}`} className="bg-accent/30 text-fg">
{part}
</mark>
) : (
<span key={`${String(index)}-${part}`}>{part}</span>
),
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
}
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { useMessagesStore, type TypingEntry } from '@/stores/messages';
interface TypingIndicatorProps {
channelId: string;
typing: TypingEntry[];
authors: AuthorDirectory;
}
/** Как часто проверяем, не погасли ли события `TYPING_START`. */
const PRUNE_INTERVAL_MS = 1000;
/**
* Строка «печатает…» под лентой. Живёт 6 секунд с последнего события
* `TYPING_START` (событие приходит раз в ~3 с, пока пользователь пишет).
*/
export function TypingIndicator({ channelId, typing, authors }: TypingIndicatorProps) {
const { t } = useTranslation();
useEffect(() => {
if (typing.length === 0) {
return;
}
const timer = window.setInterval(() => {
useMessagesStore.getState().pruneTyping();
}, PRUNE_INTERVAL_MS);
return () => {
window.clearInterval(timer);
};
}, [typing.length, channelId]);
if (typing.length === 0) {
return null;
}
const names = typing
.map((entry) => authors.resolve(entry.user_id).name)
.filter((name) => name !== '');
return (
<p role="status" data-testid="typing-indicator" className="px-4 pb-1 text-xs text-fg-muted">
{t('chat.typing', { count: names.length, names: names.join(', ') })}
</p>
);
}
@@ -0,0 +1,145 @@
import { useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchMembers, fetchRoles, guildMembersQueryKey, guildRolesQueryKey } from '@/api/guilds';
import { fetchUser, userQueryKey } from '@/api/users';
import type { GuildMember } from '@/api/types';
import { memberDisplayName, roleColorForMember } from '@/lib/chat';
import { useSessionStore } from '@/stores/session';
/**
* Справочник авторов сообщений.
*
* Имя и цвет берём из участников сервера (ник + высшая роль), для авторов вне
* списка участников — из `GET /users/{id}`; если и там пусто, показываем
* «неизвестный» (автор мог выйти с сервера).
*/
export interface ChatAuthor {
id: string;
name: string;
avatarFileId: string | undefined;
colorHex: string | null;
isInstanceAdmin: boolean;
isMember: boolean;
}
export interface AuthorDirectory {
resolve: (userId: string | undefined) => ChatAuthor;
resolveMention: (userId: string) => { name: string; isMe: boolean };
/** Загружены ли участники: до этого момента имена могут быть неполными. */
loaded: boolean;
}
export function useAuthorDirectory(guildId: string | null, userIds: string[]): AuthorDirectory {
const { t } = useTranslation();
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
const members = useQuery({
queryKey: guildMembersQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
staleTime: 60_000,
});
const roles = useQuery({
queryKey: guildRolesQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchRoles(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
staleTime: 60_000,
});
const membersLoaded = members.isSuccess;
const membersData = members.data;
const rolesData = roles.data;
const memberMap = useMemo(() => {
const map = new Map<string, GuildMember>();
for (const member of membersData ?? []) {
map.set(member.user_id, member);
}
return map;
}, [membersData]);
const unique = useMemo(() => [...new Set(userIds)].filter((id) => id !== ''), [userIds]);
const missing = useMemo(
() => (membersLoaded ? unique.filter((id) => !memberMap.has(id)) : []),
[unique, memberMap, membersLoaded],
);
const users = useQueries({
queries: missing.map((userId) => ({
queryKey: userQueryKey(userId),
queryFn: ({ signal }: { signal: AbortSignal }) => fetchUser(userId, signal),
staleTime: 300_000,
retry: 0,
})),
});
// Ключ из уже загруженных имён: сам массив запросов каждый рендер новый,
// поэтому в зависимостях используем стабильную строку.
const namesKey = missing
.map((userId, index) => {
const data = users[index]?.data;
return data === undefined
? ''
: `${userId}=${data.display_name !== '' ? data.display_name : data.username}`;
})
.join('|');
const userNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const entry of namesKey.split('|')) {
const separator = entry.indexOf('=');
if (separator > 0) {
map.set(entry.slice(0, separator), entry.slice(separator + 1));
}
}
return map;
}, [namesKey]);
return useMemo<AuthorDirectory>(() => {
const resolve = (userId: string | undefined): ChatAuthor => {
if (userId === undefined || userId === '') {
return {
id: '',
name: t('chat.unknownAuthor'),
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
};
}
const member = memberMap.get(userId);
if (member !== undefined) {
return {
id: userId,
name: memberDisplayName(member) ?? t('chat.unknownAuthor'),
avatarFileId: member.avatar_file_id,
colorHex: roleColorForMember(member, rolesData ?? []),
isInstanceAdmin: member.is_instance_admin,
isMember: true,
};
}
const fallbackName = userNameMap.get(userId);
return {
id: userId,
name: fallbackName ?? t('chat.unknownAuthor'),
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: false,
};
};
return {
resolve,
resolveMention: (userId) => {
const author = resolve(userId);
return { name: author.name, isMe: userId === currentUserId };
},
loaded: membersLoaded,
};
}, [memberMap, rolesData, userNameMap, currentUserId, membersLoaded, t]);
}
@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stores/messages';
/**
* Непрочитанные сообщения и упоминания комнаты.
*
* Счётчик непрочитанных считается по загруженной истории относительно отметки
* прочтения (`READ_STATE_UPDATE`/`ack`), упоминания приходят с сервера готовым
* числом. Для комнат, которые ещё не открывали, история неизвестна — тогда
* показываем только упоминания.
*/
export function useChannelUnread(channelId: string): { unread: number; mentions: number } {
const selector = useCallback(
(state: { channels: Record<string, ChannelMessagesState> }) => state.channels[channelId],
[channelId],
);
const record = useMessagesStore(selector);
return {
unread: record === undefined ? 0 : unreadCount(record),
mentions: record?.mentionCount ?? 0,
};
}
+23 -4
View File
@@ -18,6 +18,13 @@ interface MenuProps {
* закрывалось бы и тут же открывалось заново), ему же возвращается фокус.
*/
anchorRef?: RefObject<HTMLElement | null>;
/**
* Куда раскрывается панель: вверх (`top`, по умолчанию — меню над панелью
* пользователя) или вниз (`bottom` — меню действий у сообщения).
*/
placement?: 'top' | 'bottom';
/** Горизонтальное выравнивание панели относительно якоря. */
align?: 'left' | 'right';
className?: string;
}
@@ -29,10 +36,20 @@ const ITEM_SELECTOR =
* открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус,
* Tab закрывает меню и возвращает управление странице.
*
* Позиционирование задаёт родитель: панель абсолютная и растёт вверх
* (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя.
* Позиционирование задаёт родитель: панель абсолютная и по умолчанию растёт
* вверх (`.gl-popover`) — так меню удобно вешать над нижней панелью
* пользователя; `placement="bottom"` раскрывает его вниз (меню сообщения).
*/
export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) {
export function Menu({
open,
label,
onClose,
children,
anchorRef,
placement = 'top',
align = 'left',
className = '',
}: MenuProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
@@ -136,7 +153,9 @@ export function Menu({ open, label, onClose, children, anchorRef, className = ''
role="menu"
aria-label={label}
onKeyDown={onKeyDown}
className={`gl-popover absolute bottom-full left-0 z-40 mb-2 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${className}`}
className={`absolute z-40 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
>
{children}
</div>