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:
2026-09-20 12:06:22 +03:00
parent 65d0bafe0e
commit a126f50468
22 changed files with 1826 additions and 116 deletions
+140 -5
View File
@@ -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}