feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты
- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее, private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка без аватара и панели действий; реакции, ответы, правка и удаление работают как раньше, группировка не сливает нестандартные виды с обычными; - автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью: @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты, : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится со слоем подсветки; - `<#id>` рендерится как #имя комнаты из снапшота (иначе #id); - 287 тестов Vitest (было 262), check/lint/test/build зелёные.
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
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, type Message } from '@/api/messages';
|
||||
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
|
||||
import { EmojiPicker } from '@/components/chat/EmojiPicker';
|
||||
import { MessageContent } from '@/components/chat/MessageContent';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
@@ -40,6 +46,100 @@ const actionButtonClass =
|
||||
*/
|
||||
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
|
||||
|
||||
/** Пометка вида сообщения: «шёпот», «громко», «лично». */
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
|
||||
*
|
||||
@@ -78,6 +178,7 @@ export function MessageItem({
|
||||
const canEdit = isMine && canSend && !pending;
|
||||
const canDelete = !pending && (isMine || canManageMessages);
|
||||
const edited = message.edited_at !== undefined;
|
||||
const type = messageTypeOf(message.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
@@ -166,10 +267,38 @@ export function MessageItem({
|
||||
onReply(message);
|
||||
};
|
||||
|
||||
// Системное сообщение: центрированная приглушённая строка без шапки автора,
|
||||
// вложений и панели действий — реагировать и отвечать на неё нечего.
|
||||
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>
|
||||
{message.content === '' ? null : (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
resolveMention={authors.resolveMention}
|
||||
className="text-xs text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
</time>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
data-message-id={message.id}
|
||||
data-testid={`message-${message.id}`}
|
||||
data-message-type={type}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
||||
pending ? 'opacity-70' : ''
|
||||
@@ -234,10 +363,11 @@ export function MessageItem({
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{message.content === '' ? null : (
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
<MessageBody
|
||||
type={type}
|
||||
message={message}
|
||||
authorName={author.name}
|
||||
resolveMention={authors.resolveMention}
|
||||
className="text-sm text-fg"
|
||||
/>
|
||||
)}
|
||||
{message.attachments.length === 0 ? null : (
|
||||
@@ -310,6 +440,11 @@ export function MessageItem({
|
||||
>
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
</time>
|
||||
) : type === 'action' ? (
|
||||
// У действия нет шапки автора — время показываем всегда.
|
||||
<time dateTime={message.created_at}>
|
||||
{formatMessageTime(message.created_at, i18n.language)}
|
||||
</time>
|
||||
) : null}
|
||||
{edited ? <span>{t('chat.edited')}</span> : null}
|
||||
{pending ? <span role="status">{t('chat.pending')}</span> : null}
|
||||
|
||||
Reference in New Issue
Block a user