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:
@@ -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