diff --git a/web/src/api/messages.ts b/web/src/api/messages.ts index cb8c85a..92144df 100644 --- a/web/src/api/messages.ts +++ b/web/src/api/messages.ts @@ -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; diff --git a/web/src/components/chat/ChatView.tsx b/web/src/components/chat/ChatView.tsx index 9cdef15..368f9dd 100644 --- a/web/src/components/chat/ChatView.tsx +++ b/web/src/components/chat/ChatView.tsx @@ -218,6 +218,7 @@ export function ChatView({ channel, guildId, jumpTo = null, onJumpHandled }: Cha ({ start: 0, end: 0 }); + /** Подсказка, закрытая по Escape: её не открываем, пока жив её контекст. */ + const [dismissedKey, setDismissedKey] = useState(null); + /** + * Активный вариант подсказки: выбор привязан к позиции каретки, поэтому при + * её смене (новый ввод) он снова первый — без эффекта и лишнего рендера. + */ + const [activePick, setActivePick] = useState({ caret: 0, index: 0 }); const textareaRef = useRef(null); const mirrorRef = useRef(null); const fileInputRef = useRef(null); + /** + * Каретка после программной вставки подсказки. Ставим её в layout-эффекте: + * к этому моменту textarea уже содержит новый текст, иначе браузер оставил бы + * каретку в конце и слой подсветки разъехался бы с полем. + */ + const pendingCaret = useRef(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 => { 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} /> + setActivePick({ caret: selection.end, index })} + onPick={(item) => { + if (suggestion !== null) { + insertAutocomplete(suggestion, item); + } + }} + />