perf(ui): виртуализация реестра задач и чата (@tanstack/react-virtual)
Шаг 1.3 плана production-готовности: - TaskRegistry: виртуальные строки с динамической высотой, порог 30 - TaskChat: виртуальные сообщения, порог 100, автоскролл/scrollToMessage - выделение/сортировка/поиск/inline-редакторы работают по полному массиву - check/build чисто, vitest 96/96
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user