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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user