refactor(chat): общие ChatComposer и useChatScroll для чата задачи и мессенджера
Пункты 1–2 плана де-дубликации чатов — больше не нужно «чинить и там и там»: - components/chat/ChatComposer.tsx: textarea + меню трёх точек (Прикрепить файл/ Создать опрос) + кнопка отправки, Enter-отправка (в PWA — только кнопкой), paste/drag&drop файлов, спиннер отправки; специфика клавиш родителя (упоминания, Escape) через onKeyDown + defaultPrevented-протокол; - components/chat/useChatScroll.ts: pin к низу при открытии (useLayoutEffect до paint), автоскролл у низа/своё, тихая доставка на скрытой вкладке, счётчик «↓ N новых», pin при возврате на вкладку; - TaskChat и useChatController/Chat.tsx переведены на оба; удалены дубли (~200 строк): свои scrollToBottom/atBottomRef/lastSeenLengthRef/эффекты, свои формы композера, drag&drop-стейты.
This commit is contained in:
@@ -16,11 +16,10 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEvents } from '@/hooks/useEvents';
|
||||
import { usePersistentDraft } from '@/hooks/usePersistentDraft';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Send, User, AtSign, Reply, X, Check, MoreVertical, Bot, Link as LinkIcon, BarChart2, Paperclip, Loader2, ChevronDown } from 'lucide-react';
|
||||
import { User, AtSign, Reply, X, Check, Bot, Link as LinkIcon, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isPWAInstalled, reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
|
||||
import { saveCachedMessages, getCachedMessages } from '@/lib/messageCache';
|
||||
@@ -28,12 +27,6 @@ import { PendingAttachmentList } from '@/components/chat/ChatAttachments';
|
||||
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer';
|
||||
import { type ReactionGroup } from '@/components/chat/ReactionBar';
|
||||
import { type PollData } from '@/components/chat/PollCard';
|
||||
@@ -41,6 +34,8 @@ import { PollCreateDialog } from '@/components/chat/PollCreateDialog';
|
||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||
import { formatDateHeader, groupByDate, useLastAppendedMessageId, flattenGroupedMessages, CHAT_VIRTUALIZATION_THRESHOLD, type ChatVirtualItem } from '@/components/chat/chat-utils';
|
||||
import { useMessagePolling } from '@/components/chat/useMessagePolling';
|
||||
import { useChatScroll } from '@/components/chat/useChatScroll';
|
||||
import { ChatComposer } from '@/components/chat/ChatComposer';
|
||||
|
||||
interface TaskChatProps {
|
||||
taskId: number;
|
||||
@@ -172,9 +167,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
||||
const [replyingTo, setReplyingTo] = useState<TaskMessage | null>(null);
|
||||
const [editingMessage, setEditingMessage] = useState<TaskMessage | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [detectedTaskLink, setDetectedTaskLink] = useState<{ formId: number; taskId: number } | null>(null);
|
||||
const [linkedTaskTitle, setLinkedTaskTitle] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
@@ -456,110 +449,17 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
||||
return Array.from(readMessageIds).includes(messageId);
|
||||
};
|
||||
|
||||
// Функция для прокрутки к последнему сообщению
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда
|
||||
const atBottomRef = useRef(true);
|
||||
// Счётчик «новых снизу» (плавающий индикатор как в Telegram): показывается,
|
||||
// когда пользователь листает историю и вниз приходят новые сообщения
|
||||
const [newBelowCount, setNewBelowCount] = useState(0);
|
||||
|
||||
// Отслеживаем, находится ли пользователь у низа списка
|
||||
useEffect(() => {
|
||||
const viewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (!viewport) return;
|
||||
const onScroll = () => {
|
||||
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
|
||||
atBottomRef.current = atBottom;
|
||||
// Доскроллил до низа — индикатор «новых» больше не нужен
|
||||
if (atBottom) setNewBelowCount(0);
|
||||
};
|
||||
viewport.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => viewport.removeEventListener('scroll', onScroll);
|
||||
}, [taskId, isLoading]);
|
||||
|
||||
// Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query).
|
||||
// useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был
|
||||
// у низа списка, а не вверху старой части (иначе пользователь видит «старые
|
||||
// сообщения → прыжок к последним»). Отложенная коррекция остаётся — после
|
||||
// измерения высот виртуализатором позиция может сместиться.
|
||||
useLayoutEffect(() => {
|
||||
if (!taskId) return;
|
||||
atBottomRef.current = true;
|
||||
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи
|
||||
setNewBelowCount(0);
|
||||
scrolledToFirstBatchRef.current = false;
|
||||
scrollToBottomRef.current(); // до paint — первый кадр уже у низа
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => scrollToBottomRef.current(), 150); // коррекция после measure
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
// Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования
|
||||
// (нет кэша react-query и localStorage — первый визит в этот чат):
|
||||
// до paint подменяем верх списка на низ.
|
||||
const scrolledToFirstBatchRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (isLoading || messages.length === 0) return;
|
||||
if (scrolledToFirstBatchRef.current) return;
|
||||
scrolledToFirstBatchRef.current = true;
|
||||
scrollToBottomRef.current();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, messages.length]);
|
||||
|
||||
// Автоскролл при новых сообщениях: только если пользователь у низа списка
|
||||
// или последнее сообщение — его собственное (как в мессенджере).
|
||||
// ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены,
|
||||
// atBottomRef держит позицию на момент ухода, и любой скролл в фоне «выбрасывает»
|
||||
// пользователя вниз при возврате. Вместо этого копим счётчик «новых снизу».
|
||||
const lastSeenLengthRef = useRef(0);
|
||||
const scrollToBottomRef = useRef(scrollToBottom);
|
||||
scrollToBottomRef.current = scrollToBottom;
|
||||
useEffect(() => {
|
||||
if (isLoading || messages.length === 0) return;
|
||||
const added = messages.length - lastSeenLengthRef.current;
|
||||
lastSeenLengthRef.current = messages.length;
|
||||
if (added <= 0) return;
|
||||
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||||
const last = messages[messages.length - 1];
|
||||
const isMine = last?.author?.id === user?.id;
|
||||
if (isHidden || (!atBottomRef.current && !isMine)) {
|
||||
// Пользователь читает историю (или вкладка скрыта) — не двигаем вид,
|
||||
// показываем индикатор новых сообщений
|
||||
if (!atBottomRef.current || isHidden) setNewBelowCount(c => c + added);
|
||||
return;
|
||||
}
|
||||
setNewBelowCount(0);
|
||||
// Небольшая задержка чтобы DOM успел обновиться
|
||||
setTimeout(() => scrollToBottomRef.current(), 100);
|
||||
}, [messages.length, isLoading, user?.id]);
|
||||
|
||||
// Возврат на вкладку: если пользователь был у низа — мгновенно прижимаем
|
||||
// к последнему сообщению; если листал историю — остаёмся на месте
|
||||
// (индикатор «новых» уже показан счётчиком выше)
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottomRef.current();
|
||||
setNewBelowCount(0);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, []);
|
||||
// Скролл чата (pin к низу, «↓ N новых», тихая доставка на скрытой вкладке,
|
||||
// первый кадр сразу у последнего сообщения) — общий хук обоих чатов
|
||||
const { newBelowCount, scrollToBottom, scrollToBottomAndClear } = useChatScroll({
|
||||
scrollRef: scrollAreaRef,
|
||||
virtualizer: shouldVirtualizeChat ? chatVirtualizer : null,
|
||||
itemCount: chatItems.length,
|
||||
messageCount: messages.length,
|
||||
lastMessageIsMine: messages[messages.length - 1]?.author?.id === user?.id,
|
||||
isLoading,
|
||||
resetKey: taskId,
|
||||
});
|
||||
|
||||
// Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID)
|
||||
const userMessageIds = user && messages.length > 0
|
||||
@@ -1218,25 +1118,12 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
||||
|
||||
// Обработка клавиш для навигации по предложениям
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (showSuggestions && mentionSuggestions?.length) {
|
||||
e.preventDefault();
|
||||
selectMention(mentionSuggestions[selectedSuggestionIndex]);
|
||||
return;
|
||||
}
|
||||
// В PWA/standalone-режиме Enter вставляет новую строку, отправка — только кнопкой
|
||||
if (isPWAInstalled()) return;
|
||||
// Защита от двойного Enter: если сообщение уже отправляется — игнорируем
|
||||
if (sendMessageMutation.isPending || isUploading) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Явная отправка по Enter (нужно для случая файл без текста)
|
||||
const hasContent = messageText.trim() || pendingFiles.length > 0;
|
||||
if (hasContent) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
// Отправка по Enter — внутри ChatComposer. Здесь только навигация по
|
||||
// подсказкам упоминаний: Enter выбирает подсказку (preventDefault — и
|
||||
// встроенная отправка композера пропускается), стрелки/Escape — навигация.
|
||||
if (e.key === 'Enter' && !e.shiftKey && showSuggestions && mentionSuggestions?.length) {
|
||||
e.preventDefault();
|
||||
selectMention(mentionSuggestions[selectedSuggestionIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1291,10 +1178,9 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
const handleFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files) return;
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)].slice(0, 10));
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
@@ -1627,10 +1513,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
||||
{newBelowCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewBelowCount(0);
|
||||
scrollToBottomRef.current();
|
||||
}}
|
||||
onClick={scrollToBottomAndClear}
|
||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
data-testid="new-messages-pill"
|
||||
>
|
||||
@@ -1788,90 +1671,22 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
||||
files={pendingFiles}
|
||||
onRemove={(i) => setPendingFiles(prev => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}}
|
||||
className={cn("flex gap-2 rounded-lg transition-colors", isDragOver && "ring-2 ring-primary/40 bg-primary/5")}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// Подсвечиваем зону дропа только для файлов, чтобы не конфликтовать с выделением текста
|
||||
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }}
|
||||
title="Перетащите файл в поле сообщения"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
className="hidden"
|
||||
onChange={e => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
<AutoResizeTextarea
|
||||
value={messageText}
|
||||
ref={textareaRef}
|
||||
placeholder="Написать сообщение..."
|
||||
disabled={sendMessageMutation.isPending}
|
||||
autoComplete="off"
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const files = e.clipboardData.files;
|
||||
if (files && files.length > 0) {
|
||||
e.preventDefault();
|
||||
handleFileSelect(files);
|
||||
}
|
||||
}}
|
||||
className="flex-1 resize-none min-h-[32px]"
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
data-testid="input-message"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title="Дополнительно"
|
||||
className="shrink-0"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
|
||||
<Paperclip className="w-4 h-4 mr-2" />
|
||||
Прикрепить файл
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setPollDialogOpen(true)}>
|
||||
<BarChart2 className="w-4 h-4 mr-2" />
|
||||
Создать опрос
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={sendMessageMutation.isPending || isUploading || (!messageText.trim() && pendingFiles.length === 0)}
|
||||
data-testid="button-send-message"
|
||||
>
|
||||
{(sendMessageMutation.isPending || isUploading)
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
{/* Общий композер обоих чатов (textarea + меню трёх точек + отправка).
|
||||
Специфика клавиш (выбор упоминания стрелками/Enter, Escape) — в
|
||||
handleKeyDown родителя; Enter-отправка — внутри композера. */}
|
||||
<ChatComposer
|
||||
value={messageText}
|
||||
onChange={handleTextChange}
|
||||
onSend={handleSendMessage}
|
||||
onFileSelect={handleFileSelect}
|
||||
onPollCreate={() => setPollDialogOpen(true)}
|
||||
hasPendingFiles={pendingFiles.length > 0}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isUploading={isUploading}
|
||||
disabled={sendMessageMutation.isPending}
|
||||
inputRef={textareaRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Диалог создания опроса — сначала создаёт сообщение, потом привязывает опрос */}
|
||||
|
||||
182
client/src/components/chat/ChatComposer.tsx
Normal file
182
client/src/components/chat/ChatComposer.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { MoreVertical, Paperclip, BarChart2, Send, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { isPWAInstalled } from '@/lib/pwa';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Общий композер чата (чат задачи и мессенджер) ────────────────────────────
|
||||
// Поле ввода + меню трёх точек (Прикрепить файл / Создать опрос) + кнопка
|
||||
// отправки. Единое поведение в обоих чатах:
|
||||
// - Enter отправляет (в PWA/standalone — только кнопкой, Enter переносит строку);
|
||||
// - вставка файлов из буфера (paste) и drag&drop с подсветкой зоны;
|
||||
// - спиннер на время отправки; кнопка неактивна без текста и файлов.
|
||||
// Логика отправки (мутация, упоминания, офлайн-очередь) остаётся у родителя —
|
||||
// композер вызывает onSend. Специфические клавиши (выбор упоминания стрелками/
|
||||
// Enter, Escape) обрабатывает родитель через onKeyDown — если он сделал
|
||||
// e.preventDefault(), встроенная отправка по Enter пропускается.
|
||||
|
||||
interface ChatComposerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Отправка сообщения (мутация/очередь у родителя). */
|
||||
onSend: () => void;
|
||||
/** Выбор файлов (из меню, paste или drop). */
|
||||
onFileSelect: (files: FileList | File[]) => void;
|
||||
/** Открыть диалог создания опроса (пункт меню скрыт, если не передан). */
|
||||
onPollCreate?: () => void;
|
||||
/** Есть прикреплённые, ещё не отправленные файлы — разрешает отправку без текста. */
|
||||
hasPendingFiles: boolean;
|
||||
isSending: boolean;
|
||||
isUploading?: boolean;
|
||||
/** Отключает поле ввода (например, на время отправки в чате задачи). */
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
inputRef?: React.Ref<HTMLTextAreaElement>;
|
||||
/** Специфичные клавиши родителя (упоминания и т.п.) — вызывается первым;
|
||||
если родитель сделал e.preventDefault(), встроенная отправка пропускается. */
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onFileSelect,
|
||||
onPollCreate,
|
||||
hasPendingFiles,
|
||||
isSending,
|
||||
isUploading = false,
|
||||
disabled = false,
|
||||
placeholder = 'Написать сообщение...',
|
||||
inputRef,
|
||||
onKeyDown,
|
||||
}: ChatComposerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const hasContent = value.trim().length > 0 || hasPendingFiles;
|
||||
const busy = isSending || isUploading;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Сначала специфика родителя (выбор упоминания, Escape и т.п.)
|
||||
onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// В PWA/standalone-режиме Enter переносит строку, отправка — только кнопкой
|
||||
if (isPWAInstalled()) return;
|
||||
if (busy) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (hasContent) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (hasContent && !busy) onSend();
|
||||
}}
|
||||
className={cn('flex gap-2 relative rounded-lg transition-colors', isDragOver && 'ring-2 ring-primary/40 bg-primary/5')}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
onFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// Подсвечиваем зону дропа только для файлов, чтобы не конфликтовать с выделением текста
|
||||
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }}
|
||||
title="Перетащите файл в поле сообщения"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) onFileSelect(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<AutoResizeTextarea
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const files = e.clipboardData.files;
|
||||
if (files && files.length > 0) {
|
||||
e.preventDefault();
|
||||
onFileSelect(files);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="flex-1 text-base md:text-sm resize-none min-h-[32px]"
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
data-testid="input-message"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 shrink-0"
|
||||
title="Дополнительно"
|
||||
aria-label="Дополнительно"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
|
||||
<Paperclip className="w-3.5 h-3.5 mr-2" />
|
||||
Прикрепить файл
|
||||
</DropdownMenuItem>
|
||||
{onPollCreate && (
|
||||
<DropdownMenuItem onClick={onPollCreate}>
|
||||
<BarChart2 className="w-3.5 h-3.5 mr-2" />
|
||||
Создать опрос
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
disabled={busy || !hasContent}
|
||||
title="Отправить сообщение"
|
||||
aria-label="Отправить сообщение"
|
||||
data-testid="button-send-message"
|
||||
>
|
||||
{busy
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Send className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { PollData } from '@/components/chat/PollCard';
|
||||
import type { Conversation, ConvMessage, ConvUser } from './chat-types';
|
||||
import { groupByDate, flattenGroupedMessages, useLastAppendedMessageId, CHAT_VIRTUALIZATION_THRESHOLD, type ChatVirtualItem } from './chat-utils';
|
||||
import { useMessagePolling } from './useMessagePolling';
|
||||
import { useChatScroll } from './useChatScroll';
|
||||
|
||||
// ── Chat page controller (вся логика страницы мессенджера) ─────────────────────
|
||||
|
||||
@@ -59,9 +60,7 @@ export function useChatController() {
|
||||
const [pressedButtons, setPressedButtons] = useState<Set<string>>(new Set());
|
||||
// File attachments
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
// Tracks IndexedDB queue item IDs that are still pending. Used by the select
|
||||
// function to keep optimistic messages after server refetches.
|
||||
const activeQueueIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -278,40 +277,17 @@ export function useChatController() {
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
// Актуальные параметры списка для использования внутри эффектов/колбэков
|
||||
const chatListStateRef = useRef({ shouldVirtualize: false, count: 0 });
|
||||
chatListStateRef.current = { shouldVirtualize, count: chatItems.length };
|
||||
|
||||
// Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда
|
||||
const atBottomRef = useRef(true);
|
||||
// Длина списка на момент последнего автоскролла — для подсчёта «новых снизу»
|
||||
const lastSeenLengthRef = useRef(0);
|
||||
// Счётчик «новых снизу» (плавающий индикатор как в Telegram): показывается,
|
||||
// когда пользователь листает историю и вниз приходят новые сообщения
|
||||
const [newBelowCount, setNewBelowCount] = useState(0);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (chatListStateRef.current.shouldVirtualize) {
|
||||
chatVirtualizer.scrollToIndex(chatListStateRef.current.count - 1, { align: 'end' });
|
||||
return;
|
||||
}
|
||||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [chatVirtualizer]);
|
||||
|
||||
// Отслеживаем, находится ли пользователь у низа списка
|
||||
useEffect(() => {
|
||||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (!viewport) return;
|
||||
const onScroll = () => {
|
||||
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
|
||||
atBottomRef.current = atBottom;
|
||||
// Доскроллил до низа — индикатор «новых» больше не нужен
|
||||
if (atBottom) setNewBelowCount(0);
|
||||
};
|
||||
viewport.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => viewport.removeEventListener('scroll', onScroll);
|
||||
}, [activeConvId, msgsLoading]);
|
||||
// Скролл чата (pin к низу, «↓ N новых», тихая доставка на скрытой вкладке,
|
||||
// первый кадр сразу у последнего сообщения) — общий хук обоих чатов
|
||||
const { newBelowCount, scrollToBottomAndClear } = useChatScroll({
|
||||
scrollRef,
|
||||
virtualizer: shouldVirtualize ? chatVirtualizer : null,
|
||||
itemCount: chatItems.length,
|
||||
messageCount: initialMessages.length,
|
||||
lastMessageIsMine: initialMessages[initialMessages.length - 1]?.author?.id === user?.id,
|
||||
isLoading: msgsLoading,
|
||||
resetKey: activeConvId,
|
||||
});
|
||||
|
||||
// Keep active queue ID refs in sync with IndexedDB. This lets the select
|
||||
// function below preserve optimistic messages after server refetches.
|
||||
@@ -701,80 +677,6 @@ export function useChatController() {
|
||||
enabled: !!activeConvId && !!user,
|
||||
});
|
||||
|
||||
// ── Auto-scroll ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Scroll to bottom whenever the active conversation changes (open from cache, new chat, etc.)
|
||||
// useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был
|
||||
// у низа списка, а не вверху старой части (иначе «старые сообщения → прыжок»).
|
||||
// Отложенная коррекция остаётся — после измерения высот виртуализатором.
|
||||
useLayoutEffect(() => {
|
||||
if (!activeConvId) return;
|
||||
atBottomRef.current = true;
|
||||
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене диалога
|
||||
setNewBelowCount(0);
|
||||
scrolledToFirstBatchRef.current = false;
|
||||
scrollToBottom(); // до paint — первый кадр уже у низа
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(scrollToBottom, 120); // коррекция после measure
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeConvId, scrollToBottom]);
|
||||
|
||||
// Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования
|
||||
// (нет кэша react-query и localStorage — первый визит в этот диалог).
|
||||
const scrolledToFirstBatchRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (msgsLoading || initialMessages.length === 0) return;
|
||||
if (scrolledToFirstBatchRef.current) return;
|
||||
scrolledToFirstBatchRef.current = true;
|
||||
scrollToBottom();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [msgsLoading, initialMessages.length]);
|
||||
|
||||
// Scroll to bottom when new messages arrive (tracked via initialMessages.length),
|
||||
// NOT when old messages are prepended via load-more. Скроллим только если
|
||||
// пользователь уже у низа списка или последнее сообщение — его собственное.
|
||||
// ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены,
|
||||
// atBottomRef держит позицию на момент ухода, и любой скролл в фоне «выбрасывает»
|
||||
// пользователя вниз при возврате. Вместо этого копим счётчик «новых снизу».
|
||||
useEffect(() => {
|
||||
if (msgsLoading || initialMessages.length === 0) return;
|
||||
const added = initialMessages.length - lastSeenLengthRef.current;
|
||||
lastSeenLengthRef.current = initialMessages.length;
|
||||
if (added <= 0) return;
|
||||
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||||
const last = initialMessages[initialMessages.length - 1];
|
||||
const isMine = last?.author?.id === user?.id;
|
||||
if (isHidden || (!atBottomRef.current && !isMine)) {
|
||||
// Пользователь читает историю (или вкладка скрыта) — не двигаем вид,
|
||||
// показываем индикатор новых сообщений
|
||||
if (!atBottomRef.current || isHidden) setNewBelowCount(c => c + added);
|
||||
return;
|
||||
}
|
||||
setNewBelowCount(0);
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(scrollToBottom, 80);
|
||||
});
|
||||
// initialMessages намеренно отслеживаем только по длине: prepend старых
|
||||
// сообщений через load-more не должен вызывать автоскролл
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialMessages.length, msgsLoading, scrollToBottom, user?.id]);
|
||||
|
||||
// Возврат на вкладку: если пользователь был у низа — мгновенно прижимаем
|
||||
// к последнему сообщению; если листал историю — остаёмся на месте
|
||||
// (индикатор «новых» уже показан счётчиком выше)
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottom();
|
||||
setNewBelowCount(0);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
// ── Mark as read when switching conversations ─────────────────────────────────
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
@@ -928,39 +830,9 @@ export function useChatController() {
|
||||
|
||||
// ── Send message ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
const handleFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files) return;
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)].slice(0, 10));
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
// Подсвечиваем зону дропа только для файлов, чтобы не конфликтовать с выделением текста
|
||||
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const files = e.clipboardData.files;
|
||||
if (files && files.length > 0) {
|
||||
e.preventDefault();
|
||||
handleFileSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMutation = useMutation({
|
||||
@@ -1074,8 +946,7 @@ export function useChatController() {
|
||||
|
||||
const members = activeConv?.members ?? [];
|
||||
|
||||
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
const handleMessageChange = (val: string) => {
|
||||
setMessageText(val);
|
||||
// Detect @word at end of text
|
||||
const match = val.match(/@(\w*)$/);
|
||||
@@ -1194,11 +1065,10 @@ export function useChatController() {
|
||||
setMentionSearch(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && mentionSearch === null) {
|
||||
// В PWA/standalone-режиме Enter вставляет новую строку, отправка — только кнопкой
|
||||
if (isPWAInstalled()) return;
|
||||
// Отправка по Enter — внутри ChatComposer; при открытых подсказках
|
||||
// упоминаний Enter блокируем, чтобы не отправить сообщение случайно
|
||||
if (e.key === 'Enter' && mentionSearch !== null) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1243,8 +1113,7 @@ export function useChatController() {
|
||||
msgsLoading,
|
||||
messages,
|
||||
newBelowCount,
|
||||
setNewBelowCount,
|
||||
scrollToBottom,
|
||||
scrollToBottomAndClear,
|
||||
hasMore,
|
||||
loadMoreMessages,
|
||||
isLoadingMore,
|
||||
@@ -1278,16 +1147,10 @@ export function useChatController() {
|
||||
handleSelectMention,
|
||||
pendingFiles,
|
||||
setPendingFiles,
|
||||
isDragOver,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
fileInputRef,
|
||||
messageText,
|
||||
handleMessageChange,
|
||||
handleKeyDown,
|
||||
handlePaste,
|
||||
pollDialogOpen,
|
||||
setPollDialogOpen,
|
||||
handleSend,
|
||||
|
||||
147
client/src/components/chat/useChatScroll.ts
Normal file
147
client/src/components/chat/useChatScroll.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback, type RefObject } from 'react';
|
||||
|
||||
// ── Общий скролл чата (чат задачи и мессенджер) ──────────────────────────────
|
||||
// Поведение «как в Telegram», раньше продублированное в TaskChat и
|
||||
// useChatController почти дословно:
|
||||
// - прижатие к низу при открытии (useLayoutEffect ДО paint — первый кадр сразу
|
||||
// у последнего сообщения, без видимого «пробега» сверху);
|
||||
// - автоскролл на новые сообщения только если пользователь у низа или сообщение
|
||||
// его собственное;
|
||||
// - на скрытой вкладке скролл НЕ трогаем (scroll-события заморожены, atBottomRef
|
||||
// держит позицию на момент ухода — иначе при возврате пользователя «выбрасывает»
|
||||
// вниз); вместо этого копим счётчик «↓ N новых»;
|
||||
// - возврат на вкладку: был у низа → pin к последнему; листал историю → остаётся
|
||||
// на месте, виден плавающий индикатор «↓ N новых».
|
||||
|
||||
interface UseChatScrollOptions {
|
||||
/** Ref на корень ScrollArea (внутри ищется [data-radix-scroll-area-viewport]). */
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
/** Виртуализатор длинного списка (null — обычный DOM-скролл). */
|
||||
virtualizer?: { scrollToIndex: (index: number, opts?: { align?: 'start' | 'center' | 'end' }) => void } | null;
|
||||
/** Кол-во элементов виртуализатора (chatItems.length). */
|
||||
itemCount: number;
|
||||
/** Кол-во сообщений (для отслеживания новых). */
|
||||
messageCount: number;
|
||||
/** Последнее сообщение — от текущего пользователя (тогда скроллим всегда). */
|
||||
lastMessageIsMine: boolean;
|
||||
isLoading: boolean;
|
||||
/** Ключ чата (taskId / activeConvId) — при смене скролл сбрасывается к низу. */
|
||||
resetKey: unknown;
|
||||
}
|
||||
|
||||
interface UseChatScrollReturn {
|
||||
/** Счётчик «новых снизу» для плавающего индикатора. */
|
||||
newBelowCount: number;
|
||||
/** Проскроллить к последнему сообщению (без сброса счётчика). */
|
||||
scrollToBottom: () => void;
|
||||
/** Проскроллить к последнему и сбросить счётчик (клик по индикатору). */
|
||||
scrollToBottomAndClear: () => void;
|
||||
}
|
||||
|
||||
export function useChatScroll({
|
||||
scrollRef,
|
||||
virtualizer,
|
||||
itemCount,
|
||||
messageCount,
|
||||
lastMessageIsMine,
|
||||
isLoading,
|
||||
resetKey,
|
||||
}: UseChatScrollOptions): UseChatScrollReturn {
|
||||
// Пользователь у низа списка — автоскролл разрешён только тогда
|
||||
const atBottomRef = useRef(true);
|
||||
// Длина списка на момент последнего автоскролла — для подсчёта «новых снизу»
|
||||
const lastSeenLengthRef = useRef(0);
|
||||
const scrolledToFirstBatchRef = useRef(false);
|
||||
const [newBelowCount, setNewBelowCount] = useState(0);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (virtualizer) {
|
||||
virtualizer.scrollToIndex(itemCount - 1, { align: 'end' });
|
||||
return;
|
||||
}
|
||||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null;
|
||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [virtualizer, itemCount, scrollRef]);
|
||||
|
||||
// Свежая реализация через ref — эффекты вызывают её без зависимостей на колбэк
|
||||
const scrollToBottomRef = useRef(scrollToBottom);
|
||||
scrollToBottomRef.current = scrollToBottom;
|
||||
|
||||
const scrollToBottomAndClear = useCallback(() => {
|
||||
setNewBelowCount(0);
|
||||
scrollToBottomRef.current();
|
||||
}, []);
|
||||
|
||||
// Отслеживаем, находится ли пользователь у низа списка; доскролл до низа
|
||||
// сам очищает индикатор «новых»
|
||||
useEffect(() => {
|
||||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null;
|
||||
if (!viewport) return;
|
||||
const onScroll = () => {
|
||||
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
|
||||
atBottomRef.current = atBottom;
|
||||
if (atBottom) setNewBelowCount(0);
|
||||
};
|
||||
viewport.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => viewport.removeEventListener('scroll', onScroll);
|
||||
}, [scrollRef, resetKey, isLoading]);
|
||||
|
||||
// Открытие чата (или смена resetKey): первый кадр сразу у низа — синхронный
|
||||
// скролл ДО paint + отложенная коррекция после измерения высот виртуализатором
|
||||
useLayoutEffect(() => {
|
||||
if (!resetKey) return;
|
||||
atBottomRef.current = true;
|
||||
lastSeenLengthRef.current = 0;
|
||||
scrolledToFirstBatchRef.current = false;
|
||||
setNewBelowCount(0);
|
||||
scrollToBottomRef.current();
|
||||
const t = setTimeout(() => scrollToBottomRef.current(), 120);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetKey]);
|
||||
|
||||
// Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования
|
||||
// (нет кэша react-query и localStorage — первый визит в этот чат)
|
||||
useLayoutEffect(() => {
|
||||
if (isLoading || messageCount === 0) return;
|
||||
if (scrolledToFirstBatchRef.current) return;
|
||||
scrolledToFirstBatchRef.current = true;
|
||||
scrollToBottomRef.current();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, messageCount]);
|
||||
|
||||
// Новые сообщения: скроллим только если пользователь у низа или сообщение
|
||||
// своё; на скрытой вкладке скролл не трогаем — копим счётчик «новых снизу»
|
||||
useEffect(() => {
|
||||
if (isLoading || messageCount === 0) return;
|
||||
const added = messageCount - lastSeenLengthRef.current;
|
||||
lastSeenLengthRef.current = messageCount;
|
||||
if (added <= 0) return;
|
||||
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||||
if (isHidden || (!atBottomRef.current && !lastMessageIsMine)) {
|
||||
if (!atBottomRef.current || isHidden) setNewBelowCount((c) => c + added);
|
||||
return;
|
||||
}
|
||||
setNewBelowCount(0);
|
||||
// Небольшая задержка чтобы DOM успел обновиться
|
||||
const t = setTimeout(() => scrollToBottomRef.current(), 100);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageCount, isLoading, lastMessageIsMine]);
|
||||
|
||||
// Возврат на вкладку: если пользователь был у низа — прижимаем к последнему
|
||||
// сообщению; если листал историю — остаёмся на месте (индикатор уже виден)
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottomRef.current();
|
||||
setNewBelowCount(0);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, []);
|
||||
|
||||
return { newBelowCount, scrollToBottom, scrollToBottomAndClear };
|
||||
}
|
||||
@@ -7,17 +7,11 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
|
||||
import {
|
||||
MessageSquare, Plus, Send, Reply, X, Check,
|
||||
Users, Link2, Paperclip, Loader2,
|
||||
ChevronLeft, ChevronDown, Bell, BellOff, BarChart2,
|
||||
MoreVertical,
|
||||
MessageSquare, Plus, Reply, X, Check,
|
||||
Users, Link2, Loader2,
|
||||
ChevronLeft, ChevronDown, Bell, BellOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChatComposer } from '@/components/chat/ChatComposer';
|
||||
import { PendingAttachmentList } from '@/components/chat/ChatAttachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PollCreateDialog } from '@/components/chat/PollCreateDialog';
|
||||
@@ -62,8 +56,7 @@ export default function ChatPage() {
|
||||
msgsLoading,
|
||||
messages,
|
||||
newBelowCount,
|
||||
setNewBelowCount,
|
||||
scrollToBottom,
|
||||
scrollToBottomAndClear,
|
||||
hasMore,
|
||||
loadMoreMessages,
|
||||
isLoadingMore,
|
||||
@@ -97,16 +90,10 @@ export default function ChatPage() {
|
||||
handleSelectMention,
|
||||
pendingFiles,
|
||||
setPendingFiles,
|
||||
isDragOver,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
fileInputRef,
|
||||
messageText,
|
||||
handleMessageChange,
|
||||
handleKeyDown,
|
||||
handlePaste,
|
||||
pollDialogOpen,
|
||||
setPollDialogOpen,
|
||||
handleSend,
|
||||
@@ -537,10 +524,7 @@ export default function ChatPage() {
|
||||
{newBelowCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewBelowCount(0);
|
||||
scrollToBottom();
|
||||
}}
|
||||
onClick={scrollToBottomAndClear}
|
||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
data-testid="new-messages-pill"
|
||||
>
|
||||
@@ -614,71 +598,21 @@ export default function ChatPage() {
|
||||
files={pendingFiles}
|
||||
onRemove={(i) => setPendingFiles(prev => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
<div
|
||||
className={cn("flex gap-2 relative rounded-lg transition-colors", isDragOver && "ring-2 ring-primary/40 bg-primary/5")}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
title="Перетащите файл в поле сообщения"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
className="hidden"
|
||||
onChange={e => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
<AutoResizeTextarea
|
||||
ref={inputRef}
|
||||
value={messageText}
|
||||
onChange={handleMessageChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Написать сообщение..."
|
||||
aria-label="Написать сообщение"
|
||||
className="flex-1 text-base md:text-sm resize-none min-h-[32px]"
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
/>
|
||||
{/* Кнопки «Прикрепить файл» и «Создать опрос» — за меню трёх
|
||||
точек, как в чате задачи (единый вид композера) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 shrink-0"
|
||||
title="Дополнительно"
|
||||
aria-label="Дополнительно"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
|
||||
<Paperclip className="w-3.5 h-3.5 mr-2" />
|
||||
Прикрепить файл
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setPollDialogOpen(true)}>
|
||||
<BarChart2 className="w-3.5 h-3.5 mr-2" />
|
||||
Создать опрос
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleSend}
|
||||
disabled={(!messageText.trim() && pendingFiles.length === 0) || sendMutation.isPending || isUploading}
|
||||
title="Отправить сообщение"
|
||||
aria-label="Отправить сообщение"
|
||||
>
|
||||
{(sendMutation.isPending || isUploading)
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Send className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Общий композер обоих чатов (textarea + меню трёх точек +
|
||||
отправка). Специфика клавиш (Escape при открытых упоминаниях)
|
||||
— в handleKeyDown родителя; Enter-отправка — внутри композера. */}
|
||||
<ChatComposer
|
||||
value={messageText}
|
||||
onChange={handleMessageChange}
|
||||
onSend={handleSend}
|
||||
onFileSelect={handleFileSelect}
|
||||
onPollCreate={() => setPollDialogOpen(true)}
|
||||
hasPendingFiles={pendingFiles.length > 0}
|
||||
isSending={sendMutation.isPending}
|
||||
isUploading={isUploading}
|
||||
inputRef={inputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user