import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react'; import { useTranslation } from 'react-i18next'; import { fileContentUrl } from '@/api/files'; import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages'; import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage'; import { EmojiPicker } from '@/components/chat/EmojiPicker'; import { MessageContent } from '@/components/chat/MessageContent'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { useGuildEmojiDirectory } from '@/components/chat/useGuildEmojis'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu'; import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat'; import { customEmojiShortcode } from '@/lib/customEmoji'; import { formatBytes } from '@/lib/format'; import { parseSystemMessage } from '@/lib/systemMessage'; import { useMessagesStore } from '@/stores/messages'; interface MessageItemProps { message: Message; channelId: string; channelName: string; /** Сервер комнаты: из него пикер берёт кастомные эмодзи. */ guildId?: string | null; 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'; /** * Селектор интерактива: двойной клик по ссылке, реакции или кнопке не должен * ставить сообщение в ответ — этим элементам клик принадлежит целиком. */ const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]'; /** Пометка вида сообщения: «шёпот», «громко», «лично». */ function TypeBadge({ children, accent = false }: { children: ReactNode; accent?: boolean }) { return ( {children} ); } /** * Текст сообщения по виду из контракта: обычный, действие `/me`, шёпот, крик * и личное сообщение оформляются по-разному (AGENT.md §7.6). Системные * сообщения обрабатываются отдельной веткой — у них нет ни шапки автора, ни * панели действий. */ function MessageBody({ type, message, authorName, resolveMention, }: { type: MessageType; message: Message; authorName: string; resolveMention: AuthorDirectory['resolveMention']; }) { const { t } = useTranslation(); if (type === 'action') { // Действие: «Имя танцует» — имя акцентом, текст курсивом. return (
{authorName}
); } if (type === 'whisper') { return (
{t('chat.types.whisper')}
); } if (type === 'scream') { return (
{t('chat.types.scream')}
); } if (type === 'private') { // Личное сообщение: сервер отдаёт его только автору и адресату. return (
{t('chat.types.private')}
); } return ( ); } /** * Системное сообщение: сервер присылает JSON о событии, лента показывает его * человекочитаемой строкой через i18n. Имя участника резолвит тот же * справочник авторов; если пользователь неизвестен (вышел, удалён) — * подставляем «участник». Неизвестное событие и мусорный JSON показываем как * есть: ничего не теряем и не падаем. */ function SystemMessageBody({ message, authors }: { message: Message; authors: AuthorDirectory }) { const { t } = useTranslation(); const event = parseSystemMessage(message.content); if (event === null) { return ( ); } const nameOf = (userId: string): string => authors.resolveMemberName?.(userId) ?? t('chat.system.member'); if (event.kind === 'member_join') { return (

{t('chat.system.memberJoin', { name: nameOf(event.userId) })}

); } if (event.kind === 'member_kick') { return (

{t('chat.system.memberKick', { name: nameOf(event.userId) })}

); } return (

{t( event.channelType === 'voice' ? 'chat.system.voiceChannelCreated' : 'chat.system.channelCreated', { name: event.name }, )}

); } /** * Сообщение ленты: текст, вложения, реакции, ответы и действия. * * Действия доступны по hover (быстрые реакции, ответ, меню) и из контекстного * меню; редактирование — inline, удаление — с подтверждением. */ export function MessageItem({ message, channelId, channelName, guildId = null, authors, currentUserId, canManageMessages, canSend, compact, pending = false, replyTarget, highlight = false, onReply, onJumpToReply, }: MessageItemProps) { const { t, i18n } = useTranslation(); // Кастомные эмодзи: реакции и пикер показывают их картинкой. const emojis = useGuildEmojiDirectory(); 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(null); const [copied, setCopied] = useState(false); /** Свободного места снизу мало — меню и пикер раскрываем вверх. */ const [dropUp, setDropUp] = useState(false); const menuAnchor = useRef(null); const editorRef = useRef(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; const type = messageTypeOf(message.type); 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)); }; /** * Двойной клик по сообщению — быстрый ответ (то же, что пункт «Ответить»). * Браузер сам ограничивает интервал двойного клика (~300–500 мс). */ const onDoubleClick = (event: ReactMouseEvent): void => { if (!canSend || pending) { return; } // Выделенный пользователем текст важнее: двойной клик — типичный способ // выделить слово, поэтому ответ в этом случае не ставим. if ((window.getSelection()?.toString() ?? '') !== '') { return; } const target = event.target; if (target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null) { return; } onReply(message); }; // Системное сообщение: центрированная приглушённая строка без шапки автора, // вложений и панели действий — реагировать и отвечать на неё нечего. if (type === 'system') { return (
{t('chat.types.system')} {message.content === '' ? null : }
); } return (
{message.reply_to_id === undefined ? null : ( )} {editing ? (