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