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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Виды сообщений из контракта (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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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…",
|
||||
|
||||
@@ -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}} печатают…",
|
||||
|
||||
@@ -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) {
|
||||
return false;
|
||||
}
|
||||
// Действия, шёпот, крик, личные и системные сообщения оформляются отдельно:
|
||||
// в общую группу они не попадают ни как следующие, ни как предыдущие.
|
||||
if ((previous.type ?? 'default') !== 'default') {
|
||||
return false;
|
||||
}
|
||||
if (next.reply_to_id !== undefined) {
|
||||
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 из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
||||
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
||||
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
||||
* `> цитата`, ссылки `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';
|
||||
}
|
||||
|
||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
|
||||
|
||||
Reference in New Issue
Block a user