Files
glchat/web/src/components/chat/MessageItem.tsx
T

437 lines
16 KiB
TypeScript
Raw Normal View History

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>
);
}