perf(ui): виртуализация реестра задач и чата (@tanstack/react-virtual)

Шаг 1.3 плана production-готовности:
- TaskRegistry: виртуальные строки с динамической высотой, порог 30
- TaskChat: виртуальные сообщения, порог 100, автоскролл/scrollToMessage
- выделение/сортировка/поиск/inline-редакторы работают по полному массиву
- check/build чисто, vitest 96/96
This commit is contained in:
2026-09-07 23:46:42 +03:00
parent 667a321341
commit 6eaca79cc6
5 changed files with 495 additions and 312 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useForm } from 'react-hook-form';
import { useLocation } from 'wouter';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -186,6 +187,15 @@ const formatDateHeader = (date: Date) => {
}
};
// Порог виртуализации чата: при меньшем числе элементов (сообщения + разделители дат)
// рендерим как обычно, чтобы не менять поведение коротких чатов
const CHAT_VIRTUALIZATION_THRESHOLD = 100;
// Элемент виртуализированного списка чата: разделитель даты или сообщение
type ChatListItem =
| { type: 'header'; key: string; date: Date }
| { type: 'message'; key: string; message: TaskMessage };
const TaskChat = ({ taskId }: TaskChatProps) => {
const { toast } = useToast();
const { user } = useAuth();
@@ -359,6 +369,28 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
const messages: TaskMessage[] = (messagesData as any)?.messages || [];
const groupedMessages = groupMessagesByDate(messages);
// Плоский список для виртуализации: разделители дат + сообщения
const chatItems: ChatListItem[] = [];
groupedMessages.forEach((group, groupIndex) => {
chatItems.push({ type: 'header', key: `header-${groupIndex}-${format(group.date, 'yyyy-MM-dd')}`, date: group.date });
group.messages.forEach(message => {
chatItems.push({ type: 'message', key: `msg-${message.id}`, message });
});
});
// Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan.
// Динамическая высота измеряется через measureElement (ResizeObserver).
const shouldVirtualizeChat = chatItems.length > CHAT_VIRTUALIZATION_THRESHOLD;
const chatVirtualizer = useVirtualizer({
count: chatItems.length,
getScrollElement: () =>
(scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null) ?? null,
estimateSize: (index) => (chatItems[index]?.type === 'header' ? 28 : 64),
overscan: 10,
getItemKey: (index) => chatItems[index]?.key ?? index,
enabled: shouldVirtualizeChat,
});
// Загрузка реакций и опросов (batch)
useQuery({
queryKey: ['/api/tasks', taskId, 'reactions'],
@@ -388,17 +420,35 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Функция для скролла к оригинальному сообщению
const scrollToMessage = (messageId: number) => {
const highlightElement = (el: Element) => {
// Подсвечиваем сообщение на короткое время с помощью стилей
el.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
setTimeout(() => {
el.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
}, 2000);
};
const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
if (messageElement) {
messageElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
messageElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// Подсвечиваем сообщение на короткое время с помощью стилей
messageElement.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
setTimeout(() => {
messageElement.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
}, 2000);
highlightElement(messageElement);
return;
}
// Виртуализированный список: целевое сообщение может быть не отрендерено —
// прокручиваем к его индексу через virtualizer и подсвечиваем после рендера
if (shouldVirtualizeChat) {
const index = chatItems.findIndex(item => item.type === 'message' && item.message.id === messageId);
if (index >= 0) {
chatVirtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' });
setTimeout(() => {
const el = document.querySelector(`[data-message-id="${messageId}"]`);
if (el) highlightElement(el);
}, 400);
}
}
};
@@ -432,6 +482,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Функция для прокрутки к последнему сообщению
const scrollToBottom = () => {
if (shouldVirtualizeChat) {
// В виртуализированном списке прокручиваем к последнему элементу через virtualizer
chatVirtualizer.scrollToIndex(chatItems.length - 1, { align: 'end' });
return;
}
const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (scrollElement) {
scrollElement.scrollTop = scrollElement.scrollHeight;
@@ -1318,6 +1373,308 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
return <SafeHtmlRenderer html={message} />;
};
// Рендер одного сообщения (общий для обычного и виртуализированного списка)
const renderMessage = (message: TaskMessage) => {
const isMyMessage = user && message.author && message.author.id === user.id;
return (
<div key={message.id} className="group py-px" data-testid={`message-container-${message.id}`} data-message-id={message.id}>
{isMyMessage ? (
// Мое сообщение - справа без аватара как в WhatsApp
<div className="flex justify-end min-w-0">
<div className="max-w-[85%] md:max-w-[75%] min-w-0">
<div className="bg-primary text-primary-foreground rounded-[4px] px-2 py-1 relative [&_*]:m-0 max-w-full min-w-0 [overflow-wrap:anywhere]" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }}>
{/* Показываем информацию о родительском сообщении если это ответ */}
{message.replyToMessage && (
<div
className="bg-black/10 border-l-2 border-primary-foreground/30 pl-2 py-1 mb-1 rounded-r text-xs cursor-pointer hover:bg-black/20 transition-colors"
onClick={() => {
if (message.replyToMessage?.id) {
scrollToMessage(message.replyToMessage.id);
}
}}
>
<div className="text-primary-foreground/70 mb-1">
Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
</div>
<div className="text-primary-foreground/70 truncate">
{message.replyToMessage.message.length > 50
? message.replyToMessage.message.substring(0, 50) + '...'
: message.replyToMessage.message}
</div>
</div>
)}
{/* Текст сообщения или форма редактирования */}
{editingMessage?.id === message.id ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full min-h-[60px] p-2 text-sm bg-white/10 text-primary-foreground rounded-lg border-0 focus:ring-1 focus:ring-white/30 resize-none placeholder:text-primary-foreground/50 outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// В PWA/standalone-режиме Enter вставляет новую строку, сохранение — только кнопкой
if (isPWAInstalled()) return;
e.preventDefault();
saveEdit();
}
if (e.key === 'Escape') {
cancelEdit();
}
}}
autoFocus
placeholder="Редактировать сообщение..."
data-testid={`edit-input-${message.id}`}
/>
<div className="flex items-center justify-end gap-1.5">
<span className="text-[10px] text-primary-foreground/50 mr-auto">
Enter — сохранить, Esc — отмена
</span>
<button
type="button"
className="h-6 w-6 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 text-primary-foreground/70 hover:text-primary-foreground transition-colors"
onClick={cancelEdit}
title="Отмена"
data-testid={`cancel-edit-button-${message.id}`}
>
<X className="w-3 h-3" />
</button>
<button
type="button"
className="h-6 w-6 rounded-full flex items-center justify-center bg-green-500/80 hover:bg-green-500 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={saveEdit}
disabled={editMessageMutation.isPending || !editText.trim()}
title="Сохранить"
data-testid={`save-edit-button-${message.id}`}
>
<Check className="w-3 h-3" />
</button>
</div>
</div>
) : (
<div className="text-sm break-words min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }} data-testid={`message-text-${message.id}`}>
{renderMessageWithMentions(message.message, message.mentionedUserIds)}
</div>
)}
{/* Время и галочка прочтения внизу справа */}
<div className="flex items-center justify-end gap-1 mt-0.5">
{message.pending && (
<span className="text-[10px] text-primary-foreground/60 italic mr-0.5">
ожидает отправки
</span>
)}
<span className="text-xs text-primary-foreground/70">
{format(new Date(message.createdAt), 'HH:mm')}
</span>
{message.pending ? (
<Clock
className="w-3 h-3 text-primary-foreground/50"
data-testid={`pending-status-${message.id}`}
/>
) : (
<Check
className={`w-3 h-3 ${isMessageRead(message.id) ? 'text-green-400' : 'text-primary-foreground/50'}`}
data-testid={`read-status-${message.id}`}
/>
)}
</div>
{/* Кнопки действий для моих сообщений - слева от пузыря */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 -left-14 flex gap-0.5">
<Button
variant="ghost"
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground bg-background border border-border rounded"
onClick={() => startReply(message)}
title="Ответить"
data-testid={`reply-btn-${message.id}`}
>
<Reply className="w-3 h-3" />
</Button>
<Button
variant="ghost"
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground bg-background border border-border rounded"
onClick={() => startEdit(message)}
title="Редактировать"
data-testid={`edit-btn-${message.id}`}
>
<Edit className="w-3 h-3" />
</Button>
</div>
</div>
{message.attachments && message.attachments.length > 0 && (
<AttachmentDisplay attachments={message.attachments} isOwn={true} />
)}
{/* Опрос прикреплённый к сообщению */}
{pollsMap[message.id] && (
<PollCard
poll={pollsMap[message.id]}
onPollChange={(p) => setPollsMap(prev => ({ ...prev, [message.id]: p }))}
isOwn={true}
/>
)}
{/* Реакции */}
<div className="flex justify-end">
<ReactionBar
messageId={message.id}
messageType="task"
reactions={reactionsMap[message.id] ?? []}
onReactionsChange={(r) => setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
/>
</div>
</div>
</div>
) : message.messageType === 'bot' && message.bot ? (
// Сообщение от бота - слева с иконкой бота
<div className="flex gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center flex-shrink-0">
{message.bot.avatarUrl ? (
<img
src={message.bot.avatarUrl}
alt={message.bot.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<Bot className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0 max-w-[85%] md:max-w-[75%]">
<div className="flex items-center gap-2 mb-0.5">
<div className="flex-1 min-w-0">
<button
className="text-sm font-medium truncate hover:underline cursor-pointer text-left"
data-testid={`message-author-${message.id}`}
onClick={() => message.bot && openBotDirectMutation.mutate(message.bot.id)}
>
{message.bot.name}
</button>
</div>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{format(new Date(message.createdAt), 'HH:mm')}
</span>
</div>
{/* Текст сообщения от бота */}
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/20 rounded-lg px-3 py-2 max-w-full min-w-0 [overflow-wrap:anywhere]">
<div className="text-sm break-words min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] prose prose-sm dark:prose-invert max-w-none [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:text-xs [&_ul]:pl-4 [&_ul]:list-disc [&_ol]:pl-4 [&_ol]:list-decimal [&_li]:my-0 [&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold [&_h3]:text-sm [&_h3]:font-semibold [&_p]:my-0.5 [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-muted-foreground" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }} data-testid={`message-text-${message.id}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.message}</ReactMarkdown>
</div>
{/* Кнопки бота */}
{message.botButtons && message.botButtons.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-2 border-t border-blue-500/20">
{message.botButtons.map((button, btnIndex) => (
<Button
key={btnIndex}
size="sm"
variant={button.style === 'destructive' ? 'destructive' : button.style === 'secondary' ? 'secondary' : 'default'}
className={button.style === 'primary' ? 'bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600' : ''}
onClick={() => botCallbackMutation.mutate({
messageId: message.id,
callbackData: button.callbackData
})}
disabled={botCallbackMutation.isPending}
data-testid={`bot-button-${message.id}-${btnIndex}`}
>
{button.text}
</Button>
))}
</div>
)}
</div>
</div>
</div>
) : (
// Сообщение от другого пользователя - слева с аватаром
<div className="flex gap-2">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0 max-w-[85%] md:max-w-[75%]">
{/* Имя + Время на одной строке с обрезкой */}
<div className="flex items-center gap-2 mb-0.5">
<div className="flex-1 min-w-0">
<button
className="text-sm font-medium truncate hover:underline cursor-pointer text-left"
data-testid={`message-author-${message.id}`}
onClick={() => message.author && openDirectMutation.mutate(message.author.id)}
>
{`${message.author?.firstName || 'Система'} ${message.author?.middleName || ''} ${message.author?.lastName || ''}`.trim()}
</button>
{message.messageType !== 'comment' && (
<Badge variant="secondary" className="text-xs ml-1 inline">
{message.messageType === 'system' ? 'Система' : 'Статус'}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{format(new Date(message.createdAt), 'HH:mm')}
</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => startReply(message)}
data-testid={`reply-button-${message.id}`}
>
<Reply className="w-3 h-3" />
</Button>
</div>
</div>
{/* Показываем информацию о родительском сообщении если это ответ */}
{message.replyToMessage && (
<div
className="bg-muted/50 border-l-2 border-primary/30 pl-2 py-1 mb-1 rounded-r cursor-pointer hover:bg-muted/70 transition-colors"
onClick={() => {
if (message.replyToMessage?.id) {
scrollToMessage(message.replyToMessage.id);
}
}}
>
<div className="text-xs text-muted-foreground mb-1">
Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
</div>
<div className="text-xs text-muted-foreground truncate">
{message.replyToMessage.message.length > 50
? message.replyToMessage.message.substring(0, 50) + '...'
: message.replyToMessage.message}
</div>
</div>
)}
{/* Текст сообщения (без редактирования для чужих сообщений) */}
<div className="bg-muted/30 rounded-[4px] px-2 py-0.5 [&_*]:m-0 select-text max-w-full min-w-0 [overflow-wrap:anywhere]">
<div className="text-sm break-words min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }} data-testid={`message-text-${message.id}`}>
{renderMessageWithMentions(message.message, message.mentionedUserIds)}
</div>
</div>
{message.attachments && message.attachments.length > 0 && (
<AttachmentDisplay attachments={message.attachments} isOwn={false} />
)}
{/* Опрос прикреплённый к сообщению */}
{pollsMap[message.id] && (
<PollCard
poll={pollsMap[message.id]}
onPollChange={(p) => setPollsMap(prev => ({ ...prev, [message.id]: p }))}
isOwn={false}
/>
)}
{/* Реакции */}
<ReactionBar
messageId={message.id}
messageType="task"
reactions={reactionsMap[message.id] ?? []}
onReactionsChange={(r) => setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
/>
</div>
</div>
)}
</div>
);
};
if (taskId === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -1341,6 +1698,44 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
<div className="flex items-center justify-center py-4">
<p className="text-sm text-muted-foreground">Пока нет сообщений</p>
</div>
) : shouldVirtualizeChat ? (
// Виртуализированный список: рендерятся только видимые элементы.
// Разделители дат здесь НЕ sticky (абсолютное позиционирование строк).
<div
className="pr-2 pl-2 overflow-x-hidden"
style={{ height: `${chatVirtualizer.getTotalSize()}px`, position: 'relative' }}
>
{chatVirtualizer.getVirtualItems().map(virtualItem => {
const item = chatItems[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={chatVirtualizer.measureElement}
className="pb-0.5"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item.type === 'header' ? (
<div className="bg-background py-0.5">
<div className="text-center">
<span className="bg-muted px-2 py-1 rounded-full text-xs text-muted-foreground">
{formatDateHeader(item.date)}
</span>
</div>
</div>
) : (
renderMessage(item.message)
)}
</div>
);
})}
</div>
) : (
<div className="flex flex-col gap-1 pr-2 pl-2 overflow-x-hidden">
{groupedMessages.map((group, groupIndex) => (
@@ -1356,307 +1751,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
{/* Сообщения в группе */}
<div className="flex flex-col gap-0.5 overflow-x-hidden">
{group.messages.map((message) => {
const isMyMessage = user && message.author && message.author.id === user.id;
return (
<div key={message.id} className="group py-px" data-testid={`message-container-${message.id}`} data-message-id={message.id}>
{isMyMessage ? (
// Мое сообщение - справа без аватара как в WhatsApp
<div className="flex justify-end min-w-0">
<div className="max-w-[85%] md:max-w-[75%] min-w-0">
<div className="bg-primary text-primary-foreground rounded-[4px] px-2 py-1 relative [&_*]:m-0 max-w-full min-w-0 [overflow-wrap:anywhere]" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }}>
{/* Показываем информацию о родительском сообщении если это ответ */}
{message.replyToMessage && (
<div
className="bg-black/10 border-l-2 border-primary-foreground/30 pl-2 py-1 mb-1 rounded-r text-xs cursor-pointer hover:bg-black/20 transition-colors"
onClick={() => {
if (message.replyToMessage?.id) {
scrollToMessage(message.replyToMessage.id);
}
}}
>
<div className="text-primary-foreground/70 mb-1">
Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
</div>
<div className="text-primary-foreground/70 truncate">
{message.replyToMessage.message.length > 50
? message.replyToMessage.message.substring(0, 50) + '...'
: message.replyToMessage.message}
</div>
</div>
)}
{/* Текст сообщения или форма редактирования */}
{editingMessage?.id === message.id ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full min-h-[60px] p-2 text-sm bg-white/10 text-primary-foreground rounded-lg border-0 focus:ring-1 focus:ring-white/30 resize-none placeholder:text-primary-foreground/50 outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
// В PWA/standalone-режиме Enter вставляет новую строку, сохранение — только кнопкой
if (isPWAInstalled()) return;
e.preventDefault();
saveEdit();
}
if (e.key === 'Escape') {
cancelEdit();
}
}}
autoFocus
placeholder="Редактировать сообщение..."
data-testid={`edit-input-${message.id}`}
/>
<div className="flex items-center justify-end gap-1.5">
<span className="text-[10px] text-primary-foreground/50 mr-auto">
Enter — сохранить, Esc — отмена
</span>
<button
type="button"
className="h-6 w-6 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 text-primary-foreground/70 hover:text-primary-foreground transition-colors"
onClick={cancelEdit}
title="Отмена"
data-testid={`cancel-edit-button-${message.id}`}
>
<X className="w-3 h-3" />
</button>
<button
type="button"
className="h-6 w-6 rounded-full flex items-center justify-center bg-green-500/80 hover:bg-green-500 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={saveEdit}
disabled={editMessageMutation.isPending || !editText.trim()}
title="Сохранить"
data-testid={`save-edit-button-${message.id}`}
>
<Check className="w-3 h-3" />
</button>
</div>
</div>
) : (
<div className="text-sm break-words min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }} data-testid={`message-text-${message.id}`}>
{renderMessageWithMentions(message.message, message.mentionedUserIds)}
</div>
)}
{/* Время и галочка прочтения внизу справа */}
<div className="flex items-center justify-end gap-1 mt-0.5">
{message.pending && (
<span className="text-[10px] text-primary-foreground/60 italic mr-0.5">
ожидает отправки
</span>
)}
<span className="text-xs text-primary-foreground/70">
{format(new Date(message.createdAt), 'HH:mm')}
</span>
{message.pending ? (
<Clock
className="w-3 h-3 text-primary-foreground/50"
data-testid={`pending-status-${message.id}`}
/>
) : (
<Check
className={`w-3 h-3 ${isMessageRead(message.id) ? 'text-green-400' : 'text-primary-foreground/50'}`}
data-testid={`read-status-${message.id}`}
/>
)}
</div>
{/* Кнопки действий для моих сообщений - слева от пузыря */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 -left-14 flex gap-0.5">
<Button
variant="ghost"
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground bg-background border border-border rounded"
onClick={() => startReply(message)}
title="Ответить"
data-testid={`reply-btn-${message.id}`}
>
<Reply className="w-3 h-3" />
</Button>
<Button
variant="ghost"
className="h-5 w-5 p-0 text-muted-foreground hover:text-foreground bg-background border border-border rounded"
onClick={() => startEdit(message)}
title="Редактировать"
data-testid={`edit-btn-${message.id}`}
>
<Edit className="w-3 h-3" />
</Button>
</div>
</div>
{message.attachments && message.attachments.length > 0 && (
<AttachmentDisplay attachments={message.attachments} isOwn={true} />
)}
{/* Опрос прикреплённый к сообщению */}
{pollsMap[message.id] && (
<PollCard
poll={pollsMap[message.id]}
onPollChange={(p) => setPollsMap(prev => ({ ...prev, [message.id]: p }))}
isOwn={true}
/>
)}
{/* Реакции */}
<div className="flex justify-end">
<ReactionBar
messageId={message.id}
messageType="task"
reactions={reactionsMap[message.id] ?? []}
onReactionsChange={(r) => setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
/>
</div>
</div>
</div>
) : message.messageType === 'bot' && message.bot ? (
// Сообщение от бота - слева с иконкой бота
<div className="flex gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center flex-shrink-0">
{message.bot.avatarUrl ? (
<img
src={message.bot.avatarUrl}
alt={message.bot.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<Bot className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0 max-w-[85%] md:max-w-[75%]">
<div className="flex items-center gap-2 mb-0.5">
<div className="flex-1 min-w-0">
<button
className="text-sm font-medium truncate hover:underline cursor-pointer text-left"
data-testid={`message-author-${message.id}`}
onClick={() => message.bot && openBotDirectMutation.mutate(message.bot.id)}
>
{message.bot.name}
</button>
</div>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{format(new Date(message.createdAt), 'HH:mm')}
</span>
</div>
{/* Текст сообщения от бота */}
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/20 rounded-lg px-3 py-2 max-w-full min-w-0 [overflow-wrap:anywhere]">
<div className="text-sm break-words min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] prose prose-sm dark:prose-invert max-w-none [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:text-xs [&_ul]:pl-4 [&_ul]:list-disc [&_ol]:pl-4 [&_ol]:list-decimal [&_li]:my-0 [&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold [&_h3]:text-sm [&_h3]:font-semibold [&_p]:my-0.5 [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-muted-foreground" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }} data-testid={`message-text-${message.id}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.message}</ReactMarkdown>
</div>
{/* Кнопки бота */}
{message.botButtons && message.botButtons.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3 pt-2 border-t border-blue-500/20">
{message.botButtons.map((button, btnIndex) => (
<Button
key={btnIndex}
size="sm"
variant={button.style === 'destructive' ? 'destructive' : button.style === 'secondary' ? 'secondary' : 'default'}
className={button.style === 'primary' ? 'bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600' : ''}
onClick={() => botCallbackMutation.mutate({
messageId: message.id,
callbackData: button.callbackData
})}
disabled={botCallbackMutation.isPending}
data-testid={`bot-button-${message.id}-${btnIndex}`}
>
{button.text}
</Button>
))}
</div>
)}
</div>
</div>
</div>
) : (
// Сообщение от другого пользователя - слева с аватаром
<div className="flex gap-2">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0 max-w-[85%] md:max-w-[75%]">
{/* Имя + Время на одной строке с обрезкой */}
<div className="flex items-center gap-2 mb-0.5">
<div className="flex-1 min-w-0">
<button
className="text-sm font-medium truncate hover:underline cursor-pointer text-left"
data-testid={`message-author-${message.id}`}
onClick={() => message.author && openDirectMutation.mutate(message.author.id)}
>
{`${message.author?.firstName || 'Система'} ${message.author?.middleName || ''} ${message.author?.lastName || ''}`.trim()}
</button>
{message.messageType !== 'comment' && (
<Badge variant="secondary" className="text-xs ml-1 inline">
{message.messageType === 'system' ? 'Система' : 'Статус'}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{format(new Date(message.createdAt), 'HH:mm')}
</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => startReply(message)}
data-testid={`reply-button-${message.id}`}
>
<Reply className="w-3 h-3" />
</Button>
</div>
</div>
{/* Показываем информацию о родительском сообщении если это ответ */}
{message.replyToMessage && (
<div
className="bg-muted/50 border-l-2 border-primary/30 pl-2 py-1 mb-1 rounded-r cursor-pointer hover:bg-muted/70 transition-colors"
onClick={() => {
if (message.replyToMessage?.id) {
scrollToMessage(message.replyToMessage.id);
}
}}
>
<div className="text-xs text-muted-foreground mb-1">
Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
</div>
<div className="text-xs text-muted-foreground truncate">
{message.replyToMessage.message.length > 50
? message.replyToMessage.message.substring(0, 50) + '...'
: message.replyToMessage.message}
</div>
</div>
)}
{/* Текст сообщения (без редактирования для чужих сообщений) */}
<div className="bg-muted/30 rounded-[4px] px-2 py-0.5 [&_*]:m-0 select-text max-w-full min-w-0 [overflow-wrap:anywhere]">
<div className="text-sm break-words min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]" style={{ userSelect: 'text', WebkitUserSelect: 'text', msUserSelect: 'text' }} data-testid={`message-text-${message.id}`}>
{renderMessageWithMentions(message.message, message.mentionedUserIds)}
</div>
</div>
{message.attachments && message.attachments.length > 0 && (
<AttachmentDisplay attachments={message.attachments} isOwn={false} />
)}
{/* Опрос прикреплённый к сообщению */}
{pollsMap[message.id] && (
<PollCard
poll={pollsMap[message.id]}
onPollChange={(p) => setPollsMap(prev => ({ ...prev, [message.id]: p }))}
isOwn={false}
/>
)}
{/* Реакции */}
<ReactionBar
messageId={message.id}
messageType="task"
reactions={reactionsMap[message.id] ?? []}
onReactionsChange={(r) => setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
/>
</div>
</div>
)}
</div>
);
})}
{group.messages.map((message) => renderMessage(message))}
</div>
</div>
))}

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useQueries } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -261,6 +262,11 @@ const REGISTRY_INLINE_TYPES = new Set([
// Типы полей, которые переключаются по одиночному клику
const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']);
// Порог виртуализации реестра: при меньшем числе строк рендерим как обычно
const REGISTRY_VIRTUALIZATION_THRESHOLD = 30;
// Оценка высоты строки до измерения (h-10 по UI Style Guide)
const REGISTRY_ROW_ESTIMATED_HEIGHT = 40;
const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, anchorTaskId, onToggleSelected, onToggleSelectAll, onSelectRange, onSetAnchorTaskId, onClearSelection, onRequestBulkDelete }: TaskRegistryProps) => {
const { toast } = useToast();
@@ -547,6 +553,31 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
});
}, [tasks, searches, inputValue, showCompleted, statusMap, taskMatchesSearch]);
// Виртуализация строк реестра: рендерятся только видимые строки + overscan.
// Фильтрация/сортировка/выделение работают по полному filteredTasks и не затронуты.
const tableScrollRef = useRef<HTMLDivElement>(null);
const shouldVirtualize = filteredTasks.length > REGISTRY_VIRTUALIZATION_THRESHOLD;
const rowVirtualizer = useVirtualizer({
count: filteredTasks.length,
getScrollElement: () => tableScrollRef.current,
estimateSize: () => REGISTRY_ROW_ESTIMATED_HEIGHT,
overscan: 10,
getItemKey: (index) => filteredTasks[index]?.id ?? index,
enabled: shouldVirtualize,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualPaddingTop = shouldVirtualize && virtualRows.length > 0 ? virtualRows[0].start : 0;
const virtualPaddingBottom =
shouldVirtualize && virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0;
// Строки к рендеру: при виртуализации — только видимое окно, иначе все
const rowsToRender: Array<{ task: TaskData; virtualIndex: number | null }> = shouldVirtualize
? virtualRows.map(v => ({ task: filteredTasks[v.index], virtualIndex: v.index }))
: filteredTasks.map(task => ({ task, virtualIndex: null }));
// Полное число колонок (для colSpan padding-строк виртуализации)
const totalColumnCount = (isAdmin ? 1 : 0) + columnOrder.filter(key => visibleColumns[key]).length;
const fetchTaskColumnSuggestions = useCallback((column: string) => async (search: string): Promise<string[]> => {
const params = new URLSearchParams({ column, search });
const context = Object.fromEntries(Object.entries(filters).filter(([k, v]) => k !== column && !!v));
@@ -815,7 +846,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
</div>
{/* Таблица задач */}
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-auto" ref={tableScrollRef}>
{filteredTasks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{tasks.length === 0 ? (
@@ -937,7 +968,13 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
</TableRow>
</TableHeader>
<TableBody>
{filteredTasks.map((task) => {
{/* Верхняя padding-строка виртуализации: замещает невидимые строки выше окна */}
{virtualPaddingTop > 0 && (
<tr aria-hidden="true">
<td colSpan={totalColumnCount} style={{ height: `${virtualPaddingTop}px` }} className="p-0 border-0" />
</tr>
)}
{rowsToRender.map(({ task, virtualIndex }) => {
const taskStatus = statusMap.get(task.currentStatusId);
const isCompleted = task.isCompleted || taskStatus?.isFinal || false;
const isOverdue = !isCompleted && !!task.dueDate && new Date(task.dueDate) < new Date();
@@ -945,6 +982,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
return (
<TableRow
key={task.id}
ref={virtualIndex !== null ? rowVirtualizer.measureElement : undefined}
data-index={virtualIndex ?? undefined}
data-testid={`row-task-${task.id}`}
className={`${isCompleted ? 'bg-muted opacity-70' : ''} ${isOverdue ? 'border-l-2 border-l-red-500 bg-red-50 dark:bg-red-950/20' : ''} ${isAdmin ? 'cursor-pointer' : ''}`}
onClick={(e) => handleRowClick(task, e)}
@@ -1166,6 +1205,12 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
</TableRow>
);
})}
{/* Нижняя padding-строка виртуализации: замещает невидимые строки ниже окна */}
{virtualPaddingBottom > 0 && (
<tr aria-hidden="true">
<td colSpan={totalColumnCount} style={{ height: `${virtualPaddingBottom}px` }} className="p-0 border-0" />
</tr>
)}
</TableBody>
</Table>
</div>