feat(web): виды сообщений, автокомплиты композера и ссылки на комнаты

- лента оформляет виды сообщений от сервера: action (/me) — имя акцентом и
  курсив, whisper — приглушённый блок с бейджем, scream — крупнее и жирнее,
  private (/ls) — рамка, замок и бейдж «лично», system — центрированная строка
  без аватара и панели действий; реакции, ответы, правка и удаление работают
  как раньше, группировка не сливает нестандартные виды с обычными;
- автокомплиты в поле ввода с навигацией ↑/↓, Enter/Tab, Escape и мышью:
  @ — участники сервера (в DM собеседник), # — видимые текстовые комнаты,
  : — словарь на 113 эмодзи, / — команды с описанием; подсказки не появляются
  внутри слова и в блоках кода, вставка восстанавливает каретку и не расходится
  со слоем подсветки;
- `<#id>` рендерится как #имя комнаты из снапшота (иначе #id);
- 287 тестов Vitest (было 262), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 12:06:22 +03:00
parent 65d0bafe0e
commit a126f50468
22 changed files with 1826 additions and 116 deletions
+21 -1
View File
@@ -30,13 +30,33 @@ export interface MessageReaction {
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 {
id: string;
channel_id: string;
author_id?: string;
content: string;
reply_to_id?: string;
/** `default` | `system` | `whisper` — контракт допускает расширение. */
/** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`. */
type: string;
edited_at?: string;
pinned: boolean;
+1
View File
@@ -218,6 +218,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha
<TypingIndicator channelId={channelId} typing={record.typing} authors={authors} />
<Composer
channel={channel}
guildId={guildId}
authors={authors}
direct={isDirect}
replyTo={replyTo}
+127 -2
View File
@@ -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 { useTranslation } from 'react-i18next';
@@ -6,18 +6,26 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import { ComposerAutocomplete } from '@/components/chat/ComposerAutocomplete';
import {
COMPOSER_TEXT_CLASS,
ComposerMirror,
type ComposerSelection,
} from '@/components/chat/ComposerMirror';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import {
useComposerAutocomplete,
type ComposerAutocompleteState,
} from '@/components/chat/useComposerAutocomplete';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { detectAutocomplete, type AutocompleteItem } from '@/lib/autocomplete';
import { formatBytes, errorCode } from '@/lib/format';
import { useMessagesStore } from '@/stores/messages';
interface ComposerProps {
channel: Channel;
/** Сервер комнаты: нужен для подсказок участников и комнат. */
guildId: string | null;
authors: AuthorDirectory;
/** Личная беседа: подписи без «#» (имя собеседника вместо комнаты). */
direct?: boolean;
@@ -45,6 +53,7 @@ const LINE_HEIGHT_PX = 22;
*/
export function Composer({
channel,
guildId,
authors,
direct = false,
replyTo,
@@ -67,10 +76,38 @@ export function Composer({
const [dragging, setDragging] = useState(false);
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
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 mirrorRef = useRef<HTMLDivElement | 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`); если он неизвестен,
// ограничение проверяет сам сервер, а мы показываем его ошибку.
@@ -114,6 +151,20 @@ export function Composer({
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` — иначе интервал тикал бы вечно.
useEffect(() => {
@@ -179,6 +230,27 @@ export function Composer({
[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 content = value.trim();
if (!canSubmit || (content === '' && files.length === 0)) {
@@ -196,6 +268,8 @@ export function Composer({
setValue('');
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
setSelection({ start: 0, end: 0 });
// Подсказок после отправки нет: отказ по Escape тоже больше не нужен.
setDismissedKey(null);
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
@@ -337,6 +411,19 @@ export function Composer({
mirrorRef={mirrorRef}
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
ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
@@ -346,6 +433,13 @@ export function Composer({
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
{ 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}`}
@@ -357,8 +451,17 @@ export function Composer({
// бы поверх подсветки.
spellCheck={false}
onChange={(event) => {
setValue(event.target.value);
const next = event.target.value;
setValue(next);
syncSelection(event.target);
// Контекст подсказки потерян (стёрли триггер) — отказ по Escape
// больше не нужен: следующая подсказка откроется сразу.
if (
dismissedKey !== null &&
detectAutocomplete(next, event.target.selectionStart) === null
) {
setDismissedKey(null);
}
useMessagesStore.getState().notifyTyping(channel.id);
}}
onSelect={(event) => {
@@ -372,6 +475,28 @@ export function Composer({
}}
onScroll={syncScroll}
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) {
event.preventDefault();
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>
);
}
+12 -7
View File
@@ -1,6 +1,6 @@
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
import { useChannelNames } from '@/components/chat/useChannelNames';
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
import {
hasMarkers,
selectionIntersects,
@@ -53,7 +53,7 @@ interface ComposerMirrorProps {
function renderFormatted(
token: InlineToken,
content: string,
channels: ReadonlySet<string>,
channels: ChannelDirectory,
resolveMention: MentionResolver | undefined,
): ReactNode {
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': {
const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? (
return channels.names.has(name.toLowerCase()) ? (
<span className="text-accent">{token.raw}</span>
) : (
token.raw
@@ -122,7 +127,7 @@ function renderToken(
token: InlineToken,
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
channels: ChannelDirectory,
resolveMention: MentionResolver | undefined,
key: string,
): ReactNode {
@@ -158,7 +163,7 @@ function renderToken(
function renderTokens(
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
channels: ChannelDirectory,
resolveMention: MentionResolver | undefined,
): ReactNode {
const start = line.start + line.prefixLength;
@@ -191,7 +196,7 @@ function renderTokens(
function renderLine(
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
channels: ChannelDirectory,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (line.kind) {
@@ -227,7 +232,7 @@ export function ComposerMirror({
resolveMention,
}: ComposerMirrorProps) {
const lines = useMemo(() => toMirrorLines(value), [value]);
const channels = useChannelNames();
const channels = useChannelDirectory();
return (
<div
+18 -6
View File
@@ -1,7 +1,7 @@
import { Fragment, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useChannelNames } from '@/components/chat/useChannelNames';
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
import {
parseBlocks,
parseInline,
@@ -60,7 +60,7 @@ function renderToken(
content: string,
key: string,
resolveMention: MentionResolver,
channels: ReadonlySet<string>,
channels: ChannelDirectory,
): ReactNode {
switch (token.kind) {
case 'code':
@@ -129,10 +129,22 @@ function renderToken(
</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': {
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
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">
{token.raw}
</span>
@@ -146,7 +158,7 @@ function renderToken(
function renderInline(
text: string,
resolveMention: MentionResolver,
channels: ReadonlySet<string>,
channels: ChannelDirectory,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
@@ -179,8 +191,8 @@ function renderInline(
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
const blocks = useMemo(() => parseBlocks(content), [content]);
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
const channels = useChannelNames();
// Комнаты снапшота: `#канал` и `<#id>` подсвечиваем только для известных.
const channels = useChannelDirectory();
return (
<div className={`whitespace-pre-wrap break-words ${className}`}>
+140 -5
View File
@@ -1,8 +1,14 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
import {
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import { MESSAGE_MAX_LENGTH, type Message } from '@/api/messages';
import { MESSAGE_MAX_LENGTH, messageTypeOf, type Message, type MessageType } from '@/api/messages';
import { EmojiPicker } from '@/components/chat/EmojiPicker';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
@@ -40,6 +46,100 @@ const actionButtonClass =
*/
const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="menu"]';
/** Пометка вида сообщения: «шёпот», «громко», «лично». */
function TypeBadge({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
return (
<span
className={`inline-block rounded-full border px-1.5 align-middle text-[10px] uppercase tracking-wide ${
accent ? 'border-accent/40 text-accent' : 'border-border/60 text-fg-muted'
}`}
>
{children}
</span>
);
}
/**
* Текст сообщения по виду из контракта: обычный, действие `/me`, шёпот, крик
* и личное сообщение оформляются по-разному (AGENT.md §7.6). Системные
* сообщения обрабатываются отдельной веткой — у них нет ни шапки автора, ни
* панели действий.
*/
function MessageBody({
type,
message,
authorName,
resolveMention,
}: {
type: MessageType;
message: Message;
authorName: string;
resolveMention: AuthorDirectory['resolveMention'];
}) {
const { t } = useTranslation();
if (type === 'action') {
// Действие: «Имя танцует» — имя акцентом, текст курсивом.
return (
<div className="flex flex-wrap items-baseline gap-x-1.5 text-sm text-fg italic">
<span className="font-semibold text-accent not-italic">{authorName}</span>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0"
/>
</div>
);
}
if (type === 'whisper') {
return (
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/50 px-2 py-1">
<TypeBadge>{t('chat.types.whisper')}</TypeBadge>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0 flex-1 text-sm text-fg-muted italic"
/>
</div>
);
}
if (type === 'scream') {
return (
<div className="flex flex-wrap items-baseline gap-x-1.5">
<TypeBadge accent>{t('chat.types.scream')}</TypeBadge>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0 flex-1 text-base font-semibold text-accent"
/>
</div>
);
}
if (type === 'private') {
// Личное сообщение: сервер отдаёт его только автору и адресату.
return (
<div className="flex flex-wrap items-baseline gap-x-1.5 rounded-[var(--radius-md)] border border-accent/40 bg-accent/10 px-2 py-1">
<span aria-hidden="true" className="text-xs">
🔒
</span>
<TypeBadge accent>{t('chat.types.private')}</TypeBadge>
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="min-w-0 flex-1 text-sm text-fg"
/>
</div>
);
}
return (
<MessageContent
content={message.content}
resolveMention={resolveMention}
className="text-sm text-fg"
/>
);
}
/**
* Сообщение ленты: текст, вложения, реакции, ответы и действия.
*
@@ -78,6 +178,7 @@ export function MessageItem({
const canEdit = isMine && canSend && !pending;
const canDelete = !pending && (isMine || canManageMessages);
const edited = message.edited_at !== undefined;
const type = messageTypeOf(message.type);
useEffect(() => {
if (editing) {
@@ -166,10 +267,38 @@ export function MessageItem({
onReply(message);
};
// Системное сообщение: центрированная приглушённая строка без шапки автора,
// вложений и панели действий — реагировать и отвечать на неё нечего.
if (type === 'system') {
return (
<article
data-message-id={message.id}
data-testid={`message-${message.id}`}
data-message-type="system"
className={`flex flex-col items-center gap-0.5 px-1 py-1 text-center ${
pending ? 'opacity-70' : ''
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
>
<span className="sr-only">{t('chat.types.system')}</span>
{message.content === '' ? null : (
<MessageContent
content={message.content}
resolveMention={authors.resolveMention}
className="text-xs text-fg-muted"
/>
)}
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
{formatMessageTime(message.created_at, i18n.language)}
</time>
</article>
);
}
return (
<article
data-message-id={message.id}
data-testid={`message-${message.id}`}
data-message-type={type}
onDoubleClick={onDoubleClick}
className={`group/message relative flex flex-col gap-1 px-1 py-0.5 ${
pending ? 'opacity-70' : ''
@@ -234,10 +363,11 @@ export function MessageItem({
) : (
<div className="flex min-w-0 flex-col gap-1">
{message.content === '' ? null : (
<MessageContent
content={message.content}
<MessageBody
type={type}
message={message}
authorName={author.name}
resolveMention={authors.resolveMention}
className="text-sm text-fg"
/>
)}
{message.attachments.length === 0 ? null : (
@@ -310,6 +440,11 @@ export function MessageItem({
>
{formatMessageTime(message.created_at, i18n.language)}
</time>
) : type === 'action' ? (
// У действия нет шапки автора — время показываем всегда.
<time dateTime={message.created_at}>
{formatMessageTime(message.created_at, i18n.language)}
</time>
) : null}
{edited ? <span>{t('chat.edited')}</span> : null}
{pending ? <span role="status">{t('chat.pending')}</span> : null}
+79 -62
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
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 type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
@@ -215,77 +215,94 @@ export function MessageList({
if (first === undefined) {
return null;
}
const headType = messageTypeOf(first.type);
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 (
<li
key={row.key}
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"
>
<div className="flex gap-3">
<div className="w-9 shrink-0 pt-0.5">
<Avatar
name={author.name}
seed={author.id === '' ? row.key : author.id}
fileId={author.avatarFileId}
size="md"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-baseline gap-2">
<span
className="text-sm font-semibold"
style={author.colorHex === null ? undefined : { color: author.colorHex }}
>
{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>
<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' });
{headType === 'system' ? (
// Системная строка: по центру, без колонки аватара.
<div className="flex justify-center">{body}</div>
) : (
<div className="flex gap-3">
<div className="w-9 shrink-0 pt-0.5">
<Avatar
name={author.name}
seed={author.id === '' ? row.key : author.id}
fileId={author.avatarFileId}
size="md"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
{showHeader ? (
<header className="flex flex-wrap items-baseline gap-2">
<span
className="text-sm font-semibold"
style={
author.colorHex === null ? undefined : { color: author.colorHex }
}
}}
/>
))}
>
{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>
)}
</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]);
}
+20 -1
View File
@@ -304,6 +304,12 @@
"pending": "sending…",
"newMessages": "New messages",
"adminBadge": "admin",
"types": {
"whisper": "whisper",
"scream": "loud",
"private": "private",
"system": "System message"
},
"unknownAuthor": "unknown",
"start": {
"title": "This is the start of #{{name}}",
@@ -365,7 +371,20 @@
"denied": "You are not allowed to send messages in this channel.",
"readOnly": "This channel is read-only for you.",
"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_few": "{{names}} are typing…",
+20 -1
View File
@@ -304,6 +304,12 @@
"pending": "отправляется…",
"newMessages": "Новые сообщения",
"adminBadge": "админ",
"types": {
"whisper": "шёпот",
"scream": "громко",
"private": "лично",
"system": "Системное сообщение"
},
"unknownAuthor": "неизвестный",
"start": {
"title": "Это начало комнаты #{{name}}",
@@ -365,7 +371,20 @@
"denied": "Нет права отправлять сообщения в эту комнату.",
"readOnly": "В эту комнату нельзя писать.",
"directLabel": "Сообщение для {{name}}",
"directPlaceholder": "Написать {{name}}"
"directPlaceholder": "Написать {{name}}",
"autocomplete": {
"hint": "↑/↓ — выбор, Enter — вставить, Escape — закрыть",
"mentions": "Участники сервера",
"channels": "Комнаты сервера",
"emoji": "Эмодзи",
"commands": "Команды"
},
"commands": {
"me": "действие: имя и текст курсивом",
"whisper": "шёпот: приглушённое сообщение",
"scream": "громко: крупный акцентный текст",
"ls": "личное сообщение: /ls логин текст"
}
},
"typing_one": "{{names}} печатает…",
"typing_few": "{{names}} печатают…",
+138
View File
@@ -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) };
}
+5
View File
@@ -130,6 +130,11 @@ export function canGroup(previous: MessageLike, next: MessageLike): boolean {
if (previous.author_id === undefined || previous.author_id !== next.author_id) {
return false;
}
// Действия, шёпот, крик, личные и системные сообщения оформляются отдельно:
// в общую группу они не попадают ни как следующие, ни как предыдущие.
if ((previous.type ?? 'default') !== 'default') {
return false;
}
if (next.reply_to_id !== undefined) {
return false;
}
+155
View File
@@ -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
View File
@@ -16,7 +16,8 @@
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
* `<#id>` и `#канал`.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
@@ -32,6 +33,7 @@ export type InlineKind =
| 'spoiler'
| 'mark'
| 'mention'
| 'channel-link'
| 'link'
| 'channel';
@@ -53,12 +55,15 @@ export interface InlineToken {
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
* слова (`foo_bar_baz`).
* ссылкой, а `<#id>` — раньше `#канала`, иначе угловые скобки остались бы
* текстом. Идентификатор в `<@id>`/`<#id>` — снежинка; допускаем и другую
* запись без пробелов и угловых скобок, чтобы ссылка не разваливалась на
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
* `_` — внутри слова (`foo_bar_baz`).
*/
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[] = [
@@ -72,6 +77,7 @@ const GROUP_KINDS: readonly InlineKind[] = [
'italic',
'mark',
'mention',
'channel-link',
'link',
'channel',
];
@@ -85,8 +91,10 @@ const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
strike: 2,
spoiler: 2,
mark: 2,
// У упоминаний, ссылок и каналов маркеров нет: содержимое — весь токен.
// У упоминаний, ссылок на комнаты, ссылок и каналов маркеров нет:
// содержимое — весь токен.
mention: 0,
'channel-link': 0,
link: 0,
channel: 0,
};
@@ -107,8 +115,8 @@ function contentBounds(
raw: string,
start: number,
): { contentStart: number; contentEnd: number } {
if (kind === 'mention') {
// `<@123>`: содержимое — идентификатор без `<@` и `>`.
if (kind === 'mention' || kind === 'channel-link') {
// `<@123>` и `<#123>`: содержимое — идентификатор без `<@`/`<#` и `>`.
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
}
const marker = MARKER_LENGTH[kind];
@@ -141,11 +149,11 @@ export function parseInline(text: string, offset = 0): InlineToken[] {
}
/**
* Есть ли у токена парные маркеры. У упоминаний, ссылок и каналов их нет:
* содержимое — весь токен, прятать нечего.
* Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
* каналов их нет: содержимое — весь токен, прятать нечего.
*/
export function hasMarkers(kind: InlineKind): boolean {
return kind !== 'mention' && kind !== 'link' && kind !== 'channel';
return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
}
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */