diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 6890ea6..988eb37 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -24,7 +24,8 @@ import { EDO_API_BASE_URL_STAGE, setApprovalApiBaseUrl, setEdoApiBaseUrl, - setNotificationSettings + setNotificationSettings, + setChatSelfNotify } from './shared/config'; import { initListPartitions } from './shared/approval'; import { ErrorOverlay } from './shared/ui'; @@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps { notificationsEnabled?: boolean; /** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */ notifyOnlyTo?: string; + /** + * Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@». + * По умолчанию выключено (undefined — тоже «выкл»). + */ + chatSelfNotify?: boolean; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart): Promise { - if (!FLOW_NOTIFY_URL) { +// POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401, +// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех +// потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts). +export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise { + if (!flowUrl) { return; } const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE); - const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, { + const res = await client.post(flowUrl, AadHttpClient.configurations.v1, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); @@ -68,7 +69,7 @@ async function notifyRow( if (!(await isNotifyAllowed(approval, row.approverId))) { return; } - await postNotify(context, { + await postToFlow(context, FLOW_NOTIFY_URL, { RequestTypeId: target.requestTypeId, RowID: row.id, RequestID: requestId, diff --git a/src/webparts/requestPortal/shared/chat/chatMentions.test.ts b/src/webparts/requestPortal/shared/chat/chatMentions.test.ts new file mode 100644 index 0000000..2dcd6c2 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMentions.test.ts @@ -0,0 +1,70 @@ +import { + buildParticipants, + extractMentionedEmails, + filterParticipants, + findMentionQuery, + insertMention +} from './chatMentions'; + +const ivanov = { name: 'Иван Иванов', email: 'ivanov@tnsu.kz' }; +const petrova = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' }; + +describe('buildParticipants', () => { + it('убирает повторы, пустые адреса и самого пишущего', () => { + const result = buildParticipants( + [ivanov, { name: 'Иван Иванов', email: 'IVANOV@tnsu.kz' }, { name: 'Без почты', email: '' }, undefined, petrova], + 'petrova@tnsu.kz' + ); + expect(result).toEqual([ivanov]); + }); + + it('без ФИО показывает почту', () => { + expect(buildParticipants([{ email: 'x@tnsu.kz' }], '')).toEqual([{ name: 'x@tnsu.kz', email: 'x@tnsu.kz' }]); + }); +}); + +describe('findMentionQuery', () => { + it('находит отметку в начале и после пробела', () => { + expect(findMentionQuery('@Ив', 3)).toEqual({ start: 0, query: 'Ив' }); + expect(findMentionQuery('Привет @Иван Ив', 15)).toEqual({ start: 7, query: 'Иван Ив' }); + }); + + it('не считает отметкой адрес почты и перенос строки', () => { + expect(findMentionQuery('mail@tnsu', 9)).toBeUndefined(); + expect(findMentionQuery('@Иван\nдалее', 11)).toBeUndefined(); + expect(findMentionQuery('без отметки', 5)).toBeUndefined(); + }); +}); + +describe('filterParticipants', () => { + it('ищет по началу слов ФИО и по почте', () => { + expect(filterParticipants([ivanov, petrova], 'пет')).toEqual([petrova]); + expect(filterParticipants([ivanov, petrova], 'иван ив')).toEqual([ivanov]); + expect(filterParticipants([ivanov, petrova], 'petrova@')).toEqual([petrova]); + expect(filterParticipants([ivanov, petrova], '')).toEqual([ivanov, petrova]); + }); +}); + +describe('insertMention', () => { + it('заменяет набранное на ФИО и ставит курсор за пробелом', () => { + const text = 'Привет @Ив, глянь'; + const result = insertMention(text, { start: 7, query: 'Ив' }, 10, ivanov); + expect(result.text).toBe('Привет @Иван Иванов , глянь'); + expect(result.caret).toBe('Привет @Иван Иванов '.length); + }); +}); + +describe('extractMentionedEmails', () => { + it('берёт отметки из текста без повторов', () => { + expect(extractMentionedEmails('@Иван Иванов и @Анна Петрова, @Иван Иванов', [ivanov, petrova])).toEqual([ + 'ivanov@tnsu.kz', + 'petrova@tnsu.kz' + ]); + }); + + it('стёртая отметка не считается, короткое ФИО не цепляется за длинное', () => { + const ivan = { name: 'Иван', email: 'ivan@tnsu.kz' }; + expect(extractMentionedEmails('@Иван Иванов, привет', [ivan, ivanov])).toEqual(['ivanov@tnsu.kz']); + expect(extractMentionedEmails('Иван Иванов, привет', [ivanov])).toEqual([]); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatMentions.ts b/src/webparts/requestPortal/shared/chat/chatMentions.ts new file mode 100644 index 0000000..702f8e6 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMentions.ts @@ -0,0 +1,116 @@ +/** + * Отметки участников заявки в чате через «@». + * + * Отметка — ОБЫЧНЫЙ текст «@ФИО» в сообщении, а не штатное упоминание + * SharePoint (параметр mentions у комментария): штатное упоминание SharePoint + * сопровождает своим письмом, а письма по чату шлёт наш поток (chatNotify.ts) — + * с номером заявки и ссылкой на портал, а не на строку списка. Два письма об + * одном сообщении — лишнее. + * + * Модуль чистый (без React и PnPjs) — покрыт тестами. + */ + +/** Участник заявки, которого можно отметить в чате. */ +export interface IChatParticipant { + name: string; + email: string; +} + +/** Активная отметка, которую пользователь сейчас набирает: «@Ива|». */ +export interface IMentionQuery { + /** Позиция символа «@» в тексте. */ + start: number; + /** Набранное после «@» до курсора. */ + query: string; +} + +/** + * Предел длины набираемой отметки: ФИО с отчеством укладывается, а «@» в + * середине обычного текста (адрес почты, «@всем, смотрите…») не держит + * подсказку открытой до конца абзаца. + */ +const MAX_QUERY_LENGTH = 40; + +const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); + +/** + * Список тех, кого можно отметить: инициатор и все из маршрута согласования + * (согласующие и исполнители), без повторов и без самого пишущего — отмечать + * себя незачем. Порядок — как в маршруте, инициатор первым. + */ +export function buildParticipants( + people: Array | undefined>, + currentUserEmail: string +): IChatParticipant[] { + const result: IChatParticipant[] = []; + for (const p of people) { + const email = (p?.email || '').trim(); + if (!email) continue; + if (currentUserEmail && sameEmail(email, currentUserEmail)) continue; + if (result.some((r) => sameEmail(r.email, email))) continue; + result.push({ name: (p?.name || '').trim() || email, email }); + } + return result; +} + +/** + * Набирается ли сейчас отметка: «@» в начале текста или после пробела/переноса, + * и между ним и курсором нет переноса строки. Пробелы внутри допустимы — ФИО + * из нескольких слов. + */ +export function findMentionQuery(text: string, caret: number): IMentionQuery | undefined { + const before = text.slice(0, caret); + const start = before.lastIndexOf('@'); + if (start < 0) return undefined; + if (start > 0 && !/\s/.test(before[start - 1])) return undefined; + + const query = before.slice(start + 1); + if (query.length > MAX_QUERY_LENGTH || /[\r\n@]/.test(query)) return undefined; + return { start, query }; +} + +/** Участники, подходящие под набранное: каждое слово запроса — начало слова в ФИО или часть почты. */ +export function filterParticipants(participants: IChatParticipant[], query: string): IChatParticipant[] { + const words = query.toLowerCase().split(/\s+/).filter((w) => !!w); + if (words.length === 0) return participants; + return participants.filter((p) => { + const nameWords = p.name.toLowerCase().split(/\s+/); + const email = p.email.toLowerCase(); + return words.every((w) => nameWords.some((n) => n.startsWith(w)) || email.includes(w)); + }); +} + +/** Заменить набранное «@запрос» на «@ФИО » и вернуть новый текст и позицию курсора. */ +export function insertMention( + text: string, + mention: IMentionQuery, + caret: number, + participant: IChatParticipant +): { text: string; caret: number } { + const inserted = `@${participant.name} `; + const head = text.slice(0, mention.start); + // Пробел сразу за курсором уже есть — второй не нужен. + const tail = text.slice(caret).replace(/^ /, ''); + return { text: head + inserted + tail, caret: head.length + inserted.length }; +} + +/** + * Кого в итоге отметили — по тексту, а не по истории выбора в подсказке: + * отметку могли стереть или вписать руками целиком. Длинные ФИО проверяются + * первыми, чтобы «@Иван Иванов» не засчитал заодно участника «Иван». + */ +export function extractMentionedEmails(text: string, participants: IChatParticipant[]): string[] { + // Найденную отметку затираем пробелами той же длины — позиции остальных не + // сдвигаются, и результат идёт в порядке появления в тексте. + let rest = text; + const found: Array<{ email: string; at: number }> = []; + const byLength = [...participants].sort((a, b) => b.name.length - a.name.length); + for (const p of byLength) { + const tag = `@${p.name}`; + const at = rest.indexOf(tag); + if (at < 0) continue; + rest = rest.split(tag).join(' '.repeat(tag.length)); + if (!found.some((f) => sameEmail(f.email, p.email))) found.push({ email: p.email, at }); + } + return found.sort((a, b) => a.at - b.at).map((f) => f.email); +} diff --git a/src/webparts/requestPortal/shared/chat/chatNotify.service.ts b/src/webparts/requestPortal/shared/chat/chatNotify.service.ts new file mode 100644 index 0000000..9d35f62 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatNotify.service.ts @@ -0,0 +1,17 @@ +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { FLOW_CHAT_NOTIFY_URL } from '../config'; +import { postToFlow } from '../approval/notify.service'; +import { IChatNotifyBody } from './chatNotify'; + +/** + * Отправить оповещение о сообщении в чате. Некритично: сообщение уже записано, + * и сбой письма не должен выглядеть как сбой отправки — ошибку только пишем в + * консоль, как и прочие оповещения портала (approval/notify.service.ts). + */ +export async function notifyChatMessage(context: WebPartContext, body: IChatNotifyBody): Promise { + try { + await postToFlow(context, FLOW_CHAT_NOTIFY_URL, body); + } catch (e) { + console.warn('[Чат] Оповещение о сообщении не отправлено', e); + } +} diff --git a/src/webparts/requestPortal/shared/chat/chatNotify.test.ts b/src/webparts/requestPortal/shared/chat/chatNotify.test.ts new file mode 100644 index 0000000..57ea5f1 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatNotify.test.ts @@ -0,0 +1,56 @@ +import { buildChatNotifyBody, IChatNotifyInput } from './chatNotify'; + +const initiator = { name: 'Инициатор Заявки', email: 'init@tnsu.kz' }; +const approver = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' }; + +const base: IChatNotifyInput = { + initiatorEmail: initiator.email, + participants: [initiator, approver], + authorName: 'Анна Петрова', + authorEmail: approver.email, + messageText: 'Текст', + requestCode: '13-2608-42', + requestUrl: 'https://portal/x', + requestTitle: 'Обходной лист', + selfNotify: false +}; + +describe('buildChatNotifyBody', () => { + it('пишет согласующий без отметок — письмо инициатору, отмеченных нет', () => { + expect(buildChatNotifyBody(base)).toEqual({ + initiatorEmail: 'init@tnsu.kz', + mentionedEmails: [], + authorName: 'Анна Петрова', + messageText: 'Текст', + RequestUniqueCode: '13-2608-42', + requestUrl: 'https://portal/x', + requestTitle: 'Обходной лист' + }); + }); + + it('инициатор пишет без отметок — поток не вызываем', () => { + expect(buildChatNotifyBody({ ...base, authorEmail: 'INIT@tnsu.kz' })).toBeUndefined(); + }); + + it('инициатор отметил согласующего — инициатор подменён отмеченным, чтобы не писать самому себе', () => { + const body = buildChatNotifyBody({ ...base, authorEmail: initiator.email, messageText: '@Анна Петрова глянь' }); + expect(body?.initiatorEmail).toBe('petrova@tnsu.kz'); + expect(body?.mentionedEmails).toEqual(['petrova@tnsu.kz']); + }); + + it('отметка самого себя без режима проверки не считается', () => { + const body = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова @Инициатор Заявки' }); + expect(body?.mentionedEmails).toEqual(['init@tnsu.kz']); + }); + + it('в режиме проверки письма и о своих сообщениях, и себе по отметке', () => { + const own = buildChatNotifyBody({ ...base, authorEmail: initiator.email, selfNotify: true }); + expect(own?.initiatorEmail).toBe('init@tnsu.kz'); + const self = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова', selfNotify: true }); + expect(self?.mentionedEmails).toEqual(['petrova@tnsu.kz']); + }); + + it('нет ни инициатора, ни отметок — поток не вызываем', () => { + expect(buildChatNotifyBody({ ...base, initiatorEmail: '' })).toBeUndefined(); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatNotify.ts b/src/webparts/requestPortal/shared/chat/chatNotify.ts new file mode 100644 index 0000000..019fd25 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatNotify.ts @@ -0,0 +1,70 @@ +import { IChatParticipant, extractMentionedEmails } from './chatMentions'; + +/** + * Тело запроса к потоку оповещения о сообщении в чате (FLOW_CHAT_NOTIFY_URL). + * Контракт потока: все поля — строки, кроме mentionedEmails; никого не + * отметили — пустой массив, а не null и не пропущенное поле. + */ +export interface IChatNotifyBody { + initiatorEmail: string; + mentionedEmails: string[]; + authorName: string; + messageText: string; + RequestUniqueCode: string; + requestUrl: string; + requestTitle: string; +} + +export interface IChatNotifyInput { + /** Инициатор заявки (автор строки списка данных). */ + initiatorEmail: string; + /** Кого можно было отметить — по ним ищутся «@ФИО» в тексте. */ + participants: IChatParticipant[]; + authorName: string; + authorEmail: string; + messageText: string; + requestCode: string; + requestUrl: string; + requestTitle: string; + /** Режим проверки (свойство веб-части chatSelfNotify): письма и о своих сообщениях. */ + selfNotify: boolean; +} + +const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase(); + +/** + * Собрать тело запроса к потоку или undefined, если писать некому. + * + * Поток шлёт инициатору письмо ВСЕГДА, когда его нет среди отмеченных. Значит, + * когда пишет сам инициатор (а режим проверки выключен), письмо «новое сообщение + * по вашей заявке» ушло бы ему же. Отключить его в контракте потока нечем, + * поэтому: + * — никого не отметил → поток не вызываем вовсе; + * — кого-то отметил → в initiatorEmail кладём первого отмеченного: по + * контракту отмеченный инициатор получает только «Вас упомянули», и + * лишнего письма никому не уходит. + */ +export function buildChatNotifyBody(input: IChatNotifyInput): IChatNotifyBody | undefined { + const mentioned = extractMentionedEmails(input.messageText, input.participants).filter( + (email) => input.selfNotify || !sameEmail(email, input.authorEmail) + ); + + let initiatorEmail = input.initiatorEmail; + const authorIsInitiator = + !!initiatorEmail && !!input.authorEmail && sameEmail(initiatorEmail, input.authorEmail); + if (authorIsInitiator && !input.selfNotify) { + if (mentioned.length === 0) return undefined; + initiatorEmail = mentioned[0]; + } + if (!initiatorEmail && mentioned.length === 0) return undefined; + + return { + initiatorEmail, + mentionedEmails: mentioned, + authorName: input.authorName, + messageText: input.messageText, + RequestUniqueCode: input.requestCode, + requestUrl: input.requestUrl, + requestTitle: input.requestTitle + }; +} diff --git a/src/webparts/requestPortal/shared/chat/index.ts b/src/webparts/requestPortal/shared/chat/index.ts index 880fa18..9a1250d 100644 --- a/src/webparts/requestPortal/shared/chat/index.ts +++ b/src/webparts/requestPortal/shared/chat/index.ts @@ -3,7 +3,19 @@ export { RequestChat } from './ui/RequestChat'; export { isChatEnabledFor } from './chatEnabled'; export { useRequestChat } from './useRequestChat'; export type { IRequestChat } from './useRequestChat'; -export { getChatMessages, addChatMessage } from './requestChat.service'; +export { getChatMessages, addChatMessage, getChatRequestInfo } from './requestChat.service'; +export type { IChatRequestInfo } from './requestChat.service'; +export { + buildParticipants, + findMentionQuery, + filterParticipants, + insertMention, + extractMentionedEmails +} from './chatMentions'; +export type { IChatParticipant, IMentionQuery } from './chatMentions'; +export { buildChatNotifyBody } from './chatNotify'; +export type { IChatNotifyBody } from './chatNotify'; +export { notifyChatMessage } from './chatNotify.service'; export { uploadChatFiles, chatFilesFolderUrl, diff --git a/src/webparts/requestPortal/shared/chat/requestChat.service.ts b/src/webparts/requestPortal/shared/chat/requestChat.service.ts index 371bbbc..f330760 100644 --- a/src/webparts/requestPortal/shared/chat/requestChat.service.ts +++ b/src/webparts/requestPortal/shared/chat/requestChat.service.ts @@ -6,6 +6,8 @@ import { RequestTypeId } from '../../models/types'; import { IChatMessage } from './models/IChatMessage'; import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages'; import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service'; +import { IChatParticipant } from './chatMentions'; +import { approvalServiceFor } from '../approval/approvalServiceFactory'; /** * Чат по заявке ПОВЕРХ штатных комментариев к строке списка. @@ -76,3 +78,42 @@ export async function addChatMessage( const storedNames = await uploadChatFiles(formKey, itemId, listId, files); await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames)); } + +/** Сведения о заявке, нужные чату помимо переписки: кого отмечать и что писать в письме. */ +export interface IChatRequestInfo { + code: string; + title: string; + initiator: IChatParticipant; + /** Инициатор и все из маршрута (согласующие и исполнители), как есть — фильтрует buildParticipants. */ + people: IChatParticipant[]; +} + +/** + * Инициатор — автор строки списка данных: заявку создаёт он сам (подмена + * личности на автора записи не влияет, см. RequestChat). Участники — строки + * маршрута; если список согласования не прочитался, чат работает и так — + * просто отметить можно только инициатора. + */ +export async function getChatRequestInfo( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined +): Promise { + const [item, matrix] = await Promise.all([ + resolveDataList(formKey, listId) + .items.getById(itemId) + .select('Title', 'RequestUniqueCode', 'Author/Title', 'Author/EMail') + .expand('Author')() as Promise>, + approvalServiceFor(formKey, listId) + .getMatrixForRequest(itemId) + .catch(() => []) + ]); + const author = item.Author as { Title?: string; EMail?: string } | undefined; + const initiator = { name: author?.Title || '', email: author?.EMail || '' }; + return { + code: (item.RequestUniqueCode as string) || '', + title: (item.Title as string) || '', + initiator, + people: [initiator, ...matrix.map((r) => ({ name: r.who, email: r.email }))] + }; +} diff --git a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx index ba7dafc..266f052 100644 --- a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx +++ b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx @@ -2,22 +2,33 @@ import * as React from 'react'; import styles from './RequestChat.module.scss'; import { Button, FileDropzone, Icon } from '../../ui'; import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service'; +import { filterParticipants, findMentionQuery, IChatParticipant, insertMention } from '../chatMentions'; interface IChatComposerProps { sending: boolean; + /** Кого можно отметить через «@» (см. chatMentions.ts). */ + participants: IChatParticipant[]; /** Возвращает true, если сообщение записано — только тогда очищаем поле. */ onSend: (text: string, files: File[]) => Promise; } const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); +/** Сколько вариантов показывать в подсказке «@» — дальше пусть уточняют набором. */ +const MAX_SUGGESTIONS = 8; + /** * Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный * textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш * не пробрасывает. */ -export const ChatComposer: React.FC = ({ sending, onSend }) => { +export const ChatComposer: React.FC = ({ sending, participants, onSend }) => { const [text, setText] = React.useState(''); + const [caret, setCaret] = React.useState(0); + const [highlighted, setHighlighted] = React.useState(0); + // Esc закрывает подсказку до следующего набора — иначе её не убрать, не стерев «@». + const [mentionDismissed, setMentionDismissed] = React.useState(false); + const inputRef = React.useRef(null); const [files, setFiles] = React.useState([]); const [filesOpen, setFilesOpen] = React.useState(false); @@ -25,6 +36,30 @@ export const ChatComposer: React.FC = ({ sending, onSend }) // текст отправку не блокирует. const canSend = (text.trim().length > 0 || files.length > 0) && !sending; + const mention = mentionDismissed ? undefined : findMentionQuery(text, caret); + const suggestions = mention ? filterParticipants(participants, mention.query).slice(0, MAX_SUGGESTIONS) : []; + const suggestOpen = suggestions.length > 0; + const active = Math.min(highlighted, suggestions.length - 1); + + const pickMention = (participant: IChatParticipant): void => { + if (!mention) return; + const next = insertMention(text, mention, caret, participant); + setText(next.text); + setCaret(next.caret); + setHighlighted(0); + // Курсор ставим за вставленным ФИО — после перерисовки, когда текст уже в поле. + window.setTimeout(() => { + const input = inputRef.current; + if (!input) return; + input.focus(); + input.setSelectionRange(next.caret, next.caret); + }, 0); + }; + + const trackCaret = (e: React.SyntheticEvent): void => { + setCaret(e.currentTarget.selectionStart || 0); + }; + const send = (): void => { if (!canSend) return; onSend(text, files) @@ -38,7 +73,26 @@ export const ChatComposer: React.FC = ({ sending, onSend }) }; // Enter отправляет, Shift+Enter переносит строку — привычное поведение чата. + // Пока открыта подсказка «@», стрелки ходят по ней, а Enter/Tab выбирают. const handleKeyDown = (e: React.KeyboardEvent): void => { + if (suggestOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const step = e.key === 'ArrowDown' ? 1 : -1; + setHighlighted((active + step + suggestions.length) % suggestions.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + pickMention(suggestions[active]); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setMentionDismissed(true); + return; + } + } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); @@ -57,16 +111,49 @@ export const ChatComposer: React.FC = ({ sending, onSend }) )}
-