From 267af046a355211066a0de6a03ae29752f260ee2 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: Thu, 17 Sep 2026 22:00:10 +0300 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=D0=B0=D0=B2=D1=82=D0=BE=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=20=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0,=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=20=D1=84=D0=B8=D0=BA=D1=81.=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=80=D0=BE=D0=BC,=20=D1=83=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20@?= =?UTF-8?q?=20=D0=B8=203+=20=D0=B1=D1=83=D0=BA=D0=B2=D0=B0=D0=BC=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8/=D1=84=D0=B0=D0=BC=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskChat: atBottomRef-гвард (scrollHeight-scrollTop-clientHeight<80) — чужие сообщения больше не дёргают чат вниз, когда пользователь читает историю; свои сообщения и смена задачи скроллят как раньше - ChatAttachments: img w-180 h-180 object-cover вместо max-* — высота строки не меняется при догрузке, measureElement виртуализатора не дёргает список - упоминания: голый @ открывает список сразу; текстовый триггер от 3 букв; сервер ищет ILIKE по имени/отчеству/фамилии/email и отдаёт всех при пустом q; починен мёртвый fallback-резолв (Response без .json()) - groupByDate/flattenGroupedMessages обёрнуты в useMemo --- client/src/components/TaskChat.tsx | 67 ++++++++++++++----- .../src/components/chat/ChatAttachments.tsx | 2 +- server/routes/auth.users.routes.ts | 10 +-- server/storage/social.storage.ts | 2 + 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 3dea102..849dde3 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -321,13 +321,16 @@ const TaskChat = ({ taskId }: TaskChatProps) => { [messages], ); - const groupedMessages = groupByDate(displayMessages); + const groupedMessages = useMemo(() => groupByDate(displayMessages), [displayMessages]); // Id последнего добавленного сообщения — для анимации появления только его const lastAppendedId = useLastAppendedMessageId(displayMessages, taskId); // Плоский список для виртуализации: разделители дат + сообщения - const chatItems: ChatListItem[] = flattenGroupedMessages(groupedMessages, (m) => `msg-${m.id}`); + const chatItems: ChatListItem[] = useMemo( + () => flattenGroupedMessages(groupedMessages, (m) => `msg-${m.id}`), + [groupedMessages], + ); // Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan. // Динамическая высота измеряется через measureElement (ResizeObserver). @@ -444,21 +447,39 @@ const TaskChat = ({ taskId }: TaskChatProps) => { } }; + // Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда + const atBottomRef = useRef(true); + + // Отслеживаем, находится ли пользователь у низа списка + useEffect(() => { + const viewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]'); + if (!viewport) return; + const onScroll = () => { + atBottomRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + }; + viewport.addEventListener('scroll', onScroll, { passive: true }); + return () => viewport.removeEventListener('scroll', onScroll); + }, [taskId, isLoading]); + // Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query) useEffect(() => { if (!taskId) return; + atBottomRef.current = true; requestAnimationFrame(() => { setTimeout(scrollToBottom, 150); }); }, [taskId]); - // Автоскролл к последнему сообщению при загрузке или обновлении сообщений + // Автоскролл при новых сообщениях: только если пользователь у низа списка + // или последнее сообщение — его собственное (как в мессенджере) useEffect(() => { - if (!isLoading && messages.length > 0) { - // Небольшая задержка чтобы DOM успел обновиться - setTimeout(scrollToBottom, 100); - } - }, [messages.length, isLoading]); + if (isLoading || messages.length === 0) return; + const last = messages[messages.length - 1]; + const isMine = last?.author?.id === user?.id; + if (!atBottomRef.current && !isMine) return; + // Небольшая задержка чтобы DOM успел обновиться + setTimeout(scrollToBottom, 100); + }, [messages.length, isLoading, user?.id]); // Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID) const userMessageIds = user && messages.length > 0 @@ -779,7 +800,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => { const response = await apiRequest('GET', `/api/mentions/search?q=${encodeURIComponent(mentionQuery)}&limit=10`); return await response.json(); }, - enabled: mentionQuery.length > 1, + enabled: showSuggestions && (mentionPosition.hasAt || mentionQuery.length >= 3), retry: false, staleTime: 0 }); @@ -1038,11 +1059,26 @@ const TaskChat = ({ taskId }: TaskChatProps) => { const cursorPosition = textarea.selectionStart || 0; const textBeforeCursor = text.slice(0, cursorPosition); - // МГНОВЕННЫЙ ПОИСК БЕЗ @ СИМВОЛА (как в Discord) - // Ищем последнее слово перед курсором (поддержка Unicode) - const wordMatch = textBeforeCursor.match(/([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]+)$/i); - - if (wordMatch && wordMatch[1].length > 1) { + // Триггер по '@': голый '@' или '@слово' в конце текста перед курсором + // открывает список сразу (как в мессенджере, /(@(\w*))$/) + const atMatch = textBeforeCursor.match(/@([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]*)$/i); + + if (atMatch) { + setMentionQuery(atMatch[1]); + setMentionPosition({ + start: (atMatch.index ?? 0) + 1, // позиция сразу после '@' — символ сохраняется при подстановке + end: cursorPosition, + hasAt: true + }); + setShowSuggestions(true); + setSelectedSuggestionIndex(0); + return; + } + + // Текстовый триггер без '@': последнее слово от 3 букв (поиск по имени и фамилии) + const wordMatch = textBeforeCursor.match(/([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]{3,})$/i); + + if (wordMatch) { const query = wordMatch[1]; setMentionQuery(query); setMentionPosition({ @@ -1205,7 +1241,8 @@ const TaskChat = ({ taskId }: TaskChatProps) => { try { const searchResponse = await apiRequest('GET', `/api/users/search?q=${encodeURIComponent(searchQuery)}&limit=5`); - const foundUsers = (searchResponse as any)?.users || []; + const searchData = await searchResponse.json(); + const foundUsers = searchData?.users || []; if (foundUsers.length > 0) { const user = foundUsers[0]; diff --git a/client/src/components/chat/ChatAttachments.tsx b/client/src/components/chat/ChatAttachments.tsx index 1c222a0..87fd6a2 100644 --- a/client/src/components/chat/ChatAttachments.tsx +++ b/client/src/components/chat/ChatAttachments.tsx @@ -144,7 +144,7 @@ export function AttachmentDisplay({ {att.name} diff --git a/server/routes/auth.users.routes.ts b/server/routes/auth.users.routes.ts index 2553840..163160b 100644 --- a/server/routes/auth.users.routes.ts +++ b/server/routes/auth.users.routes.ts @@ -505,9 +505,9 @@ export function registerUserManagementRoutes(router: Router): void { try { const { q: query, limit = '10' } = req.query; - if (!query || typeof query !== 'string' || query.trim().length === 0) { - return res.status(400).json({ success: false, error: 'Поисковый запрос обязателен' }); - } + // Пустой запрос допустим (голый '@' в чате): возвращаем первых пользователей + // организации и всех доступных ботов + const trimmedQuery = typeof query === 'string' ? query.trim() : ''; if (!req.organizationId || typeof req.organizationId !== 'number') { return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' }); @@ -515,8 +515,8 @@ export function registerUserManagementRoutes(router: Router): void { const searchLimit = Math.min(parseInt(limit as string) || 10, 20); const [users, allBots] = await Promise.all([ - storage.searchUsers(query.trim(), req.organizationId, searchLimit), - storage.searchBots(query.trim(), req.organizationId, searchLimit) + storage.searchUsers(trimmedQuery, req.organizationId, searchLimit), + storage.searchBots(trimmedQuery, req.organizationId, searchLimit) ]); const currentUser = req.user!; diff --git a/server/storage/social.storage.ts b/server/storage/social.storage.ts index 1ea60d3..80c4586 100644 --- a/server/storage/social.storage.ts +++ b/server/storage/social.storage.ts @@ -370,6 +370,8 @@ export class SocialStorage extends TasksStorage { ${users.middleName} ILIKE ${'%' + query + '%'} OR ${users.lastName} ILIKE ${'%' + query + '%'} + OR + ${users.email} ILIKE ${'%' + query + '%'} )` )) .limit(limit)