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 (