feat: файлы в чате заявки — библиотека «Файлы чата»

У комментария SharePoint нет вложений, только текст. Поэтому файлы уходят
в отдельную библиотеку на сайте данных (папка на заявку), а в тексте
комментария остаётся пометка с их именами — по ней сообщение и находит свои
файлы. Адрес библиотеки резолвится по заголовку на лету, поэтому ссылки в
переписке не ломаются при её переносе, а чат без файлов к библиотеке вообще
не обращается.
This commit is contained in:
gorbenko.pavel
2026-08-24 14:05:04 +05:00
parent e85a3a239e
commit e8daf0813d
13 changed files with 597 additions and 49 deletions
@@ -0,0 +1,48 @@
import { displayFileName, fileStamp, sanitizeFileName, storedFileName } from './chatFileNames';
describe('sanitizeFileName', () => {
it('заменяет запрещённые SharePoint символы', () => {
expect(sanitizeFileName('от 24/08 №1: акт*.pdf')).toBe('от 24_08 №1_ акт_.pdf');
});
it('убирает точки в конце — такое имя SharePoint не принимает', () => {
expect(sanitizeFileName('акт...')).toBe('акт');
});
it('пустое имя заменяет заглушкой, а не пустой строкой', () => {
expect(sanitizeFileName(' ')).toBe('файл');
});
it('длинное имя обрезает, сохраняя расширение', () => {
const long = `${'я'.repeat(200)}.pdf`;
const result = sanitizeFileName(long);
expect(result.length).toBeLessThanOrEqual(120);
expect(result.endsWith('.pdf')).toBe(true);
});
});
describe('storedFileName / displayFileName', () => {
it('складское имя разбирается обратно в исходное', () => {
const stored = storedFileName('20260824-153012', 1, 'акт приёмки.pdf');
expect(stored).toBe('20260824-153012-1__акт приёмки.pdf');
expect(displayFileName(stored)).toBe('акт приёмки.pdf');
});
it('двойное подчёркивание в исходном имени не ломает разбор', () => {
const stored = storedFileName('20260824-153012', 2, 'акт__итог.pdf');
expect(displayFileName(stored)).toBe('акт__итог.pdf');
});
it('файл, положенный в библиотеку не порталом, показывается как есть', () => {
expect(displayFileName('справка.pdf')).toBe('справка.pdf');
});
it('файлы одного сообщения получают разные имена', () => {
const stamp = fileStamp(new Date(2026, 7, 24, 15, 30, 12));
expect(stamp).toBe('20260824-153012');
expect(storedFileName(stamp, 1, 'скан.jpg')).not.toBe(storedFileName(stamp, 2, 'скан.jpg'));
});
});
@@ -0,0 +1,77 @@
/**
* Имена файлов чата. Модуль чистый (без SharePoint) и покрыт тестами: от
* формата имени зависит и то, что файл вообще запишется, и то, каким он
* покажется в переписке.
*
* Имя в библиотеке: «<метка времени>-<номер>__<исходное имя>», например
* «20260824-153012-1__акт приёмки.pdf». Префикс нужен, чтобы два человека
* (или один дважды) могли прикрепить к одной заявке файлы с одинаковым
* названием — без него второй файл перезаписал бы первый.
*/
/** Разделитель служебного префикса и исходного имени. */
const SEPARATOR = '__';
/**
* Запрещённые SharePoint символы в имени файла (" * : < > ? / \ |), плюс # % ~:
* формально они допустимы в SPO, но ломают прямые ссылки на файл.
*/
const FORBIDDEN_CHARS = /["*:<>?/\\|#%~]/g;
/**
* Предел длины исходной части имени. Общий предел пути в SharePoint — 400
* символов, и в него входят адрес сайта, имя библиотеки и папка заявки;
* 120 символов на само имя оставляют запас и при этом не режут нормальные
* названия документов.
*/
const MAX_NAME_LENGTH = 120;
function splitExtension(name: string): { base: string; extension: string } {
const dot = name.lastIndexOf('.');
// Точка в начале — это не расширение, а имя вида «.gitignore».
if (dot <= 0) return { base: name, extension: '' };
return { base: name.slice(0, dot), extension: name.slice(dot) };
}
/**
* Привести имя файла к тому, что SharePoint примет: убрать запрещённые символы
* и обрезать слишком длинное имя, СОХРАНИВ расширение (без него файл не
* откроется нужным приложением).
*/
export function sanitizeFileName(name: string): string {
const cleaned = name.replace(FORBIDDEN_CHARS, '_').replace(/\s+/g, ' ').trim();
// Имя, оканчивающееся точкой, SharePoint тоже не принимает.
const withoutTrailingDots = cleaned.replace(/\.+$/, '');
if (!withoutTrailingDots) return 'файл';
if (withoutTrailingDots.length <= MAX_NAME_LENGTH) return withoutTrailingDots;
const { base, extension } = splitExtension(withoutTrailingDots);
const room = MAX_NAME_LENGTH - extension.length;
// Расширение длиннее всего лимита — вырожденный случай, режем как есть.
if (room <= 0) return withoutTrailingDots.slice(0, MAX_NAME_LENGTH);
return `${base.slice(0, room)}${extension}`;
}
function pad2(value: number): string {
return value < 10 ? `0${value}` : `${value}`;
}
/** Метка времени для префикса: «20260824-153012». */
export function fileStamp(date: Date): string {
const day = `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`;
const time = `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
return `${day}-${time}`;
}
/** Имя, под которым файл ляжет в библиотеку. index — порядок файла в сообщении. */
export function storedFileName(stamp: string, index: number, originalName: string): string {
return `${stamp}-${index}${SEPARATOR}${sanitizeFileName(originalName)}`;
}
/** Исходное имя из складского: то, что видит пользователь в переписке. */
export function displayFileName(storedName: string): string {
const at = storedName.indexOf(SEPARATOR);
// Разделителя нет — файл положили не порталом; показываем имя как есть.
return at < 0 ? storedName : storedName.slice(at + SEPARATOR.length);
}
@@ -0,0 +1,139 @@
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/folders';
import '@pnp/sp/files';
import type { IFolder } from '@pnp/sp/folders';
import { getSP } from '../pnp/pnpjs';
import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles';
import { RequestTypeId } from '../../models/types';
import { fileStamp, storedFileName } from './chatFileNames';
/**
* Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте
* данных, папка на каждую заявку: «Файлы чата/<тип заявки>-<id>».
*
* Почему не вложения самой заявки (shared/attachments): вложения — это
* документы заявки, они попадают в её карточку и печать. Файл из переписки —
* не документ заявки, а часть разговора; смешивать их значит засорять заявку.
*
* Связь «файл → сообщение» хранится в тексте комментария (см. FILE_MARKER в
* chatMessages.ts), поэтому чтение переписки НЕ обращается к библиотеке вовсе,
* пока в ней действительно нет файлов.
*/
/** Больше 10 МБ через обычную загрузку слать не стоит — это переписка, а не файлообмен. */
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
/** Сколько файлов можно приложить к одному сообщению. */
export const MAX_FILES_PER_MESSAGE = 5;
// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке
// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте
// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что
// в shared/approval/siteUser.ts).
let libraryRootPromise: Promise<string> | undefined;
/**
* Серверный относительный адрес корня библиотеки, например
* «/sites/requests/Файлы чата».
*
* Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url
* библиотеки может не совпадать с её названием (и меняться при переносе), а
* заголовок — то, что реально настраивал администратор.
*/
function chatLibraryRoot(): Promise<string> {
if (!libraryRootPromise) {
libraryRootPromise = getSP()
.web.lists.getByTitle(CHAT_FILES_LIBRARY_TITLE)
.rootFolder.select('ServerRelativeUrl')()
.then((folder) => folder.ServerRelativeUrl as string)
.catch((e) => {
libraryRootPromise = undefined;
throw new Error(
`Библиотека «${CHAT_FILES_LIBRARY_TITLE}» недоступна на сайте данных: ${(e as Error).message}`
);
});
}
return libraryRootPromise;
}
/** Имя папки заявки в библиотеке. Тип заявки в имени — чтобы id разных типов не смешивались. */
function requestFolderName(formKey: RequestTypeId, itemId: number): string {
return `${formKey}-${itemId}`;
}
/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */
export async function chatFilesFolderUrl(
formKey: RequestTypeId,
itemId: number
): Promise<string> {
const root = await chatLibraryRoot();
return `${root}/${requestFolderName(formKey, itemId)}`;
}
/**
* Папка заявки, гарантированно существующая.
*
* Проверяем существование отдельным запросом, а не создаём «поверх»:
* addSubFolderUsingPath на существующей папке возвращает ошибку, а
* addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее
* приложенные к заявке файлы.
*/
async function ensureRequestFolder(formKey: RequestTypeId, itemId: number): Promise<IFolder> {
const root = await chatLibraryRoot();
const name = requestFolderName(formKey, itemId);
const folder = getSP().web.getFolderByServerRelativePath(`${root}/${name}`);
const exists = await folder
.select('Exists')()
.then((info) => !!info.Exists)
.catch(() => false);
if (!exists) {
await getSP().web.getFolderByServerRelativePath(root).addSubFolderUsingPath(name);
}
return folder;
}
/**
* Загрузить файлы сообщения в папку заявки. Возвращает СКЛАДСКИЕ имена — их
* сервис чата кладёт в текст комментария (см. buildCommentText).
*
* Вызывается ДО создания комментария: имена файлов нужны, чтобы его собрать.
* Если создать комментарий потом не удастся, файлы останутся в библиотеке
* ничьими — это лучше, чем сообщение со ссылками на незагруженные файлы.
*/
export async function uploadChatFiles(
formKey: RequestTypeId,
itemId: number,
files: File[]
): Promise<string[]> {
if (files.length === 0) return [];
const folder = await ensureRequestFolder(formKey, itemId);
const stamp = fileStamp(new Date());
const storedNames: string[] = [];
// Последовательно, а не Promise.all: параллельная запись в одну папку чаще
// упирается в throttling SharePoint, а файлов здесь единицы.
for (let i = 0; i < files.length; i++) {
const name = storedFileName(stamp, i + 1, files[i].name);
// File — это Blob, поэтому читать его в ArrayBuffer заранее не нужно.
await folder.files.addUsingPath(name, files[i]);
storedNames.push(name);
}
return storedNames;
}
/** Проверка файлов до отправки. Возвращает текст ошибки или undefined. */
export function validateChatFiles(files: File[]): string | undefined {
if (files.length > MAX_FILES_PER_MESSAGE) {
return `К одному сообщению можно приложить не больше ${MAX_FILES_PER_MESSAGE} файлов.`;
}
const tooBig = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES)[0];
if (tooBig) {
const limitMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
return `Файл «${tooBig.name}» больше ${limitMb} МБ — приложите его к самой заявке или дайте ссылку.`;
}
return undefined;
}
@@ -1,4 +1,9 @@
import { ICommentSource, toChatMessages } from './chatMessages';
import {
ICommentSource,
buildCommentText,
hasAttachments,
toChatMessages
} from './chatMessages';
const ME = 'ivan@tnsu.kz';
@@ -73,3 +78,79 @@ describe('toChatMessages', () => {
expect(messages[1].authorName).toBe('Неизвестный автор');
});
});
describe('файлы в сообщении', () => {
const FOLDER = '/sites/requests/Файлы чата/hr-vacation-12';
const FILE = '20260824-153012-1__акт.pdf';
const SECOND_FILE = '20260824-153012-2__скан.jpg';
it('пометка о файлах не показывается в тексте сообщения', () => {
const messages = toChatMessages(
[comment({ text: `Посмотрите акт\n[rp-files]${FILE}` })],
ME,
FOLDER
);
expect(messages[0].text).toBe('Посмотрите акт');
});
it('файл получает исходное имя и ссылку в папке заявки', () => {
const messages = toChatMessages(
[comment({ text: `[rp-files]${FILE}|${SECOND_FILE}` })],
ME,
FOLDER
);
expect(messages[0].text).toBe('');
expect(messages[0].attachments).toEqual([
{ fileName: 'акт.pdf', url: `${FOLDER}/${FILE}` },
{ fileName: 'скан.jpg', url: `${FOLDER}/${SECOND_FILE}` }
]);
});
it('сообщение из одних файлов не отбрасывается как пустое', () => {
const messages = toChatMessages([comment({ text: `[rp-files]${FILE}` })], ME, FOLDER);
expect(messages).toHaveLength(1);
});
it('пометка переживает перевод строки в формате Windows', () => {
const messages = toChatMessages([comment({ text: `Текст\r\n[rp-files]${FILE}` })], ME, FOLDER);
expect(messages[0].text).toBe('Текст');
expect(messages[0].attachments).toHaveLength(1);
});
it('обычное сообщение остаётся без вложений', () => {
const messages = toChatMessages([comment({ text: 'Просто текст' })], ME);
expect(messages[0].attachments).toEqual([]);
});
it('hasAttachments отличает переписку с файлами от переписки без них', () => {
expect(hasAttachments([comment({ text: 'Просто текст' })])).toBe(false);
expect(hasAttachments([comment({ text: `[rp-files]${FILE}` })])).toBe(true);
});
});
describe('buildCommentText', () => {
it('без файлов отдаёт очищенный текст', () => {
expect(buildCommentText(' Привет ', [])).toBe('Привет');
});
it('с файлами дописывает пометку отдельной строкой', () => {
expect(buildCommentText('Смотрите', ['a__акт.pdf', 'b__скан.jpg'])).toBe(
'Смотрите\n[rp-files]a__акт.pdf|b__скан.jpg'
);
});
it('без текста остаётся одна пометка — отправка «только файл»', () => {
expect(buildCommentText('', ['a__акт.pdf'])).toBe('[rp-files]a__акт.pdf');
});
it('собранный текст читается обратно без потерь', () => {
const raw = buildCommentText('Смотрите акт', ['20260824-153012-1__акт.pdf']);
const messages = toChatMessages([comment({ text: raw })], ME, '/f');
expect(messages[0].text).toBe('Смотрите акт');
expect(messages[0].attachments[0].fileName).toBe('акт.pdf');
});
});
@@ -1,4 +1,5 @@
import { IChatMessage } from './models/IChatMessage';
import { IChatAttachment, IChatMessage } from './models/IChatMessage';
import { displayFileName } from './chatFileNames';
/**
* Часть комментария SharePoint, из которой строится сообщение чата.
@@ -15,6 +16,53 @@ export interface ICommentSource {
author?: { name?: string; email?: string };
}
/**
* Служебная пометка о прикреплённых файлах в КОНЦЕ текста комментария:
* Проверьте, пожалуйста, акт
* [rp-files]20260824-153012-1__акт.pdf|20260824-153012-2__скан.jpg
*
* Почему в тексте, а не отдельным хранилищем: у комментария SharePoint нет
* своих полей — только текст. Класть связь «сообщение → файлы» больше некуда,
* не заводя отдельный список и не превращая каждую загрузку чата в лишний
* запрос к библиотеке. Имена файлов складские (с префиксом), но не полные
* адреса — адрес библиотеки резолвится на лету, поэтому её можно переместить,
* и ссылки в старой переписке не сломаются.
*
* Разделитель имён — «|»: этот символ SharePoint в именах файлов запрещает
* (см. FORBIDDEN_CHARS), значит в имени он встретиться не может.
*/
const FILE_MARKER = '[rp-files]';
/** Пометка всегда последняя и всегда в одну строку — отсюда привязка к концу. */
const FILE_MARKER_PATTERN = /\s*\[rp-files\]([^\r\n]*)$/;
/**
* Собрать текст комментария из сообщения и складских имён файлов.
* Сообщение может быть пустым — тогда комментарий состоит из одной пометки
* (так выглядит отправка «только файл»).
*/
export function buildCommentText(text: string, storedFileNames: string[]): string {
const trimmed = text.trim();
if (storedFileNames.length === 0) return trimmed;
const marker = `${FILE_MARKER}${storedFileNames.join('|')}`;
return trimmed ? `${trimmed}\n${marker}` : marker;
}
function parseStoredNames(raw: string): string[] {
const found = FILE_MARKER_PATTERN.exec(raw);
if (!found) return [];
return found[1]
.split('|')
.map((name) => name.trim())
.filter((name) => !!name);
}
/** Есть ли в переписке файлы — по этому признаку сервис решает, нужен ли адрес библиотеки. */
export function hasAttachments(comments: ICommentSource[]): boolean {
return comments.some((c) => !!c.text && FILE_MARKER_PATTERN.test(c.text));
}
/**
* Дата комментария приходит строкой ISO. Если часового пояса в ней нет,
* new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC —
@@ -26,19 +74,32 @@ function parseCommentDate(raw: string): Date {
return new Date(hasTimeZone ? raw : `${raw}Z`);
}
function toMessage(comment: ICommentSource, currentUserEmail: string): IChatMessage {
function toAttachments(storedNames: string[], folderUrl: string): IChatAttachment[] {
return storedNames.map((storedName) => ({
fileName: displayFileName(storedName),
url: folderUrl ? `${folderUrl}/${storedName}` : ''
}));
}
function toMessage(
comment: ICommentSource,
currentUserEmail: string,
folderUrl: string
): IChatMessage {
const authorEmail = comment.author?.email || '';
const authorName = comment.author?.name || authorEmail || 'Неизвестный автор';
const storedNames = parseStoredNames(comment.text);
return {
id: comment.id,
text: comment.text,
text: comment.text.replace(FILE_MARKER_PATTERN, '').trim(),
authorName,
authorEmail,
createdAt: parseCommentDate(comment.createdDate),
isOwn:
!!authorEmail &&
!!currentUserEmail &&
authorEmail.toLowerCase() === currentUserEmail.toLowerCase()
authorEmail.toLowerCase() === currentUserEmail.toLowerCase(),
attachments: toAttachments(storedNames, folderUrl)
};
}
@@ -47,16 +108,21 @@ function toMessage(comment: ICommentSource, currentUserEmail: string): IChatMess
* переписке: свежее внизу).
*
* Порядок ответа API не документирован и мы на него не опираемся — сортируем
* по дате сами. Пустые комментарии отбрасываем: показывать пустой «пузырь»
* нечего, а в списке они появляются, если кто-то отправил комментарий не из
* портала (например, из стандартной формы SharePoint).
* по дате сами. Комментарии без текста и без файлов отбрасываем: показывать
* пустой «пузырь» нечего, а в списке они появляются, если кто-то отправил
* комментарий не из портала (например, из стандартной формы SharePoint).
*
* folderUrl — папка заявки в библиотеке «Файлы чата»; пустая строка допустима,
* когда в переписке файлов нет (тогда адрес библиотеки и не запрашивался).
*/
export function toChatMessages(
comments: ICommentSource[],
currentUserEmail: string
currentUserEmail: string,
folderUrl: string = ''
): IChatMessage[] {
return comments
.filter((c) => !!c.text && !!c.text.trim())
.map((c) => toMessage(c, currentUserEmail))
.map((c) => toMessage(c, currentUserEmail, folderUrl))
.filter((m) => !!m.text || m.attachments.length > 0)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
@@ -3,6 +3,14 @@ export { RequestChat } from './ui/RequestChat';
export { useRequestChat } from './useRequestChat';
export type { IRequestChat } from './useRequestChat';
export { getChatMessages, addChatMessage } from './requestChat.service';
export { toChatMessages } from './chatMessages';
export {
uploadChatFiles,
chatFilesFolderUrl,
validateChatFiles,
MAX_FILES_PER_MESSAGE,
MAX_FILE_SIZE_BYTES
} from './chatFiles.service';
export { toChatMessages, buildCommentText, hasAttachments } from './chatMessages';
export type { ICommentSource } from './chatMessages';
export type { IChatMessage } from './models/IChatMessage';
export { sanitizeFileName, storedFileName, displayFileName, fileStamp } from './chatFileNames';
export type { IChatMessage, IChatAttachment } from './models/IChatMessage';
@@ -1,3 +1,11 @@
/** Файл, прикреплённый к сообщению чата (лежит в библиотеке «Файлы чата»). */
export interface IChatAttachment {
/** Имя для показа — без служебного префикса (см. chatFileNames.ts). */
fileName: string;
/** Ссылка на файл в библиотеке. */
url: string;
}
/**
* Сообщение чата заявки — комментарий к элементу списка данных, приведённый
* к виду, удобному интерфейсу (SharePoint отдаёт сырой ICommentInfo).
@@ -5,6 +13,7 @@
export interface IChatMessage {
/** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */
id: string;
/** Текст сообщения БЕЗ служебной пометки о файлах. */
text: string;
/** ФИО автора; если SharePoint его не отдал — email или заглушка. */
authorName: string;
@@ -15,4 +24,6 @@ export interface IChatMessage {
* Считается по email автора, см. toChatMessages.
*/
isOwn: boolean;
/** Прикреплённые файлы; пустой массив — файлов нет. */
attachments: IChatAttachment[];
}
@@ -4,7 +4,8 @@ import type { ICommentInfo, IComments } from '@pnp/sp/comments';
import { resolveDataList } from '../dataList';
import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage';
import { toChatMessages } from './chatMessages';
import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages';
import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service';
/**
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
@@ -15,6 +16,10 @@ import { toChatMessages } from './chatMessages';
* колонки в девяти списках, ни провижининга, и переписка не мешает
* согласованию (комментарий решения — отдельное поле строки маршрута).
*
* Единственное, чего у комментария нет, — вложений: у него есть только текст.
* Поэтому файлы лежат в отдельной библиотеке (chatFiles.service.ts), а в тексте
* комментария остаётся пометка с их именами (chatMessages.ts).
*
* Заявка живёт в КОНКРЕТНОЙ партиции (списки нумерованные, см.
* shared/approval/listPartitions.ts), поэтому список резолвим общим
* resolveDataList(formKey, listId) — тем же, которым формы пишут саму заявку.
@@ -47,18 +52,27 @@ export async function getChatMessages(
currentUserEmail: string
): Promise<IChatMessage[]> {
const comments: ICommentInfo[] = await commentsOf(formKey, itemId, listId)();
return toChatMessages(comments, currentUserEmail);
// Адрес библиотеки спрашиваем, только если в переписке ЕСТЬ файлы: у заявки
// без вложений чат тогда вообще не зависит от библиотеки — ни лишнего
// запроса, ни падения, если её переименуют.
const folderUrl = hasAttachments(comments) ? await chatFilesFolderUrl(formKey, itemId) : '';
return toChatMessages(comments, currentUserEmail, folderUrl);
}
/**
* Отправить сообщение — добавить комментарий к строке заявки. Автора и время
* ставит сам SharePoint, поэтому передаём только текст.
*
* Файлы сначала уходят в библиотеку, и только потом создаётся комментарий с
* пометкой об их именах — см. uploadChatFiles о том, почему такой порядок.
*/
export async function addChatMessage(
formKey: RequestTypeId,
itemId: number,
listId: string | undefined,
text: string
text: string,
files: File[] = []
): Promise<void> {
await commentsOf(formKey, itemId, listId).add(text);
const storedNames = await uploadChatFiles(formKey, itemId, files);
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
}
@@ -1,27 +1,38 @@
import * as React from 'react';
import styles from './RequestChat.module.scss';
import { Button, Icon } from '../../ui';
import { Button, FileDropzone, Icon } from '../../ui';
import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service';
interface IChatComposerProps {
sending: boolean;
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */
onSend: (text: string) => Promise<boolean>;
onSend: (text: string, files: File[]) => Promise<boolean>;
}
const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
/**
* Поле ввода сообщения. Здесь НЕ ui-kit TextArea, а собственный textarea:
* чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш не
* пробрасывает.
* Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
* textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
* не пробрасывает.
*/
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => {
const [text, setText] = React.useState<string>('');
const canSend = text.trim().length > 0 && !sending;
const [files, setFiles] = React.useState<File[]>([]);
const [filesOpen, setFilesOpen] = React.useState<boolean>(false);
// Сообщение из одних файлов — нормальный случай («вот скан»), поэтому пустой
// текст отправку не блокирует.
const canSend = (text.trim().length > 0 || files.length > 0) && !sending;
const send = (): void => {
if (!canSend) return;
onSend(text)
onSend(text, files)
.then((sent) => {
if (sent) setText('');
if (!sent) return;
setText('');
setFiles([]);
setFilesOpen(false);
})
.catch(() => undefined);
};
@@ -35,21 +46,46 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
};
return (
<div className={styles.composer}>
<textarea
className={styles.input}
value={text}
rows={2}
disabled={sending}
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
aria-label="Сообщение в обсуждение заявки"
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button variant="accent" disabled={!canSend} onClick={send}>
<Icon name="send" size="sm" />
{sending ? 'Отправка…' : 'Отправить'}
</Button>
<div className={styles.composerBlock}>
{filesOpen && (
<div className={styles.dropzone}>
<FileDropzone files={files} onChange={setFiles} />
<div className={styles.dropzoneHint}>
До {MAX_FILES_PER_MESSAGE} файлов, каждый не больше {MAX_FILE_SIZE_MB} МБ. Файлы
складываются в библиотеку «Файлы чата» и видны всем участникам заявки.
</div>
</div>
)}
<div className={styles.composer}>
<textarea
className={styles.input}
value={text}
rows={2}
disabled={sending}
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
aria-label="Сообщение в обсуждение заявки"
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className={styles.composerActions}>
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
пропадает с глаз, и легко отправить не то, что собирался. */}
<Button
variant="secondary"
disabled={sending}
aria-label="Прикрепить файлы"
onClick={() => setFilesOpen((open) => !open || files.length > 0)}
>
<Icon name="upload" size="sm" />
{files.length > 0 ? `Файлы (${files.length})` : 'Файл'}
</Button>
<Button variant="accent" disabled={!canSend} onClick={send}>
<Icon name="send" size="sm" />
{sending ? 'Отправка…' : 'Отправить'}
</Button>
</div>
</div>
</div>
);
};
@@ -1,5 +1,6 @@
import * as React from 'react';
import styles from './RequestChat.module.scss';
import { AttachmentList } from '../../ui';
import { IChatMessage } from '../models/IChatMessage';
interface IChatMessageProps {
@@ -28,8 +29,14 @@ export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
<span className={styles.sentAt}>{formatSentAt(message.createdAt)}</span>
</div>
{/* Переносы строк сохраняем стилем (white-space: pre-wrap), а не <br/> —
текст приходит от пользователя и в разметку его подставлять нельзя. */}
<div className={styles.text}>{message.text}</div>
текст приходит от пользователя и в разметку его подставлять нельзя.
Текста может не быть вовсе: сообщение из одних файлов. */}
{message.text && <div className={styles.text}>{message.text}</div>}
{message.attachments.length > 0 && (
<div className={styles.attachments}>
<AttachmentList items={message.attachments} />
</div>
)}
</div>
</li>
);
@@ -125,11 +125,46 @@
border-radius: $r-md;
}
// Вложения сообщения — отделяем от текста тонкой линией, чтобы список файлов
// не сливался с самим сообщением.
.attachments {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid $gray-200;
}
.composerBlock {
margin-top: 12px;
}
// Зона выбора файлов — над строкой ввода: то, что уйдёт с сообщением, видно
// раньше, чем кнопка «Отправить».
.dropzone {
margin-bottom: 10px;
padding: 12px;
background: $gray-50;
border: 1px solid $gray-200;
border-radius: $r-md;
}
.dropzoneHint {
margin-top: 8px;
font-size: $fs-caption;
color: $gray-500;
line-height: 1.45;
}
.composer {
display: flex;
align-items: flex-end;
gap: 10px;
margin-top: 12px;
}
.composerActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.input {
@@ -168,6 +203,10 @@
align-items: stretch;
}
.composerActions {
justify-content: flex-end;
}
.bubble {
max-width: 100%;
}
@@ -3,6 +3,7 @@ import { friendlyErrorMessage } from '../diagnostics';
import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage';
import { addChatMessage, getChatMessages } from './requestChat.service';
import { validateChatFiles } from './chatFiles.service';
/**
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
@@ -18,11 +19,12 @@ export interface IRequestChat {
sending: boolean;
error?: string;
/**
* Отправить сообщение. Пустой текст игнорируется. Возвращает true, если
* сообщение записано: по false поле ввода НЕ очищается, иначе набранный
* текст пропал бы вместе с неудачной отправкой.
* Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
* без текста и без файлов — игнорируется. Возвращает true, если сообщение
* записано: по false поле ввода и выбранные файлы НЕ сбрасываются, иначе
* набранное пропало бы вместе с неудачной отправкой.
*/
send: (text: string) => Promise<boolean>;
send: (text: string, files: File[]) => Promise<boolean>;
/** Перечитать переписку (кнопка «Обновить»). */
reload: () => void;
}
@@ -85,12 +87,21 @@ export function useRequestChat(
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
const send = React.useCallback(
async (text: string): Promise<boolean> => {
async (text: string, files: File[]): Promise<boolean> => {
const trimmed = text.trim();
if (!trimmed) return false;
// Сообщение из одних файлов — нормальный случай («вот скан»), поэтому
// пустой текст сам по себе отправку не отменяет.
if (!trimmed && files.length === 0) return false;
const filesError = validateChatFiles(files);
if (filesError) {
setError(filesError);
return false;
}
setSending(true);
try {
await addChatMessage(formKey, itemId, listId, trimmed);
await addChatMessage(formKey, itemId, listId, trimmed, files);
setError(undefined);
reload();
return true;
@@ -142,3 +142,14 @@ export function approvalListTitleForListId(id: RequestTypeId, listId: string): s
);
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
}
/**
* Библиотека документов, куда складываются файлы, прикреплённые к сообщениям
* чата заявки (shared/chat). Живёт на том же сайте данных (DATA_SITE_URL), что
* и списки заявок, и создана вручную — провижинер её не заводит.
*
* Не партиционируется в отличие от списков заявок: файлы лежат в подпапке на
* заявку (см. chatFiles.service.ts), доступ к ним идёт по прямому пути, а не
* через представление, поэтому порог в 5000 элементов на такой доступ не влияет.
*/
export const CHAT_FILES_LIBRARY_TITLE = 'Файлы чата';