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:
2026-09-30 14:37:31 +03:00
parent d5d95e2f53
commit 06a2794090
5 changed files with 406 additions and 465 deletions

View File

@@ -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>
{/* Диалог создания опроса — сначала создаёт сообщение, потом привязывает опрос */}

View 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>
);
}

View File

@@ -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,

View 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 };
}

View File

@@ -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}
/>
</>
)}