2026-09-20 12:06:22 +03:00
|
|
|
import {
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
type MouseEvent as ReactMouseEvent,
|
|
|
|
|
type ReactNode,
|
|
|
|
|
} from 'react';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
|
|
|
|
import { fileContentUrl } from '@/api/files';
|
2026-09-20 12:06:22 +03:00
|
|
|
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
|
2026-09-20 18:13:04 +03:00
|
|
|
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { EmojiPicker } from '@/components/chat/EmojiPicker';
|
|
|
|
|
import { MessageContent } from '@/components/chat/MessageContent';
|
|
|
|
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
2026-09-20 18:13:04 +03:00
|
|
|
import { useGuildEmojiDirectory } from '@/components/chat/useGuildEmojis';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
|
|
|
|
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
|
|
|
|
import { QUICK_REACTIONS, copyText, formatMessageDateTime, formatMessageTime } from '@/lib/chat';
|
2026-09-20 18:13:04 +03:00
|
|
|
import { customEmojiShortcode } from '@/lib/customEmoji';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { formatBytes } from '@/lib/format';
|
2026-09-20 18:13:04 +03:00
|
|
|
import { parseSystemMessage } from '@/lib/systemMessage';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { useMessagesStore } from '@/stores/messages';
|
|
|
|
|
|
|
|
|
|
interface MessageItemProps {
|
|
|
|
|
message: Message;
|
|
|
|
|
channelId: string;
|
|
|
|
|
channelName: string;
|
2026-09-20 18:13:04 +03:00
|
|
|
/** Сервер комнаты: из него пикер берёт кастомные эмодзи. */
|
|
|
|
|
guildId?: string | null;
|
2026-09-20 00:15:18 +03:00
|
|
|
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';
|
|
|
|
|
|
2026-09-20 01:12:01 +03:00
|
|
|
/**
|
|
|
|
|
* Селектор интерактива: двойной клик по ссылке, реакции или кнопке не должен
|
|
|
|
|
* ставить сообщение в ответ — этим элементам клик принадлежит целиком.
|
|
|
|
|
*/
|
|
|
|
|
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
|
|
|
|
|
|
2026-09-20 12:06:22 +03:00
|
|
|
/** Пометка вида сообщения: «шёпот», «громко», «лично». */
|
|
|
|
|
function TypeBadge({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
className={`inline-block rounded-full border px-1.5 align-middle text-[10px] uppercase tracking-wide ${
|
|
|
|
|
accent ? 'border-accent/40 text-accent' : 'border-border/60 text-fg-muted'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Текст сообщения по виду из контракта: обычный, действие `/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 (
|
|
|
|
|
<div className="flex flex-wrap items-baseline gap-x-1.5 text-sm text-fg italic">
|
|
|
|
|
<span className="font-semibold text-accent not-italic">{authorName}</span>
|
|
|
|
|
<MessageContent
|
|
|
|
|
content={message.content}
|
|
|
|
|
resolveMention={resolveMention}
|
|
|
|
|
className="min-w-0"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (type === 'whisper') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/50 px-2 py-1">
|
|
|
|
|
<TypeBadge>{t('chat.types.whisper')}</TypeBadge>
|
|
|
|
|
<MessageContent
|
|
|
|
|
content={message.content}
|
|
|
|
|
resolveMention={resolveMention}
|
|
|
|
|
className="min-w-0 flex-1 text-sm text-fg-muted italic"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (type === 'scream') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap items-baseline gap-x-1.5">
|
|
|
|
|
<TypeBadge accent>{t('chat.types.scream')}</TypeBadge>
|
|
|
|
|
<MessageContent
|
|
|
|
|
content={message.content}
|
|
|
|
|
resolveMention={resolveMention}
|
|
|
|
|
className="min-w-0 flex-1 text-base font-semibold text-accent"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (type === 'private') {
|
|
|
|
|
// Личное сообщение: сервер отдаёт его только автору и адресату.
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-accent/40 bg-accent/10 px-2 py-1">
|
|
|
|
|
<span aria-hidden="true" className="text-xs">
|
|
|
|
|
🔒
|
|
|
|
|
</span>
|
|
|
|
|
<TypeBadge accent>{t('chat.types.private')}</TypeBadge>
|
|
|
|
|
<MessageContent
|
|
|
|
|
content={message.content}
|
|
|
|
|
resolveMention={resolveMention}
|
|
|
|
|
className="min-w-0 flex-1 text-sm text-fg"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<MessageContent
|
|
|
|
|
content={message.content}
|
|
|
|
|
resolveMention={resolveMention}
|
|
|
|
|
className="text-sm text-fg"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 18:13:04 +03:00
|
|
|
/**
|
|
|
|
|
* Системное сообщение: сервер присылает JSON о событии, лента показывает его
|
|
|
|
|
* человекочитаемой строкой через i18n. Имя участника резолвит тот же
|
|
|
|
|
* справочник авторов; если пользователь неизвестен (вышел, удалён) —
|
|
|
|
|
* подставляем «участник». Неизвестное событие и мусорный JSON показываем как
|
|
|
|
|
* есть: ничего не теряем и не падаем.
|
|
|
|
|
*/
|
2026-09-20 18:19:19 +03:00
|
|
|
function SystemMessageBody({ message, authors }: { message: Message; authors: AuthorDirectory }) {
|
2026-09-20 18:13:04 +03:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const event = parseSystemMessage(message.content);
|
|
|
|
|
|
|
|
|
|
if (event === null) {
|
|
|
|
|
return (
|
|
|
|
|
<MessageContent
|
|
|
|
|
content={message.content}
|
|
|
|
|
resolveMention={authors.resolveMention}
|
|
|
|
|
className="text-xs text-fg-muted"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nameOf = (userId: string): string =>
|
|
|
|
|
authors.resolveMemberName?.(userId) ?? t('chat.system.member');
|
|
|
|
|
|
|
|
|
|
if (event.kind === 'member_join') {
|
|
|
|
|
return (
|
|
|
|
|
<p className="text-xs text-fg-muted">
|
|
|
|
|
{t('chat.system.memberJoin', { name: nameOf(event.userId) })}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (event.kind === 'member_kick') {
|
|
|
|
|
return (
|
|
|
|
|
<p className="text-xs text-fg-muted">
|
|
|
|
|
{t('chat.system.memberKick', { name: nameOf(event.userId) })}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<p className="text-xs text-fg-muted">
|
|
|
|
|
{t(
|
|
|
|
|
event.channelType === 'voice'
|
|
|
|
|
? 'chat.system.voiceChannelCreated'
|
|
|
|
|
: 'chat.system.channelCreated',
|
|
|
|
|
{ name: event.name },
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
/**
|
|
|
|
|
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
|
|
|
|
|
*
|
|
|
|
|
* Действия доступны по hover (быстрые реакции, ответ, меню) и из контекстного
|
|
|
|
|
* меню; редактирование — inline, удаление — с подтверждением.
|
|
|
|
|
*/
|
|
|
|
|
export function MessageItem({
|
|
|
|
|
message,
|
|
|
|
|
channelId,
|
|
|
|
|
channelName,
|
2026-09-20 18:13:04 +03:00
|
|
|
guildId = null,
|
2026-09-20 00:15:18 +03:00
|
|
|
authors,
|
|
|
|
|
currentUserId,
|
|
|
|
|
canManageMessages,
|
|
|
|
|
canSend,
|
|
|
|
|
compact,
|
|
|
|
|
pending = false,
|
|
|
|
|
replyTarget,
|
|
|
|
|
highlight = false,
|
|
|
|
|
onReply,
|
|
|
|
|
onJumpToReply,
|
|
|
|
|
}: MessageItemProps) {
|
|
|
|
|
const { t, i18n } = useTranslation();
|
2026-09-20 18:13:04 +03:00
|
|
|
// Кастомные эмодзи: реакции и пикер показывают их картинкой.
|
|
|
|
|
const emojis = useGuildEmojiDirectory();
|
2026-09-20 00:15:18 +03:00
|
|
|
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;
|
2026-09-20 12:06:22 +03:00
|
|
|
const type = messageTypeOf(message.type);
|
2026-09-20 00:15:18 +03:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-20 01:12:01 +03:00
|
|
|
/**
|
|
|
|
|
* Двойной клик по сообщению — быстрый ответ (то же, что пункт «Ответить»).
|
|
|
|
|
* Браузер сам ограничивает интервал двойного клика (~300–500 мс).
|
|
|
|
|
*/
|
|
|
|
|
const onDoubleClick = (event: ReactMouseEvent<HTMLElement>): 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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-20 12:06:22 +03:00
|
|
|
// Системное сообщение: центрированная приглушённая строка без шапки автора,
|
|
|
|
|
// вложений и панели действий — реагировать и отвечать на неё нечего.
|
|
|
|
|
if (type === 'system') {
|
|
|
|
|
return (
|
|
|
|
|
<article
|
|
|
|
|
data-message-id={message.id}
|
|
|
|
|
data-testid={`message-${message.id}`}
|
|
|
|
|
data-message-type="system"
|
|
|
|
|
className={`flex flex-col items-center gap-0.5 px-1 py-1 text-center ${
|
|
|
|
|
pending ? 'opacity-70' : ''
|
|
|
|
|
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="sr-only">{t('chat.types.system')}</span>
|
2026-09-20 18:19:19 +03:00
|
|
|
{message.content === '' ? null : <SystemMessageBody message={message} authors={authors} />}
|
2026-09-20 12:06:22 +03:00
|
|
|
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
|
|
|
|
|
{formatMessageTime(message.created_at, i18n.language)}
|
|
|
|
|
</time>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
return (
|
|
|
|
|
<article
|
|
|
|
|
data-message-id={message.id}
|
|
|
|
|
data-testid={`message-${message.id}`}
|
2026-09-20 12:06:22 +03:00
|
|
|
data-message-type={type}
|
2026-09-20 01:12:01 +03:00
|
|
|
onDoubleClick={onDoubleClick}
|
2026-09-20 00:15:18 +03:00
|
|
|
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 : (
|
2026-09-20 12:06:22 +03:00
|
|
|
<MessageBody
|
|
|
|
|
type={type}
|
|
|
|
|
message={message}
|
|
|
|
|
authorName={author.name}
|
2026-09-20 00:15:18 +03:00
|
|
|
resolveMention={authors.resolveMention}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{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')}>
|
2026-09-20 18:13:04 +03:00
|
|
|
{message.reactions.map((reaction) => {
|
|
|
|
|
// Кастомный эмодзи едет токеном `<:имя:file_id>`: показываем
|
|
|
|
|
// картинку, а в подписи — читаемое `:имя:`.
|
|
|
|
|
const custom = emojis.resolve(reaction.emoji);
|
|
|
|
|
return (
|
|
|
|
|
<li key={reaction.emoji}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-pressed={reaction.me}
|
|
|
|
|
aria-label={t('chat.reactions.toggle', {
|
|
|
|
|
emoji: custom === null ? reaction.emoji : customEmojiShortcode(custom.name),
|
|
|
|
|
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'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{custom === null ? (
|
|
|
|
|
<span aria-hidden="true">{reaction.emoji}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<CustomEmojiImage emoji={custom} className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
<span>{reaction.count}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-09-20 00:15:18 +03:00
|
|
|
</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>
|
2026-09-20 12:06:22 +03:00
|
|
|
) : type === 'action' ? (
|
|
|
|
|
// У действия нет шапки автора — время показываем всегда.
|
|
|
|
|
<time dateTime={message.created_at}>
|
|
|
|
|
{formatMessageTime(message.created_at, i18n.language)}
|
|
|
|
|
</time>
|
2026-09-20 00:15:18 +03:00
|
|
|
) : 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'}
|
2026-09-20 18:13:04 +03:00
|
|
|
guildId={guildId}
|
2026-09-20 00:15:18 +03:00
|
|
|
/>
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|