fix(chat): автоскролл задач только у низа, картинки с фикс. размером, упоминания по @ и 3+ буквам имени/фамилии

- 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
This commit is contained in:
2026-09-17 22:00:10 +03:00
parent d0112657b8
commit 267af046a3
4 changed files with 60 additions and 21 deletions

View File

@@ -321,13 +321,16 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
[messages], [messages],
); );
const groupedMessages = groupByDate(displayMessages); const groupedMessages = useMemo(() => groupByDate(displayMessages), [displayMessages]);
// Id последнего добавленного сообщения — для анимации появления только его // Id последнего добавленного сообщения — для анимации появления только его
const lastAppendedId = useLastAppendedMessageId(displayMessages, taskId); 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. // Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan.
// Динамическая высота измеряется через measureElement (ResizeObserver). // Динамическая высота измеряется через 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) // Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query)
useEffect(() => { useEffect(() => {
if (!taskId) return; if (!taskId) return;
atBottomRef.current = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(scrollToBottom, 150); setTimeout(scrollToBottom, 150);
}); });
}, [taskId]); }, [taskId]);
// Автоскролл к последнему сообщению при загрузке или обновлении сообщений // Автоскролл при новых сообщениях: только если пользователь у низа списка
// или последнее сообщение — его собственное (как в мессенджере)
useEffect(() => { useEffect(() => {
if (!isLoading && messages.length > 0) { 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 успел обновиться // Небольшая задержка чтобы DOM успел обновиться
setTimeout(scrollToBottom, 100); setTimeout(scrollToBottom, 100);
} }, [messages.length, isLoading, user?.id]);
}, [messages.length, isLoading]);
// Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID) // Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID)
const userMessageIds = user && messages.length > 0 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`); const response = await apiRequest('GET', `/api/mentions/search?q=${encodeURIComponent(mentionQuery)}&limit=10`);
return await response.json(); return await response.json();
}, },
enabled: mentionQuery.length > 1, enabled: showSuggestions && (mentionPosition.hasAt || mentionQuery.length >= 3),
retry: false, retry: false,
staleTime: 0 staleTime: 0
}); });
@@ -1038,11 +1059,26 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
const cursorPosition = textarea.selectionStart || 0; const cursorPosition = textarea.selectionStart || 0;
const textBeforeCursor = text.slice(0, cursorPosition); const textBeforeCursor = text.slice(0, cursorPosition);
// МГНОВЕННЫЙ ПОИСК БЕЗ @ СИМВОЛА (как в Discord) // Триггер по '@': голый '@' или '@слово' в конце текста перед курсором
// Ищем последнее слово перед курсором (поддержка Unicode) // открывает список сразу (как в мессенджере, /(@(\w*))$/)
const wordMatch = textBeforeCursor.match(/([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]+)$/i); const atMatch = textBeforeCursor.match(/@([а-яё\w\u00C0-\u024F\u1E00-\u1EFF]*)$/i);
if (wordMatch && wordMatch[1].length > 1) { 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]; const query = wordMatch[1];
setMentionQuery(query); setMentionQuery(query);
setMentionPosition({ setMentionPosition({
@@ -1205,7 +1241,8 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
try { try {
const searchResponse = await apiRequest('GET', `/api/users/search?q=${encodeURIComponent(searchQuery)}&limit=5`); 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) { if (foundUsers.length > 0) {
const user = foundUsers[0]; const user = foundUsers[0];

View File

@@ -144,7 +144,7 @@ export function AttachmentDisplay({
<img <img
src={href} src={href}
alt={att.name} alt={att.name}
className="max-w-[180px] max-h-[180px] object-cover" className="w-[180px] h-[180px] object-cover"
loading="lazy" loading="lazy"
/> />
</button> </button>

View File

@@ -505,9 +505,9 @@ export function registerUserManagementRoutes(router: Router): void {
try { try {
const { q: query, limit = '10' } = req.query; 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') { if (!req.organizationId || typeof req.organizationId !== 'number') {
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' }); 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 searchLimit = Math.min(parseInt(limit as string) || 10, 20);
const [users, allBots] = await Promise.all([ const [users, allBots] = await Promise.all([
storage.searchUsers(query.trim(), req.organizationId, searchLimit), storage.searchUsers(trimmedQuery, req.organizationId, searchLimit),
storage.searchBots(query.trim(), req.organizationId, searchLimit) storage.searchBots(trimmedQuery, req.organizationId, searchLimit)
]); ]);
const currentUser = req.user!; const currentUser = req.user!;

View File

@@ -370,6 +370,8 @@ export class SocialStorage extends TasksStorage {
${users.middleName} ILIKE ${'%' + query + '%'} ${users.middleName} ILIKE ${'%' + query + '%'}
OR OR
${users.lastName} ILIKE ${'%' + query + '%'} ${users.lastName} ILIKE ${'%' + query + '%'}
OR
${users.email} ILIKE ${'%' + query + '%'}
)` )`
)) ))
.limit(limit) .limit(limit)