feat(messages): команды чата, личные сообщения и лимиты

AGENT.md 7.6:

- команды разбираются на сервере: /me (блок действия), /whisper и /wisper
  (шёпот), /scream (громкий блок), «//» экранирует слэш, неизвестная команда
  остаётся обычным текстом, пустой текст у команды — 422;
- /ls <логин> <текст> — личное сообщение: тип private, адресат в mentions,
  получатель проверяется на участие в сервере и доступ к комнате; сообщение не
  отдаётся посторонним ни в истории, ни в поиске (и FTS, и подстрочном) и
  доставляется в Gateway только автору и адресатам;
- лимиты: 10 правок в минуту, 20 уникальных реакций на сообщение, 50
  закреплений на комнату, не более 10 упоминаний, @everyone/@here только с
  MENTION_EVERYONE;
- тесты: команды и экранирование, изоляция личного сообщения (история, поиск,
  третий участник), лимит правок и @everyone.
This commit is contained in:
2026-09-20 11:32:50 +03:00
parent 4c8f888b3e
commit 65d0bafe0e
11 changed files with 1556 additions and 210 deletions
+91 -28
View File
@@ -6,6 +6,11 @@ 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 {
COMPOSER_TEXT_CLASS,
ComposerMirror,
type ComposerSelection,
} from '@/components/chat/ComposerMirror';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { formatBytes, errorCode } from '@/lib/format';
@@ -32,6 +37,11 @@ const LINE_HEIGHT_PX = 22;
* Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов,
* вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки
* (лимит частоты 429 с таймером, отсутствие прав).
*
* Над полем лежит зеркало (`ComposerMirror`): оно рисует разметку, поэтому
* форматирование видно ещё до отправки, а текст самой textarea прозрачный.
* Позиция каретки живёт в состоянии и обновляется на ввод, выделение, клик и
* отпускание клавиши.
*/
export function Composer({
channel,
@@ -55,8 +65,11 @@ export function Composer({
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
const [now, setNow] = useState(() => Date.now());
const [dragging, setDragging] = useState(false);
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const mirrorRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
@@ -69,6 +82,26 @@ export function Composer({
});
const maxUploadSize = meta.data?.max_upload_size;
/** Прокрутка зеркала повторяет прокрутку поля (текст длиннее восьми строк). */
const syncScroll = useCallback(() => {
const element = textareaRef.current;
const mirror = mirrorRef.current;
if (element === null || mirror === null) {
return;
}
mirror.scrollTop = element.scrollTop;
mirror.scrollLeft = element.scrollLeft;
}, []);
/** Обновление каретки: лишних перерисовок не делаем. */
const syncSelection = useCallback((element: HTMLTextAreaElement) => {
const start = element.selectionStart;
const end = element.selectionEnd;
setSelection((current) =>
current.start === start && current.end === end ? current : { start, end },
);
}, []);
// Автовысота: от одной строки до ~8.
useEffect(() => {
const element = textareaRef.current;
@@ -78,7 +111,8 @@ export function Composer({
element.style.height = 'auto';
const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8);
element.style.height = `${String(Math.min(element.scrollHeight, max))}px`;
}, [value]);
syncScroll();
}, [value, syncScroll]);
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
@@ -160,6 +194,8 @@ export function Composer({
...(files.length === 0 ? {} : { files }),
});
setValue('');
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
setSelection({ start: 0, end: 0 });
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
@@ -292,33 +328,60 @@ export function Composer({
event.target.value = '';
}}
/>
<textarea
ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
name: channel.name,
})}
placeholder={t(direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder', {
name: channel.name,
})}
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
onChange={(event) => {
setValue(event.target.value);
useMessagesStore.getState().notifyTyping(channel.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
подсветка должны выглядеть одинаково неактивными. */}
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
<ComposerMirror
value={value}
selection={selection}
mirrorRef={mirrorRef}
resolveMention={authors.resolveMention}
/>
<textarea
ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
name: channel.name,
})}
placeholder={t(
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
{ name: channel.name },
)}
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
// остаются родными для поля ввода.
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}`}
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
// Текст невидим, поэтому подчёркивание проверки орфографии рисовалось
// бы поверх подсветки.
spellCheck={false}
onChange={(event) => {
setValue(event.target.value);
syncSelection(event.target);
useMessagesStore.getState().notifyTyping(channel.id);
}}
onSelect={(event) => {
syncSelection(event.currentTarget);
}}
onKeyUp={(event) => {
syncSelection(event.currentTarget);
}}
onClick={(event) => {
syncSelection(event.currentTarget);
}}
onScroll={syncScroll}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
</div>
<button
type="button"
aria-label={t('chat.composer.send')}
+247
View File
@@ -0,0 +1,247 @@
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
import { useChannelNames } from '@/components/chat/useChannelNames';
import {
hasMarkers,
selectionIntersects,
splitLink,
toMirrorLines,
type InlineToken,
type MirrorLine,
} from '@/lib/markdown';
/**
* Зеркало композера: слой под `textarea`, который рисует тот же текст, но с
* разметкой. Текст в самом поле ввода прозрачный, поэтому пользователь видит
* форматирование ещё до отправки, а каретка и выделение остаются родными для
* textarea.
*
* Маркеры (`**`, `==`, `~~`, `` ` `` и т.д.) видны только у фрагмента, внутри
* которого стоит каретка, и приглушены — так их удобно править. У остальных
* фрагментов маркеры скрыты, а содержимое отформатировано — ровно как в ленте
* (`MessageContent` использует тот же разбор из `@/lib/markdown`).
*
* Оформление здесь намеренно «безметрическое»: ни отступов, ни моноширинного
* шрифта, ни изменения размера. Метрики текста обязаны совпадать с textarea,
* иначе подсветка разъедется с полем ввода.
*/
/** Позиция каретки или выделения в поле ввода. */
export interface ComposerSelection {
start: number;
end: number;
}
/**
* Метрики текста, общие у зеркала и textarea: размер, интерлиньяж и отступы.
* Любое расхождение сдвинет подсветку относительно поля ввода.
*/
export const COMPOSER_TEXT_CLASS = 'py-1 text-sm leading-5';
type MentionResolver = (userId: string) => { name: string; isMe: boolean } | null;
interface ComposerMirrorProps {
value: string;
selection: ComposerSelection;
/** Ссылка на слой: композер синхронизирует по ней прокрутку. */
mirrorRef: RefObject<HTMLDivElement | null>;
/** Как показать упоминание — как в ленте сообщений. */
resolveMention?: MentionResolver;
}
/** Содержимое токена без маркеров. */
function renderFormatted(
token: InlineToken,
content: string,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (token.kind) {
case 'code':
// Фон вместо `font-mono`: смена шрифта сдвинула бы метрики строки.
return <span className="rounded-[var(--radius-sm)] bg-surface-3">{content}</span>;
case 'bold-italic':
return (
<strong className="font-semibold">
<em>{content}</em>
</strong>
);
case 'bold':
return <strong className="font-semibold">{content}</strong>;
case 'italic':
return <em>{content}</em>;
case 'strike':
return <s className="opacity-70">{content}</s>;
case 'spoiler':
// В отличие от ленты текст спойлера виден: иначе его нельзя было бы
// править. Приглушённая подложка показывает, что фрагмент скрыт.
return <span className="rounded-[var(--radius-sm)] bg-fg-muted/40 text-fg">{content}</span>;
case 'mark':
return <mark className="rounded-[var(--radius-sm)] bg-accent/30 text-fg">{content}</mark>;
case 'mention': {
const mention = resolveMention?.(content) ?? null;
return (
<span
className={`rounded-[var(--radius-sm)] font-medium ${
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
}`}
>
@{mention?.name ?? content}
</span>
);
}
case 'link': {
const { href, trailing } = splitLink(token.raw);
return (
<>
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-accent underline decoration-accent/40 underline-offset-2"
>
{href}
</a>
{trailing}
</>
);
}
case 'channel': {
const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? (
<span className="text-accent">{token.raw}</span>
) : (
token.raw
);
}
}
}
/** Один токен: маркеры (если каретка внутри) плюс оформленное содержимое. */
function renderToken(
token: InlineToken,
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
key: string,
): ReactNode {
const text = line.text;
const toLocal = (absolute: number): number => absolute - line.start;
// Каретка внутри фрагмента — показываем маркеры приглушёнными, чтобы их
// было видно и удобно править. Иначе маркеры скрыты.
const active = hasMarkers(token.kind) && selectionIntersects(selection, token.start, token.end);
return (
<Fragment key={key}>
{active ? (
<span className="text-fg-muted">
{text.slice(toLocal(token.start), toLocal(token.contentStart))}
</span>
) : null}
{renderFormatted(
token,
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
channels,
resolveMention,
)}
{active ? (
<span className="text-fg-muted">
{text.slice(toLocal(token.contentEnd), toLocal(token.end))}
</span>
) : null}
</Fragment>
);
}
/** Строка с разметкой: текст между токенами остаётся обычным текстом. */
function renderTokens(
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
): ReactNode {
const start = line.start + line.prefixLength;
const nodes: ReactNode[] = [];
let cursor = start;
for (const [index, token] of line.tokens.entries()) {
if (token.start > cursor) {
nodes.push(line.text.slice(cursor - line.start, token.start - line.start));
}
nodes.push(
renderToken(
token,
line,
selection,
channels,
resolveMention,
`${String(line.start)}-${String(index)}`,
),
);
cursor = token.end;
}
if (cursor < line.end) {
nodes.push(line.text.slice(cursor - line.start));
}
return nodes;
}
/** Одна строка зеркала. */
function renderLine(
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (line.kind) {
case 'code':
return line.text === '' ? null : <span className="bg-surface-3/60">{line.text}</span>;
case 'fence': {
// Ограждение ``` показываем, только когда каретка внутри блока кода;
// строка при этом остаётся на месте, поэтому переносы не съезжают.
const range = line.codeRange;
const active = range !== null && selectionIntersects(selection, range.start, range.end);
return active ? <span className="text-fg-muted">{line.text}</span> : null;
}
case 'quote':
return (
<>
<span className="text-fg-muted">{line.text.slice(0, line.prefixLength)}</span>
{renderTokens(line, selection, channels, resolveMention)}
</>
);
case 'text':
return renderTokens(line, selection, channels, resolveMention);
}
}
/**
* Слой подсветки. `aria-hidden`: скринридер читает исходный текст из textarea,
* а не оформленную копию. `pointer-events-none`: весь ввод остаётся у поля.
*/
export function ComposerMirror({
value,
selection,
mirrorRef,
resolveMention,
}: ComposerMirrorProps) {
const lines = useMemo(() => toMirrorLines(value), [value]);
const channels = useChannelNames();
return (
<div
ref={mirrorRef}
aria-hidden="true"
data-testid="composer-mirror"
className={`gl-composer-scroll pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words text-fg ${COMPOSER_TEXT_CLASS}`}
>
{lines.map((line, index) => (
<Fragment key={`${String(line.start)}-${String(index)}`}>
{renderLine(line, selection, channels, resolveMention)}
{index < lines.length - 1 ? '\n' : null}
</Fragment>
))}
</div>
);
}
+107 -162
View File
@@ -1,16 +1,22 @@
import { Fragment, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useSessionStore } from '@/stores/session';
import { useChannelNames } from '@/components/chat/useChannelNames';
import {
parseBlocks,
parseInline,
splitLink,
QUOTE_PREFIX,
type InlineToken,
} from '@/lib/markdown';
/**
* Лёгкий безопасный рендер разметки сообщения.
*
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
* поэтому HTML из сообщения остаётся текстом. Сам разбор живёт в
* `@/lib/markdown` — его же использует зеркало композера, чтобы предпросмотр
* форматирования в поле ввода совпадал с лентой.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
@@ -23,27 +29,6 @@ export interface MessageContentProps {
className?: string;
}
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
* слова (`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;
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
function splitLink(raw: string): { href: string; trailing: string } {
let href = raw;
let trailing = '';
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
trailing = (href[href.length - 1] ?? '') + trailing;
href = href.slice(0, -1);
}
return { href, trailing };
}
type MentionResolver = MessageContentProps['resolveMention'];
/**
@@ -69,86 +54,68 @@ function Spoiler({ children }: { children: ReactNode }) {
);
}
function renderInline(
text: string,
/** Оформление одного токена: содержимое уже без маркеров. */
function renderToken(
token: InlineToken,
content: string,
key: string,
resolveMention: MentionResolver,
channels: ReadonlySet<string>,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
for (const match of text.matchAll(INLINE_PATTERN)) {
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push(text.slice(lastIndex, start));
}
const token = match[0];
const key = `${keyPrefix}-${String(index)}`;
index += 1;
if (token.startsWith('`')) {
nodes.push(
): ReactNode {
switch (token.kind) {
case 'code':
return (
<code
key={key}
className="rounded-[var(--radius-sm)] bg-surface-3 px-1 py-0.5 font-mono text-[0.85em]"
>
{token.slice(1, -1)}
</code>,
{content}
</code>
);
} else if (token.startsWith('***')) {
nodes.push(
case 'bold-italic':
return (
<strong key={key} className="font-semibold">
<em>{token.slice(3, -3)}</em>
</strong>,
<em>{content}</em>
</strong>
);
} else if (token.startsWith('**')) {
nodes.push(
case 'bold':
return (
<strong key={key} className="font-semibold">
{token.slice(2, -2)}
</strong>,
{content}
</strong>
);
} else if (token.startsWith('__')) {
nodes.push(
<strong key={key} className="font-semibold">
{token.slice(2, -2)}
</strong>,
);
} else if (token.startsWith('~~')) {
nodes.push(
case 'italic':
return <em key={key}>{content}</em>;
case 'strike':
return (
<s key={key} className="opacity-70">
{token.slice(2, -2)}
</s>,
{content}
</s>
);
} else if (token.startsWith('||')) {
nodes.push(<Spoiler key={key}>{token.slice(2, -2)}</Spoiler>);
} else if (token.startsWith('*')) {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
} else if (token.startsWith('_')) {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
} else if (token.startsWith('==')) {
nodes.push(
case 'spoiler':
return <Spoiler key={key}>{content}</Spoiler>;
case 'mark':
return (
<mark key={key} className="rounded-[var(--radius-sm)] bg-accent/30 px-0.5 text-fg">
{token.slice(2, -2)}
</mark>,
{content}
</mark>
);
} else if (token.startsWith('<@')) {
const userId = token.slice(2, -1);
const mention = resolveMention?.(userId) ?? null;
nodes.push(
case 'mention': {
const mention = resolveMention?.(content) ?? null;
return (
<span
key={key}
className={`rounded-[var(--radius-sm)] px-1 font-medium ${
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
}`}
>
@{mention?.name ?? userId}
</span>,
@{mention?.name ?? content}
</span>
);
} else if (token.startsWith('http')) {
const { href, trailing } = splitLink(token);
nodes.push(
}
case 'link': {
const { href, trailing } = splitLink(token.raw);
return (
<Fragment key={key}>
<a
href={href}
@@ -159,23 +126,49 @@ function renderInline(
{href}
</a>
{trailing}
</Fragment>,
);
} else {
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
const name = token.slice(1);
const known = channels.has(name.toLowerCase());
nodes.push(
known ? (
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
{token}
</span>
) : (
token
),
</Fragment>
);
}
lastIndex = start + token.length;
case 'channel': {
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? (
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
{token.raw}
</span>
) : (
token.raw
);
}
}
}
function renderInline(
text: string,
resolveMention: MentionResolver,
channels: ReadonlySet<string>,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
for (const token of parseInline(text)) {
if (token.start > lastIndex) {
nodes.push(text.slice(lastIndex, token.start));
}
const key = `${keyPrefix}-${String(index)}`;
index += 1;
nodes.push(
renderToken(
token,
text.slice(token.contentStart, token.contentEnd),
key,
resolveMention,
channels,
),
);
lastIndex = token.end;
}
if (lastIndex < text.length) {
@@ -184,68 +177,10 @@ function renderInline(
return nodes;
}
interface Block {
kind: 'text' | 'quote' | 'code';
lines: string[];
}
/** Разбиение на блоки: цитаты, огороженный код и обычный текст. */
function toBlocks(content: string): Block[] {
const lines = content.split('\n');
const blocks: Block[] = [];
let fence: Block | null = null;
for (const line of lines) {
const fenceMatch = /^```(\w*)\s*$/u.exec(line.trim());
if (fence !== null) {
if (fenceMatch !== null) {
blocks.push(fence);
fence = null;
} else {
fence.lines.push(line);
}
continue;
}
if (fenceMatch !== null) {
fence = { kind: 'code', lines: [] };
continue;
}
if (line.startsWith('> ')) {
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'quote') {
previous.lines.push(line.slice(2));
} else {
blocks.push({ kind: 'quote', lines: [line.slice(2)] });
}
continue;
}
const previous = blocks[blocks.length - 1];
if (previous !== undefined && previous.kind === 'text') {
previous.lines.push(line);
} else {
blocks.push({ kind: 'text', lines: [line] });
}
}
if (fence !== null) {
// Незакрытый блок кода — показываем как код, а не теряем текст.
blocks.push(fence);
}
return blocks;
}
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
const blocks = toBlocks(content);
const blocks = useMemo(() => parseBlocks(content), [content]);
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
const guilds = useSessionStore((state) => state.guilds);
const channels = 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]);
const channels = useChannelNames();
return (
<div className={`whitespace-pre-wrap break-words ${className}`}>
@@ -257,20 +192,30 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
key={key}
className="my-1 overflow-x-auto rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 font-mono text-xs"
>
<code>{block.lines.join('\n')}</code>
<code>{block.lines.map((line) => line.text).join('\n')}</code>
</pre>
);
}
if (block.kind === 'quote') {
return (
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
{renderInline(
block.lines.map((line) => line.text.slice(QUOTE_PREFIX.length)).join('\n'),
resolveMention,
channels,
key,
)}
</blockquote>
);
}
return (
<p key={key} className="min-w-0">
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
{renderInline(
block.lines.map((line) => line.text).join('\n'),
resolveMention,
channels,
key,
)}
</p>
);
})}
@@ -0,0 +1,20 @@
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]);
}