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:
@@ -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];
|
||||
|
||||
@@ -144,7 +144,7 @@ export function AttachmentDisplay({
|
||||
<img
|
||||
src={href}
|
||||
alt={att.name}
|
||||
className="max-w-[180px] max-h-[180px] object-cover"
|
||||
className="w-[180px] h-[180px] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user