From 06a2794090f27719011e8f014205f339be33116b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 30 Sep 2026 14:37:31 +0300 Subject: [PATCH] =?UTF-8?q?refactor(chat):=20=D0=BE=D0=B1=D1=89=D0=B8?= =?UTF-8?q?=D0=B5=20ChatComposer=20=D0=B8=20useChatScroll=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=87=D0=B0=D1=82=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=20=D0=B8=20=D0=BC=D0=B5=D1=81=D1=81=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункты 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-стейты. --- client/src/components/TaskChat.tsx | 261 +++--------------- client/src/components/chat/ChatComposer.tsx | 182 ++++++++++++ .../src/components/chat/useChatController.ts | 173 ++---------- client/src/components/chat/useChatScroll.ts | 147 ++++++++++ client/src/pages/Chat.tsx | 108 ++------ 5 files changed, 406 insertions(+), 465 deletions(-) create mode 100644 client/src/components/chat/ChatComposer.tsx create mode 100644 client/src/components/chat/useChatScroll.ts diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 0593862..69643b6 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -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(null); const [editingMessage, setEditingMessage] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); - const fileInputRef = useRef(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(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 && ( - - - fileInputRef.current?.click()}> - - Прикрепить файл - - setPollDialogOpen(true)}> - - Создать опрос - - - - - + {/* Общий композер обоих чатов (textarea + меню трёх точек + отправка). + Специфика клавиш (выбор упоминания стрелками/Enter, Escape) — в + handleKeyDown родителя; Enter-отправка — внутри композера. */} + setPollDialogOpen(true)} + hasPendingFiles={pendingFiles.length > 0} + isSending={sendMessageMutation.isPending} + isUploading={isUploading} + disabled={sendMessageMutation.isPending} + inputRef={textareaRef} + onKeyDown={handleKeyDown} + /> {/* Диалог создания опроса — сначала создаёт сообщение, потом привязывает опрос */} diff --git a/client/src/components/chat/ChatComposer.tsx b/client/src/components/chat/ChatComposer.tsx new file mode 100644 index 0000000..91f2d92 --- /dev/null +++ b/client/src/components/chat/ChatComposer.tsx @@ -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; + /** Специфичные клавиши родителя (упоминания и т.п.) — вызывается первым; + если родитель сделал e.preventDefault(), встроенная отправка пропускается. */ + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +export function ChatComposer({ + value, + onChange, + onSend, + onFileSelect, + onPollCreate, + hasPendingFiles, + isSending, + isUploading = false, + disabled = false, + placeholder = 'Написать сообщение...', + inputRef, + onKeyDown, +}: ChatComposerProps) { + const fileInputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + + const hasContent = value.trim().length > 0 || hasPendingFiles; + const busy = isSending || isUploading; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Сначала специфика родителя (выбор упоминания, 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 ( +
{ + 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="Перетащите файл в поле сообщения" + > + { + if (e.target.files?.length) onFileSelect(e.target.files); + e.target.value = ''; + }} + /> + onChange(e.target.value)} + onKeyDown={handleKeyDown} + onPaste={(e: React.ClipboardEvent) => { + 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" + /> + + + + + + fileInputRef.current?.click()}> + + Прикрепить файл + + {onPollCreate && ( + + + Создать опрос + + )} + + + + + ); +} diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts index 53591a9..7a3c857 100644 --- a/client/src/components/chat/useChatController.ts +++ b/client/src/components/chat/useChatController.ts @@ -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>(new Set()); // File attachments const [pendingFiles, setPendingFiles] = useState([]); - const fileInputRef = useRef(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>(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) => { - 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) => { - 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, diff --git a/client/src/components/chat/useChatScroll.ts b/client/src/components/chat/useChatScroll.ts new file mode 100644 index 0000000..9965948 --- /dev/null +++ b/client/src/components/chat/useChatScroll.ts @@ -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; + /** Виртуализатор длинного списка (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 }; +} diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 366233d..834cb8e 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -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 && ( - - - fileInputRef.current?.click()}> - - Прикрепить файл - - setPollDialogOpen(true)}> - - Создать опрос - - - - - + {/* Общий композер обоих чатов (textarea + меню трёх точек + + отправка). Специфика клавиш (Escape при открытых упоминаниях) + — в handleKeyDown родителя; Enter-отправка — внутри композера. */} + setPollDialogOpen(true)} + hasPendingFiles={pendingFiles.length > 0} + isSending={sendMutation.isPending} + isUploading={isUploading} + inputRef={inputRef} + onKeyDown={handleKeyDown} + /> )}