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')}