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:
+21
-1
@@ -30,13 +30,33 @@ export interface MessageReaction {
|
|||||||
user_ids?: string[];
|
user_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Виды сообщений из контракта (AGENT.md §7.6): обычное, действие `/me`, шёпот,
|
||||||
|
* крик, личное `/ls` и системное. Сервер разбирает команды сам, клиент только
|
||||||
|
* оформляет пришедший `type`.
|
||||||
|
*/
|
||||||
|
export const messageTypes = [
|
||||||
|
'default',
|
||||||
|
'action',
|
||||||
|
'whisper',
|
||||||
|
'scream',
|
||||||
|
'private',
|
||||||
|
'system',
|
||||||
|
] as const;
|
||||||
|
export type MessageType = (typeof messageTypes)[number];
|
||||||
|
|
||||||
|
/** Вид сообщения с запасным `default`: контракт допускает расширение. */
|
||||||
|
export function messageTypeOf(type: string | undefined): MessageType {
|
||||||
|
return messageTypes.find((known) => known === type) ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
author_id?: string;
|
author_id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
reply_to_id?: string;
|
reply_to_id?: string;
|
||||||
/** `default` | `system` | `whisper` — контракт допускает расширение. */
|
/** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`. */
|
||||||
type: string;
|
type: string;
|
||||||
edited_at?: string;
|
edited_at?: string;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
|
|||||||
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
|
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
|
||||||
<Composer
|
<Composer
|
||||||
channel={channel}
|
channel={channel}
|
||||||
|
guildId={guildId}
|
||||||
authors={authors}
|
authors={authors}
|
||||||
direct={isDirect}
|
direct={isDirect}
|
||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -6,18 +6,26 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
|
|||||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||||
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
|
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
|
||||||
import type { Channel } from '@/api/types';
|
import type { Channel } from '@/api/types';
|
||||||
|
import { ComposerAutocomplete } from '@/components/chat/ComposerAutocomplete';
|
||||||
import {
|
import {
|
||||||
COMPOSER_TEXT_CLASS,
|
COMPOSER_TEXT_CLASS,
|
||||||
ComposerMirror,
|
ComposerMirror,
|
||||||
type ComposerSelection,
|
type ComposerSelection,
|
||||||
} from '@/components/chat/ComposerMirror';
|
} from '@/components/chat/ComposerMirror';
|
||||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
|
import {
|
||||||
|
useComposerAutocomplete,
|
||||||
|
type ComposerAutocompleteState,
|
||||||
|
} from '@/components/chat/useComposerAutocomplete';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { detectAutocomplete, type AutocompleteItem } from '@/lib/autocomplete';
|
||||||
import { formatBytes, errorCode } from '@/lib/format';
|
import { formatBytes, errorCode } from '@/lib/format';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
|
/** Сервер комнаты: нужен для подсказок участников и комнат. */
|
||||||
|
guildId: string | null;
|
||||||
authors: AuthorDirectory;
|
authors: AuthorDirectory;
|
||||||
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
|
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
|
||||||
direct?: boolean;
|
direct?: boolean;
|
||||||
@@ -45,6 +53,7 @@ const LINE_HEIGHT_PX = 22;
|
|||||||
*/
|
*/
|
||||||
export function Composer({
|
export function Composer({
|
||||||
channel,
|
channel,
|
||||||
|
guildId,
|
||||||
authors,
|
authors,
|
||||||
direct = false,
|
direct = false,
|
||||||
replyTo,
|
replyTo,
|
||||||
@@ -67,10 +76,38 @@ export function Composer({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
|
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
|
||||||
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
|
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
|
||||||
|
/** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */
|
||||||
|
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* Активный вариант подсказки: выбор привязан к позиции каретки, поэтому при
|
||||||
|
* её смене (новый ввод) он снова первый — без эффекта и лишнего рендера.
|
||||||
|
*/
|
||||||
|
const [activePick, setActivePick] = useState({ caret: 0, index: 0 });
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
/**
|
||||||
|
* Каретка после программной вставки подсказки. Ставим её в layout-эффекте:
|
||||||
|
* к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы
|
||||||
|
* каретку в конце и слой подсветки разъехался бы с полем.
|
||||||
|
*/
|
||||||
|
const pendingCaret = useRef<number | null>(null);
|
||||||
|
const autocompleteId = useId();
|
||||||
|
|
||||||
|
const suggestion = useComposerAutocomplete({
|
||||||
|
value,
|
||||||
|
caret: selection.end,
|
||||||
|
channel,
|
||||||
|
guildId,
|
||||||
|
});
|
||||||
|
const open = suggestion !== null && suggestion.key !== dismissedKey;
|
||||||
|
const suggestionSize = suggestion?.items.length ?? 0;
|
||||||
|
const activeIndex =
|
||||||
|
!open || activePick.caret !== selection.end
|
||||||
|
? 0
|
||||||
|
: Math.min(activePick.index, suggestionSize - 1);
|
||||||
|
const activeOptionId = open ? `${autocompleteId}-option-${String(activeIndex)}` : undefined;
|
||||||
|
|
||||||
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
|
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
|
||||||
// ограничение проверяет сам сервер, а мы показываем его ошибку.
|
// ограничение проверяет сам сервер, а мы показываем его ошибку.
|
||||||
@@ -114,6 +151,20 @@ export function Composer({
|
|||||||
syncScroll();
|
syncScroll();
|
||||||
}, [value, syncScroll]);
|
}, [value, syncScroll]);
|
||||||
|
|
||||||
|
// Каретка сразу после вставленного фрагмента: иначе подсветка и правка
|
||||||
|
// разъедутся с полем ввода.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const caret = pendingCaret.current;
|
||||||
|
const element = textareaRef.current;
|
||||||
|
if (caret === null || element === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingCaret.current = null;
|
||||||
|
element.focus();
|
||||||
|
element.setSelectionRange(caret, caret);
|
||||||
|
syncSelection(element);
|
||||||
|
}, [value, syncSelection]);
|
||||||
|
|
||||||
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
|
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
|
||||||
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
|
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,6 +230,27 @@ export function Composer({
|
|||||||
[canAttach, channel.id, i18n.language, maxUploadSize, t],
|
[canAttach, channel.id, i18n.language, maxUploadSize, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вставка варианта подсказки: заменяем триггер вместе с запросом и ставим
|
||||||
|
* каретку сразу за вставленным фрагментом.
|
||||||
|
*/
|
||||||
|
const insertAutocomplete = (current: ComposerAutocompleteState, item: AutocompleteItem): void => {
|
||||||
|
pendingCaret.current = current.start + item.insert.length;
|
||||||
|
setValue(`${value.slice(0, current.start)}${item.insert}${value.slice(current.end)}`);
|
||||||
|
setActivePick({ caret: 0, index: 0 });
|
||||||
|
setDismissedKey(null);
|
||||||
|
useMessagesStore.getState().notifyTyping(channel.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Стрелки выбирают вариант; выбор живёт до следующего движения каретки. */
|
||||||
|
const moveAutocomplete = (step: number): void => {
|
||||||
|
setActivePick((current) => {
|
||||||
|
const base = current.caret === selection.end ? current.index : 0;
|
||||||
|
const index = Math.min(base, suggestionSize - 1);
|
||||||
|
return { caret: selection.end, index: (index + step + suggestionSize) % suggestionSize };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (): Promise<void> => {
|
const submit = async (): Promise<void> => {
|
||||||
const content = value.trim();
|
const content = value.trim();
|
||||||
if (!canSubmit || (content === '' && files.length === 0)) {
|
if (!canSubmit || (content === '' && files.length === 0)) {
|
||||||
@@ -196,6 +268,8 @@ export function Composer({
|
|||||||
setValue('');
|
setValue('');
|
||||||
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
|
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
|
||||||
setSelection({ start: 0, end: 0 });
|
setSelection({ start: 0, end: 0 });
|
||||||
|
// Подсказок после отправки нет: отказ по Escape тоже больше не нужен.
|
||||||
|
setDismissedKey(null);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
// Отправка прошла — лимит частоты больше не держим.
|
// Отправка прошла — лимит частоты больше не держим.
|
||||||
@@ -337,6 +411,19 @@ export function Composer({
|
|||||||
mirrorRef={mirrorRef}
|
mirrorRef={mirrorRef}
|
||||||
resolveMention={authors.resolveMention}
|
resolveMention={authors.resolveMention}
|
||||||
/>
|
/>
|
||||||
|
<ComposerAutocomplete
|
||||||
|
open={open}
|
||||||
|
label={suggestion === null ? '' : t(suggestion.labelKey)}
|
||||||
|
id={autocompleteId}
|
||||||
|
items={suggestion?.items ?? []}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onActiveIndexChange={(index) => setActivePick({ caret: selection.end, index })}
|
||||||
|
onPick={(item) => {
|
||||||
|
if (suggestion !== null) {
|
||||||
|
insertAutocomplete(suggestion, item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
|
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
|
||||||
@@ -346,6 +433,13 @@ export function Composer({
|
|||||||
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
|
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
|
||||||
{ name: channel.name },
|
{ name: channel.name },
|
||||||
)}
|
)}
|
||||||
|
// Подсказки — это listbox над полем: фокус остаётся в textarea, а
|
||||||
|
// активный вариант помечается `aria-activedescendant`.
|
||||||
|
aria-expanded={open}
|
||||||
|
{...(open ? { 'aria-controls': autocompleteId } : {})}
|
||||||
|
{...(activeOptionId === undefined ? {} : { 'aria-activedescendant': activeOptionId })}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-haspopup="listbox"
|
||||||
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
|
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
|
||||||
// остаются родными для поля ввода.
|
// остаются родными для поля ввода.
|
||||||
className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`}
|
className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`}
|
||||||
@@ -357,8 +451,17 @@ export function Composer({
|
|||||||
// бы поверх подсветки.
|
// бы поверх подсветки.
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setValue(event.target.value);
|
const next = event.target.value;
|
||||||
|
setValue(next);
|
||||||
syncSelection(event.target);
|
syncSelection(event.target);
|
||||||
|
// Контекст подсказки потерян (стёрли триггер) — отказ по Escape
|
||||||
|
// больше не нужен: следующая подсказка откроется сразу.
|
||||||
|
if (
|
||||||
|
dismissedKey !== null &&
|
||||||
|
detectAutocomplete(next, event.target.selectionStart) === null
|
||||||
|
) {
|
||||||
|
setDismissedKey(null);
|
||||||
|
}
|
||||||
useMessagesStore.getState().notifyTyping(channel.id);
|
useMessagesStore.getState().notifyTyping(channel.id);
|
||||||
}}
|
}}
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
@@ -372,6 +475,28 @@ export function Composer({
|
|||||||
}}
|
}}
|
||||||
onScroll={syncScroll}
|
onScroll={syncScroll}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
// Открытая подсказка перехватывает навигацию: стрелки выбирают
|
||||||
|
// вариант, Enter/Tab вставляют его, Escape закрывает.
|
||||||
|
if (open && suggestion !== null) {
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveAutocomplete(event.key === 'ArrowDown' ? 1 : -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = suggestion.items[activeIndex];
|
||||||
|
if (item !== undefined) {
|
||||||
|
insertAutocomplete(suggestion, item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
setDismissedKey(suggestion.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void submit();
|
void submit();
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import type { AutocompleteItem } from '@/lib/autocomplete';
|
||||||
|
|
||||||
|
interface ComposerAutocompleteProps {
|
||||||
|
open: boolean;
|
||||||
|
/** Доступное имя списка (`aria-label`). */
|
||||||
|
label: string;
|
||||||
|
/** id списка: на него ссылается поле ввода через `aria-controls`. */
|
||||||
|
id: string;
|
||||||
|
items: AutocompleteItem[];
|
||||||
|
activeIndex: number;
|
||||||
|
onActiveIndexChange: (index: number) => void;
|
||||||
|
onPick: (item: AutocompleteItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись варианта: у команды — название и описание, у остальных — одна строка. */
|
||||||
|
function OptionLabel({ item }: { item: AutocompleteItem }) {
|
||||||
|
if (item.kind === 'command') {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate font-medium text-fg">{item.label}</span>
|
||||||
|
{item.hint === undefined ? null : (
|
||||||
|
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.kind === 'emoji') {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span aria-hidden="true" className="text-lg leading-none">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-fg-muted">{item.hint}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
{item.hint === undefined ? null : (
|
||||||
|
<span className="truncate text-xs text-fg-muted">{item.hint}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список подсказок композера: `role="listbox"` над полем ввода.
|
||||||
|
*
|
||||||
|
* Фокус остаётся в поле — активный вариант помечается `aria-selected` и
|
||||||
|
* `aria-activedescendant` у textarea, поэтому список доступен и с клавиатуры,
|
||||||
|
* и скринридеру. Клик не забирает фокус (`onMouseDown` отменяется), иначе
|
||||||
|
* каретка потерялась бы до вставки.
|
||||||
|
*/
|
||||||
|
export function ComposerAutocomplete({
|
||||||
|
open,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
items,
|
||||||
|
activeIndex,
|
||||||
|
onActiveIndexChange,
|
||||||
|
onPick,
|
||||||
|
}: ComposerAutocompleteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!open || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="composer-autocomplete"
|
||||||
|
className="gl-popover absolute bottom-full left-0 z-40 mb-1 w-full max-w-80 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 p-1 shadow-[var(--shadow-3)]"
|
||||||
|
>
|
||||||
|
<p className="px-2 pb-1 text-[10px] uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('chat.composer.autocomplete.hint')}
|
||||||
|
</p>
|
||||||
|
{/* Внутри listbox только варианты: подпись с подсказкой по клавишам —
|
||||||
|
соседний узел, иначе скринридер читал бы её как вариант списка. */}
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={label}
|
||||||
|
className="gl-stagger flex max-h-56 flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
id={`${id}-option-${String(index)}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === activeIndex}
|
||||||
|
data-testid={`composer-option-${item.kind}`}
|
||||||
|
className={`flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
index === activeIndex ? 'bg-surface-3 text-fg' : 'text-fg-muted hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => onActiveIndexChange(index)}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => onPick(item)}
|
||||||
|
>
|
||||||
|
{item.avatarName === undefined ? null : (
|
||||||
|
<Avatar name={item.avatarName} seed={item.id} fileId={item.avatarFileId} size="sm" />
|
||||||
|
)}
|
||||||
|
<OptionLabel item={item} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
|
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
|
||||||
|
|
||||||
import { useChannelNames } from '@/components/chat/useChannelNames';
|
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||||
import {
|
import {
|
||||||
hasMarkers,
|
hasMarkers,
|
||||||
selectionIntersects,
|
selectionIntersects,
|
||||||
@@ -53,7 +53,7 @@ interface ComposerMirrorProps {
|
|||||||
function renderFormatted(
|
function renderFormatted(
|
||||||
token: InlineToken,
|
token: InlineToken,
|
||||||
content: string,
|
content: string,
|
||||||
channels: ReadonlySet<string>,
|
channels: ChannelDirectory,
|
||||||
resolveMention: MentionResolver | undefined,
|
resolveMention: MentionResolver | undefined,
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
switch (token.kind) {
|
switch (token.kind) {
|
||||||
@@ -106,9 +106,14 @@ function renderFormatted(
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'channel-link': {
|
||||||
|
// `<#id>`: как в ленте — имя комнаты из снапшота, иначе `#id`.
|
||||||
|
const name = channels.byId.get(content);
|
||||||
|
return <span className="text-accent">#{name ?? content}</span>;
|
||||||
|
}
|
||||||
case 'channel': {
|
case 'channel': {
|
||||||
const name = token.raw.slice(1);
|
const name = token.raw.slice(1);
|
||||||
return channels.has(name.toLowerCase()) ? (
|
return channels.names.has(name.toLowerCase()) ? (
|
||||||
<span className="text-accent">{token.raw}</span>
|
<span className="text-accent">{token.raw}</span>
|
||||||
) : (
|
) : (
|
||||||
token.raw
|
token.raw
|
||||||
@@ -122,7 +127,7 @@ function renderToken(
|
|||||||
token: InlineToken,
|
token: InlineToken,
|
||||||
line: MirrorLine,
|
line: MirrorLine,
|
||||||
selection: ComposerSelection,
|
selection: ComposerSelection,
|
||||||
channels: ReadonlySet<string>,
|
channels: ChannelDirectory,
|
||||||
resolveMention: MentionResolver | undefined,
|
resolveMention: MentionResolver | undefined,
|
||||||
key: string,
|
key: string,
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
@@ -158,7 +163,7 @@ function renderToken(
|
|||||||
function renderTokens(
|
function renderTokens(
|
||||||
line: MirrorLine,
|
line: MirrorLine,
|
||||||
selection: ComposerSelection,
|
selection: ComposerSelection,
|
||||||
channels: ReadonlySet<string>,
|
channels: ChannelDirectory,
|
||||||
resolveMention: MentionResolver | undefined,
|
resolveMention: MentionResolver | undefined,
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
const start = line.start + line.prefixLength;
|
const start = line.start + line.prefixLength;
|
||||||
@@ -191,7 +196,7 @@ function renderTokens(
|
|||||||
function renderLine(
|
function renderLine(
|
||||||
line: MirrorLine,
|
line: MirrorLine,
|
||||||
selection: ComposerSelection,
|
selection: ComposerSelection,
|
||||||
channels: ReadonlySet<string>,
|
channels: ChannelDirectory,
|
||||||
resolveMention: MentionResolver | undefined,
|
resolveMention: MentionResolver | undefined,
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
switch (line.kind) {
|
switch (line.kind) {
|
||||||
@@ -227,7 +232,7 @@ export function ComposerMirror({
|
|||||||
resolveMention,
|
resolveMention,
|
||||||
}: ComposerMirrorProps) {
|
}: ComposerMirrorProps) {
|
||||||
const lines = useMemo(() => toMirrorLines(value), [value]);
|
const lines = useMemo(() => toMirrorLines(value), [value]);
|
||||||
const channels = useChannelNames();
|
const channels = useChannelDirectory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useChannelNames } from '@/components/chat/useChannelNames';
|
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||||
import {
|
import {
|
||||||
parseBlocks,
|
parseBlocks,
|
||||||
parseInline,
|
parseInline,
|
||||||
@@ -60,7 +60,7 @@ function renderToken(
|
|||||||
content: string,
|
content: string,
|
||||||
key: string,
|
key: string,
|
||||||
resolveMention: MentionResolver,
|
resolveMention: MentionResolver,
|
||||||
channels: ReadonlySet<string>,
|
channels: ChannelDirectory,
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
switch (token.kind) {
|
switch (token.kind) {
|
||||||
case 'code':
|
case 'code':
|
||||||
@@ -129,10 +129,22 @@ function renderToken(
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'channel-link': {
|
||||||
|
// `<#id>`: имя комнаты берём из снапшота, иначе показываем `#id`.
|
||||||
|
const name = channels.byId.get(content);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
className="rounded-[var(--radius-sm)] bg-accent/15 px-1 font-medium text-accent"
|
||||||
|
>
|
||||||
|
#{name ?? content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'channel': {
|
case 'channel': {
|
||||||
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||||
const name = token.raw.slice(1);
|
const name = token.raw.slice(1);
|
||||||
return channels.has(name.toLowerCase()) ? (
|
return channels.names.has(name.toLowerCase()) ? (
|
||||||
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
||||||
{token.raw}
|
{token.raw}
|
||||||
</span>
|
</span>
|
||||||
@@ -146,7 +158,7 @@ function renderToken(
|
|||||||
function renderInline(
|
function renderInline(
|
||||||
text: string,
|
text: string,
|
||||||
resolveMention: MentionResolver,
|
resolveMention: MentionResolver,
|
||||||
channels: ReadonlySet<string>,
|
channels: ChannelDirectory,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
const nodes: ReactNode[] = [];
|
const nodes: ReactNode[] = [];
|
||||||
@@ -179,8 +191,8 @@ function renderInline(
|
|||||||
|
|
||||||
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
||||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||||
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
|
// Комнаты снапшота: `#канал` и `<#id>` подсвечиваем только для известных.
|
||||||
const channels = useChannelNames();
|
const channels = useChannelDirectory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||||
|
|||||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { fileContentUrl } from '@/api/files';
|
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 { EmojiPicker } from '@/components/chat/EmojiPicker';
|
||||||
import { MessageContent } from '@/components/chat/MessageContent';
|
import { MessageContent } from '@/components/chat/MessageContent';
|
||||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
@@ -40,6 +46,100 @@ const actionButtonClass =
|
|||||||
*/
|
*/
|
||||||
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
|
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 canEdit = isMine && canSend && !pending;
|
||||||
const canDelete = !pending && (isMine || canManageMessages);
|
const canDelete = !pending && (isMine || canManageMessages);
|
||||||
const edited = message.edited_at !== undefined;
|
const edited = message.edited_at !== undefined;
|
||||||
|
const type = messageTypeOf(message.type);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -166,10 +267,38 @@ export function MessageItem({
|
|||||||
onReply(message);
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
data-message-id={message.id}
|
data-message-id={message.id}
|
||||||
data-testid={`message-${message.id}`}
|
data-testid={`message-${message.id}`}
|
||||||
|
data-message-type={type}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
|
||||||
pending ? 'opacity-70' : ''
|
pending ? 'opacity-70' : ''
|
||||||
@@ -234,10 +363,11 @@ export function MessageItem({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
{message.content === '' ? null : (
|
{message.content === '' ? null : (
|
||||||
<MessageContent
|
<MessageBody
|
||||||
content={message.content}
|
type={type}
|
||||||
|
message={message}
|
||||||
|
authorName={author.name}
|
||||||
resolveMention={authors.resolveMention}
|
resolveMention={authors.resolveMention}
|
||||||
className="text-sm text-fg"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{message.attachments.length === 0 ? null : (
|
{message.attachments.length === 0 ? null : (
|
||||||
@@ -310,6 +440,11 @@ export function MessageItem({
|
|||||||
>
|
>
|
||||||
{formatMessageTime(message.created_at, i18n.language)}
|
{formatMessageTime(message.created_at, i18n.language)}
|
||||||
</time>
|
</time>
|
||||||
|
) : type === 'action' ? (
|
||||||
|
// У действия нет шапки автора — время показываем всегда.
|
||||||
|
<time dateTime={message.created_at}>
|
||||||
|
{formatMessageTime(message.created_at, i18n.language)}
|
||||||
|
</time>
|
||||||
) : null}
|
) : null}
|
||||||
{edited ? <span>{t('chat.edited')}</span> : null}
|
{edited ? <span>{t('chat.edited')}</span> : null}
|
||||||
{pending ? <span role="status">{t('chat.pending')}</span> : null}
|
{pending ? <span role="status">{t('chat.pending')}</span> : null}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { isPendingMessage, type Message } from '@/api/messages';
|
import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
|
||||||
import { MessageItem } from '@/components/chat/MessageItem';
|
import { MessageItem } from '@/components/chat/MessageItem';
|
||||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
@@ -215,77 +215,94 @@ export function MessageList({
|
|||||||
if (first === undefined) {
|
if (first === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const headType = messageTypeOf(first.type);
|
||||||
const author = authors.resolve(row.group.authorId ?? undefined);
|
const author = authors.resolve(row.group.authorId ?? undefined);
|
||||||
|
// У системных сообщений нет ни аватара, ни шапки автора, а действие
|
||||||
|
// `/me` рисует имя внутри строки — шапка ему тоже не нужна.
|
||||||
|
const showHeader = headType !== 'system' && headType !== 'action';
|
||||||
|
const body = (
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
{row.group.messages.map((message, index) => (
|
||||||
|
<MessageItem
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
channelId={channelId}
|
||||||
|
channelName={channelName}
|
||||||
|
authors={authors}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canManageMessages={canManageMessages}
|
||||||
|
canSend={canSend}
|
||||||
|
compact={index > 0}
|
||||||
|
{...(isPendingMessage(message) ? { pending: true } : {})}
|
||||||
|
replyTarget={
|
||||||
|
message.reply_to_id === undefined
|
||||||
|
? undefined
|
||||||
|
: messageById.get(message.reply_to_id)
|
||||||
|
}
|
||||||
|
highlight={message.id === highlightId}
|
||||||
|
onReply={onReply}
|
||||||
|
onJumpToReply={(messageId) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const target = container?.querySelector(
|
||||||
|
`[data-message-id="${escapeSelector(messageId)}"]`,
|
||||||
|
);
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
target.scrollIntoView?.({ block: 'center' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={row.key}
|
key={row.key}
|
||||||
data-testid="message-group"
|
data-testid="message-group"
|
||||||
|
data-message-type={headType}
|
||||||
className="group/msg rounded-[var(--radius-md)] px-2 py-1 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2/40"
|
className="group/msg rounded-[var(--radius-md)] px-2 py-1 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2/40"
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
{headType === 'system' ? (
|
||||||
<div className="w-9 shrink-0 pt-0.5">
|
// Системная строка: по центру, без колонки аватара.
|
||||||
<Avatar
|
<div className="flex justify-center">{body}</div>
|
||||||
name={author.name}
|
) : (
|
||||||
seed={author.id === '' ? row.key : author.id}
|
<div className="flex gap-3">
|
||||||
fileId={author.avatarFileId}
|
<div className="w-9 shrink-0 pt-0.5">
|
||||||
size="md"
|
<Avatar
|
||||||
/>
|
name={author.name}
|
||||||
</div>
|
seed={author.id === '' ? row.key : author.id}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
fileId={author.avatarFileId}
|
||||||
<header className="flex flex-wrap items-baseline gap-2">
|
size="md"
|
||||||
<span
|
/>
|
||||||
className="text-sm font-semibold"
|
</div>
|
||||||
style={author.colorHex === null ? undefined : { color: author.colorHex }}
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
>
|
{showHeader ? (
|
||||||
{author.name}
|
<header className="flex flex-wrap items-baseline gap-2">
|
||||||
</span>
|
<span
|
||||||
{author.isInstanceAdmin ? (
|
className="text-sm font-semibold"
|
||||||
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
style={
|
||||||
{t('chat.adminBadge')}
|
author.colorHex === null ? undefined : { color: author.colorHex }
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<time
|
|
||||||
dateTime={first.created_at}
|
|
||||||
className="text-xs text-fg-muted"
|
|
||||||
title={formatMessageDate(first.created_at, i18n.language)}
|
|
||||||
>
|
|
||||||
{formatMessageTime(first.created_at, i18n.language)}
|
|
||||||
</time>
|
|
||||||
</header>
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
{row.group.messages.map((message, index) => (
|
|
||||||
<MessageItem
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
channelId={channelId}
|
|
||||||
channelName={channelName}
|
|
||||||
authors={authors}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canManageMessages={canManageMessages}
|
|
||||||
canSend={canSend}
|
|
||||||
compact={index > 0}
|
|
||||||
{...(isPendingMessage(message) ? { pending: true } : {})}
|
|
||||||
replyTarget={
|
|
||||||
message.reply_to_id === undefined
|
|
||||||
? undefined
|
|
||||||
: messageById.get(message.reply_to_id)
|
|
||||||
}
|
|
||||||
highlight={message.id === highlightId}
|
|
||||||
onReply={onReply}
|
|
||||||
onJumpToReply={(messageId) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
const target = container?.querySelector(
|
|
||||||
`[data-message-id="${escapeSelector(messageId)}"]`,
|
|
||||||
);
|
|
||||||
if (target instanceof HTMLElement) {
|
|
||||||
target.scrollIntoView?.({ block: 'center' });
|
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
/>
|
{author.name}
|
||||||
))}
|
</span>
|
||||||
|
{author.isInstanceAdmin ? (
|
||||||
|
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
||||||
|
{t('chat.adminBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<time
|
||||||
|
dateTime={first.created_at}
|
||||||
|
className="text-xs text-fg-muted"
|
||||||
|
title={formatMessageDate(first.created_at, i18n.language)}
|
||||||
|
>
|
||||||
|
{formatMessageTime(first.created_at, i18n.language)}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
{body}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
|
/** Комнаты снапшота в двух видах: по id и по имени. */
|
||||||
|
export interface ChannelDirectory {
|
||||||
|
/** `id → имя`: для ссылок на комнату `<#id>`. */
|
||||||
|
byId: ReadonlyMap<string, string>;
|
||||||
|
/** Имена в нижнем регистре: `#имя` подсвечивается только для известных комнат. */
|
||||||
|
names: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Справочник комнат из снапшота: общий хук для ленты сообщений и зеркала
|
||||||
|
* композера. Один разбор — оба вида ссылок (`#имя` и `<#id>`) оформляются
|
||||||
|
* одинаково в предпросмотре и в ленте.
|
||||||
|
*/
|
||||||
|
export function useChannelDirectory(): ChannelDirectory {
|
||||||
|
const guilds = useSessionStore((state) => state.guilds);
|
||||||
|
return useMemo(() => {
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const guild of guilds) {
|
||||||
|
for (const channel of guild.channels) {
|
||||||
|
byId.set(channel.id, channel.name);
|
||||||
|
names.add(channel.name.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byId, names };
|
||||||
|
}, [guilds]);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useSessionStore } from '@/stores/session';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Имена каналов из снапшота: `#канал` подсвечивается только для известных
|
|
||||||
* комнат. Общий хук для ленты сообщений и зеркала композера.
|
|
||||||
*/
|
|
||||||
export function useChannelNames(): ReadonlySet<string> {
|
|
||||||
const guilds = useSessionStore((state) => state.guilds);
|
|
||||||
return useMemo(() => {
|
|
||||||
const names = new Set<string>();
|
|
||||||
for (const guild of guilds) {
|
|
||||||
for (const channel of guild.channels) {
|
|
||||||
names.add(channel.name.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}, [guilds]);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { fetchMembers, guildMembersQueryKey } from '@/api/guilds';
|
||||||
|
import type { Channel, GuildMember, UserSummary } from '@/api/types';
|
||||||
|
import { fetchUser, userQueryKey } from '@/api/users';
|
||||||
|
import {
|
||||||
|
detectAutocomplete,
|
||||||
|
CHAT_COMMANDS,
|
||||||
|
type AutocompleteKind,
|
||||||
|
type AutocompleteItem,
|
||||||
|
} from '@/lib/autocomplete';
|
||||||
|
import { memberDisplayName } from '@/lib/chat';
|
||||||
|
import { searchEmoji } from '@/lib/emoji';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
|
/** Сколько подсказок показываем: список должен помещаться над полем ввода. */
|
||||||
|
const MAX_SUGGESTIONS = 8;
|
||||||
|
|
||||||
|
/** Ключи перевода заголовка списка — по виду подсказки. */
|
||||||
|
const LABEL_KEYS: Readonly<Record<AutocompleteKind, string>> = {
|
||||||
|
mention: 'chat.composer.autocomplete.mentions',
|
||||||
|
channel: 'chat.composer.autocomplete.channels',
|
||||||
|
emoji: 'chat.composer.autocomplete.emoji',
|
||||||
|
command: 'chat.composer.autocomplete.commands',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Открытая подсказка: контекст триггера и готовые варианты. */
|
||||||
|
export interface ComposerAutocompleteState {
|
||||||
|
kind: AutocompleteKind;
|
||||||
|
/** Смещение символа-триггера: с него начинается заменяемый фрагмент. */
|
||||||
|
start: number;
|
||||||
|
/** Позиция каретки: конец заменяемого фрагмента. */
|
||||||
|
end: number;
|
||||||
|
/** Ключ контекста: по нему композер отличает один триггер от другого. */
|
||||||
|
key: string;
|
||||||
|
/** Ключ перевода заголовка списка. */
|
||||||
|
labelKey: string;
|
||||||
|
items: AutocompleteItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Участник, которого можно упомянуть. */
|
||||||
|
interface MentionCandidate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
avatarFileId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function membersToCandidates(members: GuildMember[] | undefined): MentionCandidate[] {
|
||||||
|
return (members ?? []).map((member) => ({
|
||||||
|
id: member.user_id,
|
||||||
|
name: memberDisplayName(member) ?? member.username,
|
||||||
|
username: member.username,
|
||||||
|
avatarFileId: member.avatar_file_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientToCandidate(recipient: UserSummary | undefined): MentionCandidate[] {
|
||||||
|
if (recipient === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: recipient.id,
|
||||||
|
name: recipient.display_name !== '' ? recipient.display_name : recipient.username,
|
||||||
|
username: recipient.username,
|
||||||
|
avatarFileId: recipient.avatar_file_id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerAutocompleteOptions {
|
||||||
|
value: string;
|
||||||
|
/** Позиция каретки в поле ввода. */
|
||||||
|
caret: number;
|
||||||
|
channel: Channel;
|
||||||
|
/** Сервер комнаты: из него берём участников и видимые текстовые комнаты. */
|
||||||
|
guildId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подсказки композера: `@` — участники сервера (в личной беседе — собеседник),
|
||||||
|
* `#` — видимые текстовые комнаты, `:` — эмодзи по коду, `/` — команды чата.
|
||||||
|
*
|
||||||
|
* Контекст триггера ищет `detectAutocomplete` (без DOM), здесь только данные и
|
||||||
|
* фильтр по префиксу. Участники читаются через тот же ключ запроса, что и
|
||||||
|
* справочник авторов, поэтому лишнего обращения к серверу не происходит.
|
||||||
|
*
|
||||||
|
* Хук не хранит состояние: закрытие по Escape — забота композера, а контекст
|
||||||
|
* триггера целиком выводится из текста и позиции каретки.
|
||||||
|
*/
|
||||||
|
export function useComposerAutocomplete(
|
||||||
|
options: ComposerAutocompleteOptions,
|
||||||
|
): ComposerAutocompleteState | null {
|
||||||
|
const { value, caret, channel, guildId } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const members = useQuery({
|
||||||
|
queryKey: guildMembersQueryKey(guildId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
|
||||||
|
enabled: guildId !== null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientId = channel.type === 'dm' ? channel.recipient_id : undefined;
|
||||||
|
const recipient = useQuery({
|
||||||
|
queryKey: userQueryKey(recipientId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchUser(recipientId ?? '', signal),
|
||||||
|
enabled: recipientId !== undefined && recipientId !== '',
|
||||||
|
staleTime: 300_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guild = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) =>
|
||||||
|
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||||
|
[guildId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = useMemo(() => detectAutocomplete(value, caret), [value, caret]);
|
||||||
|
|
||||||
|
return useMemo<ComposerAutocompleteState | null>(() => {
|
||||||
|
if (trigger === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const query = trigger.query.toLowerCase();
|
||||||
|
let items: AutocompleteItem[];
|
||||||
|
|
||||||
|
if (trigger.kind === 'command') {
|
||||||
|
items = CHAT_COMMANDS.filter((command) =>
|
||||||
|
command.command.slice(1).toLowerCase().startsWith(query),
|
||||||
|
)
|
||||||
|
.slice(0, MAX_SUGGESTIONS)
|
||||||
|
.map((command) => ({
|
||||||
|
id: `command:${command.command}`,
|
||||||
|
kind: 'command',
|
||||||
|
insert: `${command.command} `,
|
||||||
|
label: command.command,
|
||||||
|
hint: t(command.descriptionKey),
|
||||||
|
}));
|
||||||
|
} else if (trigger.kind === 'mention') {
|
||||||
|
const candidates =
|
||||||
|
guildId === null ? recipientToCandidate(recipient.data) : membersToCandidates(members.data);
|
||||||
|
items = candidates
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.username.toLowerCase().startsWith(query) ||
|
||||||
|
candidate.name.toLowerCase().startsWith(query),
|
||||||
|
)
|
||||||
|
.slice(0, MAX_SUGGESTIONS)
|
||||||
|
.map((candidate) => ({
|
||||||
|
id: `mention:${candidate.id}`,
|
||||||
|
kind: 'mention',
|
||||||
|
insert: `<@${candidate.id}>`,
|
||||||
|
label: candidate.name,
|
||||||
|
hint: `@${candidate.username}`,
|
||||||
|
avatarName: candidate.name,
|
||||||
|
avatarFileId: candidate.avatarFileId,
|
||||||
|
}));
|
||||||
|
} else if (trigger.kind === 'channel') {
|
||||||
|
items = (guild?.channels ?? [])
|
||||||
|
.filter((item) => item.type === 'text' && item.can_view !== false)
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.position - right.position)
|
||||||
|
.filter((item) => item.name.toLowerCase().startsWith(query))
|
||||||
|
.slice(0, MAX_SUGGESTIONS)
|
||||||
|
.map((item) => ({
|
||||||
|
id: `channel:${item.id}`,
|
||||||
|
kind: 'channel',
|
||||||
|
insert: `<#${item.id}>`,
|
||||||
|
label: `#${item.name}`,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
items = searchEmoji(query, MAX_SUGGESTIONS).map((entry) => ({
|
||||||
|
id: `emoji:${entry.code}`,
|
||||||
|
kind: 'emoji',
|
||||||
|
insert: entry.emoji,
|
||||||
|
label: entry.emoji,
|
||||||
|
hint: `:${entry.code}:`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: trigger.kind,
|
||||||
|
start: trigger.start,
|
||||||
|
end: trigger.end,
|
||||||
|
key: `${trigger.kind}:${String(trigger.start)}`,
|
||||||
|
labelKey: LABEL_KEYS[trigger.kind],
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}, [trigger, guildId, guild, members.data, recipient.data, t]);
|
||||||
|
}
|
||||||
@@ -304,6 +304,12 @@
|
|||||||
"pending": "sending…",
|
"pending": "sending…",
|
||||||
"newMessages": "New messages",
|
"newMessages": "New messages",
|
||||||
"adminBadge": "admin",
|
"adminBadge": "admin",
|
||||||
|
"types": {
|
||||||
|
"whisper": "whisper",
|
||||||
|
"scream": "loud",
|
||||||
|
"private": "private",
|
||||||
|
"system": "System message"
|
||||||
|
},
|
||||||
"unknownAuthor": "unknown",
|
"unknownAuthor": "unknown",
|
||||||
"start": {
|
"start": {
|
||||||
"title": "This is the start of #{{name}}",
|
"title": "This is the start of #{{name}}",
|
||||||
@@ -365,7 +371,20 @@
|
|||||||
"denied": "You are not allowed to send messages in this channel.",
|
"denied": "You are not allowed to send messages in this channel.",
|
||||||
"readOnly": "This channel is read-only for you.",
|
"readOnly": "This channel is read-only for you.",
|
||||||
"directLabel": "Message to {{name}}",
|
"directLabel": "Message to {{name}}",
|
||||||
"directPlaceholder": "Message {{name}}"
|
"directPlaceholder": "Message {{name}}",
|
||||||
|
"autocomplete": {
|
||||||
|
"hint": "↑/↓ — select, Enter — insert, Escape — close",
|
||||||
|
"mentions": "Server members",
|
||||||
|
"channels": "Server channels",
|
||||||
|
"emoji": "Emoji",
|
||||||
|
"commands": "Commands"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"me": "action: name and italic text",
|
||||||
|
"whisper": "whisper: muted message",
|
||||||
|
"scream": "loud: large accented text",
|
||||||
|
"ls": "direct message: /ls username text"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"typing_one": "{{names}} is typing…",
|
"typing_one": "{{names}} is typing…",
|
||||||
"typing_few": "{{names}} are typing…",
|
"typing_few": "{{names}} are typing…",
|
||||||
|
|||||||
@@ -304,6 +304,12 @@
|
|||||||
"pending": "отправляется…",
|
"pending": "отправляется…",
|
||||||
"newMessages": "Новые сообщения",
|
"newMessages": "Новые сообщения",
|
||||||
"adminBadge": "админ",
|
"adminBadge": "админ",
|
||||||
|
"types": {
|
||||||
|
"whisper": "шёпот",
|
||||||
|
"scream": "громко",
|
||||||
|
"private": "лично",
|
||||||
|
"system": "Системное сообщение"
|
||||||
|
},
|
||||||
"unknownAuthor": "неизвестный",
|
"unknownAuthor": "неизвестный",
|
||||||
"start": {
|
"start": {
|
||||||
"title": "Это начало комнаты #{{name}}",
|
"title": "Это начало комнаты #{{name}}",
|
||||||
@@ -365,7 +371,20 @@
|
|||||||
"denied": "Нет права отправлять сообщения в эту комнату.",
|
"denied": "Нет права отправлять сообщения в эту комнату.",
|
||||||
"readOnly": "В эту комнату нельзя писать.",
|
"readOnly": "В эту комнату нельзя писать.",
|
||||||
"directLabel": "Сообщение для {{name}}",
|
"directLabel": "Сообщение для {{name}}",
|
||||||
"directPlaceholder": "Написать {{name}}"
|
"directPlaceholder": "Написать {{name}}",
|
||||||
|
"autocomplete": {
|
||||||
|
"hint": "↑/↓ — выбор, Enter — вставить, Escape — закрыть",
|
||||||
|
"mentions": "Участники сервера",
|
||||||
|
"channels": "Комнаты сервера",
|
||||||
|
"emoji": "Эмодзи",
|
||||||
|
"commands": "Команды"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"me": "действие: имя и текст курсивом",
|
||||||
|
"whisper": "шёпот: приглушённое сообщение",
|
||||||
|
"scream": "громко: крупный акцентный текст",
|
||||||
|
"ls": "личное сообщение: /ls логин текст"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"typing_one": "{{names}} печатает…",
|
"typing_one": "{{names}} печатает…",
|
||||||
"typing_few": "{{names}} печатают…",
|
"typing_few": "{{names}} печатают…",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Автокомплит композера: поиск контекста триггера (`@`, `#`, `:`, `/`) и
|
||||||
|
* список команд чата. Разбор живёт отдельно от компонентов: правило «где
|
||||||
|
* вообще уместна подсказка» тестируется без DOM, а `Composer` только рисует
|
||||||
|
* список и вставляет выбранное.
|
||||||
|
*
|
||||||
|
* Контекст обязателен: `@` в середине слова (`почта@пример`) и любой триггер
|
||||||
|
* внутри `` `кода` `` или блока ``` — обычный текст, а `/` — команда только в
|
||||||
|
* самом начале сообщения.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Вид подсказки. */
|
||||||
|
export type AutocompleteKind = 'mention' | 'channel' | 'emoji' | 'command';
|
||||||
|
|
||||||
|
/** Найденный контекст подсказки. */
|
||||||
|
export interface AutocompleteTrigger {
|
||||||
|
kind: AutocompleteKind;
|
||||||
|
/** Смещение символа-триггера в тексте. */
|
||||||
|
start: number;
|
||||||
|
/** Позиция каретки: конец заменяемого запроса. */
|
||||||
|
end: number;
|
||||||
|
/** Текст между триггером и кареткой (`bo` в `@bo`). */
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вариант подсказки: то, что видит пользователь, и то, что вставляется. */
|
||||||
|
export interface AutocompleteItem {
|
||||||
|
/** Стабильный ключ варианта. */
|
||||||
|
id: string;
|
||||||
|
kind: AutocompleteKind;
|
||||||
|
/** Текст, который вставляется вместо запроса вместе с триггером. */
|
||||||
|
insert: string;
|
||||||
|
/** Основная подпись. */
|
||||||
|
label: string;
|
||||||
|
/** Дополнительная подпись: логин, код эмодзи или описание команды. */
|
||||||
|
hint?: string | undefined;
|
||||||
|
/** Имя для аватара-заглушки (есть только у участников). */
|
||||||
|
avatarName?: string | undefined;
|
||||||
|
avatarFileId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Команда чата: сервер разбирает её сам (AGENT.md §7.6). */
|
||||||
|
export interface ChatCommand {
|
||||||
|
/** Команда со слэшем — ровно то, что вставляется в поле. */
|
||||||
|
command: string;
|
||||||
|
/** Ключ перевода описания. */
|
||||||
|
descriptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Команды, которые понимает сервер; порядок — как в подсказке. */
|
||||||
|
export const CHAT_COMMANDS: readonly ChatCommand[] = [
|
||||||
|
{ command: '/me', descriptionKey: 'chat.composer.commands.me' },
|
||||||
|
{ command: '/whisper', descriptionKey: 'chat.composer.commands.whisper' },
|
||||||
|
{ command: '/scream', descriptionKey: 'chat.composer.commands.scream' },
|
||||||
|
{ command: '/ls', descriptionKey: 'chat.composer.commands.ls' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Триггеры подсказок: символ — вид. */
|
||||||
|
const TRIGGERS: Readonly<Record<string, AutocompleteKind>> = {
|
||||||
|
'@': 'mention',
|
||||||
|
'#': 'channel',
|
||||||
|
':': 'emoji',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Что допустимо в запросе после триггера (пробел закрывает подсказку). */
|
||||||
|
const QUERY_CHAR = /[\p{L}\p{N}_.+-]/u;
|
||||||
|
|
||||||
|
/** Строка-ограждение блока кода. */
|
||||||
|
function isFence(line: string): boolean {
|
||||||
|
return line.trim().startsWith('```');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каретка внутри кода: строка блока кода, незакрытое ограждение или
|
||||||
|
* непарная `` ` `` в текущей строке. Внутри кода подсказки не показываем —
|
||||||
|
* там `:`, `@` и `#` обычный текст.
|
||||||
|
*/
|
||||||
|
export function insideCode(value: string, caret: number): boolean {
|
||||||
|
const lines = value.slice(0, caret).split('\n');
|
||||||
|
const current = lines[lines.length - 1] ?? '';
|
||||||
|
let fenced = false;
|
||||||
|
for (const line of lines.slice(0, -1)) {
|
||||||
|
if (isFence(line)) {
|
||||||
|
fenced = !fenced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fenced || isFence(current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ticks = (current.match(/`/gu) ?? []).length;
|
||||||
|
return ticks % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекст подсказки по тексту и позиции каретки. `null` — подсказку
|
||||||
|
* показывать не нужно (нет триггера, он внутри слова или внутри кода).
|
||||||
|
*/
|
||||||
|
export function detectAutocomplete(value: string, caret: number): AutocompleteTrigger | null {
|
||||||
|
if (caret < 0 || caret > value.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (insideCode(value, caret)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const before = value.slice(0, caret);
|
||||||
|
|
||||||
|
// Команда: `/` только первым символом сообщения. `//` экранирует слэш —
|
||||||
|
// это обычный текст, сервер команду не разбирает.
|
||||||
|
if (before.startsWith('/')) {
|
||||||
|
if (before.startsWith('//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const query = before.slice(1);
|
||||||
|
return /^[\p{L}\p{N}_-]*$/u.test(query)
|
||||||
|
? { kind: 'command', start: 0, end: caret, query }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остальные триггеры: отступаем от каретки по символам запроса и смотрим,
|
||||||
|
// что стоит перед ними.
|
||||||
|
let index = caret;
|
||||||
|
while (index > 0 && QUERY_CHAR.test(value[index - 1] ?? '')) {
|
||||||
|
index -= 1;
|
||||||
|
}
|
||||||
|
const triggerIndex = index - 1;
|
||||||
|
if (triggerIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const kind = TRIGGERS[value[triggerIndex] ?? ''];
|
||||||
|
if (kind === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previous = triggerIndex === 0 ? '' : (value[triggerIndex - 1] ?? '');
|
||||||
|
if (previous !== '' && !/\s/u.test(previous)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { kind, start: triggerIndex, end: caret, query: value.slice(index, caret) };
|
||||||
|
}
|
||||||
@@ -130,6 +130,11 @@ export function canGroup(previous: MessageLike, next: MessageLike): boolean {
|
|||||||
if (previous.author_id === undefined || previous.author_id !== next.author_id) {
|
if (previous.author_id === undefined || previous.author_id !== next.author_id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Действия, шёпот, крик, личные и системные сообщения оформляются отдельно:
|
||||||
|
// в общую группу они не попадают ни как следующие, ни как предыдущие.
|
||||||
|
if ((previous.type ?? 'default') !== 'default') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (next.reply_to_id !== undefined) {
|
if (next.reply_to_id !== undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Словарь эмодзи для автокомплита в композере: короткий код (`:fire:`) и сам
|
||||||
|
* символ. Отдельный модуль, а не внешняя зависимость: инстанс автономен, а
|
||||||
|
* набор кодов должен совпадать с тем, что видит пользователь в подсказке.
|
||||||
|
*
|
||||||
|
* Коды намеренно «классические» (как в Slack/GitHub), чтобы привычные
|
||||||
|
* сокращения работали и в нашем клиенте.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Эмодзи с коротким кодом. */
|
||||||
|
export interface EmojiEntry {
|
||||||
|
/** Короткий код без двоеточий (`smile`, `thumbsup`). */
|
||||||
|
code: string;
|
||||||
|
/** Сам символ, который вставляется в сообщение. */
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Популярные эмодзи: словарь подсказок `:код:`. */
|
||||||
|
export const EMOJI: readonly EmojiEntry[] = [
|
||||||
|
{ code: 'smile', emoji: '😄' },
|
||||||
|
{ code: 'grin', emoji: '😁' },
|
||||||
|
{ code: 'joy', emoji: '😂' },
|
||||||
|
{ code: 'rofl', emoji: '🤣' },
|
||||||
|
{ code: 'wink', emoji: '😉' },
|
||||||
|
{ code: 'blush', emoji: '😊' },
|
||||||
|
{ code: 'heart_eyes', emoji: '😍' },
|
||||||
|
{ code: 'thinking', emoji: '🤔' },
|
||||||
|
{ code: 'neutral', emoji: '😐' },
|
||||||
|
{ code: 'cry', emoji: '😢' },
|
||||||
|
{ code: 'sob', emoji: '😭' },
|
||||||
|
{ code: 'angry', emoji: '😠' },
|
||||||
|
{ code: 'rage', emoji: '😡' },
|
||||||
|
{ code: 'scream', emoji: '😱' },
|
||||||
|
{ code: 'sunglasses', emoji: '😎' },
|
||||||
|
{ code: 'sleeping', emoji: '😴' },
|
||||||
|
{ code: 'sick', emoji: '🤒' },
|
||||||
|
{ code: 'ghost', emoji: '👻' },
|
||||||
|
{ code: 'robot', emoji: '🤖' },
|
||||||
|
{ code: 'clown', emoji: '🤡' },
|
||||||
|
{ code: 'skull', emoji: '💀' },
|
||||||
|
{ code: 'poop', emoji: '💩' },
|
||||||
|
{ code: 'heart', emoji: '❤️' },
|
||||||
|
{ code: 'broken_heart', emoji: '💔' },
|
||||||
|
{ code: 'sparkles', emoji: '✨' },
|
||||||
|
{ code: 'star', emoji: '⭐' },
|
||||||
|
{ code: 'star2', emoji: '🌟' },
|
||||||
|
{ code: 'fire', emoji: '🔥' },
|
||||||
|
{ code: 'zap', emoji: '⚡' },
|
||||||
|
{ code: 'boom', emoji: '💥' },
|
||||||
|
{ code: 'rainbow', emoji: '🌈' },
|
||||||
|
{ code: 'sun', emoji: '☀️' },
|
||||||
|
{ code: 'moon', emoji: '🌙' },
|
||||||
|
{ code: 'cloud', emoji: '☁️' },
|
||||||
|
{ code: 'snowflake', emoji: '❄️' },
|
||||||
|
{ code: 'droplet', emoji: '💧' },
|
||||||
|
{ code: 'ocean', emoji: '🌊' },
|
||||||
|
{ code: 'earth', emoji: '🌍' },
|
||||||
|
{ code: 'rocket', emoji: '🚀' },
|
||||||
|
{ code: 'airplane', emoji: '✈️' },
|
||||||
|
{ code: 'car', emoji: '🚗' },
|
||||||
|
{ code: 'bulb', emoji: '💡' },
|
||||||
|
{ code: 'warning', emoji: '⚠️' },
|
||||||
|
{ code: 'check', emoji: '✅' },
|
||||||
|
{ code: 'x', emoji: '❌' },
|
||||||
|
{ code: 'question', emoji: '❓' },
|
||||||
|
{ code: 'exclamation', emoji: '❗' },
|
||||||
|
{ code: '100', emoji: '💯' },
|
||||||
|
{ code: 'ok', emoji: '👌' },
|
||||||
|
{ code: 'thumbsup', emoji: '👍' },
|
||||||
|
{ code: 'thumbsdown', emoji: '👎' },
|
||||||
|
{ code: 'clap', emoji: '👏' },
|
||||||
|
{ code: 'pray', emoji: '🙏' },
|
||||||
|
{ code: 'muscle', emoji: '💪' },
|
||||||
|
{ code: 'wave', emoji: '👋' },
|
||||||
|
{ code: 'point_up', emoji: '☝️' },
|
||||||
|
{ code: 'point_right', emoji: '👉' },
|
||||||
|
{ code: 'eyes', emoji: '👀' },
|
||||||
|
{ code: 'handshake', emoji: '🤝' },
|
||||||
|
{ code: 'salute', emoji: '🫡' },
|
||||||
|
{ code: 'brain', emoji: '🧠' },
|
||||||
|
{ code: 'coffee', emoji: '☕' },
|
||||||
|
{ code: 'beer', emoji: '🍺' },
|
||||||
|
{ code: 'pizza', emoji: '🍕' },
|
||||||
|
{ code: 'cake', emoji: '🍰' },
|
||||||
|
{ code: 'gift', emoji: '🎁' },
|
||||||
|
{ code: 'tada', emoji: '🎉' },
|
||||||
|
{ code: 'party', emoji: '🥳' },
|
||||||
|
{ code: 'balloon', emoji: '🎈' },
|
||||||
|
{ code: 'soccer', emoji: '⚽' },
|
||||||
|
{ code: 'basketball', emoji: '🏀' },
|
||||||
|
{ code: 'game', emoji: '🎮' },
|
||||||
|
{ code: 'trophy', emoji: '🏆' },
|
||||||
|
{ code: 'medal', emoji: '🏅' },
|
||||||
|
{ code: 'dice', emoji: '🎲' },
|
||||||
|
{ code: 'book', emoji: '📖' },
|
||||||
|
{ code: 'memo', emoji: '📝' },
|
||||||
|
{ code: 'pushpin', emoji: '📌' },
|
||||||
|
{ code: 'paperclip', emoji: '📎' },
|
||||||
|
{ code: 'bell', emoji: '🔔' },
|
||||||
|
{ code: 'hourglass', emoji: '⏳' },
|
||||||
|
{ code: 'alarm', emoji: '⏰' },
|
||||||
|
{ code: 'lock', emoji: '🔒' },
|
||||||
|
{ code: 'key', emoji: '🔑' },
|
||||||
|
{ code: 'mag', emoji: '🔍' },
|
||||||
|
{ code: 'hammer', emoji: '🔨' },
|
||||||
|
{ code: 'wrench', emoji: '🔧' },
|
||||||
|
{ code: 'bug', emoji: '🐛' },
|
||||||
|
{ code: 'cat', emoji: '🐱' },
|
||||||
|
{ code: 'dog', emoji: '🐶' },
|
||||||
|
{ code: 'fox', emoji: '🦊' },
|
||||||
|
{ code: 'unicorn', emoji: '🦄' },
|
||||||
|
{ code: 'panda', emoji: '🐼' },
|
||||||
|
{ code: 'sunflower', emoji: '🌻' },
|
||||||
|
{ code: 'rose', emoji: '🌹' },
|
||||||
|
{ code: 'tree', emoji: '🌳' },
|
||||||
|
{ code: 'apple', emoji: '🍎' },
|
||||||
|
{ code: 'watermelon', emoji: '🍉' },
|
||||||
|
{ code: 'burger', emoji: '🍔' },
|
||||||
|
{ code: 'fries', emoji: '🍟' },
|
||||||
|
{ code: 'sushi', emoji: '🍣' },
|
||||||
|
{ code: 'icecream', emoji: '🍦' },
|
||||||
|
{ code: 'popcorn', emoji: '🍿' },
|
||||||
|
{ code: 'music', emoji: '🎵' },
|
||||||
|
{ code: 'guitar', emoji: '🎸' },
|
||||||
|
{ code: 'mic', emoji: '🎤' },
|
||||||
|
{ code: 'camera', emoji: '📷' },
|
||||||
|
{ code: 'movie', emoji: '🎬' },
|
||||||
|
{ code: 'computer', emoji: '💻' },
|
||||||
|
{ code: 'phone', emoji: '📱' },
|
||||||
|
{ code: 'battery', emoji: '🔋' },
|
||||||
|
{ code: 'gear', emoji: '⚙️' },
|
||||||
|
{ code: 'shield', emoji: '🛡️' },
|
||||||
|
{ code: 'crown', emoji: '👑' },
|
||||||
|
{ code: 'gem', emoji: '💎' },
|
||||||
|
{ code: 'money', emoji: '💰' },
|
||||||
|
{ code: 'chart', emoji: '📈' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмодзи по префиксу кода: `fir` → 🔥. Регистр не важен, порядок словаря
|
||||||
|
* сохраняется — популярные эмодзи стоят в начале и попадают в список первыми.
|
||||||
|
*/
|
||||||
|
export function searchEmoji(query: string, limit: number): EmojiEntry[] {
|
||||||
|
const prefix = query.toLowerCase();
|
||||||
|
const found: EmojiEntry[] = [];
|
||||||
|
for (const entry of EMOJI) {
|
||||||
|
if (entry.code.startsWith(prefix)) {
|
||||||
|
found.push(entry);
|
||||||
|
if (found.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
+19
-11
@@ -16,7 +16,8 @@
|
|||||||
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
||||||
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
||||||
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
||||||
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
|
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
|
||||||
|
* `<#id>` и `#канал`.
|
||||||
*
|
*
|
||||||
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
||||||
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
||||||
@@ -32,6 +33,7 @@ export type InlineKind =
|
|||||||
| 'spoiler'
|
| 'spoiler'
|
||||||
| 'mark'
|
| 'mark'
|
||||||
| 'mention'
|
| 'mention'
|
||||||
|
| 'channel-link'
|
||||||
| 'link'
|
| 'link'
|
||||||
| 'channel';
|
| 'channel';
|
||||||
|
|
||||||
@@ -53,12 +55,15 @@ export interface InlineToken {
|
|||||||
/**
|
/**
|
||||||
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||||
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||||
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
|
* ссылкой, а `<#id>` — раньше `#канала`, иначе угловые скобки остались бы
|
||||||
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
|
* текстом. Идентификатор в `<@id>`/`<#id>` — снежинка; допускаем и другую
|
||||||
* слова (`foo_bar_baz`).
|
* запись без пробелов и угловых скобок, чтобы ссылка не разваливалась на
|
||||||
|
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
|
||||||
|
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
|
||||||
|
* `_` — внутри слова (`foo_bar_baz`).
|
||||||
*/
|
*/
|
||||||
const INLINE_PATTERN =
|
const INLINE_PATTERN =
|
||||||
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@[^\s<>]+>)|(<#[^\s<>]+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||||
|
|
||||||
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
|
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
|
||||||
const GROUP_KINDS: readonly InlineKind[] = [
|
const GROUP_KINDS: readonly InlineKind[] = [
|
||||||
@@ -72,6 +77,7 @@ const GROUP_KINDS: readonly InlineKind[] = [
|
|||||||
'italic',
|
'italic',
|
||||||
'mark',
|
'mark',
|
||||||
'mention',
|
'mention',
|
||||||
|
'channel-link',
|
||||||
'link',
|
'link',
|
||||||
'channel',
|
'channel',
|
||||||
];
|
];
|
||||||
@@ -85,8 +91,10 @@ const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
|
|||||||
strike: 2,
|
strike: 2,
|
||||||
spoiler: 2,
|
spoiler: 2,
|
||||||
mark: 2,
|
mark: 2,
|
||||||
// У упоминаний, ссылок и каналов маркеров нет: содержимое — весь токен.
|
// У упоминаний, ссылок на комнаты, ссылок и каналов маркеров нет:
|
||||||
|
// содержимое — весь токен.
|
||||||
mention: 0,
|
mention: 0,
|
||||||
|
'channel-link': 0,
|
||||||
link: 0,
|
link: 0,
|
||||||
channel: 0,
|
channel: 0,
|
||||||
};
|
};
|
||||||
@@ -107,8 +115,8 @@ function contentBounds(
|
|||||||
raw: string,
|
raw: string,
|
||||||
start: number,
|
start: number,
|
||||||
): { contentStart: number; contentEnd: number } {
|
): { contentStart: number; contentEnd: number } {
|
||||||
if (kind === 'mention') {
|
if (kind === 'mention' || kind === 'channel-link') {
|
||||||
// `<@123>`: содержимое — идентификатор без `<@` и `>`.
|
// `<@123>` и `<#123>`: содержимое — идентификатор без `<@`/`<#` и `>`.
|
||||||
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
|
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
|
||||||
}
|
}
|
||||||
const marker = MARKER_LENGTH[kind];
|
const marker = MARKER_LENGTH[kind];
|
||||||
@@ -141,11 +149,11 @@ export function parseInline(text: string, offset = 0): InlineToken[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Есть ли у токена парные маркеры. У упоминаний, ссылок и каналов их нет:
|
* Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
|
||||||
* содержимое — весь токен, прятать нечего.
|
* каналов их нет: содержимое — весь токен, прятать нечего.
|
||||||
*/
|
*/
|
||||||
export function hasMarkers(kind: InlineKind): boolean {
|
export function hasMarkers(kind: InlineKind): boolean {
|
||||||
return kind !== 'mention' && kind !== 'link' && kind !== 'channel';
|
return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
|
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import type { Channel, GuildMember, UserSummary } from '@/api/types';
|
||||||
|
import { Composer } from '@/components/chat/Composer';
|
||||||
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
|
import type { SessionGuild } from '@/stores/session';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import {
|
||||||
|
createTestQueryClient,
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
installGatewaySocket,
|
||||||
|
json,
|
||||||
|
makeMessage,
|
||||||
|
makeReadySnapshot,
|
||||||
|
makeUser,
|
||||||
|
messagesRoutes,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автокомплиты композера: `@` — участники, `#` — комнаты, `:` — эмодзи,
|
||||||
|
* `/` — команды. Проверяем появление по триггеру, фильтр, вставку, навигацию
|
||||||
|
* с клавиатуры и правила контекста (слово, код, начало сообщения).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const textChannel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
|
||||||
|
const dmChannel: Channel = {
|
||||||
|
id: 'dm-1',
|
||||||
|
name: 'Боб',
|
||||||
|
type: 'dm',
|
||||||
|
position: 0,
|
||||||
|
recipient_id: '102',
|
||||||
|
};
|
||||||
|
|
||||||
|
const members: GuildMember[] = [
|
||||||
|
{
|
||||||
|
user_id: '101',
|
||||||
|
username: 'alice',
|
||||||
|
display_name: 'Аля',
|
||||||
|
status: 'online',
|
||||||
|
is_instance_admin: false,
|
||||||
|
joined_at: '2026-09-01T10:00:00Z',
|
||||||
|
role_ids: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: '102',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Боб',
|
||||||
|
status: 'online',
|
||||||
|
is_instance_admin: false,
|
||||||
|
joined_at: '2026-09-02T10:00:00Z',
|
||||||
|
role_ids: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: '103',
|
||||||
|
username: 'carol',
|
||||||
|
display_name: 'Кэрол',
|
||||||
|
status: 'online',
|
||||||
|
is_instance_admin: false,
|
||||||
|
joined_at: '2026-09-03T10:00:00Z',
|
||||||
|
role_ids: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recipient: UserSummary = {
|
||||||
|
id: '102',
|
||||||
|
username: 'bob',
|
||||||
|
display_name: 'Боб',
|
||||||
|
is_instance_admin: false,
|
||||||
|
badges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const guild: SessionGuild = {
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: false,
|
||||||
|
member_count: 3,
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: [],
|
||||||
|
channels: [
|
||||||
|
textChannel,
|
||||||
|
{ id: 'c-2', name: 'новости', type: 'text', position: 1 },
|
||||||
|
{ id: 'c-3', name: 'Голосовая', type: 'voice', position: 2 },
|
||||||
|
{ id: 'c-4', name: 'скрытая', type: 'text', position: 3, can_view: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const authors: AuthorDirectory = {
|
||||||
|
resolve: () => ({
|
||||||
|
id: '102',
|
||||||
|
name: 'Боб',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: true,
|
||||||
|
}),
|
||||||
|
resolveMention: (userId) => ({
|
||||||
|
name: members.find((member) => member.user_id === userId)?.display_name ?? userId,
|
||||||
|
isMe: false,
|
||||||
|
}),
|
||||||
|
loaded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderComposer(channel: Channel = textChannel, guildId: string | null = 'g-1') {
|
||||||
|
const routes: FetchRoute[] = [
|
||||||
|
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
|
||||||
|
{ match: '/api/v1/users/102', response: () => json({ user: recipient }) },
|
||||||
|
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/channels/c-1/messages',
|
||||||
|
method: 'POST',
|
||||||
|
response: () =>
|
||||||
|
json({ message: makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-1' }) }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const fetchMock = installFetch(routes);
|
||||||
|
const view = render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<Composer
|
||||||
|
channel={channel}
|
||||||
|
guildId={guildId}
|
||||||
|
authors={authors}
|
||||||
|
direct={channel.type === 'dm'}
|
||||||
|
replyTo={null}
|
||||||
|
onCancelReply={() => undefined}
|
||||||
|
disabledReason={null}
|
||||||
|
canAttach
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return { ...view, fetchMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поле ввода: у комнаты подпись «Сообщение в комнату …», у беседы — «… для …». */
|
||||||
|
function field(name = 'Сообщение в комнату общий'): HTMLTextAreaElement {
|
||||||
|
return screen.getByLabelText(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Слой подсветки: вставка обязана обновить и его. */
|
||||||
|
function layer(): HTMLElement {
|
||||||
|
return screen.getByTestId('composer-mirror');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список подсказок (может ещё не появиться). */
|
||||||
|
function listbox(): HTMLElement | null {
|
||||||
|
return screen.queryByTestId('composer-autocomplete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ставит каретку и сообщает об этом React — так же, как это делает браузер
|
||||||
|
* (`select` синтезируется из `keyup`/`selectionchange`).
|
||||||
|
*/
|
||||||
|
function placeCaret(input: HTMLTextAreaElement, start: number, end = start): void {
|
||||||
|
input.setSelectionRange(start, end);
|
||||||
|
fireEvent.select(input);
|
||||||
|
fireEvent.keyUp(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ввод значения без набора по символу: для проверок контекста. */
|
||||||
|
function setValue(input: HTMLTextAreaElement, value: string): void {
|
||||||
|
fireEvent.change(input, { target: { value } });
|
||||||
|
placeCaret(input, value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
useSessionStore.setState({ guilds: [guild] });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('автокомплит: участники (@)', () => {
|
||||||
|
it('открывает список с именами и логинами, фильтрует и вставляет <@id>', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
await visitor.type(input, '@');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
const box = within(list).getByRole('listbox');
|
||||||
|
expect(box).toHaveAttribute('aria-label', 'Участники сервера');
|
||||||
|
// Список раскрывается над полем ввода и не перекрывает текст.
|
||||||
|
expect(list).toHaveClass('bottom-full', 'gl-popover');
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
expect(input).toHaveAttribute('aria-controls', box.id);
|
||||||
|
// Активный вариант помечен и связан с полем через aria-activedescendant.
|
||||||
|
const active = list.querySelector('[aria-selected="true"]');
|
||||||
|
expect(active).not.toBeNull();
|
||||||
|
expect(input).toHaveAttribute('aria-activedescendant', active?.id ?? '');
|
||||||
|
|
||||||
|
// Показываем имя и @логин.
|
||||||
|
expect(within(list).getByText('Боб')).toBeVisible();
|
||||||
|
expect(within(list).getByText('@bob')).toBeVisible();
|
||||||
|
expect(within(list).getByText('Кэрол')).toBeVisible();
|
||||||
|
|
||||||
|
// Фильтр по префиксу логина.
|
||||||
|
await visitor.type(input, 'bo');
|
||||||
|
expect(within(list).getByText('Боб')).toBeVisible();
|
||||||
|
expect(within(list).queryByText('Кэрол')).toBeNull();
|
||||||
|
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('<@102>');
|
||||||
|
// Каретка сразу после вставленного фрагмента — подсветка не разъезжается.
|
||||||
|
expect(input.selectionStart).toBe('<@102>'.length);
|
||||||
|
expect(layer().textContent).toBe('@Боб');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фильтрует по отображаемому имени и не срабатывает внутри слова', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '@кэр');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
expect(within(list).getByText('Кэрол')).toBeVisible();
|
||||||
|
expect(within(list).queryByText('Боб')).toBeNull();
|
||||||
|
|
||||||
|
await visitor.keyboard('{Escape}');
|
||||||
|
setValue(input, 'почта@пример');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
// Внутри кода упоминание — обычный текст.
|
||||||
|
setValue(input, '`@bo`');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вставляет выбранный стрелками вариант и закрывается по Escape', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '@');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
|
||||||
|
// ↓ — второй вариант, ↑ — возврат к первому.
|
||||||
|
await visitor.keyboard('{ArrowDown}');
|
||||||
|
const second = list.querySelectorAll('[role="option"]')[1];
|
||||||
|
expect(second).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(input).toHaveAttribute('aria-activedescendant', second?.id ?? '');
|
||||||
|
await visitor.keyboard('{ArrowUp}');
|
||||||
|
expect(list.querySelectorAll('[role="option"]')[0]).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
expect(input).toHaveValue('<@101>');
|
||||||
|
|
||||||
|
// Escape закрывает подсказку и она не всплывает снова на том же триггере.
|
||||||
|
setValue(input, '@');
|
||||||
|
await screen.findByTestId('composer-autocomplete');
|
||||||
|
await visitor.keyboard('{Escape}');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
await visitor.type(input, 'bo');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
// Новый триггер после стёртого текста снова открывает список.
|
||||||
|
await visitor.clear(input);
|
||||||
|
await visitor.type(input, '@b');
|
||||||
|
expect(await screen.findByTestId('composer-autocomplete')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вставляет вариант по клику мышью', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '@bo');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
await visitor.click(within(list).getByText('@bob'));
|
||||||
|
|
||||||
|
expect(input).toHaveValue('<@102>');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tab вставляет, а Enter с открытым списком не отправляет сообщение', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const { fetchMock } = renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '@bo');
|
||||||
|
await screen.findByTestId('composer-autocomplete');
|
||||||
|
await visitor.keyboard('{Tab}');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('<@102>');
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('в личной беседе предлагает собеседника', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer(dmChannel, null);
|
||||||
|
const input = field('Сообщение для Боб');
|
||||||
|
|
||||||
|
await visitor.type(input, '@');
|
||||||
|
expect(await screen.findByText('@bob')).toBeVisible();
|
||||||
|
expect(screen.queryByText('Кэрол')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('автокомплит: комнаты (#)', () => {
|
||||||
|
it('показывает видимые текстовые комнаты и вставляет <#id>', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '#');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Комнаты сервера');
|
||||||
|
expect(within(list).getByText('#общий')).toBeVisible();
|
||||||
|
expect(within(list).getByText('#новости')).toBeVisible();
|
||||||
|
// Голосовая комната и скрытая в подсказках не участвуют.
|
||||||
|
expect(within(list).queryByText('#Голосовая')).toBeNull();
|
||||||
|
expect(within(list).queryByText('#скрытая')).toBeNull();
|
||||||
|
|
||||||
|
await visitor.type(input, 'нов');
|
||||||
|
expect(within(list).queryByText('#общий')).toBeNull();
|
||||||
|
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
expect(input).toHaveValue('<#c-2>');
|
||||||
|
expect(input.selectionStart).toBe('<#c-2>'.length);
|
||||||
|
// Слой подсветки показывает имя комнаты из снапшота.
|
||||||
|
expect(layer().textContent).toBe('#новости');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не срабатывает внутри слова', () => {
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
setValue(input, 'задача#12');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('автокомплит: эмодзи (:)', () => {
|
||||||
|
it('показывает символ и код, вставляет эмодзи', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, ':fir');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Эмодзи');
|
||||||
|
expect(within(list).getByText(':fire:')).toBeVisible();
|
||||||
|
expect(within(list).getByText('🔥')).toBeVisible();
|
||||||
|
|
||||||
|
await visitor.type(input, 'ezzz');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
setValue(input, ':fir');
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
expect(input).toHaveValue('🔥');
|
||||||
|
expect(input.selectionStart).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не срабатывает внутри кода', () => {
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
setValue(input, '`код :fir`');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
setValue(input, 'текст\n```\n:fir\n```');
|
||||||
|
placeCaret(input, 'текст\n```\n:fir'.length);
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
// Вне кода подсказка снова работает.
|
||||||
|
setValue(input, 'текст :fir');
|
||||||
|
expect(listbox()).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('автокомплит: команды (/)', () => {
|
||||||
|
it('показывает команды с описанием и вставляет команду с пробелом', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '/');
|
||||||
|
const list = await screen.findByTestId('composer-autocomplete');
|
||||||
|
expect(within(list).getByRole('listbox')).toHaveAttribute('aria-label', 'Команды');
|
||||||
|
expect(within(list).getByText('/me')).toBeVisible();
|
||||||
|
expect(within(list).getByText('действие: имя и текст курсивом')).toBeVisible();
|
||||||
|
|
||||||
|
await visitor.type(input, 'sc');
|
||||||
|
expect(within(list).queryByText('/me')).toBeNull();
|
||||||
|
expect(within(list).getByText('/scream')).toBeVisible();
|
||||||
|
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
expect(input).toHaveValue('/scream ');
|
||||||
|
expect(input.selectionStart).toBe('/scream '.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('срабатывает только первым символом сообщения', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
setValue(input, 'путь /me');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
// `//` экранирует слэш — это обычный текст.
|
||||||
|
setValue(input, '//me');
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
|
||||||
|
await visitor.clear(input);
|
||||||
|
await visitor.type(input, '/me');
|
||||||
|
expect(await screen.findByTestId('composer-autocomplete')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('автокомплит: закрытие по потере контекста', () => {
|
||||||
|
it('пробел после запроса закрывает список', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, '@bo');
|
||||||
|
await screen.findByTestId('composer-autocomplete');
|
||||||
|
|
||||||
|
await visitor.type(input, ' ');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отправка закрывает список и очищает поле', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const { fetchMock } = renderComposer();
|
||||||
|
const input = field();
|
||||||
|
|
||||||
|
await visitor.type(input, 'привет');
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
expect(listbox()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('автокомплит в приложении', () => {
|
||||||
|
it('предлагает участников сервера в настоящем чате и вставляет упоминание', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
installFetch([
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
|
||||||
|
...messagesRoutes('c-1', []),
|
||||||
|
]);
|
||||||
|
const gateway = installGatewaySocket(
|
||||||
|
makeReadySnapshot([
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
is_main: true,
|
||||||
|
my_permissions: ['SEND_MESSAGES'],
|
||||||
|
channels: [{ id: 'c-1', name: 'общий' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderApp('/app/g-1/c-1');
|
||||||
|
await screen.findByTestId('guild-rail');
|
||||||
|
await gateway.greet();
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText('Сообщение в комнату общий');
|
||||||
|
await visitor.type(input, '@б');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('composer-autocomplete')).toBeVisible();
|
||||||
|
await visitor.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('<@102>');
|
||||||
|
expect(screen.getByTestId('composer-mirror').textContent).toBe('@Боб');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,14 @@ describe('группировка сообщений', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('не присоединяет обычное сообщение к действию, шёпоту и личному', () => {
|
||||||
|
const next = message({ id: 'm-2', author_id: 'user-1', created_at: at(19, 12, 1) });
|
||||||
|
for (const type of ['action', 'whisper', 'scream', 'private', 'system']) {
|
||||||
|
const previous = message({ id: 'm-1', author_id: 'user-1', created_at: at(19, 12, 0), type });
|
||||||
|
expect(canGroup(previous, next)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('строит строки ленты с разделителями дат', () => {
|
it('строит строки ленты с разделителями дат', () => {
|
||||||
const rows = buildChatRows(
|
const rows = buildChatRows(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function renderComposer() {
|
|||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<Composer
|
<Composer
|
||||||
channel={channel}
|
channel={channel}
|
||||||
|
guildId={null}
|
||||||
authors={authors}
|
authors={authors}
|
||||||
replyTo={null}
|
replyTo={null}
|
||||||
onCancelReply={() => undefined}
|
onCancelReply={() => undefined}
|
||||||
|
|||||||
@@ -158,4 +158,33 @@ describe('разметка: упоминания каналов', () => {
|
|||||||
expect(container.querySelectorAll('span')).toHaveLength(0);
|
expect(container.querySelectorAll('span')).toHaveLength(0);
|
||||||
expect(container.textContent).toBe('см. #общий');
|
expect(container.textContent).toBe('см. #общий');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('рендерит ссылку на комнату <#id> именем из снапшота, иначе #id', () => {
|
||||||
|
useSessionStore.setState({ guilds: [guild] });
|
||||||
|
|
||||||
|
const { container } = markup('см. <#c-1> и <#999>');
|
||||||
|
|
||||||
|
const spans = Array.from(container.querySelectorAll('span'));
|
||||||
|
expect(spans.some((span) => span.textContent === '#общий')).toBe(true);
|
||||||
|
expect(spans.some((span) => span.textContent === '#999')).toBe(true);
|
||||||
|
// Угловые скобки не остаются текстом.
|
||||||
|
expect(container.textContent).toBe('см. #общий и #999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не ломает разметку рядом с упоминанием, ссылкой и кодом', () => {
|
||||||
|
useSessionStore.setState({ guilds: [guild] });
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MessageContent
|
||||||
|
content={'<@42> пишет в <#c-1>: https://example.com/#якорь\n`<#c-1>`'}
|
||||||
|
resolveMention={() => ({ name: 'Аля', isMe: false })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.textContent).toBe('@Аля пишет в #общий: https://example.com/#якорь\n<#c-1>');
|
||||||
|
// Ссылка осталась ссылкой целиком, вместе с якорем.
|
||||||
|
expect(container.querySelector('a')).toHaveAttribute('href', 'https://example.com/#якорь');
|
||||||
|
// Внутри кода разметка не разбирается.
|
||||||
|
expect(container.querySelector('code')).toHaveTextContent('<#c-1>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import type { Message } from '@/api/messages';
|
||||||
|
import { MessageList } from '@/components/chat/MessageList';
|
||||||
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Виды сообщений из контракта (AGENT.md §7.6): обычное, действие `/me`, шёпот,
|
||||||
|
* крик, личное `/ls` и системное. Лента оформляет их по-разному, но реакции,
|
||||||
|
* ответы и правка работают у всех, кроме системных.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const authors: AuthorDirectory = {
|
||||||
|
resolve: (userId) => ({
|
||||||
|
id: userId ?? '',
|
||||||
|
name: userId === 'user-1' ? 'Аля' : 'Bob',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: true,
|
||||||
|
}),
|
||||||
|
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
||||||
|
loaded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function message(overrides: Partial<Message> & { id: string }): Message {
|
||||||
|
return {
|
||||||
|
channel_id: 'c-1',
|
||||||
|
author_id: 'user-2',
|
||||||
|
content: 'текст',
|
||||||
|
type: 'default',
|
||||||
|
pinned: false,
|
||||||
|
attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
reactions: [],
|
||||||
|
created_at: '2026-09-19T10:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(messages: Message[], onReply = vi.fn()) {
|
||||||
|
const view = render(
|
||||||
|
<MessageList
|
||||||
|
channelId="c-1"
|
||||||
|
channelName="общий"
|
||||||
|
messages={messages}
|
||||||
|
status="ready"
|
||||||
|
error={null}
|
||||||
|
hasMore={false}
|
||||||
|
loadingMore={false}
|
||||||
|
unreadAnchorId={null}
|
||||||
|
highlightId={null}
|
||||||
|
authors={authors}
|
||||||
|
currentUserId="user-1"
|
||||||
|
canManageMessages
|
||||||
|
canSend
|
||||||
|
onReload={() => undefined}
|
||||||
|
onLoadMore={() => undefined}
|
||||||
|
onReply={onReply}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...view, onReply };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сообщение ленты по id. */
|
||||||
|
function article(id: string): HTMLElement {
|
||||||
|
return screen.getByTestId(`message-${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Панель действий: реакции, ответ и меню. */
|
||||||
|
function expectActions(id: string): void {
|
||||||
|
const body = article(id);
|
||||||
|
expect(within(body).getByRole('button', { name: 'Поставить реакцию 👍' })).toBeVisible();
|
||||||
|
expect(within(body).getByRole('button', { name: 'Ответить' })).toBeVisible();
|
||||||
|
expect(within(body).getByRole('button', { name: 'Действия с сообщением' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('виды сообщений: оформление', () => {
|
||||||
|
it('действие /me показывает имя акцентом, а текст курсивом', () => {
|
||||||
|
renderList([message({ id: 'm-1', type: 'action', content: 'танцует' })]);
|
||||||
|
|
||||||
|
const body = article('m-1');
|
||||||
|
expect(body).toHaveAttribute('data-message-type', 'action');
|
||||||
|
expect(body).toHaveTextContent('Bobтанцует');
|
||||||
|
|
||||||
|
// Имя автора — акцентным цветом, вся строка — курсивом.
|
||||||
|
expect(within(body).getByText('Bob')).toHaveClass('text-accent');
|
||||||
|
expect(body.querySelector('.italic')).not.toBeNull();
|
||||||
|
// Шапки группы нет: имя рисует само действие, а не заголовок.
|
||||||
|
expect(within(body).getAllByText('Bob')).toHaveLength(1);
|
||||||
|
expectActions('m-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('шёпот приглушён, помечен подписью и остаётся обычным сообщением', () => {
|
||||||
|
renderList([message({ id: 'm-1', type: 'whisper', content: 'только между нами' })]);
|
||||||
|
|
||||||
|
const body = article('m-1');
|
||||||
|
expect(body).toHaveAttribute('data-message-type', 'whisper');
|
||||||
|
expect(within(body).getByText('шёпот')).toBeVisible();
|
||||||
|
// Приглушённый блок: полупрозрачный фон и курсив.
|
||||||
|
const block = body.querySelector('.bg-surface-2\\/50');
|
||||||
|
expect(block).not.toBeNull();
|
||||||
|
expect(block).toHaveTextContent('только между нами');
|
||||||
|
expect(body.querySelector('.italic')).not.toBeNull();
|
||||||
|
// Шапка автора на месте (она часть строки группы, а не самого сообщения).
|
||||||
|
expect(within(screen.getByTestId('message-group')).getByText('Bob')).toBeVisible();
|
||||||
|
expectActions('m-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('крик крупнее обычного текста и помечен подписью «громко»', () => {
|
||||||
|
renderList([message({ id: 'm-1', type: 'scream', content: 'ОЧЕНЬ ГРОМКО' })]);
|
||||||
|
|
||||||
|
const body = article('m-1');
|
||||||
|
expect(body).toHaveAttribute('data-message-type', 'scream');
|
||||||
|
expect(within(body).getByText('громко')).toBeVisible();
|
||||||
|
|
||||||
|
const text = body.querySelector('.text-base');
|
||||||
|
expect(text).toHaveTextContent('ОЧЕНЬ ГРОМКО');
|
||||||
|
expect(text).toHaveClass('font-semibold', 'text-accent');
|
||||||
|
expectActions('m-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('личное сообщение выделено фоном с акцентом, замком и подписью «лично»', () => {
|
||||||
|
renderList([message({ id: 'm-1', type: 'private', content: 'секрет для тебя' })]);
|
||||||
|
|
||||||
|
const body = article('m-1');
|
||||||
|
expect(body).toHaveAttribute('data-message-type', 'private');
|
||||||
|
expect(within(body).getByText('лично')).toBeVisible();
|
||||||
|
expect(body.textContent).toContain('🔒');
|
||||||
|
|
||||||
|
const block = body.querySelector('.bg-accent\\/10');
|
||||||
|
expect(block).not.toBeNull();
|
||||||
|
expect(block).toHaveTextContent('секрет для тебя');
|
||||||
|
expectActions('m-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('системное сообщение центрировано, без аватара и панели действий', () => {
|
||||||
|
// У системного сообщения автора может не быть вовсе.
|
||||||
|
const system: Message = {
|
||||||
|
id: 'm-1',
|
||||||
|
channel_id: 'c-1',
|
||||||
|
content: 'Аля вошла',
|
||||||
|
type: 'system',
|
||||||
|
pinned: false,
|
||||||
|
attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
reactions: [],
|
||||||
|
created_at: '2026-09-19T10:00:00Z',
|
||||||
|
};
|
||||||
|
renderList([system]);
|
||||||
|
|
||||||
|
const body = article('m-1');
|
||||||
|
expect(body).toHaveAttribute('data-message-type', 'system');
|
||||||
|
expect(body).toHaveClass('items-center', 'text-center');
|
||||||
|
expect(within(body).getByText('Системное сообщение')).toHaveClass('sr-only');
|
||||||
|
expect(body).toHaveTextContent('Аля вошла');
|
||||||
|
// Ни реакций, ни ответа, ни меню.
|
||||||
|
expect(within(body).queryByRole('button')).toBeNull();
|
||||||
|
|
||||||
|
// И ни аватара, ни шапки автора в строке группы.
|
||||||
|
const group = screen.getByTestId('message-group');
|
||||||
|
expect(group).toHaveAttribute('data-message-type', 'system');
|
||||||
|
expect(within(group).queryByText('Bob')).toBeNull();
|
||||||
|
expect(group.querySelector('.inline-flex.shrink-0')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('виды сообщений: действия', () => {
|
||||||
|
it('ответ доступен у действия, шёпота, крика и личного сообщения', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const types = ['action', 'whisper', 'scream', 'private'] as const;
|
||||||
|
const { onReply } = renderList(
|
||||||
|
types.map((type, index) => message({ id: `m-${String(index)}`, type, content: 'текст' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [index, type] of types.entries()) {
|
||||||
|
const id = `m-${String(index)}`;
|
||||||
|
expect(article(id)).toHaveAttribute('data-message-type', type);
|
||||||
|
await visitor.click(within(article(id)).getByRole('button', { name: 'Ответить' }));
|
||||||
|
expect(onReply).toHaveBeenLastCalledWith(expect.objectContaining({ id, type }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обычное сообщение после системного не сливается с ним в группу', () => {
|
||||||
|
renderList([
|
||||||
|
message({ id: 'm-1', type: 'system', content: 'Аля вошла' }),
|
||||||
|
message({ id: 'm-2', type: 'default', content: 'привет' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groups = screen.getAllByTestId('message-group');
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0]).toHaveAttribute('data-message-type', 'system');
|
||||||
|
// Обычное сообщение снова с шапкой автора.
|
||||||
|
expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user