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